mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: enhance form styling and validation for contract creation
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Real CSS for the shared form fields. Mantine v7's `styles` prop only accepts
|
||||
* flat properties — pseudo-classes and `&[data-...]` attribute selectors there
|
||||
* are ignored (and `&[data-...]` logs an "Unsupported style property" warning).
|
||||
* Those interactive states live here and are wired via `classNames` (see
|
||||
* `fieldClassNames` in `shared.tsx`).
|
||||
*/
|
||||
|
||||
.edrFieldInput:hover {
|
||||
border-color: #cbd8e4;
|
||||
}
|
||||
|
||||
.edrFieldInput:focus,
|
||||
.edrFieldInput:focus-within {
|
||||
border-color: #0ea371;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(14, 163, 113, 0.13),
|
||||
0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
}
|
||||
|
||||
.edrFieldOption[data-combobox-selected] {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(14, 163, 113, 0.1),
|
||||
rgba(14, 163, 113, 0.05)
|
||||
);
|
||||
color: #0a6f4d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edrFieldOption[data-combobox-active] {
|
||||
background: #f1f6fa;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
FieldError as RhfFieldError,
|
||||
} from "react-hook-form";
|
||||
import type { BookingFormInputValues } from "./schema";
|
||||
import "./field-styles.css";
|
||||
|
||||
// Brand tokens (kept local so the form reads consistently with the booking
|
||||
// detail page and the scheduling step).
|
||||
@@ -267,7 +268,12 @@ export function StepHeader({
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared Mantine input styling so every field in the form matches. */
|
||||
/**
|
||||
* Shared Mantine input styling so every field in the form matches. Mantine v7's
|
||||
* `styles` prop accepts only flat properties, so interactive states (`:hover`,
|
||||
* `:focus`, `[data-combobox-selected]`…) live in `field-styles.css` and are
|
||||
* applied through `fieldClassNames` below — never as `&`-nested keys here.
|
||||
*/
|
||||
export const fieldStyles = {
|
||||
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
|
||||
input: {
|
||||
@@ -284,12 +290,6 @@ export const fieldStyles = {
|
||||
boxShadow: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
transition:
|
||||
"border-color 130ms ease, box-shadow 130ms ease, background 130ms ease",
|
||||
"&:hover": { borderColor: "#CBD8E4" },
|
||||
"&:focus, &:focusWithin": {
|
||||
borderColor: GREEN,
|
||||
background: "#FFFFFF",
|
||||
boxShadow: `0 0 0 3px ${GREEN}22, 0 1px 2px rgba(16,24,40,0.05)`,
|
||||
},
|
||||
},
|
||||
section: { color: MUTED },
|
||||
dropdown: {
|
||||
@@ -303,15 +303,19 @@ export const fieldStyles = {
|
||||
fontSize: 13.5,
|
||||
fontWeight: 500,
|
||||
padding: "9px 10px",
|
||||
"&[data-combobox-selected]": {
|
||||
background: `linear-gradient(135deg, ${GREEN}1A, ${GREEN}0D)`,
|
||||
color: GREEN_DARK,
|
||||
fontWeight: 600,
|
||||
},
|
||||
"&[data-combobox-active]": { background: "#F1F6FA" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Class names carrying the field's interactive states (hover/focus ring and
|
||||
* combobox selected/active option). Pair with `fieldStyles` on every Select /
|
||||
* InputBase so the look matches and no unsupported-selector warning is logged.
|
||||
*/
|
||||
export const fieldClassNames = {
|
||||
input: "edrFieldInput",
|
||||
option: "edrFieldOption",
|
||||
} as const;
|
||||
|
||||
export function SelectField({
|
||||
field,
|
||||
error,
|
||||
@@ -349,6 +353,7 @@ export function SelectField({
|
||||
leftSection={leftSection}
|
||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||
styles={fieldStyles}
|
||||
classNames={fieldClassNames}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -401,6 +406,7 @@ export function AsyncComboboxField({
|
||||
disabled={disabled}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
classNames={{ input: fieldClassNames.input }}
|
||||
value={searchQuery || selectedLabel}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.currentTarget.value);
|
||||
|
||||
@@ -269,7 +269,15 @@ export default function NewContractPage() {
|
||||
const valid = await form.trigger(contractStepFields[step], {
|
||||
shouldFocus: true,
|
||||
});
|
||||
if (!valid) return;
|
||||
if (!valid) {
|
||||
// TEMP DEBUG — surface which step-1 fields block Continue.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[contract continue blocked] step", step, {
|
||||
errors: JSON.parse(JSON.stringify(form.formState.errors)),
|
||||
values: form.getValues(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Step 2 — Documents: every required document must be on file or uploaded.
|
||||
if (step === 2 && docsValidatorRef.current && !docsValidatorRef.current()) {
|
||||
return;
|
||||
|
||||
@@ -99,6 +99,16 @@ export type ContractKindOption = (typeof CONTRACT_KINDS)[number];
|
||||
export const CONTAINER_SIZES = ["20ft", "40ft"] as const;
|
||||
export type ContainerSize = (typeof CONTAINER_SIZES)[number];
|
||||
|
||||
// A GENERAL-contract quantity cap. The Mantine NumberInput backing these fields
|
||||
// can briefly emit "" / undefined / NaN (cleared or never-touched field); those
|
||||
// all mean "uncapped", so coerce them to 0 before the >= 0 check rather than
|
||||
// letting them fail validation and silently block the Cargo & Route step.
|
||||
const nonNegativeQuantityCap = z.preprocess(
|
||||
(v) =>
|
||||
v === "" || v === null || v === undefined || Number.isNaN(v) ? 0 : v,
|
||||
z.number().nonnegative(),
|
||||
);
|
||||
|
||||
export const contractFormSchema = z
|
||||
.object({
|
||||
// Operation drives trade direction (import/export → IMPORT/EXPORT;
|
||||
@@ -152,13 +162,18 @@ export const contractFormSchema = z
|
||||
// Optional commodity label for the contract PDF (container scope).
|
||||
cargoCommodityId: z.string().default(""),
|
||||
// GENERAL only: per-size container quantity cap (total bookable over the
|
||||
// validity window). Keyed by size; 0/undefined = uncapped.
|
||||
containerSizeCaps: z.record(z.string(), z.number().nonnegative()).default({}),
|
||||
// validity window). Keyed by size; 0/undefined = uncapped. The NumberInput
|
||||
// can momentarily hold "" / undefined (empty field) — coerce those to 0 so
|
||||
// an untouched cap never blocks the step.
|
||||
containerSizeCaps: z
|
||||
.record(z.string(), nonNegativeQuantityCap)
|
||||
.default({}),
|
||||
// Bulk scope: the cargo type path (group → commodity).
|
||||
cargoTypePath: z.array(z.string()).default([]),
|
||||
cargoFreeText: z.string().default(""),
|
||||
// GENERAL only: total bulk tons/items bookable. 0 = uncapped.
|
||||
bulkQuantityCap: z.number().nonnegative().default(0),
|
||||
// GENERAL only: total bulk tons/items bookable. 0 = uncapped. Same empty-
|
||||
// field coercion as the container caps above.
|
||||
bulkQuantityCap: nonNegativeQuantityCap.default(0),
|
||||
// Contract-level billing flags.
|
||||
isHazardous: z.boolean().default(false),
|
||||
isRefrigerated: z.boolean().default(false),
|
||||
|
||||
@@ -27,7 +27,10 @@ export {
|
||||
StepLabel,
|
||||
} from "@/pages/bookings/new-booking-form/shared";
|
||||
|
||||
import { fieldStyles } from "@/pages/bookings/new-booking-form/shared";
|
||||
import {
|
||||
fieldClassNames,
|
||||
fieldStyles,
|
||||
} from "@/pages/bookings/new-booking-form/shared";
|
||||
|
||||
/**
|
||||
* Generic Select field — identical look to the booking wizard's SelectField but
|
||||
@@ -73,6 +76,7 @@ export function SelectField<
|
||||
leftSection={leftSection}
|
||||
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
|
||||
styles={fieldStyles}
|
||||
classNames={fieldClassNames}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +124,11 @@ export function AsyncComboboxField<
|
||||
};
|
||||
|
||||
return (
|
||||
<Input.Wrapper label={label} error={error?.message} styles={fieldStyles}>
|
||||
<Input.Wrapper
|
||||
label={label}
|
||||
error={error?.message}
|
||||
styles={fieldStyles}
|
||||
>
|
||||
<Combobox
|
||||
store={combobox}
|
||||
disabled={disabled}
|
||||
@@ -134,6 +142,7 @@ export function AsyncComboboxField<
|
||||
disabled={disabled}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
classNames={{ input: fieldClassNames.input }}
|
||||
value={searchQuery || selectedLabel}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.currentTarget.value);
|
||||
|
||||
Reference in New Issue
Block a user