From 243803fc2942892a848dcceeb2ae794e2f011171 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 2 Jun 2026 10:09:05 +0300 Subject: [PATCH 1/8] feat(bookings): Integrate booking list and detail pages with API --- .../src/pages/bookings/BookingDetailPage.tsx | 222 ++++++++++-------- .../portal/src/pages/bookings/MyBookings.tsx | 91 +++---- .../portal/src/services/bookings.service.ts | 8 +- 3 files changed, 164 insertions(+), 157 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx index 8b153515d..6a4f13227 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -1,4 +1,5 @@ import { useNavigate, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { Calendar, MapPin, @@ -22,10 +23,12 @@ import { CreditCard, FileSignature, PackageCheck, + LoaderCircle, } from "lucide-react"; import Breadcrumbs from "@/components/Breadcrumbs"; -import { getBookingById } from "./bookings.mock"; +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; import { Card, CardHeader, @@ -37,45 +40,67 @@ import { } from "@edr/ui-common"; import { cn } from "@/lib/utils"; -// Grouping the 15 granular statuses into 6 logical progress stages for the UI tracker const PROGRESS_STAGES = [ - { label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] }, - { label: "Quotation", icon: ClipboardCheck, statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"] }, - { label: "Approval", icon: ShieldCheck, statuses: ["PENDING_APPROVAL", "APPROVED"] }, - { label: "Execution", icon: FileSignature, statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"] }, - { label: "In Transit", icon: Train, statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] }, - { label: "Complete", icon: PackageCheck, statuses: ["COMPLETED"] }, + { label: "Request", icon: FileText, statuses: ["DRAFT"] }, + { label: "Approval", icon: ClipboardCheck, statuses: ["CONFIRMED"] }, + { label: "In Transit", icon: Train, statuses: ["IN_TRANSIT"] }, + { label: "Complete", icon: PackageCheck, statuses: ["DELIVERED"] }, ]; const STATUS_MAP: Record = { DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 }, - RFQ_SUBMITTED: { title: "RFQ Submitted", description: "Request for Quotation has been sent to the operations team.", color: "text-amber-600", stage: 0 }, - QUOTATION_SENT: { title: "Quotation Received", description: "EDR has sent a formal quotation for your review.", color: "text-sky-600", stage: 1 }, - QUOTATION_APPROVED: { title: "Quotation Approved", description: "You have accepted the quotation terms.", color: "text-emerald-600", stage: 1 }, - QUOTATION_REJECTED: { title: "Quotation Rejected", description: "The quotation was not accepted.", color: "text-red-600", stage: 1 }, - PENDING_APPROVAL: { title: "Internal Approval", description: "Booking is undergoing final administrative review.", color: "text-amber-600", stage: 2 }, - APPROVED: { title: "Booking Approved", description: "Request is fully approved and ready for execution.", color: "text-emerald-600", stage: 2 }, - SIGNED_CUSTOMER: { title: "Customer Signed", description: "Contract has been signed by the customer.", color: "text-sky-600", stage: 3 }, - FULLY_EXECUTED: { title: "Contract Executed", description: "All parties have signed. Operational setup in progress.", color: "text-indigo-600", stage: 3 }, - PAID: { title: "Payment Received", description: "Initial payments confirmed. Cargo ready for dispatch.", color: "text-emerald-600", stage: 3 }, - IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 4 }, - PENDING_CONSOLIDATION: { title: "Consolidation Node", description: "Cargo is waiting to be consolidated with other shipments.", color: "text-amber-500", stage: 4 }, - CONSOLIDATED: { title: "Load Consolidated", description: "Cargo has been successfully merged into a larger shipment.", color: "text-indigo-500", stage: 4 }, - COMPLETED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 5 }, + CONFIRMED: { title: "Booking Confirmed", description: "Booking has been confirmed and approved.", color: "text-emerald-600", stage: 1 }, + IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 2 }, + DELIVERED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 3 }, CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 }, }; export default function BookingDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const booking = id ? getBookingById(id) : undefined; + + const { data: booking, isLoading, isError, error } = useQuery( + api.bookings.get.queryOptions({ + input: { id: id! }, + enabled: !!id, + }), + ); + + if (isLoading) { + return ( +
+
+ +

Loading booking details…

+
+
+ ); + } + + if (isError) { + return ( +
+ +
+ +
+

+ Failed to load booking +

+

+ {error instanceof Error ? error.message : "An unexpected error occurred."} +

+
+
+ ); + } if (!booking) { return (
- +

Booking not found @@ -85,16 +110,17 @@ export default function BookingDetailPage() { ); } - // Normalize status to upper case for mapping - const normalizedStatus = (booking.status === "In Transit" ? "IN_TRANSIT" : booking.status === "Pending" ? "RFQ_SUBMITTED" : booking.status.toUpperCase()) as keyof typeof STATUS_MAP; + const normalizedStatus = booking.status as keyof typeof STATUS_MAP; const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT; const currentStageIndex = statusConfig.stage; + const containerCount = booking.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0; + const containerType = booking.containers?.[0]?.type ?? null; + return (
- {/* Breadcrumbs Restored */} - {/* Compact Header Card */}
@@ -117,11 +142,9 @@ export default function BookingDetailPage() {
- {booking.customer} - - {booking.requestedDate} + {booking.scheduledDate ?? booking.createdAt}
@@ -129,7 +152,6 @@ export default function BookingDetailPage() { - {/* Granular Status Lifecycle */} @@ -140,7 +162,6 @@ export default function BookingDetailPage() {
- {/* Progress Line */}
- {normalizedStatus !== "CANCELLED" && normalizedStatus !== "COMPLETED" && ( + {normalizedStatus !== "CANCELLED" && normalizedStatus !== "DELIVERED" && (

Est. Waiting

@@ -200,7 +221,6 @@ export default function BookingDetailPage() {
- {/* Route & Core Service Card */} @@ -232,14 +252,13 @@ export default function BookingDetailPage() {
- } label="Service" value="Rail & Forwarding" /> - } label="Return" value="With Return" /> - } label="Customs" value="Enabled" /> + } label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} /> + } label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} /> + } label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} />
- {/* Mile Services Card */} @@ -252,18 +271,19 @@ export default function BookingDetailPage() {

First Mile

- +

Last Mile

-

Not requested

+

+ {booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"} +

- {/* Cargo Specifications Card */} @@ -273,40 +293,44 @@ export default function BookingDetailPage() {
- } label="Category" value={booking.cargoType} /> - } label="Weight" value={`${booking.weightTons} Tons`} /> - } label="Shipping Line" value="MSC" /> + } label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} /> + } label="Weight (VGM)" value={`${booking.cargoTotalWeightVgm} Tons`} /> + } label="Currency" value={booking.paymentCurrency} />
- - -
-

Load Details

