@burdenoff/microfe-movethewheels 2026.1031.6 → 2026.1101.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/MoveTheWheelsRoot.js +141 -133
  2. package/dist/MoveTheWheelsRoutes.js +29 -25
  3. package/dist/components/EpodCaptureForm.js +256 -0
  4. package/dist/generated/wspace-types.d.ts +9012 -132
  5. package/dist/hooks/index.js +1 -0
  6. package/dist/hooks/useCarrierIntegrations.js +141 -0
  7. package/dist/hooks/useDriverDay.js +153 -0
  8. package/dist/hooks/useOrders.js +1 -1
  9. package/dist/index.js +27 -26
  10. package/dist/movethewheels/components/EpodCaptureForm.d.ts +12 -0
  11. package/dist/movethewheels/hooks/index.d.ts +1 -0
  12. package/dist/movethewheels/hooks/useCarrierIntegrations.d.ts +39 -0
  13. package/dist/movethewheels/hooks/useDriverDay.d.ts +53 -0
  14. package/dist/movethewheels/pages/IntegrationsPage.d.ts +6 -0
  15. package/dist/movethewheels/utils/carrierIntegrations.d.ts +88 -0
  16. package/dist/movethewheels/utils/driverNavigation.d.ts +31 -0
  17. package/dist/node_modules/lucide-react/dist/esm/icons/calendar-days.js +50 -0
  18. package/dist/node_modules/lucide-react/dist/esm/icons/copy.js +16 -0
  19. package/dist/node_modules/lucide-react/dist/esm/icons/pause.js +19 -0
  20. package/dist/node_modules/lucide-react/dist/esm/icons/play.js +8 -0
  21. package/dist/node_modules/lucide-react/dist/esm/icons/wifi-off.js +34 -0
  22. package/dist/pages/DeliveryCapturePage.js +450 -321
  23. package/dist/pages/IntegrationsPage.js +616 -0
  24. package/dist/pages/SettingsPage.js +168 -150
  25. package/dist/utils/carrierIntegrations.js +105 -0
  26. package/dist/utils/driverNavigation.js +51 -0
  27. package/dist/utils/formatters.js +18 -9
  28. package/package.json +4 -2
  29. package/src/generated/wspace-types.ts +10266 -142
  30. package/src/movethewheels/MoveTheWheelsRoot.tsx +8 -0
  31. package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
  32. package/src/movethewheels/components/EpodCaptureForm.tsx +343 -0
  33. package/src/movethewheels/hooks/index.ts +9 -0
  34. package/src/movethewheels/hooks/useCarrierIntegrations.ts +221 -0
  35. package/src/movethewheels/hooks/useDriverDay.ts +289 -0
  36. package/src/movethewheels/pages/DeliveryCapturePage.tsx +478 -392
  37. package/src/movethewheels/pages/IntegrationsPage.tsx +752 -0
  38. package/src/movethewheels/pages/SettingsPage.tsx +19 -3
  39. package/src/movethewheels/utils/carrierIntegrations.ts +252 -0
  40. package/src/movethewheels/utils/driverNavigation.ts +103 -0
@@ -44,6 +44,7 @@ import {
44
44
  Skeleton,
45
45
  } from '../components/ui';
46
46
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
47
+ import { joinPath } from '../utils/navigation';
47
48
  import { useModuleSettings } from '../providers/ModuleSettingsProvider';
48
49
  import { formatCurrency, formatDateTime, formatDistance, formatWeight } from '../utils/formatters';
