Merge pull request #806 from Tria-plc/freight_feature/usermanagement

add contract clearance detail page and enhance contract requests fil…
This commit is contained in:
marshal
2026-07-19 11:41:45 +03:00
committed by GitHub
12 changed files with 619 additions and 132 deletions

View File

@@ -28,9 +28,15 @@ export const BookingRow = memo(function BookingRow({
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page.
// instead of navigating to the detail page. A general contract is payable as
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's
// SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction.
const payableStatus =
booking.bookingType === "GENERAL_CONTRACT"
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
booking.status === payableStatus && booking.paymentStatus !== "PAID";
// Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);

View File

@@ -37,6 +37,11 @@ import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
ContractSignButton,
bookingIsSignable,
} from "./contract/ContractSignButton";
import { ApproveDeliveryButton } from "./delivery/ApproveDeliveryButton";
import {
BookingStatusBadge as StatusBadge,
BookingTypeBadge,
@@ -54,6 +59,7 @@ import {
type ColumnDef,
usePagination,
} from "@edr/ui-common";
import "./bookings-table.css";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
@@ -200,6 +206,14 @@ function PrimaryAction({
if (status === payableStatus && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />;
}
// Contract ready for the customer's signature → full-page contract viewer.
if (bookingIsSignable(booking)) {
return <ContractSignButton booking={booking} size="xs" />;
}
// Delivered cargo with a handover awaiting the customer's signature.
if (booking.handoverAwaitingSignature) {
return <ApproveDeliveryButton bookingId={id} size="xs" stopPropagation />;
}
return (
<Button
size="xs"
@@ -886,7 +900,7 @@ export default function BookingsListPage() {
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
containerClassName="edr-bookings-table border-0 shadow-none rounded-none"
footer={DataTableFooter}
/>
)}

View File

@@ -0,0 +1,84 @@
/*
* Scoped to .edr-bookings-table — the DataTable container div on the bookings
* list only; no other DataTable is affected. Mirrors contracts-table.css:
* content-sized columns with a 40px floor, horizontal scroll when the table
* outgrows the card, and a sticky shadowed action column.
*/
.edr-bookings-table {
overflow-x: auto;
}
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the container scrolls instead.
* min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-bookings-table table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
.edr-bookings-table th,
.edr-bookings-table td {
min-width: 40px;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
* to their text so the column grows to fit them.
*/
.edr-bookings-table .mantine-Badge-root {
max-width: none;
}
/*
* Full-width rows (error / empty state) span every column via colspan — leave
* their wrapping alone.
*/
.edr-bookings-table th,
.edr-bookings-table td:not([colspan]) {
white-space: nowrap;
}
/* Sticky header row. */
.edr-bookings-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Sticky action column, shrunk to its content. The width overrides the inline
* width DataTable stamps from tanstack's default column size — hence
* !important. `:not([colspan])` keeps the full-width error/empty rows out.
*/
.edr-bookings-table th:last-child,
.edr-bookings-table td:last-child:not([colspan]) {
width: 1% !important;
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through.
*/
.edr-bookings-table td:last-child:not([colspan]) {
/* Very light blue-grey tint sets the action column apart from the rows. */
background: #f5f8fb;
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-bookings-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8);
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-bookings-table th:last-child {
background: #f4f7fa;
z-index: 3;
}

View File

@@ -403,6 +403,7 @@ export default function ContractsList() {
<Table.Th>Cargo</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Trade</Table.Th>
<Table.Th>Customs</Table.Th>
<Table.Th>Currency</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Valid Until</Table.Th>
@@ -413,7 +414,7 @@ export default function ContractsList() {
<Table.Tbody>
{isLoading && (
<Table.Tr>
<Table.Td colSpan={11}>
<Table.Td colSpan={12}>
<Center py={48}>
<Loader color="edr-green" size="sm" />
</Center>
@@ -423,7 +424,7 @@ export default function ContractsList() {
{!isLoading && isError && (
<Table.Tr>
<Table.Td colSpan={11}>
<Table.Td colSpan={12}>
<Center py={48}>
<Text fz={13} c="red">
Failed to load contracts. Please try again.
@@ -435,7 +436,7 @@ export default function ContractsList() {
{!isLoading && !isError && rows.length === 0 && (
<Table.Tr>
<Table.Td colSpan={11}>
<Table.Td colSpan={12}>
<Stack align="center" gap={8} py={48}>
<Inbox
size={26}
@@ -560,6 +561,15 @@ export default function ContractsList() {
{tradeLabel}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={c.customsClearingEnabled ? "edr-green" : "gray"}
radius="sm"
>
{c.customsClearingEnabled ? "With customs" : "Without"}
</Badge>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"}
@@ -612,7 +622,7 @@ export default function ContractsList() {
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td
colSpan={11}
colSpan={12}
style={{ padding: "6px 20px 18px" }}
>
<ContractStepBanner contract={c} />

View File

@@ -1670,8 +1670,39 @@ function ContainerLineEditor({
</Text>
) : null}
<Stack gap={10} mt={8}>
{/* Header row — input labels + handling-service labels, one aligned
grid shared by every unit row below. */}
{Math.max(quantity, units.length) > 0 && (
<Group gap={10} wrap="nowrap" align="flex-end">
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
</Text>
{handlingColumns.map((col) => (
<Group
key={col.key}
gap={4}
wrap="nowrap"
justify="center"
style={{ width: 96, flexShrink: 0 }}
>
<span style={{ color: col.color, display: "flex" }}>
{col.icon}
</span>
<Text fz={12} fw={600} c="#10202F">
{col.label}
</Text>
</Group>
))}
</Group>
)}
{Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => (
<Group key={u} gap={10} grow align="flex-start">
<Group key={u} gap={10} wrap="nowrap" align="flex-start">
<Controller
name={`containers.${index}.units.${u}.containerNumber`}
control={form.control}
@@ -1681,11 +1712,11 @@ function ContainerLineEditor({
onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase())
}
label={u === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
@@ -1695,10 +1726,10 @@ function ContainerLineEditor({
render={({ field }) => (
<TextInput
{...field}
label={u === 0 ? "Seal number" : undefined}
placeholder="Optional"
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
@@ -1710,13 +1741,13 @@ function ContainerLineEditor({
{...field}
type="number"
onKeyDown={blockNegative}
label={u === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
@@ -1726,28 +1757,25 @@ function ContainerLineEditor({
name={`containers.${index}.units.${u}.${col.key}`}
control={form.control}
render={({ field }) => (
<Switch
checked={Boolean(field.value)}
aria-label={`${col.label} — container ${u + 1}`}
onChange={(e) =>
toggleUnitHandling(u, col.key, e.currentTarget.checked)
}
label={
u === 0 ? (
<Group gap={4} wrap="nowrap">
<span style={{ color: col.color, display: "flex" }}>
{col.icon}
</span>
<Text fz={12} fw={600} c="#10202F">
{col.label}
</Text>
</Group>
) : undefined
}
labelPosition="right"
size="sm"
mt={u === 0 ? 26 : 6}
/>
<Box
style={{
width: 96,
flexShrink: 0,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Switch
checked={Boolean(field.value)}
aria-label={`${col.label} — container ${u + 1}`}
onChange={(e) =>
toggleUnitHandling(u, col.key, e.currentTarget.checked)
}
size="sm"
/>
</Box>
)}
/>
))}

View File

@@ -2,35 +2,51 @@
* Scoped to .edr-contracts-table — every rule below is prefixed, so no other
* Mantine Table in the portal is affected.
*
* Column sizing: table-layout stays `auto`, so a column with short content
* (a currency code, a badge) keeps its natural narrow width. The cap only
* kicks in for columns whose content would otherwise push past it — those
* wrap onto extra lines instead of widening the table.
* Column sizing: table-layout stays `auto`. Every column sizes to its content
* with a 30px floor and no wrapping — when the columns together outgrow the
* viewport, the table widens and the wrapper's overflow-x takes over.
*/
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the overflow-x wrapper scrolls
* instead. min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-contracts-table {
/* Single knob for the cap — raise this if the columns read too cramped. */
--edr-col-max: 60px;
table-layout: auto;
width: max-content;
min-width: 100%;
}
.edr-contracts-table th,
.edr-contracts-table td {
max-width: var(--edr-col-max);
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
min-width: 40px;
}
/*
* The two fixed-purpose columns are exempt from the cap: the expander is a
* 28px icon button that must not wrap, and the action column holds two
* buttons side by side.
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label ("General" →
* "Gen…"). Let the badge size to its text so the column grows to fit it.
*/
.edr-contracts-table .mantine-Badge-root {
max-width: none;
}
/*
* Full-width rows (loading / error / empty / expanded step banner) span every
* column via colspan — leave their wrapping alone.
*/
.edr-contracts-table th,
.edr-contracts-table td:not([colspan]) {
white-space: nowrap;
}
/*
* Action column hugs its content: width 1% + nowrap makes the browser give it
* the minimum width its buttons need and nothing more.
*/
.edr-contracts-table th:first-child,
.edr-contracts-table td:first-child:not([colspan]),
.edr-contracts-table th:last-child,
.edr-contracts-table td:last-child:not([colspan]) {
max-width: none;
white-space: nowrap;
width: 1%;
}
/* Sticky header row (moved off the Mantine `styles` prop — see ContractsList). */
@@ -49,7 +65,7 @@
.edr-contracts-table td:last-child:not([colspan]) {
position: sticky;
right: 0;
box-shadow: -8px 0 8px -8px rgba(16, 32, 47, 0.18);
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
}
/*
@@ -58,7 +74,8 @@
* the background the row already has.
*/
.edr-contracts-table td:last-child:not([colspan]) {
background: #ffffff;
/* Very light blue-grey tint sets the action column apart from the rows. */
background: #f5f8fb;
z-index: 2;
}