@burdenoff/microfe-movethewheels 2026.1028.1 → 2026.1031.1
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.
- package/dist/MoveTheWheelsRoot.js +130 -122
- package/dist/MoveTheWheelsRoutes.js +55 -51
- package/dist/components/ExportStatusBanner.js +53 -0
- package/dist/components/marketplace/MarketplaceDialogs.js +524 -0
- package/dist/generated/wspace-types.d.ts +30737 -8505
- package/dist/hooks/useEntityExport.js +85 -0
- package/dist/hooks/useMarketplace.js +265 -0
- package/dist/movethewheels/components/ExportStatusBanner.d.ts +7 -0
- package/dist/movethewheels/components/marketplace/MarketplaceDialogs.d.ts +18 -0
- package/dist/movethewheels/hooks/useEntityExport.d.ts +32 -0
- package/dist/movethewheels/hooks/useMarketplace.d.ts +121 -0
- package/dist/movethewheels/pages/MarketplacePage.d.ts +1 -0
- package/dist/movethewheels/utils/entityExport.d.ts +64 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/badge-check.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/chevron-down.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/chevron-up.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/pencil.js +11 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/store.js +26 -0
- package/dist/pages/CustomersPage.js +64 -58
- package/dist/pages/DriversPage.js +113 -107
- package/dist/pages/MarketplacePage.js +503 -0
- package/dist/pages/OrdersListPage.js +85 -79
- package/dist/pages/ProductsPage.js +63 -57
- package/dist/pages/VehiclesPage.js +91 -85
- package/dist/utils/entityExport.js +54 -0
- package/package.json +3 -2
- package/src/generated/wspace-types.ts +34017 -8803
- package/src/movethewheels/MoveTheWheelsRoot.tsx +8 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
- package/src/movethewheels/components/ExportStatusBanner.tsx +78 -0
- package/src/movethewheels/components/marketplace/MarketplaceDialogs.tsx +633 -0
- package/src/movethewheels/hooks/useEntityExport.ts +130 -0
- package/src/movethewheels/hooks/useMarketplace.ts +526 -0
- package/src/movethewheels/pages/CustomersPage.tsx +11 -2
- package/src/movethewheels/pages/DriversPage.tsx +11 -2
- package/src/movethewheels/pages/MarketplacePage.tsx +571 -0
- package/src/movethewheels/pages/OrdersListPage.tsx +13 -2
- package/src/movethewheels/pages/ProductsPage.tsx +11 -2
- package/src/movethewheels/pages/VehiclesPage.tsx +11 -2
- package/src/movethewheels/utils/entityExport.ts +140 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
3
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
4
|
+
import {
|
|
5
|
+
GET_MTW_EXPORT_JOB_QUERY,
|
|
6
|
+
MTW_EXPORT_ENTITY_TYPES,
|
|
7
|
+
START_MTW_EXPORT_MUTATION,
|
|
8
|
+
triggerUrlDownload,
|
|
9
|
+
waitForExportJob,
|
|
10
|
+
type ExportJobSnapshot,
|
|
11
|
+
type MtwExportEntity,
|
|
12
|
+
} from '../utils/entityExport';
|
|
13
|
+
|
|
14
|
+
export type EntityExportState =
|
|
15
|
+
| { phase: 'idle' }
|
|
16
|
+
| { phase: 'running'; message: string }
|
|
17
|
+
| { phase: 'ready'; message: string }
|
|
18
|
+
| { phase: 'pending'; message: string; jobId: string }
|
|
19
|
+
| { phase: 'error'; message: string };
|
|
20
|
+
|
|
21
|
+
export interface UseEntityExportResult {
|
|
22
|
+
state: EntityExportState;
|
|
23
|
+
isExporting: boolean;
|
|
24
|
+
/** Start a CSV export of every record of this entity in the workspace. */
|
|
25
|
+
startExport: () => void;
|
|
26
|
+
dismiss: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Drives the list-page "Export" action: starts an `exportMtwEntities` job for
|
|
31
|
+
* the page's entity type, waits for wspace-export-svc to build the archive and
|
|
32
|
+
* starts the download. A job that takes longer than the wait budget keeps
|
|
33
|
+
* running and is reported as pending, with its id, so the user can collect it
|
|
34
|
+
* from the shared Exports page.
|
|
35
|
+
*/
|
|
36
|
+
export function useEntityExport(entity: MtwExportEntity, label: string): UseEntityExportResult {
|
|
37
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
38
|
+
useMoveTheWheels();
|
|
39
|
+
const [state, setState] = useState<EntityExportState>({ phase: 'idle' });
|
|
40
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
41
|
+
const runningRef = useRef(false);
|
|
42
|
+
|
|
43
|
+
const graphQLOptions = useMemo(
|
|
44
|
+
() => ({
|
|
45
|
+
apiGatewayUrl,
|
|
46
|
+
authToken,
|
|
47
|
+
workspaceToken,
|
|
48
|
+
workspaceId,
|
|
49
|
+
tenantId,
|
|
50
|
+
actorId: currentUser?.id,
|
|
51
|
+
}),
|
|
52
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
56
|
+
|
|
57
|
+
const startExport = useCallback(() => {
|
|
58
|
+
// Starting a second job while one is being prepared would only duplicate work.
|
|
59
|
+
if (runningRef.current) return;
|
|
60
|
+
if (!workspaceId) {
|
|
61
|
+
setState({ phase: 'error', message: 'Select a workspace before exporting.' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
abortRef.current?.abort();
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
abortRef.current = controller;
|
|
67
|
+
setState({ phase: 'running', message: `Preparing your ${label} export…` });
|
|
68
|
+
|
|
69
|
+
const run = async () => {
|
|
70
|
+
runningRef.current = true;
|
|
71
|
+
try {
|
|
72
|
+
const started = await executeGraphQL<{ exportMtwEntities: ExportJobSnapshot }>(
|
|
73
|
+
graphQLOptions,
|
|
74
|
+
START_MTW_EXPORT_MUTATION,
|
|
75
|
+
{
|
|
76
|
+
input: {
|
|
77
|
+
workspaceId,
|
|
78
|
+
entityTypes: [MTW_EXPORT_ENTITY_TYPES[entity]],
|
|
79
|
+
format: 'CSV',
|
|
80
|
+
includeMetadata: true,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
// Starting a job is not idempotent: never replay it on a transient error.
|
|
84
|
+
'SINGLE_ATTEMPT'
|
|
85
|
+
);
|
|
86
|
+
const outcome = await waitForExportJob(
|
|
87
|
+
started.exportMtwEntities,
|
|
88
|
+
async (id) => {
|
|
89
|
+
const data = await executeGraphQL<{ getExportJob: ExportJobSnapshot | null }>(
|
|
90
|
+
{ ...graphQLOptions, signal: controller.signal },
|
|
91
|
+
GET_MTW_EXPORT_JOB_QUERY,
|
|
92
|
+
{ id }
|
|
93
|
+
);
|
|
94
|
+
return data.getExportJob;
|
|
95
|
+
},
|
|
96
|
+
{ signal: controller.signal }
|
|
97
|
+
);
|
|
98
|
+
if (controller.signal.aborted) return;
|
|
99
|
+
if (outcome.kind === 'ready') {
|
|
100
|
+
triggerUrlDownload(outcome.downloadUrl);
|
|
101
|
+
setState({ phase: 'ready', message: `Your ${label} export is downloading.` });
|
|
102
|
+
} else if (outcome.kind === 'pending') {
|
|
103
|
+
setState({
|
|
104
|
+
phase: 'pending',
|
|
105
|
+
jobId: outcome.jobId,
|
|
106
|
+
message: `Your ${label} export is still being prepared. It will be available on the Exports page when it finishes.`,
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
setState({ phase: 'error', message: `Export failed: ${outcome.message}` });
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (controller.signal.aborted) return;
|
|
113
|
+
setState({
|
|
114
|
+
phase: 'error',
|
|
115
|
+
message:
|
|
116
|
+
error instanceof Error && error.message
|
|
117
|
+
? `Export failed: ${error.message}`
|
|
118
|
+
: 'Export failed. Please try again.',
|
|
119
|
+
});
|
|
120
|
+
} finally {
|
|
121
|
+
runningRef.current = false;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
void run();
|
|
125
|
+
}, [entity, graphQLOptions, label, workspaceId]);
|
|
126
|
+
|
|
127
|
+
const dismiss = useCallback(() => setState({ phase: 'idle' }), []);
|
|
128
|
+
|
|
129
|
+
return { state, isExporting: state.phase === 'running', startExport, dismiss };
|
|
130
|
+
}
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace hooks (BOFF-3988, MOV-UC-13).
|
|
3
|
+
*
|
|
4
|
+
* The workspace's directory of vetted service providers and their offerings,
|
|
5
|
+
* served by wspace-movethewheels-svc's marketplace module. A provider becomes a
|
|
6
|
+
* shipment's carrier through `assignShipmentServiceProvider`.
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
9
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
10
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
11
|
+
|
|
12
|
+
export const PROVIDER_TYPES = [
|
|
13
|
+
'CARRIER',
|
|
14
|
+
'FREIGHT_FORWARDER',
|
|
15
|
+
'THIRD_PARTY_LOGISTICS',
|
|
16
|
+
'WAREHOUSE',
|
|
17
|
+
'CUSTOMS_BROKER',
|
|
18
|
+
'LAST_MILE',
|
|
19
|
+
'COURIER',
|
|
20
|
+
'OTHER',
|
|
21
|
+
] as const;
|
|
22
|
+
export type ProviderType = (typeof PROVIDER_TYPES)[number];
|
|
23
|
+
|
|
24
|
+
export const OFFERING_CATEGORIES = [
|
|
25
|
+
'FULL_TRUCKLOAD',
|
|
26
|
+
'LESS_THAN_TRUCKLOAD',
|
|
27
|
+
'LAST_MILE',
|
|
28
|
+
'EXPRESS',
|
|
29
|
+
'OCEAN_FREIGHT',
|
|
30
|
+
'AIR_FREIGHT',
|
|
31
|
+
'RAIL_FREIGHT',
|
|
32
|
+
'WAREHOUSING',
|
|
33
|
+
'CUSTOMS_CLEARANCE',
|
|
34
|
+
'COLD_CHAIN',
|
|
35
|
+
'OTHER',
|
|
36
|
+
] as const;
|
|
37
|
+
export type OfferingCategory = (typeof OFFERING_CATEGORIES)[number];
|
|
38
|
+
|
|
39
|
+
export const AVAILABILITIES = ['AVAILABLE', 'LIMITED', 'UNAVAILABLE'] as const;
|
|
40
|
+
export type Availability = (typeof AVAILABILITIES)[number];
|
|
41
|
+
|
|
42
|
+
/** "THIRD_PARTY_LOGISTICS" → "Third party logistics". */
|
|
43
|
+
export function humanizeEnum(value: string): string {
|
|
44
|
+
const words = value.toLowerCase().split('_');
|
|
45
|
+
const text = words.join(' ');
|
|
46
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ProviderContact {
|
|
50
|
+
contactName?: string;
|
|
51
|
+
email?: string;
|
|
52
|
+
phone?: string;
|
|
53
|
+
website?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ServiceOffering {
|
|
57
|
+
id: string;
|
|
58
|
+
providerId: string;
|
|
59
|
+
name: string;
|
|
60
|
+
description: string | null;
|
|
61
|
+
category: OfferingCategory;
|
|
62
|
+
price: number | null;
|
|
63
|
+
currency: string;
|
|
64
|
+
pricingUnit: string | null;
|
|
65
|
+
transitDays: number | null;
|
|
66
|
+
availability: Availability;
|
|
67
|
+
isActive: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ServiceProvider {
|
|
71
|
+
id: string;
|
|
72
|
+
name: string;
|
|
73
|
+
type: ProviderType;
|
|
74
|
+
description: string | null;
|
|
75
|
+
rating: number | null;
|
|
76
|
+
contact: ProviderContact;
|
|
77
|
+
services: string[];
|
|
78
|
+
serviceAreas: string[];
|
|
79
|
+
isVerified: boolean;
|
|
80
|
+
isActive: boolean;
|
|
81
|
+
offeringsCount: number;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface MarketplaceStats {
|
|
86
|
+
providers: number;
|
|
87
|
+
verifiedProviders: number;
|
|
88
|
+
activeProviders: number;
|
|
89
|
+
offerings: number;
|
|
90
|
+
avgRating: number | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ProviderFilters {
|
|
94
|
+
search?: string;
|
|
95
|
+
type?: ProviderType;
|
|
96
|
+
isVerified?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ProviderInput {
|
|
100
|
+
name: string;
|
|
101
|
+
type: ProviderType;
|
|
102
|
+
description: string | null;
|
|
103
|
+
rating: number | null;
|
|
104
|
+
contactInfo: ProviderContact | null;
|
|
105
|
+
services: string[];
|
|
106
|
+
serviceAreas: string[];
|
|
107
|
+
isVerified: boolean;
|
|
108
|
+
isActive: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface OfferingInput {
|
|
112
|
+
name: string;
|
|
113
|
+
category: OfferingCategory;
|
|
114
|
+
description: string | null;
|
|
115
|
+
price: number | null;
|
|
116
|
+
currency: string;
|
|
117
|
+
pricingUnit: string | null;
|
|
118
|
+
transitDays: number | null;
|
|
119
|
+
availability: Availability;
|
|
120
|
+
isActive: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface ApiProvider {
|
|
124
|
+
id: string;
|
|
125
|
+
name: string;
|
|
126
|
+
type: ProviderType;
|
|
127
|
+
description?: string | null;
|
|
128
|
+
rating?: number | null;
|
|
129
|
+
contactInfo?: unknown;
|
|
130
|
+
services: string[];
|
|
131
|
+
serviceAreas: string[];
|
|
132
|
+
isVerified: boolean;
|
|
133
|
+
isActive: boolean;
|
|
134
|
+
offeringsCount: number;
|
|
135
|
+
createdAt: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const PROVIDER_FIELDS = `
|
|
139
|
+
id
|
|
140
|
+
name
|
|
141
|
+
type
|
|
142
|
+
description
|
|
143
|
+
rating
|
|
144
|
+
contactInfo
|
|
145
|
+
services
|
|
146
|
+
serviceAreas
|
|
147
|
+
isVerified
|
|
148
|
+
isActive
|
|
149
|
+
offeringsCount
|
|
150
|
+
createdAt
|
|
151
|
+
`;
|
|
152
|
+
|
|
153
|
+
const OFFERING_FIELDS = `
|
|
154
|
+
id
|
|
155
|
+
providerId
|
|
156
|
+
name
|
|
157
|
+
description
|
|
158
|
+
category
|
|
159
|
+
price
|
|
160
|
+
currency
|
|
161
|
+
pricingUnit
|
|
162
|
+
transitDays
|
|
163
|
+
availability
|
|
164
|
+
isActive
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
const LIST_PROVIDERS_QUERY = `
|
|
168
|
+
query ListMtwServiceProviders($filter: MtwServiceProviderFilterInput, $pagination: PaginationInput) {
|
|
169
|
+
serviceProviders(filter: $filter, pagination: $pagination) {
|
|
170
|
+
${PROVIDER_FIELDS}
|
|
171
|
+
}
|
|
172
|
+
serviceProvidersCount(filter: $filter)
|
|
173
|
+
}
|
|
174
|
+
`;
|
|
175
|
+
|
|
176
|
+
const MARKETPLACE_STATS_QUERY = `
|
|
177
|
+
query MtwMarketplaceStats {
|
|
178
|
+
marketplaceStats {
|
|
179
|
+
providers
|
|
180
|
+
verifiedProviders
|
|
181
|
+
activeProviders
|
|
182
|
+
offerings
|
|
183
|
+
avgRating
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
const LIST_OFFERINGS_QUERY = `
|
|
189
|
+
query ListMtwServiceOfferings($filter: MtwServiceOfferingFilterInput, $pagination: PaginationInput) {
|
|
190
|
+
serviceOfferings(filter: $filter, pagination: $pagination) {
|
|
191
|
+
${OFFERING_FIELDS}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
`;
|
|
195
|
+
|
|
196
|
+
const PROVIDER_SHIPMENTS_QUERY = `
|
|
197
|
+
query MtwProviderShipments($filter: ShipmentFilterInput, $pagination: PaginationInput) {
|
|
198
|
+
shipments(filter: $filter, pagination: $pagination) {
|
|
199
|
+
id
|
|
200
|
+
shipmentNumber
|
|
201
|
+
status
|
|
202
|
+
}
|
|
203
|
+
shipmentsCount(filter: $filter)
|
|
204
|
+
}
|
|
205
|
+
`;
|
|
206
|
+
|
|
207
|
+
const CREATE_PROVIDER_MUTATION = `
|
|
208
|
+
mutation CreateMtwServiceProvider($input: MtwCreateServiceProviderInput!) {
|
|
209
|
+
createServiceProvider(input: $input) {
|
|
210
|
+
${PROVIDER_FIELDS}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
`;
|
|
214
|
+
|
|
215
|
+
const UPDATE_PROVIDER_MUTATION = `
|
|
216
|
+
mutation UpdateMtwServiceProvider($id: ID!, $input: MtwUpdateServiceProviderInput!) {
|
|
217
|
+
updateServiceProvider(id: $id, input: $input) {
|
|
218
|
+
${PROVIDER_FIELDS}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
`;
|
|
222
|
+
|
|
223
|
+
const DELETE_PROVIDER_MUTATION = `
|
|
224
|
+
mutation DeleteMtwServiceProvider($id: ID!) {
|
|
225
|
+
deleteServiceProvider(id: $id)
|
|
226
|
+
}
|
|
227
|
+
`;
|
|
228
|
+
|
|
229
|
+
const CREATE_OFFERING_MUTATION = `
|
|
230
|
+
mutation CreateMtwServiceOffering($input: MtwCreateServiceOfferingInput!) {
|
|
231
|
+
createServiceOffering(input: $input) {
|
|
232
|
+
${OFFERING_FIELDS}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
`;
|
|
236
|
+
|
|
237
|
+
const UPDATE_OFFERING_MUTATION = `
|
|
238
|
+
mutation UpdateMtwServiceOffering($id: ID!, $input: MtwUpdateServiceOfferingInput!) {
|
|
239
|
+
updateServiceOffering(id: $id, input: $input) {
|
|
240
|
+
${OFFERING_FIELDS}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
const DELETE_OFFERING_MUTATION = `
|
|
246
|
+
mutation DeleteMtwServiceOffering($id: ID!) {
|
|
247
|
+
deleteServiceOffering(id: $id)
|
|
248
|
+
}
|
|
249
|
+
`;
|
|
250
|
+
|
|
251
|
+
const ASSIGN_SHIPMENT_MUTATION = `
|
|
252
|
+
mutation AssignMtwShipmentServiceProvider($shipmentId: ID!, $providerId: ID) {
|
|
253
|
+
assignShipmentServiceProvider(shipmentId: $shipmentId, providerId: $providerId) {
|
|
254
|
+
id
|
|
255
|
+
shipmentNumber
|
|
256
|
+
carrierId
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
`;
|
|
260
|
+
|
|
261
|
+
function readContact(value: unknown): ProviderContact {
|
|
262
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
263
|
+
const record = value as Record<string, unknown>;
|
|
264
|
+
const pick = (key: string) =>
|
|
265
|
+
typeof record[key] === 'string' && (record[key] as string).trim()
|
|
266
|
+
? (record[key] as string)
|
|
267
|
+
: undefined;
|
|
268
|
+
return {
|
|
269
|
+
contactName: pick('contactName'),
|
|
270
|
+
email: pick('email'),
|
|
271
|
+
phone: pick('phone'),
|
|
272
|
+
website: pick('website'),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function mapProvider(provider: ApiProvider): ServiceProvider {
|
|
277
|
+
return {
|
|
278
|
+
id: provider.id,
|
|
279
|
+
name: provider.name,
|
|
280
|
+
type: provider.type,
|
|
281
|
+
description: provider.description ?? null,
|
|
282
|
+
rating: provider.rating ?? null,
|
|
283
|
+
contact: readContact(provider.contactInfo),
|
|
284
|
+
services: provider.services,
|
|
285
|
+
serviceAreas: provider.serviceAreas,
|
|
286
|
+
isVerified: provider.isVerified,
|
|
287
|
+
isActive: provider.isActive,
|
|
288
|
+
offeringsCount: provider.offeringsCount,
|
|
289
|
+
createdAt: provider.createdAt,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function useGraphQLOptions() {
|
|
294
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
295
|
+
useMoveTheWheels();
|
|
296
|
+
return useMemo(
|
|
297
|
+
() => ({
|
|
298
|
+
apiGatewayUrl,
|
|
299
|
+
authToken,
|
|
300
|
+
workspaceToken,
|
|
301
|
+
workspaceId,
|
|
302
|
+
tenantId,
|
|
303
|
+
actorId: currentUser?.id,
|
|
304
|
+
}),
|
|
305
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function toError(error: unknown, fallback: string): Error {
|
|
310
|
+
return error instanceof Error ? error : new Error(fallback);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function useServiceProviders(options: {
|
|
314
|
+
filters: ProviderFilters;
|
|
315
|
+
limit: number;
|
|
316
|
+
offset: number;
|
|
317
|
+
}) {
|
|
318
|
+
const graphQLOptions = useGraphQLOptions();
|
|
319
|
+
const { limit, offset } = options;
|
|
320
|
+
const { search, type, isVerified } = options.filters;
|
|
321
|
+
const [providers, setProviders] = useState<ServiceProvider[]>([]);
|
|
322
|
+
const [total, setTotal] = useState(0);
|
|
323
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
324
|
+
const [error, setError] = useState<Error | null>(null);
|
|
325
|
+
|
|
326
|
+
const load = useCallback(async () => {
|
|
327
|
+
setIsLoading(true);
|
|
328
|
+
setError(null);
|
|
329
|
+
try {
|
|
330
|
+
const data = await executeGraphQL<{
|
|
331
|
+
serviceProviders: ApiProvider[];
|
|
332
|
+
serviceProvidersCount: number;
|
|
333
|
+
}>(graphQLOptions, LIST_PROVIDERS_QUERY, {
|
|
334
|
+
filter: {
|
|
335
|
+
search: search?.trim() || undefined,
|
|
336
|
+
type,
|
|
337
|
+
isVerified,
|
|
338
|
+
},
|
|
339
|
+
pagination: { limit, offset },
|
|
340
|
+
});
|
|
341
|
+
setProviders(data.serviceProviders.map(mapProvider));
|
|
342
|
+
setTotal(data.serviceProvidersCount);
|
|
343
|
+
} catch (loadError) {
|
|
344
|
+
setError(toError(loadError, 'Failed to load service providers'));
|
|
345
|
+
} finally {
|
|
346
|
+
setIsLoading(false);
|
|
347
|
+
}
|
|
348
|
+
}, [graphQLOptions, search, type, isVerified, limit, offset]);
|
|
349
|
+
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
void load();
|
|
352
|
+
}, [load]);
|
|
353
|
+
|
|
354
|
+
return { providers, total, isLoading, error, refetch: load };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function useMarketplaceStats(refreshKey: number) {
|
|
358
|
+
const graphQLOptions = useGraphQLOptions();
|
|
359
|
+
const [stats, setStats] = useState<MarketplaceStats | null>(null);
|
|
360
|
+
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
let cancelled = false;
|
|
363
|
+
executeGraphQL<{ marketplaceStats: MarketplaceStats }>(graphQLOptions, MARKETPLACE_STATS_QUERY)
|
|
364
|
+
.then((data) => {
|
|
365
|
+
if (!cancelled) setStats(data.marketplaceStats);
|
|
366
|
+
})
|
|
367
|
+
.catch(() => {
|
|
368
|
+
// Tiles fall back to "—"; the directory itself reports load errors.
|
|
369
|
+
if (!cancelled) setStats(null);
|
|
370
|
+
});
|
|
371
|
+
return () => {
|
|
372
|
+
cancelled = true;
|
|
373
|
+
};
|
|
374
|
+
}, [graphQLOptions, refreshKey]);
|
|
375
|
+
|
|
376
|
+
return stats;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function useProviderOfferings(providerId: string | null, refreshKey: number) {
|
|
380
|
+
const graphQLOptions = useGraphQLOptions();
|
|
381
|
+
const [offerings, setOfferings] = useState<ServiceOffering[]>([]);
|
|
382
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
383
|
+
const [error, setError] = useState<Error | null>(null);
|
|
384
|
+
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
if (!providerId) {
|
|
387
|
+
setOfferings([]);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
let cancelled = false;
|
|
391
|
+
setIsLoading(true);
|
|
392
|
+
setError(null);
|
|
393
|
+
executeGraphQL<{ serviceOfferings: ServiceOffering[] }>(graphQLOptions, LIST_OFFERINGS_QUERY, {
|
|
394
|
+
filter: { providerId },
|
|
395
|
+
pagination: { limit: 100, offset: 0 },
|
|
396
|
+
})
|
|
397
|
+
.then((data) => {
|
|
398
|
+
if (!cancelled) setOfferings(data.serviceOfferings);
|
|
399
|
+
})
|
|
400
|
+
.catch((loadError: unknown) => {
|
|
401
|
+
if (!cancelled) setError(toError(loadError, 'Failed to load offerings'));
|
|
402
|
+
})
|
|
403
|
+
.finally(() => {
|
|
404
|
+
if (!cancelled) setIsLoading(false);
|
|
405
|
+
});
|
|
406
|
+
return () => {
|
|
407
|
+
cancelled = true;
|
|
408
|
+
};
|
|
409
|
+
}, [graphQLOptions, providerId, refreshKey]);
|
|
410
|
+
|
|
411
|
+
return { offerings, isLoading, error };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface ShipmentOption {
|
|
415
|
+
id: string;
|
|
416
|
+
shipmentNumber: string;
|
|
417
|
+
status: string;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Shipments, optionally only those whose carrier is the given provider. */
|
|
421
|
+
export function useShipmentOptions(options: {
|
|
422
|
+
search?: string;
|
|
423
|
+
carrierId?: string;
|
|
424
|
+
limit: number;
|
|
425
|
+
}) {
|
|
426
|
+
const graphQLOptions = useGraphQLOptions();
|
|
427
|
+
const { search, carrierId, limit } = options;
|
|
428
|
+
const [shipments, setShipments] = useState<ShipmentOption[]>([]);
|
|
429
|
+
const [total, setTotal] = useState(0);
|
|
430
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
431
|
+
|
|
432
|
+
const load = useCallback(async () => {
|
|
433
|
+
setIsLoading(true);
|
|
434
|
+
try {
|
|
435
|
+
const data = await executeGraphQL<{ shipments: ShipmentOption[]; shipmentsCount: number }>(
|
|
436
|
+
graphQLOptions,
|
|
437
|
+
PROVIDER_SHIPMENTS_QUERY,
|
|
438
|
+
{
|
|
439
|
+
filter: { search: search?.trim() || undefined, carrierId },
|
|
440
|
+
pagination: { limit, offset: 0 },
|
|
441
|
+
}
|
|
442
|
+
);
|
|
443
|
+
setShipments(data.shipments);
|
|
444
|
+
setTotal(data.shipmentsCount);
|
|
445
|
+
} catch {
|
|
446
|
+
setShipments([]);
|
|
447
|
+
setTotal(0);
|
|
448
|
+
} finally {
|
|
449
|
+
setIsLoading(false);
|
|
450
|
+
}
|
|
451
|
+
}, [graphQLOptions, search, carrierId, limit]);
|
|
452
|
+
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
void load();
|
|
455
|
+
}, [load]);
|
|
456
|
+
|
|
457
|
+
return { shipments, total, isLoading, refetch: load };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function useMarketplaceActions() {
|
|
461
|
+
const graphQLOptions = useGraphQLOptions();
|
|
462
|
+
|
|
463
|
+
return useMemo(
|
|
464
|
+
() => ({
|
|
465
|
+
createProvider: async (input: ProviderInput) => {
|
|
466
|
+
const data = await executeGraphQL<{ createServiceProvider: ApiProvider }>(
|
|
467
|
+
graphQLOptions,
|
|
468
|
+
CREATE_PROVIDER_MUTATION,
|
|
469
|
+
{ input }
|
|
470
|
+
);
|
|
471
|
+
return mapProvider(data.createServiceProvider);
|
|
472
|
+
},
|
|
473
|
+
updateProvider: async (id: string, input: Partial<ProviderInput>) => {
|
|
474
|
+
const data = await executeGraphQL<{ updateServiceProvider: ApiProvider }>(
|
|
475
|
+
graphQLOptions,
|
|
476
|
+
UPDATE_PROVIDER_MUTATION,
|
|
477
|
+
{ id, input }
|
|
478
|
+
);
|
|
479
|
+
return mapProvider(data.updateServiceProvider);
|
|
480
|
+
},
|
|
481
|
+
deleteProvider: async (id: string) => {
|
|
482
|
+
const data = await executeGraphQL<{ deleteServiceProvider: boolean }>(
|
|
483
|
+
graphQLOptions,
|
|
484
|
+
DELETE_PROVIDER_MUTATION,
|
|
485
|
+
{ id }
|
|
486
|
+
);
|
|
487
|
+
return data.deleteServiceProvider;
|
|
488
|
+
},
|
|
489
|
+
createOffering: async (providerId: string, input: OfferingInput) => {
|
|
490
|
+
const data = await executeGraphQL<{ createServiceOffering: ServiceOffering }>(
|
|
491
|
+
graphQLOptions,
|
|
492
|
+
CREATE_OFFERING_MUTATION,
|
|
493
|
+
{ input: { ...input, providerId } }
|
|
494
|
+
);
|
|
495
|
+
return data.createServiceOffering;
|
|
496
|
+
},
|
|
497
|
+
updateOffering: async (id: string, input: Partial<OfferingInput>) => {
|
|
498
|
+
const data = await executeGraphQL<{ updateServiceOffering: ServiceOffering }>(
|
|
499
|
+
graphQLOptions,
|
|
500
|
+
UPDATE_OFFERING_MUTATION,
|
|
501
|
+
{ id, input }
|
|
502
|
+
);
|
|
503
|
+
return data.updateServiceOffering;
|
|
504
|
+
},
|
|
505
|
+
deleteOffering: async (id: string) => {
|
|
506
|
+
const data = await executeGraphQL<{ deleteServiceOffering: boolean }>(
|
|
507
|
+
graphQLOptions,
|
|
508
|
+
DELETE_OFFERING_MUTATION,
|
|
509
|
+
{ id }
|
|
510
|
+
);
|
|
511
|
+
return data.deleteServiceOffering;
|
|
512
|
+
},
|
|
513
|
+
assignShipment: async (shipmentId: string, providerId: string | null) => {
|
|
514
|
+
const data = await executeGraphQL<{
|
|
515
|
+
assignShipmentServiceProvider: {
|
|
516
|
+
id: string;
|
|
517
|
+
shipmentNumber: string;
|
|
518
|
+
carrierId: string | null;
|
|
519
|
+
};
|
|
520
|
+
}>(graphQLOptions, ASSIGN_SHIPMENT_MUTATION, { shipmentId, providerId });
|
|
521
|
+
return data.assignShipmentServiceProvider;
|
|
522
|
+
},
|
|
523
|
+
}),
|
|
524
|
+
[graphQLOptions]
|
|
525
|
+
);
|
|
526
|
+
}
|
|
@@ -15,10 +15,18 @@ import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
|
|
15
15
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
16
16
|
import { formatDate } from '../utils/formatters';
|
|
17
17
|
import { joinPath } from '../utils/navigation';
|
|
18
|
+
import { useEntityExport } from '../hooks/useEntityExport';
|
|
19
|
+
import { ExportStatusBanner } from '../components/ExportStatusBanner';
|
|
18
20
|
import type { Customer } from '../types';
|
|
19
21
|
|
|
20
22
|
export default function CustomersPage() {
|
|
21
23
|
const { basePath, navigate } = useMoveTheWheels();
|
|
24
|
+
const {
|
|
25
|
+
state: exportState,
|
|
26
|
+
isExporting,
|
|
27
|
+
startExport,
|
|
28
|
+
dismiss: dismissExport,
|
|
29
|
+
} = useEntityExport('customers', 'customer');
|
|
22
30
|
const [page, setPage] = useState(0);
|
|
23
31
|
const [pageSize, setPageSize] = useState(25);
|
|
24
32
|
const [search, setSearch] = useState('');
|
|
@@ -104,9 +112,9 @@ export default function CustomersPage() {
|
|
|
104
112
|
actions={[
|
|
105
113
|
{ label: 'Refresh', icon: <RefreshCw size={16} />, onSelect: () => refetch() },
|
|
106
114
|
{
|
|
107
|
-
label: 'Export',
|
|
115
|
+
label: isExporting ? 'Exporting…' : 'Export',
|
|
108
116
|
icon: <Download size={16} />,
|
|
109
|
-
onSelect:
|
|
117
|
+
onSelect: startExport,
|
|
110
118
|
},
|
|
111
119
|
]}
|
|
112
120
|
/>
|
|
@@ -117,6 +125,7 @@ export default function CustomersPage() {
|
|
|
117
125
|
satisfaction ratings here, and open a customer to see their order history before booking the
|
|
118
126
|
next shipment.
|
|
119
127
|
</PagePurpose>
|
|
128
|
+
<ExportStatusBanner state={exportState} onDismiss={dismissExport} />
|
|
120
129
|
|
|
121
130
|
{/* Customer base at a glance — premium stat cards */}
|
|
122
131
|
<div>
|