Merge pull request #68 from Tria-plc/freight/feature/booking-integration

freight/feature/booking integration
This commit is contained in:
Nathnael Wondisha
2026-06-03 11:26:35 +03:00
committed by GitHub
22 changed files with 733 additions and 412 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -16,26 +13,29 @@ import type {
const CARGO_TYPE_PARENT_PAGE_SIZE = 500;
const CONTAINER_TYPE_OPTIONS_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<RuleEngineRecord>(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<RuleEngineRecord>("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) => {
@@ -46,7 +46,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];
@@ -84,20 +86,20 @@ export const useContainerTypeOptions = (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<string, unknown>) =>
ruleEngineService.create(resource, payload),
api.ruleEngine.create.call({ resource, payload }),
onSuccess: () => {
toast.success("Created successfully");
invalidate();
@@ -112,7 +114,7 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
}: {
id: string;
payload: Record<string, unknown>;
}) => ruleEngineService.update(resource, id, payload),
}) => api.ruleEngine.update.call({ resource, id, payload }),
onSuccess: () => {
toast.success("Updated successfully");
invalidate();
@@ -121,7 +123,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();
@@ -135,10 +138,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();
@@ -147,8 +150,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();

View File

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

View File

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

View File

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

View File

@@ -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<BookingRequest | undefined>(
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 (

View File

@@ -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<string | null>(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();

View File

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

View File

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

View File

@@ -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<DropdownSetting[]>(

View File

@@ -0,0 +1,251 @@
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";
import { bookingsService, BookingListFilter } from "./bookings.service";
import type { Freight, PaginatedResponse } from "@edr/types";
export const api = {
fileUploadSettings: {
list: endpoint<void, FileUploadSetting[]>(
"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<CreateFileUploadSettingDto, FileUploadSetting>(
"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<void, DropdownSetting[]>(
"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<CreateDropdownSettingDto, DropdownSetting>(
"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<RuleEngineRecord>
>("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<string, unknown> },
RuleEngineRecord
>("rule-engine", "create", ({ resource, payload }) =>
ruleEngineService.create(resource, payload),
),
update: endpoint<
{
resource: RuleEngineResourceSlug;
id: string;
payload: Record<string, unknown>;
},
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<void, RuleEngineRecord[]>(
"rule-engine",
"getApprovalChain",
() => ruleEngineService.getApprovalChain(),
),
},
bookings: {
list: endpoint<
{ filter?: BookingListFilter },
PaginatedResponse<Freight.IBooking>
>("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),
),
},
};

View File

@@ -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<PaginatedResponse<Freight.IBooking>> => {
const response = await client.get<PaginatedResponse<Freight.IBooking>>(
BASE,
{ params: filter },
);
return unwrap(response.data);
},
getById: async (id: string): Promise<Freight.IBooking> => {
const response = await client.get<Freight.IBooking>(
URL_CONSTANTS.BOOKINGS.BY_ID(id),
);
return unwrap(response.data);
},
updateStatus: async (
id: string,
payload: { action: string; reason?: string },
): Promise<Freight.IBooking> => {
const response = await client.patch<Freight.IBooking>(
`${URL_CONSTANTS.BOOKINGS.BY_ID(id)}/status`,
payload,
);
return unwrap(response.data);
},
remove: async (id: string): Promise<void> => {
await client.delete(URL_CONSTANTS.BOOKINGS.BY_ID(id));
},
};

View File

@@ -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<string, FileUploadSetting>(
QUERY_KEYS.FILES.FILE_UPLOAD_SETTINGS,
QUERY_KEYS.FILES.BY_CODE,
(code: any) =>
client
.get<
ApiResponse<FileUploadSetting>
>(`${URL_CONSTANTS.FILES.FILE_UPLOAD_SETTINGS_BY_CODE}/${code}`)
.then((res: any) => res.data.data),
);

View File

@@ -0,0 +1,29 @@
export type Result<T, E = { code: string; message: string; statusCode?: number }> =
| { 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<string, unknown>;
const response = obj.response as Record<string, unknown> | undefined;
if (response) {
const statusCode = response.status as number | undefined;
const data = response.data as Record<string, unknown> | 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" };
}

View File

@@ -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: <Home /> },
@@ -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;

View File

@@ -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<string, { title: string; description: string; color: string; stage: number }> = {
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 (
<div className="container mx-auto flex items-center justify-center p-12">
<div className="flex flex-col items-center gap-4">
<LoaderCircle className="size-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading booking details</p>
</div>
</div>
);
}
if (isError) {
return (
<div className="container mx-auto p-6">
<Card className="flex flex-col items-center p-12 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-red-50 text-red-400">
<AlertTriangle className="size-8" />
</div>
<h1 className="mt-4 text-2xl font-bold text-slate-900">
Failed to load booking
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{error instanceof Error ? error.message : "An unexpected error occurred."}
</p>
</Card>
</div>
);
}
if (!booking) {
return (
<div className="container mx-auto p-6">
<Card className="flex flex-col items-center p-12 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
<Package className="size-8" />
<Package className="size-8" />
</div>
<h1 className="mt-4 text-2xl font-bold text-slate-900">
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 (
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="flex flex-col gap-8">
{/* Breadcrumbs Restored */}
<Breadcrumbs
items={[
{ label: "Bookings", href: "/bookings" },
@@ -102,7 +128,6 @@ export default function BookingDetailPage() {
]}
/>
{/* Compact Header Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-6">
@@ -117,11 +142,9 @@ export default function BookingDetailPage() {
<StatusBadge status={normalizedStatus} />
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-semibold">{booking.customer}</span>
<Separator orientation="vertical" className="h-3" />
<span className="flex items-center gap-1">
<Calendar className="size-3" />
{booking.requestedDate}
{booking.scheduledDate ?? booking.createdAt}
</span>
</div>
</div>
@@ -129,7 +152,6 @@ export default function BookingDetailPage() {
</CardHeader>
</Card>
{/* Granular Status Lifecycle */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -140,7 +162,6 @@ export default function BookingDetailPage() {
</CardHeader>
<CardContent className="flex flex-col gap-8">
<div className="relative flex w-full justify-between px-2">
{/* Progress Line */}
<div className="absolute top-4 left-0 h-0.5 w-full bg-muted">
<div
className="h-full bg-primary transition-all duration-500"
@@ -185,7 +206,7 @@ export default function BookingDetailPage() {
{statusConfig.description}
</p>
</div>
{normalizedStatus !== "CANCELLED" && normalizedStatus !== "COMPLETED" && (
{normalizedStatus !== "CANCELLED" && normalizedStatus !== "DELIVERED" && (
<div className="ml-auto flex items-center gap-4 border-l border-border pl-6">
<div className="flex flex-col">
<p className="text-[9px] font-bold uppercase text-muted-foreground">Est. Waiting</p>
@@ -200,7 +221,6 @@ export default function BookingDetailPage() {
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="flex flex-col gap-8 lg:col-span-2">
{/* Route & Core Service Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -232,14 +252,13 @@ export default function BookingDetailPage() {
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoItem icon={<Layers />} label="Service" value="Rail & Forwarding" />
<InfoItem icon={<ShieldCheck />} label="Return" value="With Return" />
<InfoItem icon={<FileText />} label="Customs" value="Enabled" />
<InfoItem icon={<Layers />} label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} />
<InfoItem icon={<ShieldCheck />} label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} />
<InfoItem icon={<FileText />} label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} />
</div>
</CardContent>
</Card>
{/* Mile Services Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -252,18 +271,19 @@ export default function BookingDetailPage() {
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
First Mile
</h3>
<InfoItem label="Address" value="Inside Addis Ababa Yard, Gate 2" />
<InfoItem label="Address" value={booking.firstMileEnabled && booking.firstMilePickupAddress ? booking.firstMilePickupAddress : "Not requested"} />
</div>
<div className="flex flex-col gap-3">
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
Last Mile
</h3>
<p className="pl-4 text-xs text-muted-foreground italic">Not requested</p>
<p className="pl-4 text-xs text-muted-foreground italic">
{booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"}
</p>
</div>
</CardContent>
</Card>
{/* Cargo Specifications Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -273,40 +293,44 @@ export default function BookingDetailPage() {
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoItem icon={<Package />} label="Category" value={booking.cargoType} />
<InfoItem icon={<Weight />} label="Weight" value={`${booking.weightTons} Tons`} />
<InfoItem icon={<Ship />} label="Shipping Line" value="MSC" />
<InfoItem icon={<Package />} label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} />
<InfoItem icon={<Weight />} label="Weight (VGM)" value={`${booking.cargoTotalWeightVgm} Tons`} />
<InfoItem icon={<Ship />} label="Currency" value={booking.paymentCurrency} />
</div>
<Separator />
<div className="flex flex-col gap-3">
<h3 className="text-xs font-bold uppercase tracking-wide text-foreground">Load Details</h3>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-left text-xs">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="px-3 py-2 font-semibold">Description</th>
<th className="px-3 py-2 font-semibold text-center">Unit</th>
<th className="px-3 py-2 font-semibold text-right">Value</th>
</tr>
</thead>
<tbody className="divide-y">
<tr>
<td className="px-3 py-2 font-medium">Main Equipment</td>
<td className="px-3 py-2 text-center">20FT Container</td>
<td className="px-3 py-2 text-right">4 Units</td>
</tr>
</tbody>
</table>
</div>
</div>
{booking.containers && booking.containers.length > 0 && (
<>
<Separator />
<div className="flex flex-col gap-3">
<h3 className="text-xs font-bold uppercase tracking-wide text-foreground">Load Details</h3>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-left text-xs">
<thead className="bg-muted text-muted-foreground">
<tr>
<th className="px-3 py-2 font-semibold">Type</th>
<th className="px-3 py-2 font-semibold text-center">Quantity</th>
<th className="px-3 py-2 font-semibold text-right">VGM (Tons)</th>
</tr>
</thead>
<tbody className="divide-y">
{booking.containers.map((c, i) => (
<tr key={i}>
<td className="px-3 py-2 font-medium">{c.type}</td>
<td className="px-3 py-2 text-center">{c.qty} Units</td>
<td className="px-3 py-2 text-right">{c.vgm}t</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-8">
{/* Contract Card */}
<Card className="border-primary/20 bg-primary/[0.02]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -315,40 +339,48 @@ export default function BookingDetailPage() {
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem label="Type" value="Renewal" />
<InfoItem label="Ref" value="EDR-2024-88123" />
<InfoItem label="Type" value={booking.contractType === "RENEWAL" ? "Renewal" : "New"} />
<InfoItem label="Customer ID" value={booking.customerId} />
<Separator />
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-background text-[9px]">
Hazardous: No
Hazardous: {booking.isHazardous ? "Yes" : "No"}
</Badge>
<Badge variant="outline" className="bg-background text-[9px]">
Refrigerated: No
Refrigerated: {booking.isRefrigerated ? "Yes" : "No"}
</Badge>
</div>
</CardContent>
</Card>
{/* Notes Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Additional Info</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Description</p>
<p className="text-xs text-foreground leading-relaxed italic">"{booking.cargoDescription}"</p>
</div>
<Separator />
<div className="flex flex-col gap-1">
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Instructions</p>
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
<p className="text-xs text-amber-900 flex gap-2">
<StickyNote className="size-3 shrink-0 mt-0.5 text-amber-500" />
{booking.specialInstructions}
</p>
</div>
</div>
{booking.freightSubtype && (
<div className="flex flex-col gap-1">
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Cargo Description</p>
<p className="text-xs text-foreground leading-relaxed italic">"{booking.freightSubtype}"</p>
</div>
)}
{booking.financialTerms && (
<>
<Separator />
<div className="flex flex-col gap-1">
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Financial Terms</p>
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
<p className="text-xs text-amber-900 flex gap-2">
<StickyNote className="size-3 shrink-0 mt-0.5 text-amber-500" />
{booking.financialTerms}
</p>
</div>
</div>
</>
)}
{!booking.freightSubtype && !booking.financialTerms && (
<p className="text-xs text-muted-foreground italic">No additional information provided.</p>
)}
</CardContent>
</Card>
</div>
@@ -396,7 +428,7 @@ function InfoItem({
{icon && <div className="mt-0.5 text-muted-foreground [&_svg]:size-3.5">{icon}</div>}
<div className="flex flex-col">
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
<p className="text-xs font-bold text-foreground">{value || "—"}</p>
<p className="text-xs font-bold text-foreground">{value ?? "—"}</p>
</div>
</div>
);
@@ -405,20 +437,10 @@ function InfoItem({
function StatusBadge({ status }: { status: string }) {
const statusColors: Record<string, string> = {
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 (

View File

@@ -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<Booking>[] = [
const columns: ColumnDef<Freight.IBooking>[] = [
{
accessorKey: "reference",
header: "Reference",
@@ -89,7 +86,7 @@ export default function MyBookings() {
</div>
<div>
<p className="font-medium text-slate-900">{booking.reference}</p>
<p className="text-sm text-slate-500">{booking.requestedDate}</p>
<p className="text-sm text-slate-500">{booking.scheduledDate ?? booking.createdAt}</p>
</div>
</div>
);
@@ -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 (
<div className="text-sm text-slate-700">
<p>{b.cargoType}</p>
<p>{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}</p>
<p className="text-xs text-slate-500">
{b.containerCount > 0 ? `${b.containerCount} × ${b.containerType} · ` : ""}{b.weightTons}t
{containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t
</p>
</div>
);
},
},
{
accessorKey: "transportMode",
id: "transportMode",
header: "Transport",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{row.original.transportMode}
{row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"}
</span>
),
},
@@ -158,19 +157,6 @@ export default function MyBookings() {
<Eye />
View
</DropdownMenuItem>
<DropdownMenuSeparator />
<DeleteBookingDialog
bookingReference={booking.reference}
onConfirm={() => handleDeleteConfirm(booking.id)}
>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DeleteBookingDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -179,10 +165,11 @@ export default function MyBookings() {
},
];
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
{/* Header Section Card */}
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
@@ -214,14 +201,13 @@ export default function MyBookings() {
</div>
</Card>
{/* Stat Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Bookings</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{myBookings.length}
{bookings.length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
@@ -259,7 +245,6 @@ export default function MyBookings() {
</Card>
</div>
{/* Data Table */}
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
@@ -276,7 +261,7 @@ export default function MyBookings() {
</CardHeader>
<CardContent className="px-0">
{total === 0 ? (
{total === 0 && dataTableStatus === "success" ? (
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<Package className="h-12 w-12 text-slate-300 mb-4" />
<h3 className="text-sm font-semibold text-slate-900">No bookings found</h3>
@@ -288,8 +273,8 @@ export default function MyBookings() {
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={(row) => 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<BookingStatus, string> = {
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<string, string> = {
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 (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status] ?? "bg-slate-100 text-slate-700"}`}
>
{status}
{status.replace(/_/g, ' ')}
</span>
);
}

View File

@@ -130,7 +130,7 @@ export function Step5CargoDetails({
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-4 w-4 text-primary" />
</div>
<p className="font-semibold">Container</p>
<p className="font-semibold">Containerized</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Pre-packed containerized cargo (20ft / 40ft).
</p>
@@ -145,7 +145,7 @@ export function Step5CargoDetails({
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
<Weight className="h-4 w-4 text-amber-600" />
</div>
<p className="font-semibold">Bulk</p>
<p className="font-semibold">General Cargo</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Bulk commodities or break-bulk cargo.
</p>

View File

@@ -6,22 +6,22 @@ export type CreateBookingPayload = Freight.CreateBookingDto;
export const bookingsService = {
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
const { data } = await client.get("/bookings");
const { data } = await client.get("/api/bookings");
return data.data;
},
get: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.get(`/bookings/${id}`);
const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
const { data } = await client.post("/api/bookings", payload);
return data.data;
return data.data.booking;
},
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
const { data } = await client.get("/api/bookings/reference-data");
return data.data;
},
remove: async (id: string): Promise<void> => {
await client.delete(`/bookings/${id}`);
await client.delete(`/api/bookings/${id}`);
},
};