From c977deb460895567ece5d593394dac6d3eda410c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 14:01:50 +0000 Subject: [PATCH 01/10] modify booking request page --- .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 1 + .../bookings/entities/booking.entity.ts | 4 + apps/edr-freight-web/backoffice/src/App.tsx | 20 +- .../pages/bookings/BookingRequestsPage.tsx | 249 +++++++++--------- .../src/services/bookings.service.ts | 4 + 6 files changed, 156 insertions(+), 128 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 3f696bc6b..846fd2c9a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -35,6 +35,7 @@ export interface BookingListFilterOptions { serviceTypeId?: string; cargoTypeId?: string; freightType?: string; + bookingType?: string; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -737,6 +738,11 @@ export class BookingsRepository extends BaseRepository { freightType: options.freightType, }); } + if (options.bookingType) { + qb.andWhere('booking.bookingType = :bookingType', { + bookingType: options.bookingType, + }); + } if (options.createdFrom) { qb.andWhere('booking.created_at >= :createdFrom', { createdFrom: options.createdFrom, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index ac639a4bd..e3d882baa 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1001,6 +1001,7 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 4b033bab5..cf0ca76f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -157,6 +157,10 @@ export class Booking extends BaseEntity { @Column({ name: 'contract_route_id', type: 'uuid', nullable: true }) contractRouteId?: string | null; + /** Booking origin: ONE_TIME (single-shipment) or GENERAL_CONTRACT (drawdown). */ + @Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' }) + bookingType!: string; + /** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */ @Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true }) contractKind?: string | null; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index e7e567c7a..1e7d1e280 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -52,8 +52,9 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; -import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; -import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; +// Hidden for now — Shipment Requests pages disabled (imports kept commented). +// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; +// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; @@ -178,12 +179,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, + // Hidden for now — Shipment Requests nav item disabled. + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", @@ -672,6 +674,7 @@ const App = () => { } /> + {/* Hidden for now — Shipment Requests pages disabled. { } /> + */} {/* GL (Path B) contract clearance review hub */} t.key === tab); - if (!match?.statuses?.length) return undefined; - return match.statuses.join(","); -} +/** The two booking-kind tabs: one-time vs general-contract bookings. */ +type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT"; + +const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [ + { value: "ONE_TIME", label: "One-time booking" }, + { value: "GENERAL_CONTRACT", label: "General booking" }, +]; + +/** Status options for the filter select — built from the shared status styles. */ +const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map( + ([value, { label }]) => ({ value, label }), +); + +const TRADE_DIRECTION_OPTIONS = [ + { value: "IMPORT", label: "Import" }, + { value: "EXPORT", label: "Export" }, + { value: "DOMESTIC", label: "Domestic" }, +]; + +const FREIGHT_TYPE_OPTIONS = [ + { value: "CONTAINER", label: "Container" }, + { value: "BULK", label: "Bulk" }, +]; function formatDate(value: string | null | undefined): string { if (!value) return "—"; @@ -75,14 +89,16 @@ function formatDate(value: string | null | undefined): string { }); } -type OperationsSubTab = "ready" | "scheduled"; - export default function BookingRequestsPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); - const [activeTab, setActiveTab] = useState("all"); - const [operationsSubTab, setOperationsSubTab] = useState("ready"); + // Booking-kind tabs (one-time vs general contract) replace the old status tabs. + const [kindTab, setKindTab] = useState("ONE_TIME"); + // Per-tab filter selects (each nullable = "all"). + const [statusFilter, setStatusFilter] = useState(null); + const [directionFilter, setDirectionFilter] = useState(null); + const [freightTypeFilter, setFreightTypeFilter] = useState(null); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); const suppressRowClickRef = useRef(false); @@ -93,47 +109,26 @@ export default function BookingRequestsPage() { }, 400); }, []); - const tabStatuses = getStatusesForTab(activeTab); - const isOperationsTab = activeTab === "operations"; - const filter: BookingListFilter = useMemo(() => { - if (isOperationsTab) { - if (operationsSubTab === "ready") { - return { - page: 1, - pageSize: 100, - statuses: "PAID", - assignedToSchedule: "false", - sortBy: "createdAt", - sortOrder: "DESC", - tab: activeTab, - }; - } - return { - page: 1, - pageSize: 100, - statuses: "PAID", - schedulingStatuses: "SCHEDULED,DISPATCHED", - sortBy: "scheduledDate", - sortOrder: "ASC", - tab: activeTab, - }; - } return { page: pagination.pageIndex + 1, pageSize: pagination.pageSize, sortBy: "createdAt", sortOrder: "DESC", - tab: activeTab, - ...(tabStatuses ? { statuses: tabStatuses } : {}), + // React Query cache key per kind tab. + tab: kindTab, + bookingType: kindTab, + ...(statusFilter ? { statuses: statusFilter } : {}), + ...(directionFilter ? { tradeDirection: directionFilter } : {}), + ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), }; }, [ - isOperationsTab, - operationsSubTab, pagination.pageIndex, pagination.pageSize, - activeTab, - tabStatuses, + kindTab, + statusFilter, + directionFilter, + freightTypeFilter, ]); const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter); @@ -171,18 +166,6 @@ export default function BookingRequestsPage() { void refetchSummary(); }, [refetch, refetchSummary]); - const handleAllocateFromQueue = useCallback( - (ids: string[]) => { - const selected = rows.filter((b) => ids.includes(b.id)); - const sorted = [...selected].sort( - (a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0), - ); - setAllocateIds(sorted.map((b) => b.id)); - setAllocateOpen(true); - }, - [rows], - ); - const handleRowClick = useCallback( (row: BookingListRow) => { if (suppressRowClickRef.current) return; @@ -356,6 +339,8 @@ export default function BookingRequestsPage() { ]} /> + {/* Status tabs replaced by booking-kind tabs (one-time / general). The + old BookingStatusTabs is commented out — status is now a filter select. { @@ -364,73 +349,97 @@ export default function BookingRequestsPage() { }} counts={tabCounts} /> + */} + + { + setKindTab((value as BookingKindTab) ?? "ONE_TIME"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + > + + {BOOKING_KIND_TABS.map((t) => ( + + {t.label} + + ))} + + - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query && ( - setQuery("")} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - - {total} record{total !== 1 ? "s" : ""} - - + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query && ( + setQuery("")} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> + + {total} record{total !== 1 ? "s" : ""} + + + + { + setDirectionFilter(v); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + clearable + radius="lg" + style={{ minWidth: 170 }} + /> + + + + + + + + + ); +} + +// ── Sub-components ─────────────────────────────────────────────────────────── + +function PanelColumn({ + title, + hint, + count, + accent, + loading, + emptyIcon: EmptyIcon, + emptyText, + children, +}: { + title: string; + hint: string; + count: number; + accent: string; + loading?: boolean; + emptyIcon: typeof Inbox; + emptyText: string; + children: React.ReactNode; +}) { + const isEmpty = !loading && count === 0; + return ( + + + + + + {title} + + + {count} + + + + {hint} + + + + {isEmpty ? ( + + + + {emptyText} + + + ) : ( + + + {loading ? ( + + Loading… + + ) : ( + children + )} + + + )} + + ); +} + +function BookingCard({ + reference, + customer, + weightTons, + status, + right, +}: { + reference: string; + customer?: string | null; + weightTons?: number | null; + status?: string | null; + right?: React.ReactNode; +}) { + return ( + { + e.currentTarget.style.borderColor = GREEN; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)"; + }} + > + + + + + {reference} + + {status ? : null} + + + + {customer ?? "—"} + + {weightTons != null ? ( + + + + {Number(weightTons).toFixed(1)}T + + + ) : null} + + + {right ? {right} : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 9319e6a01..8ff192ef6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -8,6 +8,7 @@ import { Paper, RingProgress, Stack, + Tabs, Text, Textarea, TextInput, @@ -25,10 +26,12 @@ import { LayoutGrid, Navigation, Package, + PackageCheck, Route as RouteIcon, Send, Train, Weight, + Workflow as WorkflowIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; @@ -45,6 +48,7 @@ import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvai import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; +import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { RouteCorridor, @@ -1028,6 +1032,18 @@ export default function TrainScheduleV2DetailPage() { ) : null} + + + }> + Workflow + + }> + Workspace + + + + + {/* Workflow header with ring progress */} @@ -1085,7 +1101,20 @@ export default function TrainScheduleV2DetailPage() { - + + + + + + { + autoPreviewedRef.current = false; + void detailQuery.refetch(); + }} + /> + + {scheduleId ? ( ({ ...current, maxTrainLengthMeters: value })) } min={1} - clampBehavior="strict" disabled={loading} /> ({ ...current, maxTrainWeightTons: value })) } min={1} - clampBehavior="strict" disabled={loading} /> ({ ...current, maxWagonsPerTrain: value })) } min={1} - clampBehavior="strict" disabled={loading} /> @@ -158,7 +153,6 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, importWindowLeadDays: value })) } min={0} - clampBehavior="strict" disabled={loading} /> ({ ...current, exportBookingLeadHours: value })) } min={1} - clampBehavior="strict" disabled={loading} /> ({ ...current, docReviewMinutes: value })) } min={0} - clampBehavior="strict" disabled={loading} /> ({ ...current, paymentWindowMinutes: value })) } min={1} - clampBehavior="strict" disabled={loading} /> ({ ...current, reopenDelayMinutes: value })) } min={1} - clampBehavior="strict" disabled={loading} /> From a5c46505e372610e5e2dc6ff196e8231cb047465 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 16:37:09 +0000 Subject: [PATCH 05/10] Add migration to widen window_duration_hours precision and update related components for duration handling --- ...00000-WidenWindowDurationHoursPrecision.ts | 28 ++++ ...pdate-train-scheduling-global-rules.dto.ts | 4 +- .../train-scheduling-global-rules.entity.ts | 6 +- .../detail/ContractDetailTabCards.tsx | 10 +- .../trainScheduling/DurationField.tsx | 123 ++++++++++++++++++ .../backoffice/src/hooks/use-toast.ts | 7 +- .../contracts/ContractRequestDetailPage.tsx | 52 +++++++- .../TrainSchedulingGlobalRulesPage.tsx | 74 ++++++++--- 8 files changed, 274 insertions(+), 30 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx diff --git a/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts new file mode 100644 index 000000000..194c0d056 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Widen train_scheduling_global_rules.window_duration_hours from numeric(4,2) + * to numeric(6,4). The UI now lets staff enter the booking-window duration in + * minutes / hours / days and converts to the column's native hours unit; a + * 4-minute window is 0.0667h, which numeric(4,2) rounds to 0.07 (≈3.96 min). + * Four decimals store sub-minute durations exactly (0.0667h → 4.00 min). + */ +export class WidenWindowDurationHoursPrecision1910000000000 + implements MigrationInterface +{ + name = "WidenWindowDurationHoursPrecision1910000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(6, 4); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(4, 2); + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 1171b0c90..2e82feb6a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -60,11 +60,13 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Max(23) windowOpenHour?: number; + // Stored in hours. The UI enters this in minutes/hours/days and converts to + // hours before sending, so the floor is 1 minute (0.0166h) — not 15 min. @ApiPropertyOptional({ example: 3 }) @IsOptional() @Type(() => Number) @IsNumber() - @Min(0.25) + @Min(0.0166) @Max(12) windowDurationHours?: number; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 7b8d9b26a..1a67bb791 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -54,11 +54,13 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'window_open_hour', type: 'int', default: 8 }) windowOpenHour!: number; + // Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h) + // are exact. See WidenWindowDurationHoursPrecision migration. @Column({ name: 'window_duration_hours', type: 'numeric', - precision: 4, - scale: 2, + precision: 6, + scale: 4, default: 3, }) windowDurationHours!: number; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index 44ece975d..de8ff1953 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -208,6 +208,10 @@ function codeLabel(code?: string | null): string | null { export interface ContractDocumentsCardProps { files: ContractFile[]; + /** Card heading. Defaults to "Documents". */ + title?: string; + /** Message shown when there are no files. */ + emptyText?: string; /** Open the file inline in a viewer modal. */ onView?: (file: ContractFile) => void; /** Download the file to disk. */ @@ -217,13 +221,15 @@ export interface ContractDocumentsCardProps { /** Rich list of the contract's attached documents: type, size, view + download. */ export function ContractDocumentsCard({ files, + title = "Documents", + emptyText = "No documents attached to this contract.", onView, onDownload, }: ContractDocumentsCardProps) { return ( @@ -233,7 +239,7 @@ export function ContractDocumentsCard({ > {files.length === 0 ? ( - No documents attached to this contract. + {emptyText} ) : ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx new file mode 100644 index 000000000..06c9013d4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Group, NumberInput, Select, Stack } from "@mantine/core"; + +export type DurationUnit = "minutes" | "hours" | "days"; + +const UNIT_MINUTES: Record = { + minutes: 1, + hours: 60, + days: 1440, +}; + +const UNIT_OPTIONS: { value: DurationUnit; label: string }[] = [ + { value: "minutes", label: "min" }, + { value: "hours", label: "hr" }, + { value: "days", label: "day" }, +]; + +/** Convert a value expressed in `from` units to `to` units. */ +function convert(value: number, from: DurationUnit, to: DurationUnit): number { + return (value * UNIT_MINUTES[from]) / UNIT_MINUTES[to]; +} + +/** Pick the largest unit that keeps a value a clean-ish whole number, so a + * stored 0.0667h loads back as "4 min" rather than "0.0667 hr". */ +function bestDisplayUnit(minutes: number): DurationUnit { + if (minutes <= 0) return "minutes"; + if (minutes % 1440 === 0) return "days"; + if (minutes % 60 === 0) return "hours"; + return "minutes"; +} + +export interface DurationFieldProps { + label: string; + description?: string; + /** Current value, expressed in `nativeUnit` (what the API/DB stores). */ + value: number | string; + /** The unit the parent stores/sends. The field converts to this on change. */ + nativeUnit: DurationUnit; + /** Called with the value converted back to `nativeUnit` (or "" when blank). */ + onChange: (nativeValue: number | "") => void; + /** Smallest allowed value, in `nativeUnit`. */ + min?: number; + disabled?: boolean; +} + +export default function DurationField({ + label, + description, + value, + nativeUnit, + onChange, + min, + disabled, +}: DurationFieldProps) { + const nativeMinutes = useMemo(() => { + const num = value === "" || value == null ? NaN : Number(value); + return Number.isFinite(num) ? num * UNIT_MINUTES[nativeUnit] : NaN; + }, [value, nativeUnit]); + + // Display unit is user-driven; seed it from the incoming value once. + const [unit, setUnit] = useState(() => + Number.isFinite(nativeMinutes) ? bestDisplayUnit(nativeMinutes) : nativeUnit, + ); + + // The value usually arrives async (after the initial "" render), so the + // useState seed above runs before it exists. Re-pick the friendliest display + // unit the first time a real value shows up — but never again, so the user's + // manual unit choice sticks. + const seeded = useRef(false); + useEffect(() => { + if (!seeded.current && Number.isFinite(nativeMinutes)) { + seeded.current = true; + setUnit(bestDisplayUnit(nativeMinutes)); + } + }, [nativeMinutes]); + + const displayValue: number | "" = Number.isFinite(nativeMinutes) + ? Number(convert(nativeMinutes, "minutes", unit).toFixed(4)) + : ""; + + const emitNative = (display: number | "", displayUnit: DurationUnit) => { + if (display === "" || !Number.isFinite(Number(display))) { + onChange(""); + return; + } + const native = convert(Number(display), displayUnit, nativeUnit); + onChange(Number(native.toFixed(6))); + }; + + return ( + + + + emitNative(v === "" ? "" : Number(v), unit) + } + clampBehavior="none" + allowDecimal + min={min != null ? convert(min, nativeUnit, unit) : 0} + disabled={disabled} + style={{ flex: 1 }} + /> +