-
- - - - - - - - - - - - - - - -
DescriptionUnitValue
Main Equipment20FT Container4 Units
-
-
+ {booking.containers && booking.containers.length > 0 && ( + <> + +
+

Load Details

+
+ + + + + + + + + + {booking.containers.map((c, i) => ( + + + + + + ))} + +
TypeQuantityVGM (Tons)
{c.type}{c.qty} Units{c.vgm}t
+
+
+ + )}
- {/* Contract Card */} @@ -315,40 +339,48 @@ export default function BookingDetailPage() { - - + +
- Hazardous: No + Hazardous: {booking.isHazardous ? "Yes" : "No"} - Refrigerated: No + Refrigerated: {booking.isRefrigerated ? "Yes" : "No"}
- {/* Notes Card */} Additional Info -
-

Description

-

"{booking.cargoDescription}"

-
- -
-

Instructions

-
-

- - {booking.specialInstructions} -

-
-
+ {booking.freightSubtype && ( +
+

Cargo Description

+

"{booking.freightSubtype}"

+
+ )} + {booking.financialTerms && ( + <> + +
+

Financial Terms

+
+

+ + {booking.financialTerms} +

+
+
+ + )} + {!booking.freightSubtype && !booking.financialTerms && ( +

No additional information provided.

+ )}
@@ -396,7 +428,7 @@ function InfoItem({ {icon &&
{icon}
}

{label}

-

{value || "—"}

+

{value ?? "—"}

); @@ -405,20 +437,10 @@ function InfoItem({ function StatusBadge({ status }: { status: string }) { const statusColors: Record = { DRAFT: "bg-slate-50 text-slate-700 border-slate-200", - RFQ_SUBMITTED: "bg-amber-50 text-amber-700 border-amber-200", - QUOTATION_SENT: "bg-sky-50 text-sky-700 border-sky-200", - QUOTATION_APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200", - QUOTATION_REJECTED: "bg-red-50 text-red-700 border-red-200", - PENDING_APPROVAL: "bg-amber-50 text-amber-700 border-amber-200", - APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200", - SIGNED_CUSTOMER: "bg-sky-50 text-sky-700 border-sky-200", - FULLY_EXECUTED: "bg-indigo-50 text-indigo-700 border-indigo-200", - PAID: "bg-emerald-50 text-emerald-700 border-emerald-200", + CONFIRMED: "bg-emerald-50 text-emerald-700 border-emerald-200", IN_TRANSIT: "bg-sky-50 text-sky-700 border-sky-200", - COMPLETED: "bg-indigo-50 text-indigo-700 border-indigo-200", + DELIVERED: "bg-indigo-50 text-indigo-700 border-indigo-200", CANCELLED: "bg-red-50 text-red-700 border-red-200", - PENDING_CONSOLIDATION: "bg-amber-50 text-amber-700 border-amber-200", - CONSOLIDATED: "bg-indigo-50 text-indigo-700 border-indigo-200", }; return ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index 9d8501df0..960d9c0f2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { ArrowRight, Clock, @@ -9,13 +10,11 @@ import { Package, Plus, Search, - Trash2, Truck, } from "lucide-react"; -import DeleteBookingDialog from "./DeleteBookingDialog"; -import { getMyBookings } from "@/lib/currentCustomer"; -import { deleteBooking, type Booking, type BookingStatus } from "./bookings.mock"; +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; import { DataTable, DataTableFooter, @@ -32,32 +31,30 @@ import { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, } from "@edr/ui-common"; export default function MyBookings() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [searchTerm, setSearchTerm] = useState(""); - const [myBookings, setMyBookings] = useState(() => getMyBookings()); - const handleDeleteConfirm = (id: number) => { - deleteBooking(id); - setMyBookings(getMyBookings()); - }; + const { data, isLoading, isError } = useQuery( + api.bookings.list.queryOptions(), + ); + + const bookings = data?.items ?? []; const filteredData = useMemo(() => { - return myBookings.filter((b) => { + return bookings.filter((b) => { const term = searchTerm.toLowerCase(); return ( b.reference.toLowerCase().includes(term) || b.originStation.toLowerCase().includes(term) || b.destinationStation.toLowerCase().includes(term) || - b.cargoDescription.toLowerCase().includes(term) || b.status.toLowerCase().includes(term) ); }); - }, [myBookings, searchTerm]); + }, [bookings, searchTerm]); const total = filteredData.length; const pageCount = Math.ceil(total / pagination.pageSize); @@ -67,16 +64,16 @@ export default function MyBookings() { const paginatedData = useMemo(() => filteredData.slice(start, end), [filteredData, start, end]); const activeCount = useMemo(() => { - return myBookings.filter( - (b) => b.status === "Confirmed" || b.status === "In Transit", + return bookings.filter( + (b) => b.status === "CONFIRMED" || b.status === "IN_TRANSIT", ).length; - }, [myBookings]); + }, [bookings]); const pendingCount = useMemo(() => { - return myBookings.filter((b) => b.status === "Pending").length; - }, [myBookings]); + return bookings.filter((b) => b.status === "DRAFT").length; + }, [bookings]); - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { accessorKey: "reference", header: "Reference", @@ -89,7 +86,7 @@ export default function MyBookings() {

{booking.reference}

-

{booking.requestedDate}

+

{booking.scheduledDate ?? booking.createdAt}

); @@ -111,22 +108,24 @@ export default function MyBookings() { header: "Cargo", cell: ({ row }) => { const b = row.original; + const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0; + const containerType = b.containers?.[0]?.type ?? null; return (
-

{b.cargoType}

+

{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}

- {b.containerCount > 0 ? `${b.containerCount} × ${b.containerType} · ` : ""}{b.weightTons}t + {containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t

); }, }, { - accessorKey: "transportMode", + id: "transportMode", header: "Transport", cell: ({ row }) => ( - {row.original.transportMode} + {row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"} ), }, @@ -158,19 +157,6 @@ export default function MyBookings() { View - - handleDeleteConfirm(booking.id)} - > - e.preventDefault()} - variant="destructive" - > - - Delete - -
@@ -179,10 +165,11 @@ export default function MyBookings() { }, ]; + const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; + return (
- {/* Header Section Card */}

@@ -214,14 +201,13 @@ export default function MyBookings() {

- {/* Stat Cards */}

Total Bookings

- {myBookings.length} + {bookings.length}

@@ -259,7 +245,6 @@ export default function MyBookings() {
- {/* Data Table */}
@@ -276,7 +261,7 @@ export default function MyBookings() { - {total === 0 ? ( + {total === 0 && dataTableStatus === "success" ? (

No bookings found

@@ -288,8 +273,8 @@ export default function MyBookings() { navigate(`/bookings/${(row as Booking).id}`)} + status={dataTableStatus} + onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)} pagination={{ pageIndex: pagination.pageIndex, pageSize: pagination.pageSize, @@ -311,20 +296,20 @@ export default function MyBookings() { ); } -function StatusBadge({ status }: { status: BookingStatus }) { - const styles: Record = { - Pending: "bg-amber-100 text-amber-700", - Confirmed: "bg-sky-100 text-sky-700", - "In Transit": "bg-indigo-100 text-indigo-700", - Delivered: "bg-emerald-100 text-emerald-700", - Cancelled: "bg-red-100 text-red-700", +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + DRAFT: "bg-amber-100 text-amber-700", + CONFIRMED: "bg-sky-100 text-sky-700", + IN_TRANSIT: "bg-indigo-100 text-indigo-700", + DELIVERED: "bg-emerald-100 text-emerald-700", + CANCELLED: "bg-red-100 text-red-700", }; return ( - {status} + {status.replace(/_/g, ' ')} ); } diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index dd4c9f0dd..6ac93a742 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -6,22 +6,22 @@ export type CreateBookingPayload = Freight.CreateBookingDto; export const bookingsService = { list: async (): Promise> => { - const { data } = await client.get("/bookings"); + const { data } = await client.get("/api/bookings"); return data.data; }, get: async (id: string): Promise => { - const { data } = await client.get(`/bookings/${id}`); + const { data } = await client.get(`/api/bookings/${id}`); return data.data; }, create: async (payload: CreateBookingPayload): Promise => { const { data } = await client.post("/api/bookings", payload); - return data.data; + return data.data.booking; }, getReferenceData: async (): Promise => { const { data } = await client.get("/api/bookings/reference-data"); return data.data; }, remove: async (id: string): Promise => { - await client.delete(`/bookings/${id}`); + await client.delete(`/api/bookings/${id}`); }, }; From 88219ee9b864df501ecac4bd2a06fe557c7abcf8 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Tue, 2 Jun 2026 12:12:20 +0300 Subject: [PATCH 2/8] WIP --- apps/edr-freight-web/backoffice/src/App.tsx | 8 +- .../user-management/PositionTypesPage.tsx | 590 ++++++++++++++++++ 2 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 3bfc004ea..50ae8103b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -7,6 +7,7 @@ import LoginPage from "./pages/auth/LoginPage"; import OverviewPage from "./pages/dashboard/OverviewPage"; import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; +import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; import UsersPage from "./pages/dashboard/user-management/UsersPage"; @@ -42,6 +43,10 @@ const baseSidebarItems: SidebarItem[] = [ label: "Users", href: "/dashboard/user-management/users", }, + { + label: "Position Type", + href: "/dashboard/user-management/position-types", + }, { label: "Employees", href: "/dashboard/user-management/employees", @@ -161,6 +166,7 @@ const App = () => { } /> } /> + } /> } /> } /> } /> @@ -188,4 +194,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx new file mode 100644 index 000000000..177fccbd0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx @@ -0,0 +1,590 @@ +import { useEffect, useMemo, useState } from "react"; +import { isAxiosError } from "axios"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@edr/ui-common"; + +import { api } from "@/auth/http"; +import { useAuth } from "@/auth/useAuth"; + +interface LocaleText { + en?: string; + am?: string; +} + +interface OrganizationRecord { + id: string; + key: string; + name?: LocaleText; +} + +interface UnitRecord { + id: string; + key: string; + name?: LocaleText; +} + +interface PositionTypeRecord { + id: string; + key: string; + name?: LocaleText; + isSystem?: boolean; + unitId?: string | null; + createdAt?: string; + updatedAt?: string | null; +} + +interface PermissionRecord { + id: string; + key: string; + name?: LocaleText; +} + +interface ListResponse { + count?: number; + items?: T[]; + data?: T[]; +} + +const PAGE_SIZE = 1000; + +const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => + value?.en ?? value?.am ?? fallback; + +const getItems = (payload: ListResponse | T[] | undefined | null) => { + if (!payload) { + return [] as T[]; + } + + if (Array.isArray(payload)) { + return payload; + } + + return payload.items ?? payload.data ?? []; +}; + +const formatDate = (value?: string | null) => { + if (!value) { + return "-"; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "-"; + } + + return new Intl.DateTimeFormat("en", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(date); +}; + +const PositionTypesPage = () => { + const { user } = useAuth(); + const [organizations, setOrganizations] = useState([]); + const [units, setUnits] = useState([]); + const [positionTypes, setPositionTypes] = useState([]); + const [selectedOrgId, setSelectedOrgId] = useState(""); + const [selectedUnitId, setSelectedUnitId] = useState(""); + const [loadingOrganizations, setLoadingOrganizations] = useState(true); + const [loadingUnits, setLoadingUnits] = useState(false); + const [loadingPositionTypes, setLoadingPositionTypes] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [selectedPositionType, setSelectedPositionType] = useState(null); + const [positionTypePermissions, setPositionTypePermissions] = useState([]); + const [permissionsLoading, setPermissionsLoading] = useState(false); + const [permissionsError, setPermissionsError] = useState(null); + + const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin")); + const allowedOrgIds = useMemo( + () => + new Set( + (user?.employee ?? []) + .map((employee) => employee.organizationId) + .filter((organizationId): organizationId is string => Boolean(organizationId)), + ), + [user?.employee], + ); + + const visibleOrganizations = useMemo(() => { + if (isSuperAdmin) { + return organizations; + } + + return organizations.filter((organization) => allowedOrgIds.has(organization.id)); + }, [allowedOrgIds, isSuperAdmin, organizations]); + + const selectedOrganization = + visibleOrganizations.find((organization) => organization.id === selectedOrgId) ?? null; + const selectedUnit = units.find((unit) => unit.id === selectedUnitId) ?? null; + + useEffect(() => { + let isMounted = true; + + const loadOrganizations = async () => { + setLoadingOrganizations(true); + setErrorMessage(null); + + try { + const response = await api.get>("/organizations"); + + if (!isMounted) { + return; + } + + setOrganizations(getItems(response.data)); + } catch (error) { + if (!isMounted) { + return; + } + + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load organizations." + : "Unable to load organizations.", + ); + } finally { + if (isMounted) { + setLoadingOrganizations(false); + } + } + }; + + void loadOrganizations(); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + if (!visibleOrganizations.length) { + setSelectedOrgId(""); + setSelectedUnitId(""); + setUnits([]); + setPositionTypes([]); + return; + } + + if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) { + return; + } + + setSelectedOrgId(visibleOrganizations[0]?.id ?? ""); + }, [selectedOrgId, visibleOrganizations]); + + useEffect(() => { + if (!selectedOrgId) { + setUnits([]); + setSelectedUnitId(""); + setPositionTypes([]); + return; + } + + let isMounted = true; + + const loadUnits = async () => { + setLoadingUnits(true); + setErrorMessage(null); + setSelectedUnitId(""); + setPositionTypes([]); + + try { + const response = await api.get>(`/units/list/${selectedOrgId}`); + const items = getItems(response.data); + + if (!isMounted) { + return; + } + + setUnits(items); + setSelectedUnitId(items[0]?.id ?? ""); + } catch (error) { + if (!isMounted) { + return; + } + + setUnits([]); + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load units." + : "Unable to load units.", + ); + } finally { + if (isMounted) { + setLoadingUnits(false); + } + } + }; + + void loadUnits(); + + return () => { + isMounted = false; + }; + }, [selectedOrgId]); + + useEffect(() => { + if (!selectedUnitId) { + setPositionTypes([]); + return; + } + + let isMounted = true; + + const loadPositionTypes = async () => { + setLoadingPositionTypes(true); + setErrorMessage(null); + + try { + const response = await api.get>( + `/position-types/list-with-commons/${selectedUnitId}`, + { + params: { + skip: 0, + take: PAGE_SIZE, + orderBy: "createdAt:Desc", + }, + }, + ); + + if (!isMounted) { + return; + } + + setPositionTypes(getItems(response.data)); + } catch (error) { + if (!isMounted) { + return; + } + + setPositionTypes([]); + setErrorMessage( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load position types." + : "Unable to load position types.", + ); + } finally { + if (isMounted) { + setLoadingPositionTypes(false); + } + } + }; + + void loadPositionTypes(); + + return () => { + isMounted = false; + }; + }, [selectedUnitId]); + + useEffect(() => { + if (!selectedPositionType) { + setPositionTypePermissions([]); + setPermissionsError(null); + setPermissionsLoading(false); + return; + } + + let isMounted = true; + + const loadPermissions = async () => { + setPermissionsLoading(true); + setPermissionsError(null); + + try { + const response = await api.get>( + `/position-type-permissions/given-first/${selectedPositionType.id}`, + ); + + if (!isMounted) { + return; + } + + setPositionTypePermissions(getItems(response.data)); + } catch (error) { + if (!isMounted) { + return; + } + + setPositionTypePermissions([]); + setPermissionsError( + isAxiosError(error) + ? error.response?.data?.message ?? "Unable to load position type permissions." + : "Unable to load position type permissions.", + ); + } finally { + if (isMounted) { + setPermissionsLoading(false); + } + } + }; + + void loadPermissions(); + + return () => { + isMounted = false; + }; + }, [selectedPositionType]); + + return ( +
+
+
+

+ User Management +

+
+
+

Position Type

+

+ Browse position types for a selected organization unit, including shared system entries. +

+
+
+ {positionTypes.length} position types +
+
+
+ +
+
+

+ Organization +

+ +
+ +
+

+ Unit +

+ +
+ +

+ {selectedOrganization && selectedUnit + ? `Showing position types for ${getLocaleLabel(selectedUnit.name, selectedUnit.key)} in ${getLocaleLabel(selectedOrganization.name, selectedOrganization.key)}.` + : "Select an organization and unit to load position types."} +

+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : loadingOrganizations || loadingUnits || loadingPositionTypes ? ( +
+ Loading position types... +
+ ) : !visibleOrganizations.length ? ( +
+ No organization scope is available for this account. +
+ ) : !selectedOrgId ? ( +
+ Select an organization to continue. +
+ ) : !units.length ? ( +
+ No units are available for the selected organization. +
+ ) : !selectedUnitId ? ( +
+ Select a unit to load position types. +
+ ) : positionTypes.length ? ( +
+ + + + + + + + + + + + + {positionTypes.map((positionType) => ( + setSelectedPositionType(positionType)} + className="cursor-pointer border-t border-border bg-background transition hover:bg-accent/20" + > + + + + + + + + ))} + +
NameKeyScopeUnit IDCreated AtUpdated At
+ {getLocaleLabel(positionType.name, positionType.key)} + {positionType.key} + {positionType.isSystem ? "System" : "Unit"} + + {positionType.unitId ?? "-"} + + {formatDate(positionType.createdAt)} + + {formatDate(positionType.updatedAt)} +
+
+ ) : ( +
+ No position types were found for the selected unit. +
+ )} +
+ + !open && setSelectedPositionType(null)} + > + + + + {selectedPositionType + ? getLocaleLabel(selectedPositionType.name, selectedPositionType.key) + : "Position type details"} + + + {selectedPositionType + ? `Review the permission set assigned to ${getLocaleLabel(selectedPositionType.name, selectedPositionType.key)}.` + : undefined} + + + + {selectedPositionType ? ( +
+
+
+

+ Position type +

+

+ {getLocaleLabel(selectedPositionType.name, selectedPositionType.key)} +

+
+
+

+ Key +

+

+ {selectedPositionType.key} +

+
+
+

+ Scope +

+

+ {selectedPositionType.isSystem ? "System" : "Unit"} +

+
+
+

+ Unit ID +

+

+ {selectedPositionType.unitId ?? "-"} +

+
+
+ +
+
+

Permissions

+
+ {positionTypePermissions.length} permissions +
+
+ + {permissionsLoading ? ( +
+ Loading position type permissions... +
+ ) : permissionsError ? ( +
+ {permissionsError} +
+ ) : positionTypePermissions.length ? ( +
+ {positionTypePermissions.map((permission) => ( +
+
+

+ {getLocaleLabel(permission.name, permission.key)} +

+

+ {permission.key} +

+
+
+ ))} +
+ ) : ( +
+ No permissions are assigned to this position type. +
+ )} +
+
+ ) : null} +
+
+
+ ); +}; + +export default PositionTypesPage; From 2a489ca18d59a88725798c6dd6c12a28b5396eec Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Tue, 2 Jun 2026 12:12:28 +0300 Subject: [PATCH 3/8] WIP --- apps/edr-freight-api/src/app.module.ts | 9 +- .../modules/backoffice/backoffice.service.ts | 68 +++++- .../dto/create-organization-user.dto.ts | 7 +- .../src/seed/edr-freight.seed.ts | 227 ++++++++++++++++++ .../src/seed/edr-org.seeder.ts | 84 ++----- .../dashboard/user-management/UsersPage.tsx | 33 ++- 6 files changed, 353 insertions(+), 75 deletions(-) create mode 100644 apps/edr-freight-api/src/seed/edr-freight.seed.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index b2a1f0c1a..49f32be76 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -21,6 +21,10 @@ import { OtpModule } from './modules/otp/otp.module'; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; +import { + EDR_FREIGHT_APPLICATION, + EDR_FREIGHT_PERMISSIONS, +} from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; @@ -36,7 +40,10 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder"; config.get("database")!, }), SharedAuthModule, - IamModule.forRoot(), + IamModule.forRoot({ + applications: [EDR_FREIGHT_APPLICATION], + permissions: EDR_FREIGHT_PERMISSIONS, + }), BookingsModule, FilesModule, ConsignmentsModule, diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index d6b68982f..7c3e05154 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -6,7 +6,7 @@ import { import { InjectRepository } from "@nestjs/typeorm"; import { hashPassword } from "@tria-plc/api-common/utils/argon"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; -import { DataSource, In, IsNull, Repository } from "typeorm"; +import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; @@ -21,6 +21,8 @@ const RESERVED_ROLE_KEYS = new Set([ "unit_admin", ]); const DEFAULT_USER_PASSWORD = "12345678"; +const ORGANIZATION_ADMIN_ROLE_KEY = "organization_admin"; +const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager"; @Injectable() export class BackofficeService { @@ -51,6 +53,7 @@ export class BackofficeService { const email = dto.email.trim().toLowerCase(); const username = dto.username.trim().toLowerCase(); const phoneNumber = dto.phoneNumber?.trim() || undefined; + const assignOrganizationAdmin = dto.assignOrganizationAdmin === true; const name = { en: dto.name.en.trim(), ...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}), @@ -168,6 +171,16 @@ export class BackofficeService { throw new NotFoundException("employee_create_failed"); } + const userId = user.id; + + if (!userId) { + throw new NotFoundException("user_create_failed"); + } + + if (assignOrganizationAdmin) { + await this.ensureOrganizationAdminAccess(manager, organizationId, userId); + } + return employee; }); } @@ -268,4 +281,57 @@ export class BackofficeService { throw new NotFoundException("user_not_found_in_organization"); } } + + private async ensureOrganizationAdminAccess( + manager: EntityManager, + organizationId: string, + userId: string, + ) { + const roles = await manager.getRepository(Role).find({ + where: [ + { key: ORGANIZATION_ADMIN_ROLE_KEY }, + { key: EDR_ORG_MANAGER_ROLE_KEY }, + ], + select: { id: true, key: true }, + }); + + const requiredRoles = [ORGANIZATION_ADMIN_ROLE_KEY, EDR_ORG_MANAGER_ROLE_KEY].map((key) => { + const role = roles.find((item) => item.key === key); + + if (!role?.id) { + throw new NotFoundException(`required_role_not_seeded:${key}`); + } + + return { + id: role.id, + key: role.key, + }; + }); + + const existingRoleIds = new Set( + ( + await manager.getRepository(UserRole).find({ + where: { + userId, + organizationId, + }, + select: { roleId: true }, + }) + ).map((userRole) => userRole.roleId), + ); + + const rolesToInsert = requiredRoles + .filter((role) => !existingRoleIds.has(role.id)) + .map((role) => ({ + userId, + roleId: role.id, + organizationId, + })); + + if (!rolesToInsert.length) { + return; + } + + await manager.getRepository(UserRole).insert(rolesToInsert); + } } diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts index 1623ac075..cf324a501 100644 --- a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; +import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; class CreateOrganizationUserNameDto { @ApiProperty() @@ -31,4 +31,9 @@ export class CreateOrganizationUserDto { @ApiProperty({ type: CreateOrganizationUserNameDto }) @IsObject() name!: CreateOrganizationUserNameDto; + + @ApiProperty({ required: false, default: false }) + @IsOptional() + @IsBoolean() + assignOrganizationAdmin?: boolean; } diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts new file mode 100644 index 000000000..7fc9b9697 --- /dev/null +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -0,0 +1,227 @@ +export type FreightSeedRole = { + key: string; + name: { en: string }; + permissionKeys: string[]; +}; + +const IAM_PERMISSION_KEYS = { + activateEmployee: "can:activateEmployee", + activateUser: "can:activateUser", + createEmployee: "can:createEmployee", + createPositionPermission: "can:create:position_permission", + createUnit: "can:create:unit", + createUserRole: "can:create:user_role", + deactivateEmployee: "can:deactivateEmployee", + deletePositionPermission: "can:delete:position_permission", + deleteUnit: "can:delete:unit", + deleteUserRole: "can:delete:user_role", + manageOrganizationAdmin: "manage:organizationAdmin", + manageUnitAdmin: "manage:unitAdmin", + updateUnit: "can:update:unit", + viewPositionPermission: "can:view:position_permission", + viewUserRole: "can:view:user_role", +} as const; + +export const EDR_FREIGHT_APPLICATION = { + id: "7f5a2175-c270-495b-bec9-d59ddbdab5d1", + key: "edr_freight_app", + name: { + am: "EDR Freight App", + en: "EDR Freight App", + }, +} as const; + +const EMPLOYEE_REGISTRATION_PERMISSIONS = [ + { + id: "62b5aa2d-4ef6-474d-913a-994568dce1c8", + key: "edr_freight_app:employee_registration:view", + name: { am: "View employee registration", en: "View employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "8072204d-26de-4e62-88aa-74afd916a0cb", + key: "edr_freight_app:employee_registration:create", + name: { am: "Create employee registration", en: "Create employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "b7dc55a6-ae7c-4558-8c4e-7d8ce5c7fa08", + key: "edr_freight_app:employee_registration:update", + name: { am: "Update employee registration", en: "Update employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "7ef06121-bd31-4c0d-b36d-5401b4bfd05c", + key: "edr_freight_app:employee_registration:activate", + name: { am: "Activate employee registration", en: "Activate employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "2688e144-7f0c-4704-8d59-e92b0c08117a", + key: "edr_freight_app:employee_registration:deactivate", + name: { am: "Deactivate employee registration", en: "Deactivate employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const ROLE_ASSIGNMENT_PERMISSIONS = [ + { + id: "4de87873-e00d-4330-9b4f-f4fb065f49e0", + key: "edr_freight_app:role_assignment:view", + name: { am: "View role assignment", en: "View role assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "36f022b4-4b94-4220-a46c-df7bd1a1b184", + key: "edr_freight_app:role_assignment:assign", + name: { am: "Assign role", en: "Assign role" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "c1f34177-a0ae-4a46-a24a-3281b9137bab", + key: "edr_freight_app:role_assignment:replace", + name: { am: "Replace role assignment", en: "Replace role assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_UNIT_PERMISSIONS = [ + { + id: "2bfa2428-ec40-4588-9b01-dfacce6a2b82", + key: "edr_freight_app:hierarchy_units:view", + name: { am: "View hierarchy units", en: "View hierarchy units" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "1e92daff-9cc7-4a67-9994-879f34bfda16", + key: "edr_freight_app:hierarchy_units:create", + name: { am: "Create hierarchy unit", en: "Create hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "4ef2d8ad-c627-4448-b4b6-dd6b8b602dc1", + key: "edr_freight_app:hierarchy_units:update", + name: { am: "Update hierarchy unit", en: "Update hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "15353ac5-246b-42e6-9ac3-eb61c4f1cd22", + key: "edr_freight_app:hierarchy_units:delete", + name: { am: "Delete hierarchy unit", en: "Delete hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_POSITION_PERMISSIONS = [ + { + id: "37ff6f5b-9fb0-4139-af99-22fe54703029", + key: "edr_freight_app:hierarchy_positions:view", + name: { am: "View hierarchy positions", en: "View hierarchy positions" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "af6c091a-6448-4459-a635-c2181efd1de0", + key: "edr_freight_app:hierarchy_positions:create", + name: { am: "Create hierarchy position", en: "Create hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "e78f624d-b570-4cd6-8f16-12090a4a9d31", + key: "edr_freight_app:hierarchy_positions:update", + name: { am: "Update hierarchy position", en: "Update hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "7fba7887-a365-4281-96ea-fb14582b047e", + key: "edr_freight_app:hierarchy_positions:delete", + name: { am: "Delete hierarchy position", en: "Delete hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "a33905ff-f2b8-40b9-a8cf-e2968f6f46fb", + key: "edr_freight_app:hierarchy_positions:change_parent", + name: { am: "Change hierarchy position parent", en: "Change hierarchy position parent" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS = [ + { + id: "b6ca90ff-3e95-4af2-bac8-fb298ca62080", + key: "edr_freight_app:hierarchy_employee_assignment:view", + name: { am: "View hierarchy employee assignment", en: "View hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "0637472f-d6b7-4332-85bb-eaa6a02205c1", + key: "edr_freight_app:hierarchy_employee_assignment:invite", + name: { am: "Invite hierarchy employee assignment", en: "Invite hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "de366c81-b6d1-4cf9-a5f1-a5c8a6fb5e7b", + key: "edr_freight_app:hierarchy_employee_assignment:assign", + name: { am: "Assign hierarchy employee assignment", en: "Assign hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const POSITION_TYPE_PERMISSIONS = [ + { + id: "f258fb51-2890-4c93-b024-271b09d705d0", + key: "edr_freight_app:position_types:view", + name: { am: "View position types", en: "View position types" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +export const EDR_FREIGHT_PERMISSIONS = [ + ...EMPLOYEE_REGISTRATION_PERMISSIONS, + ...ROLE_ASSIGNMENT_PERMISSIONS, + ...HIERARCHY_UNIT_PERMISSIONS, + ...HIERARCHY_POSITION_PERMISSIONS, + ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS, + ...POSITION_TYPE_PERMISSIONS, +]; + +export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ + { + key: "edr_employee", + name: { en: "EDR Employee" }, + permissionKeys: [ + "edr_freight_app:employee_registration:view", + "edr_freight_app:role_assignment:view", + "edr_freight_app:hierarchy_units:view", + "edr_freight_app:hierarchy_positions:view", + "edr_freight_app:hierarchy_employee_assignment:view", + "edr_freight_app:position_types:view", + ], + }, + { + key: "edr_org_manager", + name: { en: "EDR Org Manager" }, + permissionKeys: [ + ...EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key), + IAM_PERMISSION_KEYS.createEmployee, + IAM_PERMISSION_KEYS.deactivateEmployee, + IAM_PERMISSION_KEYS.activateEmployee, + IAM_PERMISSION_KEYS.activateUser, + IAM_PERMISSION_KEYS.createUserRole, + IAM_PERMISSION_KEYS.deleteUserRole, + IAM_PERMISSION_KEYS.viewUserRole, + IAM_PERMISSION_KEYS.manageOrganizationAdmin, + IAM_PERMISSION_KEYS.manageUnitAdmin, + IAM_PERMISSION_KEYS.createUnit, + IAM_PERMISSION_KEYS.updateUnit, + IAM_PERMISSION_KEYS.deleteUnit, + IAM_PERMISSION_KEYS.createPositionPermission, + IAM_PERMISSION_KEYS.deletePositionPermission, + IAM_PERMISSION_KEYS.viewPositionPermission, + ], + }, + { + key: "edr_customer", + name: { en: "EDR Customer" }, + permissionKeys: [], + }, +]; diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index d03b9ef7f..49620b593 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -8,45 +8,17 @@ import { } from "@tria-plc/iamapi-common"; import { DataSource, EntityManager, In } from "typeorm"; +import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; + const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; const SEED_FLAG = "SEED_EDR_ORG"; -type SeedPermission = { - key: string; - name: { en: string }; -}; - -type SeedRole = { - key: string; - name: { en: string }; - permissions: SeedPermission[]; -}; - type SeedOrganization = { id: string; key: string; }; -const SEED_ROLES: SeedRole[] = [ - { - key: "edr_employee", - name: { en: "EDR Employee" }, - permissions: [ - // { key: "permission:key", name: { en: "Permission Name" } }, - { key: "permission:key", name: { en: "Permission Name" } }, - ], - }, - { - key: "edr_customer", - name: { en: "EDR Customer" }, - permissions: [ - // { key: "permission:key", name: { en: "Permission Name" } }, - { key: "permission:key", name: { en: "Permission Name" } }, - ], - }, -]; - @Injectable() export class EdrOrgSeeder { private readonly logger = new Logger(EdrOrgSeeder.name); @@ -63,9 +35,8 @@ export class EdrOrgSeeder { const organization = await this.ensureOrganization(manager); await this.ensureOrganizationConfiguration(manager, organization.id); - await this.ensurePermissions(manager, SEED_ROLES); - await this.ensureRoles(manager, SEED_ROLES); - await this.ensureRolePermissions(manager, SEED_ROLES); + await this.ensureRoles(manager, EDR_FREIGHT_ROLES); + await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -127,34 +98,7 @@ export class EdrOrgSeeder { ); } - private collectPermissions(seedRoles: SeedRole[]) { - const permissionByKey = new Map(); - - for (const role of seedRoles) { - for (const permission of role.permissions) { - permissionByKey.set(permission.key, permission); - } - } - - return [...permissionByKey.values()]; - } - - private async ensurePermissions(manager: EntityManager, seedRoles: SeedRole[]) { - const permissions = this.collectPermissions(seedRoles); - - if (!permissions.length) { - this.logger.log("No EDR role permissions configured; skipping permission seed"); - return; - } - - await manager.getRepository(Permission).upsert(permissions, { - conflictPaths: { key: true }, - }); - - this.logger.log(`Ensured ${permissions.length} EDR permissions`); - } - - private async ensureRoles(manager: EntityManager, seedRoles: SeedRole[]) { + private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) { await manager.getRepository(Role).upsert( seedRoles.map(({ key, name }) => ({ key, name })), { @@ -169,24 +113,24 @@ export class EdrOrgSeeder { private async ensureRolePermissions( manager: EntityManager, - seedRoles: SeedRole[], + seedRoles: FreightSeedRole[], ) { - const permissions = this.collectPermissions(seedRoles); + const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))]; - if (!permissions.length) { + if (!permissionKeys.length) { + this.logger.log("No EDR role permissions configured; skipping role-permission links"); return; } const roleRepository = manager.getRepository(Role); - const permissionRepository = manager.getRepository(Permission); const rolePermissionRepository = manager.getRepository(RolePermission); const roles = await roleRepository.find({ where: { key: In(seedRoles.map((role) => role.key)) }, select: { id: true, key: true }, }); - const seededPermissions = await permissionRepository.find({ - where: { key: In(permissions.map((permission) => permission.key)) }, + const seededPermissions = await manager.getRepository(Permission).find({ + where: { key: In(permissionKeys) }, select: { id: true, key: true }, }); @@ -202,11 +146,11 @@ export class EdrOrgSeeder { throw new Error(`missing_role:${role.key}`); } - return role.permissions.map((permission) => { - const seededPermission = permissionByKey.get(permission.key); + return role.permissionKeys.map((permissionKey) => { + const seededPermission = permissionByKey.get(permissionKey); if (!seededPermission) { - throw new Error(`missing_permission:${permission.key}`); + throw new Error(`missing_permission:${permissionKey}`); } return { diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx index 63402aa1b..18d7a1511 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx @@ -60,6 +60,7 @@ interface UserFormState { email: string; username: string; phoneNumber: string; + assignOrganizationAdmin: boolean; } interface ListResponse { @@ -75,6 +76,7 @@ const emptyUserForm: UserFormState = { email: "", username: "", phoneNumber: "", + assignOrganizationAdmin: false, }; const inputClassName = @@ -346,14 +348,23 @@ const UsersPage = () => { am: createUserForm.nameAm.trim(), en: createUserForm.nameEn.trim(), }, + assignOrganizationAdmin: createUserForm.assignOrganizationAdmin, }, ); + const shouldAssignOrganizationAdmin = createUserForm.assignOrganizationAdmin; setCreateUserForm(emptyUserForm); setIsCreateUserOpen(false); - setActionSuccess("User created. Default password: 12345678."); + setActionSuccess( + shouldAssignOrganizationAdmin + ? "User created as organization admin. Default password: 12345678." + : "User created. Default password: 12345678.", + ); await loadOrgEmployees(selectedOrgId); - await openManageRolesDialog(response.data); + + if (!shouldAssignOrganizationAdmin) { + await openManageRolesDialog(response.data); + } } catch (error) { setActionError(getErrorMessage(error, "Failed to create user.")); } finally { @@ -669,6 +680,24 @@ const UsersPage = () => { onChange={(event) => setCreateUserForm((current) => ({ ...current, phoneNumber: event.target.value }))} /> +
+
@@ -497,7 +838,7 @@ const PositionTypesPage = () => { {selectedPositionType - ? `Review the permission set assigned to ${getLocaleLabel(selectedPositionType.name, selectedPositionType.key)}.` + ? `Review and update the permission set assigned to ${getLocaleLabel(selectedPositionType.name, selectedPositionType.key)}.` : undefined} @@ -540,10 +881,51 @@ const PositionTypesPage = () => {
+
+ + + + {selectedPositionType.isSystem ? ( +

+ System position types keep their name and key, but you can still manage permissions here. +

+ ) : null} +
+

Permissions

- {positionTypePermissions.length} permissions + {selectedPermissionIds.length} permissions selected
@@ -555,27 +937,86 @@ const PositionTypesPage = () => {
{permissionsError}
- ) : positionTypePermissions.length ? ( -
- {positionTypePermissions.map((permission) => ( -
-
-

- {getLocaleLabel(permission.name, permission.key)} -

-

- {permission.key} -

-
-
- ))} -
) : ( -
- No permissions are assigned to this position type. +
+ setPermissionSearch(event.target.value)} + placeholder="Search permissions by name or key" + /> + + + + {loadingPermissionsCatalog ? ( +
+ Loading permissions catalog... +
+ ) : filteredPermissions.length ? ( +
+ {filteredPermissions.map((permission) => ( + + ))} +
+ ) : ( +
+ No permissions match the current search. +
+ )} + +
+ + +
)}
@@ -583,6 +1024,175 @@ const PositionTypesPage = () => { ) : null} + + + + + Create position type + + Add a new position type for the selected unit and optionally copy permissions from an existing one. + + + +
void handleCreatePositionType(event)}> +
+ + + + + + + +
+ +
+
+

Permissions

+
+ {createPermissionIds.length} selected +
+
+ + setCreatePermissionSearch(event.target.value)} + placeholder="Search permissions by name or key" + /> + + + + {loadingPermissionsCatalog ? ( +
+ Loading permissions catalog... +
+ ) : filteredCreatePermissions.length ? ( +
+ {filteredCreatePermissions.map((permission) => ( + + ))} +
+ ) : ( +
+ No permissions match the current search. +
+ )} +
+ + {createForm.copyPermissionFromId ? ( +
+
+ + The new position type will inherit permissions from the selected source. +
+
+ ) : null} + + {createError ? ( +
+ {createError} +
+ ) : null} + +
+ + +
+
+
+
); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx index 88fa34647..725512c8a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx @@ -188,6 +188,46 @@ const getItems = (payload: ListResponse | T[] | undefined | null) => { return payload.items ?? payload.data ?? []; }; +const mergeEmployeesByUser = (employees: EmployeeRecord[]) => { + const employeesByUserId = new Map(); + + for (const employee of employees) { + const userId = employee.user?.id; + + if (!userId) { + employeesByUserId.set(employee.id, employee); + continue; + } + + const existing = employeesByUserId.get(userId); + + if (!existing) { + employeesByUserId.set(userId, employee); + continue; + } + + const existingPositions = existing.employeePositions ?? []; + const nextPositions = employee.employeePositions ?? []; + const mergedPositions = Array.from( + new Map( + [...existingPositions, ...nextPositions].map((position) => [position.id, position]), + ).values(), + ); + + employeesByUserId.set(userId, { + ...existing, + ...employee, + id: existing.id, + name: existing.name ?? employee.name, + status: existing.status ?? employee.status, + user: existing.user ?? employee.user, + employeePositions: mergedPositions, + }); + } + + return [...employeesByUserId.values()]; +}; + const toInternalKey = (value: string) => value .trim() @@ -611,9 +651,9 @@ const UserManagementPage = () => { try { const response = await api.get>( - `/employees/${organizationId}/by-organization`, + `/backoffice/organizations/${organizationId}/employees`, ); - setOrgEmployees(getItems(response.data)); + setOrgEmployees(mergeEmployeesByUser(getItems(response.data))); } catch { setOrgEmployees([]); } finally { @@ -772,7 +812,7 @@ const UserManagementPage = () => { return; } - setSelectedOrgId(null); + setSelectedOrgId(visibleOrganizations[0]?.id ?? null); setSelectedOrgConfiguration(null); setUnits([]); setPositions([]); @@ -780,6 +820,14 @@ const UserManagementPage = () => { setOrgEmployees([]); }, [selectedOrgId, visibleOrganizations]); + useEffect(() => { + if (!selectedOrgId) { + return; + } + + void refreshSelectedOrg(); + }, [refreshSelectedOrg, selectedOrgId]); + useEffect(() => { if (!selectedOrgId) { setUnits([]); diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx index 18d7a1511..70d58d313 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx @@ -104,6 +104,46 @@ const getItems = (payload: ListResponse | T[] | undefined | null) => { return payload.items ?? payload.data ?? []; }; +const mergeEmployeesByUser = (employees: EmployeeRecord[]) => { + const employeesByUserId = new Map(); + + for (const employee of employees) { + const userId = employee.user?.id; + + if (!userId) { + employeesByUserId.set(employee.id, employee); + continue; + } + + const existing = employeesByUserId.get(userId); + + if (!existing) { + employeesByUserId.set(userId, employee); + continue; + } + + const existingPositions = existing.employeePositions ?? []; + const nextPositions = employee.employeePositions ?? []; + const mergedPositions = Array.from( + new Map( + [...existingPositions, ...nextPositions].map((position) => [position.id, position]), + ).values(), + ); + + employeesByUserId.set(userId, { + ...existing, + ...employee, + id: existing.id, + name: existing.name ?? employee.name, + status: existing.status ?? employee.status, + user: existing.user ?? employee.user, + employeePositions: mergedPositions, + }); + } + + return [...employeesByUserId.values()]; +}; + const getErrorMessage = (error: unknown, fallback: string) => { if (isAxiosError(error)) { const message = error.response?.data?.message; @@ -231,9 +271,9 @@ const UsersPage = () => { try { const response = await api.get>( - `/employees/${organizationId}/by-organization`, + `/backoffice/organizations/${organizationId}/employees`, ); - setOrgEmployees(getItems(response.data)); + setOrgEmployees(mergeEmployeesByUser(getItems(response.data))); } catch { setOrgEmployees([]); } finally { From 169e49faae1a461fd06582c92c706c9508ca7b40 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 2 Jun 2026 14:04:23 +0300 Subject: [PATCH 5/8] fixes --- apps/edr-freight-web/portal/src/App.tsx | 5 ++--- .../pages/bookings/new-booking-form/step5-cargo-details.tsx | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index d16a70175..1187422f8 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -32,8 +32,6 @@ import NewBookingPage from "./pages/bookings/NewBookingPage"; import TrackingPage from "./pages/tracking/TrackingPage"; import BillingPage from "./pages/billing/BillingPage"; import { useEffect } from "react"; -import CustomerOnBoarding from "./pages/customers/on_boarding/TransportrOnBoarding"; -import CustomerOnboardingPage from "./pages/customers/on_boarding/CustomerOnboardingPage"; const sidebarItems: SidebarItem[] = [ { label: "Home", href: "/portal", icon: }, @@ -46,12 +44,13 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, isPending, logout, customer } = useAuth(); + const { user, isPending, logout, customer, customerQuery } = useAuth(); useEffect(() => { if (isPending) return; const isInProtectedRoutes = sidebarItems.find((item) => location.pathname.startsWith(item.href), ); + console.log({ isInProtectedRoutes, location }); if (!user) { if (isInProtectedRoutes) return navigate("/login"); return; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index 758cdede0..ee54196e8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -130,7 +130,7 @@ export function Step5CargoDetails({
-

Container

+

Containerized

Pre-packed containerized cargo (20ft / 40ft).

@@ -145,7 +145,7 @@ export function Step5CargoDetails({
-

Bulk

+

General Cargo

Bulk commodities or break-bulk cargo.

From 34bf4ade48c8d39bc739c6c95578338fac0c444d Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 2 Jun 2026 14:43:18 +0300 Subject: [PATCH 6/8] refactor(api): Introduce unified API layer for freight backoffice --- .../backoffice/src/hooks/hooks/useBookings.ts | 16 -- .../src/hooks/hooks/useConsignments.ts | 16 -- .../src/hooks/hooks/useCustomers.ts | 50 ---- .../backoffice/src/hooks/hooks/useTracking.ts | 10 - .../src/hooks/rule-engine/useRuleEngine.ts | 70 +++--- .../src/hooks/useDropdownSettings.ts | 72 +++--- .../src/hooks/useFileUploadSettings.ts | 86 +++---- .../documents/FileUploadSettingsPage.tsx | 8 +- .../DropdownSettingsPage.tsx | 11 +- .../backoffice/src/services/api.ts | 223 ++++++++++++++++++ .../services/fileUploadSettings.service.ts | 15 +- .../backoffice/src/utils/result.ts | 29 +++ 12 files changed, 376 insertions(+), 230 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/hooks/useBookings.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/hooks/useConsignments.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/hooks/useCustomers.ts delete mode 100644 apps/edr-freight-web/backoffice/src/hooks/hooks/useTracking.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/api.ts create mode 100644 apps/edr-freight-web/backoffice/src/utils/result.ts diff --git a/apps/edr-freight-web/backoffice/src/hooks/hooks/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/hooks/useBookings.ts deleted file mode 100644 index 7f353d50a..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/hooks/useBookings.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { bookingsService } from "../services/bookings.service"; - -export const useBookings = () => - useQuery({ - queryKey: ["bookings"], - queryFn: bookingsService.list, - }); - -export const useBooking = (id: string) => - useQuery({ - queryKey: ["bookings", id], - queryFn: () => bookingsService.get(id), - enabled: Boolean(id), - }); diff --git a/apps/edr-freight-web/backoffice/src/hooks/hooks/useConsignments.ts b/apps/edr-freight-web/backoffice/src/hooks/hooks/useConsignments.ts deleted file mode 100644 index 593c6be5d..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/hooks/useConsignments.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { consignmentsService } from "../services/consignments.service"; - -export const useConsignments = () => - useQuery({ - queryKey: ["consignments"], - queryFn: consignmentsService.list, - }); - -export const useConsignment = (id: string) => - useQuery({ - queryKey: ["consignments", id], - queryFn: () => consignmentsService.get(id), - enabled: Boolean(id), - }); diff --git a/apps/edr-freight-web/backoffice/src/hooks/hooks/useCustomers.ts b/apps/edr-freight-web/backoffice/src/hooks/hooks/useCustomers.ts deleted file mode 100644 index ec26af310..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/hooks/useCustomers.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - -import { customersService } from "@/services/customers.service"; -import type { - CreateCustomerDto, - UpdateCustomerDto, -} from "@/types/customers"; - -const KEY = ["customers"] as const; - -export const useCustomers = () => - useQuery({ - queryKey: KEY, - queryFn: customersService.list, - }); - -export const useCustomer = (id: string | undefined) => - useQuery({ - queryKey: [...KEY, "id", id], - queryFn: () => customersService.getById(id!), - enabled: Boolean(id), - }); - -export const useCreateCustomer = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (dto: CreateCustomerDto) => customersService.create(dto), - onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), - }); -}; - -export const useUpdateCustomer = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, dto }: { id: string; dto: UpdateCustomerDto }) => - customersService.update(id, dto), - onSuccess: (_data, { id }) => { - qc.invalidateQueries({ queryKey: KEY }); - qc.invalidateQueries({ queryKey: [...KEY, "id", id] }); - }, - }); -}; - -export const useDeleteCustomer = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (id: string) => customersService.remove(id), - onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), - }); -}; diff --git a/apps/edr-freight-web/backoffice/src/hooks/hooks/useTracking.ts b/apps/edr-freight-web/backoffice/src/hooks/hooks/useTracking.ts deleted file mode 100644 index 403dac071..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/hooks/useTracking.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { trackingService } from "../services/tracking.service"; - -export const useTracking = (consignmentId: string) => - useQuery({ - queryKey: ["tracking", consignmentId], - queryFn: () => trackingService.forConsignment(consignmentId), - enabled: Boolean(consignmentId), - }); diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index 6db9b446f..d56da7465 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -1,11 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import toast from "react-hot-toast"; -import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY"; -import { - ruleEngineService, - type RuleEngineListParams, -} from "@/services/ruleEngine/ruleEngine.service"; +import { api } from "@/services/api"; +import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service"; import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources"; import type { ApproveRatePayload, @@ -15,26 +12,29 @@ import type { const CARGO_TYPE_PARENT_PAGE_SIZE = 500; +const listKey = (resource: RuleEngineResourceSlug) => + ["rule-engine", resource] as const; + export const useRuleEngineList = ( resource: RuleEngineResourceSlug, params: RuleEngineListParams, ) => - useQuery({ - queryKey: [...QUERY_KEYS.RULE_ENGINE.list(resource), params], - queryFn: () => ruleEngineService.list(resource, params), - }); + useQuery( + api.ruleEngine.list.queryOptions({ + input: { resource, params }, + }), + ); export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) => useQuery({ - queryKey: [ - ...QUERY_KEYS.RULE_ENGINE.list("cargo-types"), - "parent-options", - excludeId ?? "", - ], + queryKey: api.ruleEngine.list.queryKey({ + resource: "cargo-types", + params: { page: 1, pageSize: CARGO_TYPE_PARENT_PAGE_SIZE }, + }), queryFn: () => - ruleEngineService.list("cargo-types", { - page: 1, - pageSize: CARGO_TYPE_PARENT_PAGE_SIZE, + api.ruleEngine.list.call({ + resource: "cargo-types", + params: { page: 1, pageSize: CARGO_TYPE_PARENT_PAGE_SIZE }, }), enabled, select: (result) => { @@ -45,7 +45,9 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) => const name = String(row.cargoTypeName ?? "").trim(); const code = String(row.code ?? "").trim(); const label = - name && code ? `${name} (${code})` : name || code || String(row.id); + name && code + ? `${name} (${code})` + : name || code || String(row.id); return { label, value: String(row.id) }; }); return [noneOption, ...parents]; @@ -53,20 +55,20 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) => }); export const useApprovalChain = (enabled: boolean) => - useQuery({ - queryKey: QUERY_KEYS.RULE_ENGINE.chain, - queryFn: () => ruleEngineService.getApprovalChain(), - enabled, - }); + useQuery( + api.ruleEngine.getApprovalChain.queryOptions({ + enabled, + }), + ); export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { const qc = useQueryClient(); const invalidate = () => - qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.list(resource) }); + qc.invalidateQueries({ queryKey: listKey(resource) }); const create = useMutation({ mutationFn: (payload: Record) => - ruleEngineService.create(resource, payload), + api.ruleEngine.create.call({ resource, payload }), onSuccess: () => { toast.success("Created successfully"); invalidate(); @@ -81,7 +83,7 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { }: { id: string; payload: Record; - }) => ruleEngineService.update(resource, id, payload), + }) => api.ruleEngine.update.call({ resource, id, payload }), onSuccess: () => { toast.success("Updated successfully"); invalidate(); @@ -90,7 +92,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { }); const remove = useMutation({ - mutationFn: (id: string) => ruleEngineService.remove(resource, id), + mutationFn: (id: string) => + api.ruleEngine.remove.call({ resource, id }), onSuccess: () => { toast.success("Deleted successfully"); invalidate(); @@ -104,10 +107,10 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { export const useRateWorkflow = () => { const qc = useQueryClient(); const invalidate = () => - qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.list("rates") }); + qc.invalidateQueries({ queryKey: listKey("rates") }); const submit = useMutation({ - mutationFn: (id: string) => ruleEngineService.submitRate(id), + mutationFn: (id: string) => api.ruleEngine.submitRate.call({ id }), onSuccess: () => { toast.success("Rate submitted for approval"); invalidate(); @@ -116,8 +119,13 @@ export const useRateWorkflow = () => { }); const approve = useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: ApproveRatePayload }) => - ruleEngineService.approveRate(id, payload), + mutationFn: ({ + id, + payload, + }: { + id: string; + payload: ApproveRatePayload; + }) => api.ruleEngine.approveRate.call({ id, payload }), onSuccess: () => { toast.success("Rate approved"); invalidate(); diff --git a/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts index c2b8a4423..256fc1702 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts @@ -1,6 +1,6 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { dropdownSettingsService } from "@/services/dropdownSettings.service"; +import { api } from "@/services/api"; import type { CreateDropdownOptionDto, CreateDropdownSettingDto, @@ -8,38 +8,15 @@ import type { UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; -const KEY = ["dropdown-settings"] as const; - -/* ------------------------------ Queries ------------------------------ */ - -export const useDropdownSettings = () => - useQuery({ - queryKey: KEY, - queryFn: dropdownSettingsService.list, - }); - -export const useDropdownSetting = (id: string) => - useQuery({ - queryKey: [...KEY, "id", id], - queryFn: () => dropdownSettingsService.getById(id), - enabled: Boolean(id), - }); - -export const useDropdownSettingByCode = (code: string) => - useQuery({ - queryKey: [...KEY, "code", code], - queryFn: () => dropdownSettingsService.getByCode(code), - enabled: Boolean(code), - }); - /* ----------------------------- Mutations ----------------------------- */ export const useCreateDropdownSetting = () => { const qc = useQueryClient(); return useMutation({ mutationFn: (dto: CreateDropdownSettingDto) => - dropdownSettingsService.create(dto), - onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + api.dropdownSettings.create.call(dto), + onSuccess: () => + qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), }); }; @@ -52,10 +29,12 @@ export const useUpdateDropdownSetting = () => { }: { id: string; dto: UpdateDropdownSettingDto; - }) => dropdownSettingsService.update(id, dto), + }) => api.dropdownSettings.update.call({ id, dto }), onSuccess: (_data, { id }) => { - qc.invalidateQueries({ queryKey: KEY }); - qc.invalidateQueries({ queryKey: [...KEY, "id", id] }); + qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }); + qc.invalidateQueries({ + queryKey: api.dropdownSettings.getById.queryKey({ id }), + }); }, }); }; @@ -63,8 +42,9 @@ export const useUpdateDropdownSetting = () => { export const useDeleteDropdownSetting = () => { const qc = useQueryClient(); return useMutation({ - mutationFn: (id: string) => dropdownSettingsService.remove(id), - onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + mutationFn: (id: string) => api.dropdownSettings.remove.call({ id }), + onSuccess: () => + qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), }); }; @@ -77,10 +57,12 @@ export const useReplaceDropdownOptions = () => { }: { settingId: string; options: CreateDropdownOptionDto[]; - }) => dropdownSettingsService.replaceOptions(settingId, options), + }) => api.dropdownSettings.replaceOptions.call({ id: settingId, options }), onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ queryKey: KEY }); - qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] }); + qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }); + qc.invalidateQueries({ + queryKey: api.dropdownSettings.getById.queryKey({ id: settingId }), + }); }, }); }; @@ -94,10 +76,12 @@ export const useAddDropdownOption = () => { }: { settingId: string; dto: CreateDropdownOptionDto; - }) => dropdownSettingsService.addOption(settingId, dto), + }) => api.dropdownSettings.addOption.call({ id: settingId, dto }), onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ queryKey: KEY }); - qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] }); + qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }); + qc.invalidateQueries({ + queryKey: api.dropdownSettings.getById.queryKey({ id: settingId }), + }); }, }); }; @@ -111,8 +95,9 @@ export const useUpdateDropdownOption = () => { }: { optionId: string; dto: UpdateDropdownOptionDto; - }) => dropdownSettingsService.updateOption(optionId, dto), - onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + }) => api.dropdownSettings.updateOption.call({ optionId, dto }), + onSuccess: () => + qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), }); }; @@ -120,7 +105,8 @@ export const useRemoveDropdownOption = () => { const qc = useQueryClient(); return useMutation({ mutationFn: (optionId: string) => - dropdownSettingsService.removeOption(optionId), - onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + api.dropdownSettings.removeOption.call({ optionId }), + onSuccess: () => + qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), }); }; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts index f916915f9..748de4f88 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts @@ -1,6 +1,6 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { fileUploadSettingsService } from "@/services/fileUploadSettings.service"; +import { api } from "@/services/api"; import type { CreateFileUploadFieldDto, CreateFileUploadSettingDto, @@ -8,38 +8,17 @@ import type { UpdateFileUploadSettingDto, } from "@/types/fileUploadSettings"; -const KEY = ["file-upload-settings"] as const; - -/* ------------------------------ Queries ------------------------------ */ - -export const useFileUploadSettings = () => - useQuery({ - queryKey: KEY, - queryFn: fileUploadSettingsService.list, - }); - -export const useFileUploadSetting = (id: string) => - useQuery({ - queryKey: [...KEY, "id", id], - queryFn: () => fileUploadSettingsService.getById(id), - enabled: Boolean(id), - }); - -export const useFileUploadSettingByCode = (code: string) => - useQuery({ - queryKey: [...KEY, "code", code], - queryFn: () => fileUploadSettingsService.getByCode(code), - enabled: Boolean(code), - }); - /* ----------------------------- Mutations ----------------------------- */ export const useCreateFileUploadSetting = () => { const qc = useQueryClient(); return useMutation({ mutationFn: (dto: CreateFileUploadSettingDto) => - fileUploadSettingsService.create(dto), - onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + api.fileUploadSettings.create.call(dto), + onSuccess: () => + qc.invalidateQueries({ + queryKey: api.fileUploadSettings.list.queryKey(), + }), }); }; @@ -52,10 +31,14 @@ export const useUpdateFileUploadSetting = () => { }: { id: string; dto: UpdateFileUploadSettingDto; - }) => fileUploadSettingsService.update(id, dto), + }) => api.fileUploadSettings.update.call({ id, dto }), onSuccess: (_data, { id }) => { - qc.invalidateQueries({ queryKey: KEY }); - qc.invalidateQueries({ queryKey: [...KEY, "id", id] }); + qc.invalidateQueries({ + queryKey: api.fileUploadSettings.list.queryKey(), + }); + qc.invalidateQueries({ + queryKey: api.fileUploadSettings.getById.queryKey({ id }), + }); }, }); }; @@ -63,8 +46,11 @@ export const useUpdateFileUploadSetting = () => { export const useDeleteFileUploadSetting = () => { const qc = useQueryClient(); return useMutation({ - mutationFn: (id: string) => fileUploadSettingsService.remove(id), - onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + mutationFn: (id: string) => api.fileUploadSettings.remove.call({ id }), + onSuccess: () => + qc.invalidateQueries({ + queryKey: api.fileUploadSettings.list.queryKey(), + }), }); }; @@ -77,10 +63,14 @@ export const useReplaceFileUploadFields = () => { }: { settingId: string; fields: CreateFileUploadFieldDto[]; - }) => fileUploadSettingsService.replaceFields(settingId, fields), + }) => api.fileUploadSettings.replaceFields.call({ id: settingId, fields }), onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ queryKey: KEY }); - qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] }); + qc.invalidateQueries({ + queryKey: api.fileUploadSettings.list.queryKey(), + }); + qc.invalidateQueries({ + queryKey: api.fileUploadSettings.getById.queryKey({ id: settingId }), + }); }, }); }; @@ -94,10 +84,14 @@ export const useAddFileUploadField = () => { }: { settingId: string; dto: CreateFileUploadFieldDto; - }) => fileUploadSettingsService.addField(settingId, dto), + }) => api.fileUploadSettings.addField.call({ settingId, dto }), onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ queryKey: KEY }); - qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] }); + qc.invalidateQueries({ + queryKey: api.fileUploadSettings.list.queryKey(), + }); + qc.invalidateQueries({ + queryKey: api.fileUploadSettings.getById.queryKey({ id: settingId }), + }); }, }); }; @@ -111,8 +105,11 @@ export const useUpdateFileUploadField = () => { }: { fieldId: string; dto: UpdateFileUploadFieldDto; - }) => fileUploadSettingsService.updateField(fieldId, dto), - onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + }) => api.fileUploadSettings.updateField.call({ fieldId, dto }), + onSuccess: () => + qc.invalidateQueries({ + queryKey: api.fileUploadSettings.list.queryKey(), + }), }); }; @@ -120,7 +117,10 @@ export const useRemoveFileUploadField = () => { const qc = useQueryClient(); return useMutation({ mutationFn: (fieldId: string) => - fileUploadSettingsService.removeField(fieldId), - onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + api.fileUploadSettings.removeField.call({ fieldId }), + onSuccess: () => + qc.invalidateQueries({ + queryKey: api.fileUploadSettings.list.queryKey(), + }), }); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx index ce2163bad..9cb0e94e3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx @@ -20,12 +20,16 @@ import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog"; import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { getMinFiles } from "@/types/fileUploadSettings"; -import { useDeleteFileUploadSetting, useFileUploadSettings } from "@/hooks/useFileUploadSettings"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; +import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings"; export default function FileUploadSettingsPage() { const [query, setQuery] = useState(""); - const { data, isLoading, isError, error } = useFileUploadSettings(); + const { data, isLoading, isError, error } = useQuery( + api.fileUploadSettings.list.queryOptions(), + ); const deleteMutation = useDeleteFileUploadSetting(); const fileUploadSettings = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx index 8a336cdaf..7edaeb7b1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx @@ -21,10 +21,9 @@ import Breadcrumbs from "@/components/ui/Breadcrumbs"; import EditDropdownSettingDialog from "./EditDropdownSettingDialog"; import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog"; import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog"; -import { - useDeleteDropdownSetting, - useDropdownSettings, -} from "@/hooks/useDropdownSettings"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; +import { useDeleteDropdownSetting } from "@/hooks/useDropdownSettings"; import type { DropdownSetting } from "@/types/dropdownSettings"; import { DataTable, @@ -86,7 +85,9 @@ export default function DropdownSettingsPage() { return () => cancelAnimationFrame(id); }, [activeDialog]); - const { data, isLoading, isError, error } = useDropdownSettings(); + const { data, isLoading, isError, error } = useQuery( + api.dropdownSettings.list.queryOptions(), + ); const deleteMutation = useDeleteDropdownSetting(); const dropdownSettings = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts new file mode 100644 index 000000000..f45b3e29e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -0,0 +1,223 @@ +import { endpoint } from "@/utils/endpoint"; +import type { + CreateFileUploadFieldDto, + CreateFileUploadSettingDto, + FileUploadField, + FileUploadSetting, + UpdateFileUploadFieldDto, + UpdateFileUploadSettingDto, +} from "@/types/fileUploadSettings"; +import { + CreateDropdownOptionDto, + CreateDropdownSettingDto, + DropdownOption, + DropdownSetting, + UpdateDropdownOptionDto, + UpdateDropdownSettingDto, +} from "@/types/dropdownSettings"; +import { + ApproveRatePayload, + RuleEngineListResult, + RuleEngineRecord, + RuleEngineResourceSlug, +} from "@/types/rule-engine"; +import { fileUploadSettingsService } from "./fileUploadSettings.service"; +import { dropdownSettingsService } from "./dropdownSettings.service"; +import { + ruleEngineService, + RuleEngineListParams, +} from "./ruleEngine/ruleEngine.service"; + +export const api = { + fileUploadSettings: { + list: endpoint( + "file-upload-settings", + "list", + fileUploadSettingsService.list, + ), + + getById: endpoint<{ id: string }, FileUploadSetting>( + "file-upload-settings", + "getById", + ({ id }) => fileUploadSettingsService.getById(id), + ), + + getByCode: endpoint<{ code: string }, FileUploadSetting>( + "file-upload-settings", + "getByCode", + ({ code }) => fileUploadSettingsService.getByCode(code), + ), + + create: endpoint( + "file-upload-settings", + "create", + (payload) => fileUploadSettingsService.create(payload), + ), + + update: endpoint< + { id: string; dto: UpdateFileUploadSettingDto }, + FileUploadSetting + >("file-upload-settings", "update", ({ id, dto }) => + fileUploadSettingsService.update(id, dto), + ), + + remove: endpoint<{ id: string }, void>( + "file-upload-settings", + "remove", + ({ id }) => fileUploadSettingsService.remove(id), + ), + + replaceFields: endpoint< + { id: string; fields: CreateFileUploadFieldDto[] }, + FileUploadField[] + >("file-upload-settings", "replaceFields", ({ id, fields }) => + fileUploadSettingsService.replaceFields(id, fields), + ), + + addField: endpoint< + { settingId: string; dto: CreateFileUploadFieldDto }, + FileUploadField + >("file-upload-settings", "addField", ({ settingId, dto }) => + fileUploadSettingsService.addField(settingId, dto), + ), + + updateField: endpoint< + { fieldId: string; dto: UpdateFileUploadFieldDto }, + FileUploadField + >("file-upload-settings", "updateField", ({ fieldId, dto }) => + fileUploadSettingsService.updateField(fieldId, dto), + ), + + removeField: endpoint<{ fieldId: string }, void>( + "file-upload-settings", + "removeField", + ({ fieldId }) => fileUploadSettingsService.removeField(fieldId), + ), + }, + + dropdownSettings: { + list: endpoint( + "dropdown-settings", + "list", + dropdownSettingsService.list, + ), + + getById: endpoint<{ id: string }, DropdownSetting>( + "dropdown-settings", + "getById", + ({ id }) => dropdownSettingsService.getById(id), + ), + + getByCode: endpoint<{ code: string }, DropdownSetting>( + "dropdown-settings", + "getByCode", + ({ code }) => dropdownSettingsService.getByCode(code), + ), + + create: endpoint( + "dropdown-settings", + "create", + (payload) => dropdownSettingsService.create(payload), + ), + + update: endpoint< + { id: string; dto: UpdateDropdownSettingDto }, + DropdownSetting + >("dropdown-settings", "update", ({ id, dto }) => + dropdownSettingsService.update(id, dto), + ), + + remove: endpoint<{ id: string }, void>( + "dropdown-settings", + "remove", + ({ id }) => dropdownSettingsService.remove(id), + ), + + replaceOptions: endpoint< + { id: string; options: CreateDropdownOptionDto[] }, + DropdownOption[] + >("dropdown-settings", "replaceOptions", ({ id, options }) => + dropdownSettingsService.replaceOptions(id, options), + ), + + addOption: endpoint< + { id: string; dto: CreateDropdownOptionDto }, + DropdownOption + >("dropdown-settings", "addOption", ({ id, dto }) => + dropdownSettingsService.addOption(id, dto), + ), + + updateOption: endpoint< + { optionId: string; dto: UpdateDropdownOptionDto }, + DropdownOption + >("dropdown-settings", "updateOption", ({ optionId, dto }) => + dropdownSettingsService.updateOption(optionId, dto), + ), + + removeOption: endpoint<{ optionId: string }, void>( + "dropdown-settings", + "removeOption", + ({ optionId }) => dropdownSettingsService.removeOption(optionId), + ), + }, + + ruleEngine: { + list: endpoint< + { resource: RuleEngineResourceSlug; params?: RuleEngineListParams }, + RuleEngineListResult + >("rule-engine", "list", ({ resource, params }) => + ruleEngineService.list(resource, params), + ), + + getById: endpoint< + { resource: RuleEngineResourceSlug; id: string }, + RuleEngineRecord + >("rule-engine", "getById", ({ resource, id }) => + ruleEngineService.getById(resource, id), + ), + + create: endpoint< + { resource: RuleEngineResourceSlug; payload: Record }, + RuleEngineRecord + >("rule-engine", "create", ({ resource, payload }) => + ruleEngineService.create(resource, payload), + ), + + update: endpoint< + { + resource: RuleEngineResourceSlug; + id: string; + payload: Record; + }, + RuleEngineRecord + >("rule-engine", "update", ({ resource, id, payload }) => + ruleEngineService.update(resource, id, payload), + ), + + remove: endpoint< + { resource: RuleEngineResourceSlug; id: string }, + void + >("rule-engine", "remove", ({ resource, id }) => + ruleEngineService.remove(resource, id), + ), + + submitRate: endpoint<{ id: string }, RuleEngineRecord>( + "rule-engine", + "submitRate", + ({ id }) => ruleEngineService.submitRate(id), + ), + + approveRate: endpoint< + { id: string; payload: ApproveRatePayload }, + RuleEngineRecord + >("rule-engine", "approveRate", ({ id, payload }) => + ruleEngineService.approveRate(id, payload), + ), + + getApprovalChain: endpoint( + "rule-engine", + "getApprovalChain", + () => ruleEngineService.getApprovalChain(), + ), + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/services/fileUploadSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/fileUploadSettings.service.ts index 0313a0561..777100dc8 100644 --- a/apps/edr-freight-web/backoffice/src/services/fileUploadSettings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/fileUploadSettings.service.ts @@ -8,10 +8,8 @@ import type { UpdateFileUploadFieldDto, UpdateFileUploadSettingDto, } from "@/types/fileUploadSettings"; -import { URL_CONSTANTS } from "@/constants/URLS"; -import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY"; import { ApiResponse } from "@/types/apiResponse"; -import { endpoint, unwrap } from "@/utils/endpoint"; +import { unwrap } from "@/utils/endpoint"; const BASE = "/file-upload-settings"; @@ -124,14 +122,3 @@ export const fileUploadSettingsService = { await client.delete(`${BASE}/fields/${fieldId}`); }, }; - -export const getFileUploadSettingByCode = endpoint( - QUERY_KEYS.FILES.FILE_UPLOAD_SETTINGS, - QUERY_KEYS.FILES.BY_CODE, - (code: any) => - client - .get< - ApiResponse - >(`${URL_CONSTANTS.FILES.FILE_UPLOAD_SETTINGS_BY_CODE}/${code}`) - .then((res: any) => res.data.data), -); diff --git a/apps/edr-freight-web/backoffice/src/utils/result.ts b/apps/edr-freight-web/backoffice/src/utils/result.ts new file mode 100644 index 000000000..3e9627445 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/utils/result.ts @@ -0,0 +1,29 @@ +export type Result = + | { success: true; data: T } + | { success: false; error: E }; + +export type ApiError = { + code: string; + message: string; + statusCode?: number; +}; + +export function extractApiError(err: unknown): ApiError { + if (err && typeof err === "object") { + const obj = err as Record; + const response = obj.response as Record | undefined; + if (response) { + const statusCode = response.status as number | undefined; + const data = response.data as Record | undefined; + return { + code: (data?.error as string) || (data?.message as string) || "api_error", + message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred", + statusCode, + }; + } + if (obj.message && typeof obj.message === "string") { + return { code: "client_error", message: obj.message }; + } + } + return { code: "unknown_error", message: "An unexpected error occurred" }; +} From b5dbfc25c309735127f06e322f3790ea66606ffb Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Tue, 2 Jun 2026 15:59:58 +0300 Subject: [PATCH 7/8] WIP --- apps/edr-freight-web/backoffice/src/App.tsx | 8 ++-- .../user-management/UserManagementPage.tsx | 44 ++++++++++++++----- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8fc296b2f..bc3e0bcef 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -47,10 +47,10 @@ const baseSidebarItems: SidebarItem[] = [ label: "Position Type", href: "/dashboard/user-management/position-types", }, - { - label: "Employees", - href: "/dashboard/user-management/employees", - }, + // { + // label: "Employees", + // href: "/dashboard/user-management/employees", + // }, { label: "Permissions", href: "/dashboard/user-management/permissions", diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx index 725512c8a..8c3806165 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx @@ -785,13 +785,19 @@ const UserManagementPage = () => { const refreshSelectedUnit = useCallback(async () => { if (!selectedUnitId) { setPositions([]); + setPositionTypes([]); + setPositionTypesError(null); setPositionMembers([]); setUnitAdminUserIds(new Set()); return; } - await Promise.all([loadPositions(selectedUnitId), loadUnitAdmins(selectedUnitId)]); - }, [loadPositions, loadUnitAdmins, selectedUnitId]); + await Promise.all([ + loadPositions(selectedUnitId), + loadPositionTypes(selectedUnitId), + loadUnitAdmins(selectedUnitId), + ]); + }, [loadPositionTypes, loadPositions, loadUnitAdmins, selectedUnitId]); useEffect(() => { void loadOrganizations(); @@ -850,9 +856,23 @@ const UserManagementPage = () => { return; } - if (!units.some((unit) => unit.id === selectedUnitId)) { - setSelectedUnitId(null); + if (units.some((unit) => unit.id === selectedUnitId)) { + return; } + + setSelectedUnitId(units[0]?.id ?? null); + }, [selectedOrgId, selectedUnitId, units]); + + useEffect(() => { + if (!selectedOrgId || !units.length) { + return; + } + + if (selectedUnitId && units.some((unit) => unit.id === selectedUnitId)) { + return; + } + + setSelectedUnitId(units[0]?.id ?? null); }, [selectedOrgId, selectedUnitId, units]); useEffect(() => { @@ -868,6 +888,14 @@ const UserManagementPage = () => { } }, [selectedUnitId]); + useEffect(() => { + if (!selectedUnitId) { + return; + } + + void refreshSelectedUnit(); + }, [refreshSelectedUnit, selectedUnitId]); + useEffect(() => { if (!selectedUnitId || !selectedPositionId) { return; @@ -930,15 +958,11 @@ const UserManagementPage = () => { setSelectedPositionId(null); setExpandedDepartmentIds(new Set()); setPositions([]); + setPositionTypes([]); + setPositionTypesError(null); setPositionMembers([]); setUnitAdminUserIds(new Set()); resetMessages(); - - try { - await Promise.all([loadPositions(unitId), loadUnitAdmins(unitId)]); - } catch { - // Individual loaders already handle their own error state. - } }; const handleToggleDepartment = (departmentId: string) => { From dab72217e19980f82b29e5f9e84d35f6462589a2 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Tue, 2 Jun 2026 16:04:47 +0300 Subject: [PATCH 8/8] feat(bookings): Integrate booking list and detail pages with backend API --- .../backoffice/src/hooks/useBookings.ts | 48 +++++++++++++++++ .../bookings/BookingRequestDetailPage.tsx | 38 +++++++------- .../pages/bookings/BookingRequestsPage.tsx | 12 ++++- .../pages/bookings/booking-requests.mock.ts | 32 ++++++++++++ .../backoffice/src/services/api.ts | 28 ++++++++++ .../src/services/bookings.service.ts | 51 +++++++++++++++++++ 6 files changed, 189 insertions(+), 20 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useBookings.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/bookings.service.ts diff --git a/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts new file mode 100644 index 000000000..5ee2e5749 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts @@ -0,0 +1,48 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { api } from "@/services/api"; +import type { BookingListFilter } from "@/services/bookings.service"; + +export const useBookingList = (filter?: BookingListFilter) => { + const input = { filter }; + return { + queryKey: api.bookings.list.queryKey(input), + queryFn: () => api.bookings.list.call(input), + }; +}; + +export const useBooking = (id: string) => ({ + queryKey: api.bookings.getById.queryKey({ id }), + queryFn: () => api.bookings.getById.call({ id }), + enabled: Boolean(id), +}); + +export const useUpdateBookingStatus = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + action, + reason, + }: { + id: string; + action: string; + reason?: string; + }) => api.bookings.updateStatus.call({ id, action, reason }), + onSuccess: (_data, { id }) => { + qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + qc.invalidateQueries({ + queryKey: api.bookings.getById.queryKey({ id }), + }); + }, + }); +}; + +export const useDeleteBooking = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.bookings.remove.call({ id }), + onSuccess: () => + qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() }), + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 86b4f483e..3bb75b6bf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { AlertCircle, AlertTriangle, @@ -27,14 +27,9 @@ import { import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { cn } from "@/lib/utils"; -import { - getBookingRequestById, - getBookingRequests, - saveBookingRequestsToStorage, - updateBookingRequestStatus, - BOOKING_STATUSES, - type BookingRequest, -} from "./booking-requests.mock"; +import { api } from "@/services/api"; +import { useUpdateBookingStatus } from "@/hooks/useBookings"; +import { mapBookingToRequest, BOOKING_STATUSES } from "./booking-requests.mock"; import { Badge, Button, @@ -233,9 +228,16 @@ const STATUS_CONFIG: Record< export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const [booking, setBooking] = useState( - id ? getBookingRequestById(id) : undefined, + + const { data: bookingData } = useQuery( + api.bookings.getById.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), ); + const updateStatus = useUpdateBookingStatus(); + + const booking = bookingData ? mapBookingToRequest(bookingData) : undefined; if (!booking) { return ( @@ -270,20 +272,20 @@ export default function BookingRequestDetailPage() { booking.status, ); + const isPending = updateStatus.isPending; + function handleApprove() { if (!booking) return; - const nextStatus = + const action = booking.status === "RFQ_SUBMITTED" - ? ("QUOTATION_SENT" as const) - : ("APPROVED" as const); - updateBookingRequestStatus(booking.id, nextStatus); - setBooking(getBookingRequestById(booking.id)); + ? "SEND_QUOTATION" + : "APPROVE"; + updateStatus.mutate({ id: booking.id, action }); } function handleReject() { if (!booking) return; - updateBookingRequestStatus(booking.id, "CANCELLED"); - setBooking(getBookingRequestById(booking.id)); + updateStatus.mutate({ id: booking.id, action: "CANCEL", reason: "Cancelled by backoffice" }); } return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index d59658131..643257bbd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { AlertCircle, ArrowRight, @@ -18,10 +19,11 @@ import { import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { cn } from "@/lib/utils"; +import { api } from "@/services/api"; import { - getBookingRequests, BOOKING_STATUSES, type BookingRequest, + mapBookingToRequest, } from "./booking-requests.mock"; import { DataTable, @@ -153,7 +155,13 @@ export default function BookingRequestsPage() { const [query, setQuery] = useState(""); const [statusFilter, setStatusFilter] = useState(null); - const bookingRequests = useMemo(() => getBookingRequests(), []); + const { data: bookingData } = useQuery( + api.bookings.list.queryOptions({ input: { filter: { page: pagination.pageIndex + 1, pageSize: pagination.pageSize } } }), + ); + const bookingRequests = useMemo( + () => (bookingData?.items ?? []).map(mapBookingToRequest), + [bookingData], + ); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts index a5d13cbc0..9adb8eb33 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts @@ -1,3 +1,5 @@ +import type { Freight } from "@edr/types"; + export interface BookingRequest { id: string; reference: string; @@ -131,6 +133,36 @@ export function updateBookingRequestStatus(id: string, newStatus: (typeof BOOKIN saveBookingRequestsToStorage(requests); } +export function mapBookingToRequest(booking: Freight.IBooking): BookingRequest { + return { + id: booking.id, + reference: booking.reference, + customer: booking.customerId, + status: booking.status as BookingRequest["status"], + scheduledDate: booking.scheduledDate, + totalAmount: booking.totalAmount, + paymentStatus: booking.paymentStatus, + contractType: booking.contractType, + serviceType: + booking.serviceType === "RAIL_ONLY" ? "RAIL" : (booking.serviceType as string), + tradeDirection: booking.tradeDirection as string, + originYard: booking.originStation, + destinationYard: booking.destinationStation, + cargoType: booking.freightType ?? booking.freightSubtype ?? "", + cargoTotalWeightVgm: booking.cargoTotalWeightVgm, + isHazardous: booking.isHazardous, + paymentCurrency: booking.paymentCurrency, + priorityScore: booking.priorityScore, + firstMilePickupAddress: booking.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: booking.lastMileDeliveryAddress ?? null, + shippingLine: null, + pnrCode: null, + createdBy: booking.customerId, + createdAt: booking.createdAt, + updatedAt: booking.updatedAt, + }; +} + export function getBookingRequests(): BookingRequest[] { if (typeof window === "undefined" || !window.localStorage) { return INITIAL_REQUESTS; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index f45b3e29e..f0d585859 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -27,6 +27,8 @@ import { ruleEngineService, RuleEngineListParams, } from "./ruleEngine/ruleEngine.service"; +import { bookingsService, BookingListFilter } from "./bookings.service"; +import type { Freight, PaginatedResponse } from "@edr/types"; export const api = { fileUploadSettings: { @@ -220,4 +222,30 @@ export const api = { () => ruleEngineService.getApprovalChain(), ), }, + + bookings: { + list: endpoint< + { filter?: BookingListFilter }, + PaginatedResponse + >("bookings", "list", ({ filter }) => bookingsService.list(filter)), + + getById: endpoint<{ id: string }, Freight.IBooking>( + "bookings", + "getById", + ({ id }) => bookingsService.getById(id), + ), + + updateStatus: endpoint< + { id: string; action: string; reason?: string }, + Freight.IBooking + >("bookings", "updateStatus", ({ id, action, reason }) => + bookingsService.updateStatus(id, { action, reason }), + ), + + remove: endpoint<{ id: string }, void>( + "bookings", + "remove", + ({ id }) => bookingsService.remove(id), + ), + }, }; diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts new file mode 100644 index 000000000..e823b2146 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -0,0 +1,51 @@ +import type { Freight, PaginatedResponse } from "@edr/types"; + +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; + +const BASE = URL_CONSTANTS.BOOKINGS.BASE; + +export interface BookingListFilter { + status?: string; + customerId?: string; + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; +} + +export const bookingsService = { + list: async ( + filter?: BookingListFilter, + ): Promise> => { + const response = await client.get>( + BASE, + { params: filter }, + ); + return unwrap(response.data); + }, + + getById: async (id: string): Promise => { + const response = await client.get( + URL_CONSTANTS.BOOKINGS.BY_ID(id), + ); + return unwrap(response.data); + }, + + updateStatus: async ( + id: string, + payload: { action: string; reason?: string }, + ): Promise => { + const response = await client.patch( + `${URL_CONSTANTS.BOOKINGS.BY_ID(id)}/status`, + payload, + ); + return unwrap(response.data); + }, + + remove: async (id: string): Promise => { + await client.delete(URL_CONSTANTS.BOOKINGS.BY_ID(id)); + }, +};