49
50
  import {
@@ -111,8 +112,8 @@ const PLATFORM_LINKS = [
111
112
  },
112
113
  {
113
114
  id: 'integrations',
114
- title: 'Integrations',
115
- description: 'Carrier, ERP and telematics connections',
115
+ title: 'Platform integrations',
116
+ description: 'ERP, messaging and app connections shared across products',
116
117
  path: '/integrations/overview',
117
118
  icon: Plug,
118
119
  },
@@ -128,7 +129,7 @@ export default function SettingsPage() {
128
129
  }
129
130
 
130
131
  function SettingsWorkbench() {
131
- const { navigate, workspaceId, currentUser } = useMoveTheWheels();
132
+ const { navigate, basePath, workspaceId, currentUser } = useMoveTheWheels();
132
133
  const { settings, isLoaded, error: loadError, save, reload } = useModuleSettings();
133
134
  const permissions = usePermissions();
134
135
  const [draft, setDraft] = useState<Draft>(() => toDraft(settings));
@@ -487,6 +488,21 @@ function SettingsWorkbench() {
487
488
  </CardDescription>
488
489
  </CardHeader>
489
490
  <CardContent className="space-y-2">
491
+ <button
492
+ type="button"
493
+ className="flex w-full items-start gap-3 rounded-lg border border-border-default px-3 py-2 text-left transition-colors hover:bg-surface-secondary"
494
+ onClick={() => navigate(joinPath(basePath, 'integrations'))}
495
+ >
496
+ <Plug size={18} className="mt-0.5 shrink-0 text-text-secondary" />
497
+ <span className="min-w-0 flex-1">
498
+ <span className="block text-sm font-medium text-text-primary">
499
+ Tracking integrations
500
+ </span>
501
+ <span className="block text-xs text-text-secondary">
502
+ Carrier, ELD and GPS feeds that update shipments automatically
503
+ </span>
504
+ </span>
505
+ </button>
490
506
  {PLATFORM_LINKS.map((link) => {
491
507
  const Icon = link.icon;
492
508
  return (
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Carrier and telematics integrations (BOFF-4404).
3
+ *
4
+ * A workspace connects an external carrier, ELD or GPS feed to MoveTheWheels
5
+ * as a signed webhook. Each delivery is verified with HMAC-SHA256 over the raw
6
+ * request body using the integration's signing secret, and every accepted
7
+ * update becomes a tracking event on the matching shipment.
8
+ *
9
+ * This module holds the GraphQL documents and the pure helpers the page uses;
10
+ * the network calls live in `hooks/useCarrierIntegrations`.
11
+ */
12
+ import { resolveGraphqlUrl } from './graphqlClient';
13
+
14
+ /** Providers the service can ingest today. Vendor adapters are refused by the API. */
15
+ export const SUPPORTED_PROVIDERS = ['GENERIC_WEBHOOK'] as const;
16
+ export type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number];
17
+
18
+ /** Named networks that need a signed vendor agreement before an adapter can ship. */
19
+ export const ROADMAP_PROVIDERS = [
20
+ { id: 'PROJECT44', label: 'project44' },
21
+ { id: 'FOURKITES', label: 'FourKites' },
22
+ { id: 'DESCARTES', label: 'Descartes' },
23
+ ] as const;
24
+
25
+ export const INTEGRATION_STATUSES = ['ACTIVE', 'PAUSED', 'DISABLED'] as const;
26
+ export type IntegrationStatus = (typeof INTEGRATION_STATUSES)[number];
27
+
28
+ export type IntegrationProvider = 'GENERIC_WEBHOOK' | 'PROJECT44' | 'FOURKITES' | 'DESCARTES';
29
+
30
+ export type IntegrationEventStatus =
31
+ 'RECEIVED' | 'ACCEPTED' | 'PARTIAL' | 'REJECTED' | 'DUPLICATE' | 'CONFLICT';
32
+
33
+ /** Tracking event types a delivery may carry (the service's TrackingEventType). */
34
+ export const TRACKING_EVENT_TYPES = [
35
+ 'CREATED',
36
+ 'PICKED_UP',
37
+ 'IN_TRANSIT',
38
+ 'ARRIVED',
39
+ 'DEPARTED',
40
+ 'OUT_FOR_DELIVERY',
41
+ 'DELIVERED',
42
+ 'FAILED',
43
+ 'RETURNED',
44
+ ] as const;
45
+
46
+ /** The service refuses shorter secrets; the generator always produces 64 hex characters. */
47
+ export const MIN_SECRET_LENGTH = 32;
48
+
49
+ /** Header the workspace gateway forwards; custom headers do not survive the gateway. */
50
+ export const SIGNATURE_HEADER = 'x-webhook-signature';
51
+
52
+ /** Updates accepted in one delivery (a JSON array of at most this many objects). */
53
+ export const MAX_UPDATES_PER_DELIVERY = 5;
54
+
55
+ export interface CarrierIntegration {
56
+ id: string;
57
+ workspaceId: string;
58
+ provider: IntegrationProvider;
59
+ name: string;
60
+ status: IntegrationStatus;
61
+ lastSyncAt: string | null;
62
+ lastErrorAt: string | null;
63
+ lastErrorMessage: string | null;
64
+ createdAt: string;
65
+ updatedAt: string;
66
+ }
67
+
68
+ export interface CarrierIntegrationEvent {
69
+ id: string;
70
+ integrationId: string;
71
+ externalEventId: string | null;
72
+ shipmentId: string | null;
73
+ eventType: string | null;
74
+ status: IntegrationEventStatus;
75
+ errorMessage: string | null;
76
+ receivedAt: string;
77
+ processedAt: string | null;
78
+ }
79
+
80
+ const INTEGRATION_FIELDS = `
81
+ id
82
+ workspaceId
83
+ provider
84
+ name
85
+ status
86
+ lastSyncAt
87
+ lastErrorAt
88
+ lastErrorMessage
89
+ createdAt
90
+ updatedAt
91
+ `;
92
+
93
+ export const LIST_INTEGRATIONS_QUERY = `
94
+ query MtwCarrierIntegrations($filter: IntegrationFilterInput, $pagination: PaginationInput) {
95
+ integrations(filter: $filter, pagination: $pagination) {
96
+ ${INTEGRATION_FIELDS}
97
+ }
98
+ integrationsCount(filter: $filter)
99
+ }
100
+ `;
101
+
102
+ export const INTEGRATION_COUNTS_QUERY = `
103
+ query MtwCarrierIntegrationCounts {
104
+ all: integrationsCount
105
+ active: integrationsCount(filter: { status: ACTIVE })
106
+ }
107
+ `;
108
+
109
+ export const INTEGRATION_EVENTS_QUERY = `
110
+ query MtwCarrierIntegrationEvents($integrationId: ID, $pagination: PaginationInput) {
111
+ integrationEvents(integrationId: $integrationId, pagination: $pagination) {
112
+ id
113
+ integrationId
114
+ externalEventId
115
+ shipmentId
116
+ eventType
117
+ status
118
+ errorMessage
119
+ receivedAt
120
+ processedAt
121
+ }
122
+ }
123
+ `;
124
+
125
+ export const CREATE_INTEGRATION_MUTATION = `
126
+ mutation CreateMtwCarrierIntegration($input: CreateIntegrationInput!) {
127
+ createIntegration(input: $input) {
128
+ ${INTEGRATION_FIELDS}
129
+ }
130
+ }
131
+ `;
132
+
133
+ export const UPDATE_INTEGRATION_MUTATION = `
134
+ mutation UpdateMtwCarrierIntegration($id: ID!, $input: UpdateIntegrationInput!) {
135
+ updateIntegration(id: $id, input: $input) {
136
+ ${INTEGRATION_FIELDS}
137
+ }
138
+ }
139
+ `;
140
+
141
+ export const DELETE_INTEGRATION_MUTATION = `
142
+ mutation DeleteMtwCarrierIntegration($id: ID!) {
143
+ deleteIntegration(id: $id)
144
+ }
145
+ `;
146
+
147
+ /** Minimal source of randomness, so tests can supply a deterministic one. */
148
+ export interface RandomSource {
149
+ getRandomValues<T extends ArrayBufferView>(array: T): T;
150
+ }
151
+
152
+ /** A fresh 256-bit signing secret as 64 lowercase hex characters. */
153
+ export function generateSigningSecret(random: RandomSource = globalThis.crypto): string {
154
+ const bytes = random.getRandomValues(new Uint8Array(32));
155
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
156
+ }
157
+
158
+ /** Returns an error message for an unusable secret, or null when it is fine. */
159
+ export function signingSecretError(secret: string): string | null {
160
+ if (secret.trim().length < MIN_SECRET_LENGTH) {
161
+ return `Use a signing secret of at least ${MIN_SECRET_LENGTH} characters.`;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ /** Returns an error message for an unusable name, or null when it is fine. */
167
+ export function integrationNameError(name: string): string | null {
168
+ const trimmed = name.trim();
169
+ if (!trimmed) return 'Give the integration a name.';
170
+ if (trimmed.length > 120) return 'Keep the name under 120 characters.';
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * The public URL a carrier posts deliveries to. It lives on the same workspace
176
+ * gateway host the app already talks to: `/api/integrations/webhook/<workspace>/<integration>`.
177
+ * A relative gateway URL (local development behind the dev proxy) resolves
178
+ * against `fallbackOrigin`.
179
+ */
180
+ export function webhookUrl(
181
+ apiGatewayUrl: string | undefined,
182
+ workspaceId: string,
183
+ integrationId: string,
184
+ fallbackOrigin: string
185
+ ): string {
186
+ const origin = new URL(resolveGraphqlUrl(apiGatewayUrl), fallbackOrigin).origin;
187
+ return `${origin}/api/integrations/webhook/${workspaceId}/${integrationId}`;
188
+ }
189
+
190
+ /** An example delivery body for the setup instructions. */
191
+ export function examplePayload(now: Date = new Date()): string {
192
+ return JSON.stringify(
193
+ {
194
+ externalEventId: 'evt_0001',
195
+ trackingNumber: 'YOUR-TRACKING-NUMBER',
196
+ eventType: 'IN_TRANSIT',
197
+ timestamp: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
198
+ location: { lat: 12.9716, lng: 77.5946, address: 'Bengaluru hub' },
199
+ details: 'Departed origin hub',
200
+ },
201
+ null,
202
+ 2
203
+ );
204
+ }
205
+
206
+ /** Quotes a value for a POSIX shell: single quotes, with embedded ones escaped. */
207
+ export function shellQuote(value: string): string {
208
+ return `'${value.split("'").join(`'\\''`)}'`;
209
+ }
210
+
211
+ /**
212
+ * A copy-paste shell example that signs and sends one delivery. The secret is
213
+ * referenced as an environment variable so it never appears in the snippet.
214
+ */
215
+ export function curlExample(url: string): string {
216
+ return [
217
+ `BODY='{"externalEventId":"evt_0001","trackingNumber":"YOUR-TRACKING-NUMBER","eventType":"IN_TRANSIT"}'`,
218
+ `SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$MTW_WEBHOOK_SECRET" -hex | sed 's/^.* //')`,
219
+ `curl -X POST ${shellQuote(url)} \\`,
220
+ ` -H 'Content-Type: application/json' \\`,
221
+ ` -H "${SIGNATURE_HEADER}: sha256=$SIG" \\`,
222
+ ` --data "$BODY"`,
223
+ ].join('\n');
224
+ }
225
+
226
+ /** "OUT_FOR_DELIVERY" → "Out for delivery". */
227
+ export function humanize(value: string): string {
228
+ const text = value.toLowerCase().split('_').join(' ');
229
+ return text.charAt(0).toUpperCase() + text.slice(1);
230
+ }
231
+
232
+ export type BadgeTone = 'default' | 'secondary' | 'outline' | 'destructive';
233
+
234
+ export function integrationStatusTone(status: IntegrationStatus): BadgeTone {
235
+ if (status === 'ACTIVE') return 'default';
236
+ if (status === 'PAUSED') return 'secondary';
237
+ return 'outline';
238
+ }
239
+
240
+ export function eventStatusTone(status: IntegrationEventStatus): BadgeTone {
241
+ if (status === 'ACCEPTED') return 'default';
242
+ if (status === 'DUPLICATE' || status === 'PARTIAL' || status === 'RECEIVED') return 'secondary';
243
+ return 'destructive';
244
+ }
245
+
246
+ /** What a webhook sender sees for each status, shown next to the status control. */
247
+ export function statusExplanation(status: IntegrationStatus): string {
248
+ if (status === 'ACTIVE') return 'Signed deliveries are accepted and become tracking events.';
249
+ if (status === 'PAUSED')
250
+ return 'Deliveries are refused with HTTP 423 so the sender can retry after you resume.';
251
+ return 'Deliveries are refused with HTTP 410. Resume to accept them again.';
252
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Turn-by-turn navigation hand-off for the driver day view (BOFF-3991).
3
+ *
4
+ * The driver app does not embed its own routing engine: it hands the stop
5
+ * (or the remaining stops, in sequence) to the device's maps app through the
6
+ * universal Google Maps URL scheme, which opens the native Google Maps / Apple
7
+ * Maps-compatible handler on Android and iOS and a browser tab on desktop.
8
+ */
9
+ import type { Address } from '../types';
10
+ import { formatAddress } from './formatters';
11
+
12
+ const MAPS_DIRECTIONS_BASE = 'https://www.google.com/maps/dir/?api=1';
13
+
14
+ /**
15
+ * Google's universal URL accepts at most 9 intermediate waypoints (plus the
16
+ * destination). Longer days are handed off in chunks: the driver restarts
17
+ * navigation from the next remaining stop once these are done.
18
+ */
19
+ export const MAX_NAVIGATION_WAYPOINTS = 9;
20
+
21
+ /** The best destination string for an address: coordinates when present, else the formatted address. */
22
+ export function navigationDestination(address: Partial<Address> | null | undefined): string | null {
23
+ if (!address) return null;
24
+ if (
25
+ typeof address.latitude === 'number' &&
26
+ typeof address.longitude === 'number' &&
27
+ Number.isFinite(address.latitude) &&
28
+ Number.isFinite(address.longitude)
29
+ ) {
30
+ return `${address.latitude},${address.longitude}`;
31
+ }
32
+ const formatted = formatAddress(address).trim();
33
+ return formatted.length > 0 ? formatted : null;
34
+ }
35
+
36
+ /** Directions from the device's current position to one stop. */
37
+ export function stopNavigationUrl(address: Partial<Address> | null | undefined): string | null {
38
+ const destination = navigationDestination(address);
39
+ if (!destination) return null;
40
+ const params = new URLSearchParams({ destination, travelmode: 'driving' });
41
+ return `${MAPS_DIRECTIONS_BASE}&${params.toString()}`;
42
+ }
43
+
44
+ /**
45
+ * Directions through the remaining stops in sequence: the last stop in the
46
+ * chunk is the destination, the ones before it are waypoints. Stops without a
47
+ * usable address are skipped. Returns null when no stop is navigable.
48
+ */
49
+ export function routeNavigationUrl(
50
+ addresses: Array<Partial<Address> | null | undefined>
51
+ ): string | null {
52
+ const destinations = addresses
53
+ .map((address) => navigationDestination(address))
54
+ .filter((value): value is string => value !== null)
55
+ .slice(0, MAX_NAVIGATION_WAYPOINTS + 1);
56
+ if (destinations.length === 0) return null;
57
+ const destination = destinations[destinations.length - 1];
58
+ const waypoints = destinations.slice(0, -1);
59
+ const params = new URLSearchParams({ destination, travelmode: 'driving' });
60
+ if (waypoints.length > 0) params.set('waypoints', waypoints.join('|'));
61
+ return `${MAPS_DIRECTIONS_BASE}&${params.toString()}`;
62
+ }
63
+
64
+ /** A `tel:` link for a phone number, keeping only dialable characters. */
65
+ export function phoneHref(phone: string | null | undefined): string | null {
66
+ if (!phone) return null;
67
+ const dialable = phone.replace(/[^\d+]/g, '');
68
+ return dialable.length >= 3 ? `tel:${dialable}` : null;
69
+ }
70
+
71
+ /** The device-local calendar date as YYYY-MM-DD (what `myDeliveryDay` expects). */
72
+ export function localDateString(date: Date): string {
73
+ const year = date.getFullYear();
74
+ const month = String(date.getMonth() + 1).padStart(2, '0');
75
+ const day = String(date.getDate()).padStart(2, '0');
76
+ return `${year}-${month}-${day}`;
77
+ }
78
+
79
+ /** Shift a YYYY-MM-DD date by whole days, staying in local calendar terms. */
80
+ export function shiftLocalDate(date: string, days: number): string {
81
+ const [year, month, day] = date.split('-').map(Number);
82
+ return localDateString(new Date(year, month - 1, day + days));
83
+ }
84
+
85
+ /** The `Date#getTimezoneOffset()` value for local midnight of a YYYY-MM-DD date (DST-correct). */
86
+ export function timezoneOffsetFor(date: string): number {
87
+ const [year, month, day] = date.split('-').map(Number);
88
+ return new Date(year, month - 1, day).getTimezoneOffset();
89
+ }
90
+
91
+ /**
92
+ * The device's IANA time zone (e.g. `Asia/Kolkata`), so the server can build
93
+ * the day window from real local midnights, including on DST transition
94
+ * days. Null when the runtime cannot report one; the fixed offset is then used.
95
+ */
96
+ export function deviceTimeZone(): string | null {
97
+ try {
98
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
99
+ return typeof zone === 'string' && zone.length > 0 ? zone : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }