@murumets-ee/commerce-ui 0.13.0

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/LICENSE ADDED
@@ -0,0 +1,94 @@
1
+ Elastic License 2.0 (ELv2)
2
+
3
+ URL: https://www.elastic.co/licensing/elastic-license
4
+
5
+ ## Acceptance
6
+
7
+ By using the software, you agree to all of the terms and conditions below.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
12
+ non-sublicensable, non-transferable license to use, copy, distribute, make
13
+ available, and prepare derivative works of the software, in each case subject
14
+ to the limitations and conditions below.
15
+
16
+ ## Limitations
17
+
18
+ You may not provide the software to third parties as a hosted or managed
19
+ service, where the service provides users with access to any substantial set
20
+ of the features or functionality of the software.
21
+
22
+ You may not move, change, disable, or circumvent the license key functionality
23
+ in the software, and you may not remove or obscure any functionality in the
24
+ software that is protected by the license key.
25
+
26
+ You may not alter, remove, or obscure any licensing, copyright, or other
27
+ notices of the licensor in the software. Any use of the licensor's trademarks
28
+ is subject to applicable law.
29
+
30
+ ## Patents
31
+
32
+ The licensor grants you a license, under any patent claims the licensor can
33
+ license, or becomes able to license, to make, have made, use, sell, offer for
34
+ sale, import and have imported the software, in each case subject to the
35
+ limitations and conditions in this license. This license does not cover any
36
+ patent claims that you cause to be infringed by modifications or additions to
37
+ the software. If you or your company make any written claim that the software
38
+ infringes or contributes to infringement of any patent, your patent license
39
+ for the software granted under these terms ends immediately. If your company
40
+ makes such a claim, your patent license ends immediately for work on behalf
41
+ of your company.
42
+
43
+ ## Notices
44
+
45
+ You must ensure that anyone who gets a copy of any part of the software from
46
+ you also gets a copy of these terms.
47
+
48
+ If you modify the software, you must include in any modified copies of the
49
+ software prominent notices stating that you have modified the software.
50
+
51
+ ## No Other Rights
52
+
53
+ These terms do not imply any licenses other than those expressly granted in
54
+ these terms.
55
+
56
+ ## Termination
57
+
58
+ If you use the software in violation of these terms, such use is not licensed,
59
+ and your licenses will automatically terminate. If the licensor provides you
60
+ with a notice of your violation, and you cease all violation of this license
61
+ no later than 30 days after you receive that notice, your licenses will be
62
+ reinstated retroactively. However, if you violate these terms after such
63
+ reinstatement, any additional violation of these terms will cause your
64
+ licenses to terminate automatically and permanently.
65
+
66
+ ## No Liability
67
+
68
+ As far as the law allows, the software comes as is, without any warranty or
69
+ condition, and the licensor will not be liable to you for any damages arising
70
+ out of these terms or the use or nature of the software, under any kind of
71
+ legal claim.
72
+
73
+ ## Definitions
74
+
75
+ The **licensor** is the entity offering these terms, and the **software** is
76
+ the software the licensor makes available under these terms, including any
77
+ portion of it.
78
+
79
+ **you** refers to the individual or entity agreeing to these terms.
80
+
81
+ **your company** is any legal entity, sole proprietorship, or other kind of
82
+ organization that you work for, plus all organizations that have control over,
83
+ are under the control of, or are under common control with that organization.
84
+ **control** means ownership of substantially all the assets of an entity, or
85
+ the power to direct the management and policies of an entity (for example, by
86
+ voting right, contract, or otherwise). Control can be direct or indirect.
87
+
88
+ **your licenses** are all the licenses granted to you for the software under
89
+ these terms.
90
+
91
+ **use** means anything you do with the software requiring one of your
92
+ licenses.
93
+
94
+ **trademark** means trademarks, service marks, and similar rights.
@@ -0,0 +1,315 @@
1
+
2
+ //#region src/types.d.ts
3
+ /**
4
+ * Shared types for the commerce admin UI surface (PR 8a-2).
5
+ *
6
+ * Server pages serialize these shapes as RSC props for the client
7
+ * components, so every field has to be JSON-safe (no Dates, no class
8
+ * instances). The pages flatten Postgres timestamps to ISO strings
9
+ * before handing them across the boundary.
10
+ */
11
+ /** Lightweight reference for picker dropdowns. Server pages populate from AdminClient.findMany. */
12
+ interface CommercePickerOption {
13
+ id: string;
14
+ label: string;
15
+ }
16
+ /**
17
+ * History row rendered on the imports page. One per `import_run` row,
18
+ * narrowed to the columns the operator actually reads. The full entity
19
+ * lives at `/admin/import_run/[id]` (auto-EntityEditPage) for deep
20
+ * inspection — this page just lists the recent batches.
21
+ */
22
+ interface ImportRunHistoryRow {
23
+ id: string;
24
+ label: string;
25
+ status: string;
26
+ /** ISO-8601 string. */
27
+ createdAt: string;
28
+ /** ISO-8601 string. Null until the worker picks up the job. */
29
+ startedAt: string | null;
30
+ /** ISO-8601 string. Null until the worker finishes. */
31
+ finishedAt: string | null;
32
+ /** Linked queue job — used to poll progress. Null when the enqueue txn rolled back. */
33
+ queueJobId: string | null;
34
+ /**
35
+ * `{ submitted, succeeded, failed, skipped, batches }`. Carried through as
36
+ * an opaque map because the worker writes it as JSONB and downstream UI
37
+ * only formats it for display.
38
+ */
39
+ totals: Record<string, number> | null;
40
+ }
41
+ /**
42
+ * `toolkit_jobs.progress` snapshot for an active import. Mirrors
43
+ * `ImportRunProgress` from `@murumets-ee/imports/runner` but JSON-safe
44
+ * (no class instances) — duplicated here so the UI package doesn't take
45
+ * a runtime dep on the runner module.
46
+ */
47
+ interface ImportProgressSnapshot {
48
+ rowsRead: number;
49
+ rowsSucceeded: number;
50
+ rowsFailed: number;
51
+ rowsSkipped: number;
52
+ batchesCompleted: number;
53
+ elapsedSeconds: number;
54
+ rowsPerSecond: number;
55
+ distinctErrorPatterns: number;
56
+ /**
57
+ * Total data-row count from the worker's pre-pass. Optional — absent
58
+ * when the worker skipped the pre-count (count failed, etc.). When
59
+ * present the UI renders `processed / total` percent + remaining-time
60
+ * estimate; otherwise falls back to bare counts.
61
+ */
62
+ totalRows?: number;
63
+ }
64
+ /**
65
+ * In-flight import job snapshot, server-rendered into the page so the
66
+ * Active panel survives a navigate-away-and-back. Mirrors the runtime
67
+ * `ActiveJob` shape that the client maintains in React state — fields
68
+ * are JSON-safe (no Date instances) so the RSC→client serialisation
69
+ * doesn't trip.
70
+ *
71
+ * Populated from the `import_run` rows whose status is `pending` or
72
+ * `running`. `progress` is read from the linked `toolkit_jobs.progress`
73
+ * column when the row has a `queueJobId`; absent otherwise (no worker
74
+ * has claimed the job yet).
75
+ */
76
+ interface ActiveImportJobInitial {
77
+ /** `toolkit_jobs.id` — drives realtime topic subscriptions. */
78
+ jobId: string;
79
+ importRunId: string;
80
+ /** Operator-readable label (`brand_slug — filename`). */
81
+ brandLabel: string;
82
+ supplierLabel: string;
83
+ filename: string;
84
+ /** Mirrors `import_run.status`: `pending` / `running` / `succeeded` / `failed`. */
85
+ status: string;
86
+ /** ISO timestamp of `import_run.createdAt` so the UI can render elapsed-from. */
87
+ startedAt: string;
88
+ /** Latest progress snapshot read from `toolkit_jobs.progress`. */
89
+ progress: ImportProgressSnapshot | null;
90
+ /** Last error message — null until the worker writes one. */
91
+ finalError: string | null;
92
+ }
93
+ /**
94
+ * Initial server-side payload for the imports admin page.
95
+ *
96
+ * Brand + supplier picker options are fetched from AdminClient at page
97
+ * render time (limit 500 — auto-mode entity list pages already cap their
98
+ * pickers at this size). The history slice is the most-recent N
99
+ * `import_run` rows; older rows are visible at `/admin/import_run`.
100
+ */
101
+ interface ImportsPageInitialData {
102
+ brands: CommercePickerOption[];
103
+ suppliers: CommercePickerOption[];
104
+ history: ImportRunHistoryRow[];
105
+ /**
106
+ * In-flight `import_run` rows (status `pending` or `running`) joined
107
+ * with their queue job's progress. Pre-rendered so the Active jobs
108
+ * panel survives a navigate-away-and-back: the page mounts already
109
+ * populated and the realtime layer takes over for subsequent
110
+ * updates. Empty array when nothing is running.
111
+ */
112
+ activeJobs: ActiveImportJobInitial[];
113
+ /** Path the client POSTs the multipart upload to. Defaults to `/api/admin/commerce/imports/run`. */
114
+ uploadEndpoint: string;
115
+ /**
116
+ * Path the client uses for the one-shot progress fetch on a
117
+ * newly-uploaded job (after the optimistic add, before the first
118
+ * realtime event arrives). Defaults to `/api/admin/queue/jobs/:id`.
119
+ */
120
+ queueJobPath: string;
121
+ /** Path the client GETs for the parts-search proxy. Used to detect "ES not configured". */
122
+ partsSearchEndpoint: string;
123
+ }
124
+ /**
125
+ * Single result row rendered on the parts-search page. Mirrors the
126
+ * shape produced by `ElasticsearchProvider`'s configured `transform`
127
+ * for the parts index — see `apps/admin-playground/lib/parts-search.ts`.
128
+ */
129
+ interface PartsSearchRow {
130
+ /** ES `_id` — `<code_normalized>__<supplier_id>`. */
131
+ id: string;
132
+ /** Score from the underlying query. Always 1.0 for term/prefix without ranking. */
133
+ score: number;
134
+ /** Display fields from the parts ES doc. */
135
+ data: {
136
+ code: string;
137
+ code_normalized: string;
138
+ brand_slug: string;
139
+ supplier_display_name: string;
140
+ name_en: string | null;
141
+ base_price_eur: number | null;
142
+ imported_at: string;
143
+ };
144
+ }
145
+ /**
146
+ * Bucket inside a parts-search facet aggregation. Mirrors
147
+ * `FacetBucket` from `@murumets-ee/search/types` — duplicated here so
148
+ * commerce-ui doesn't take a runtime dep on the search package.
149
+ */
150
+ interface PartsBrandFacet {
151
+ value: string;
152
+ count: number;
153
+ }
154
+ /**
155
+ * One facet aggregation in the parts-search response. Mirrors
156
+ * `FacetAggregation` from `@murumets-ee/search/types` — the route
157
+ * forwards `result.facets` (an ARRAY) untouched, so the client must
158
+ * find by `field` rather than reading by key.
159
+ */
160
+ interface PartsFacetAggregation {
161
+ field: string;
162
+ buckets: readonly PartsBrandFacet[];
163
+ }
164
+ /** Whole response shape from `GET /api/admin/commerce/parts-search`. */
165
+ interface PartsSearchResponse {
166
+ resource: 'parts';
167
+ capabilities: {
168
+ fullText: boolean;
169
+ ranking: boolean;
170
+ facets: boolean;
171
+ fuzzy: boolean;
172
+ prefix: boolean;
173
+ };
174
+ rows: PartsSearchRow[];
175
+ total: number;
176
+ facets: readonly PartsFacetAggregation[];
177
+ durationMs: number;
178
+ }
179
+ //#endregion
180
+ //#region src/imports/imports-page-client.d.ts
181
+ interface CommerceImportsPageClientProps {
182
+ initialData: ImportsPageInitialData;
183
+ /** Optional: disables the upload form when the operator lacks `commerce_import:create`. */
184
+ canCreate: boolean;
185
+ /** Optional: hides the active-jobs panel when the operator lacks `queue:update`. */
186
+ canPollProgress: boolean;
187
+ }
188
+ /**
189
+ * Public component. Subscribes to `queue.job.**` (active-jobs panel) and
190
+ * `imports.run.**` (history slice) for live revalidation. Must be
191
+ * mounted inside a `<RealtimeProvider>` (the admin shell does this for
192
+ * you); without one the live updates fall back to the no-op subscriber
193
+ * and only the Refresh button revalidates.
194
+ */
195
+ declare function CommerceImportsPageClient({
196
+ initialData,
197
+ canCreate,
198
+ canPollProgress
199
+ }: CommerceImportsPageClientProps): React.ReactElement;
200
+ //#endregion
201
+ //#region src/parts-search/parts-search-page-client.d.ts
202
+ /**
203
+ * Parts search admin page — client component.
204
+ *
205
+ * Bespoke search box (per PR 8a-2 plan: NOT the generic <SearchBox> from
206
+ * PR 3 — that supersedes this when it ships). Single text input + mode
207
+ * toggle + brand-facet chip group + paged result list. Talks to
208
+ * `GET /api/admin/commerce/parts-search` (the proxy registered by
209
+ * `commerceRoutes` when the consumer wires a SearchProvider).
210
+ *
211
+ * The endpoint returns `null` when the proxy isn't mounted — meaning
212
+ * the operator's deployment doesn't have ES configured. The page then
213
+ * renders a "not configured" notice rather than throwing.
214
+ *
215
+ * Search runs on form submit + on facet toggle. Pagination is offset-
216
+ * based against the server's `total`; mechanics caps at 50 rows / page
217
+ * and 1000 rows of offset, so deep pagination redirects to "narrow your
218
+ * query" rather than letting the operator skip past the cap.
219
+ */
220
+ interface CommercePartsSearchPageClientProps {
221
+ /** GET endpoint for the parts-search proxy. */
222
+ endpoint: string;
223
+ }
224
+ declare function CommercePartsSearchPageClient({
225
+ endpoint
226
+ }: CommercePartsSearchPageClientProps): React.ReactElement;
227
+ //#endregion
228
+ //#region src/lib/api.d.ts
229
+ /** Thrown on any non-2xx response. `.status` is the HTTP code. */
230
+ declare class CommerceApiError extends Error {
231
+ readonly status: number;
232
+ constructor(message: string, status: number);
233
+ }
234
+ /** Bytes-uploaded snapshot pushed to the optional `onProgress` callback. */
235
+ interface UploadProgress {
236
+ loaded: number;
237
+ total: number;
238
+ /** 0–100 percent, or `null` when total isn't known (rare; keep callers defensive). */
239
+ percent: number | null;
240
+ }
241
+ /**
242
+ * POST a multipart upload to the imports/run route. Returns the new
243
+ * `import_run.id` and the linked `toolkit_jobs.id` so the caller can
244
+ * start polling import progress.
245
+ *
246
+ * Uses `XMLHttpRequest` (NOT `fetch`) because `fetch()` doesn't expose
247
+ * upload-progress events. For a 500 MB feed the operator otherwise sees
248
+ * a frozen "Uploading…" with no signal. XHR's `xhr.upload.onprogress`
249
+ * fires on every chunk transmitted by the browser, so the page can
250
+ * render a live percent / byte counter while the file streams up.
251
+ *
252
+ * Errors map to `CommerceApiError` for parity with `fetch`-based
253
+ * callers. Network failures → `Upload failed`. Aborts → `Upload
254
+ * aborted` (so consumers can distinguish from a real error and skip
255
+ * the toast).
256
+ */
257
+ declare function uploadImport(endpoint: string, args: {
258
+ file: File;
259
+ brandId: string;
260
+ supplierId: string;
261
+ onProgress?: (snapshot: UploadProgress) => void; /** Allow the caller to abort an in-flight upload (e.g. on page unload). */
262
+ signal?: AbortSignal;
263
+ }): Promise<{
264
+ importRunId: string;
265
+ jobId: string;
266
+ storageKey: string;
267
+ }>;
268
+ /**
269
+ * Poll `GET /api/admin/queue/jobs/:id` for the current progress snapshot.
270
+ *
271
+ * Permission note: the queue route gates this on `queue:update` (NOT
272
+ * `queue:view`) because the response includes the full job payload —
273
+ * see the file header comment in `@murumets-ee/queue/admin.ts`. Roles
274
+ * granting `commerce_import:view` therefore also need `queue:update`
275
+ * for the live progress panel to populate. The default admin role
276
+ * carries both; restricted operator roles need both granted in the
277
+ * role editor.
278
+ *
279
+ * Uses `AbortSignal` so callers can cancel an in-flight request when the
280
+ * component unmounts. Returns `null` on 404 (job purged after retention)
281
+ * and on 403 (role missing `queue:update`) so the page can render a
282
+ * neutral notice instead of an error toast on every poll tick.
283
+ */
284
+ declare function fetchJobProgress(basePath: string, jobId: string, signal?: AbortSignal): Promise<{
285
+ status: string;
286
+ progress: ImportProgressSnapshot | null;
287
+ attempts: number;
288
+ lastError: string | null;
289
+ } | null>;
290
+ /**
291
+ * GET the recent `import_run` history. Server pages ship the first page;
292
+ * the client refetches when the operator triggers a new run.
293
+ *
294
+ * The endpoint is the auto-mounted entity-CRUD GET at
295
+ * `${apiBasePath}/import_runs` (admin-ui registers one per entity using
296
+ * the naive `name + 's'` pluralizer). Sort is fixed at `createdAt desc`
297
+ * so the most recent run sits on top.
298
+ */
299
+ declare function fetchImportHistory(apiBasePath: string, limit?: number, signal?: AbortSignal): Promise<ImportRunHistoryRow[]>;
300
+ /**
301
+ * GET `/api/admin/commerce/parts-search`. Forwards the query / mode /
302
+ * brand-facet selections to the search proxy. Returns null when the
303
+ * endpoint is not mounted (404 → ES not configured) so the page can
304
+ * render a "not configured" notice without throwing.
305
+ */
306
+ declare function searchParts(endpoint: string, args: {
307
+ q: string;
308
+ mode: 'prefix' | 'term';
309
+ brandSlugs?: string[];
310
+ limit?: number;
311
+ offset?: number;
312
+ }, signal?: AbortSignal): Promise<PartsSearchResponse | null>;
313
+ //#endregion
314
+ export { type ActiveImportJobInitial, CommerceApiError, CommerceImportsPageClient, type CommerceImportsPageClientProps, CommercePartsSearchPageClient, type CommercePartsSearchPageClientProps, type CommercePickerOption, type ImportProgressSnapshot, type ImportRunHistoryRow, type ImportsPageInitialData, type PartsBrandFacet, type PartsFacetAggregation, type PartsSearchResponse, type PartsSearchRow, fetchImportHistory, fetchJobProgress, searchParts, uploadImport };
315
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/imports/imports-page-client.tsx","../src/parts-search/parts-search-page-client.tsx","../src/lib/api.ts"],"mappings":"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"}
package/dist/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ "use client";
2
+ import{useLiveSnapshot as e,useRealtimeEffect as t}from"@murumets-ee/admin-ui/realtime";import{useFormatter as n}from"next-intl";import{useCallback as r,useEffect as i,useState as a}from"react";import{Fragment as o,jsx as s,jsxs as c}from"react/jsx-runtime";var l=class extends Error{status;constructor(e,t){super(e),this.name=`CommerceApiError`,this.status=t}};async function u(e,t){try{let t=await e.json();if(typeof t.error==`string`)return t.error}catch{}return`${t} (${e.status})`}async function d(e,t){let n=new FormData;return n.append(`file`,t.file),n.append(`brandId`,t.brandId),n.append(`supplierId`,t.supplierId),new Promise((r,i)=>{let a=new XMLHttpRequest;if(a.open(`POST`,e),a.withCredentials=!0,t.onProgress&&a.upload.addEventListener(`progress`,e=>{t.onProgress?.({loaded:e.loaded,total:e.total,percent:e.lengthComputable&&e.total>0?e.loaded/e.total*100:null})}),a.addEventListener(`load`,()=>{if(a.status>=200&&a.status<300){try{r(JSON.parse(a.responseText))}catch{i(new l(`Upload succeeded but response was unparseable`,a.status))}return}let e=`Upload failed (${a.status})`;try{let t=JSON.parse(a.responseText);typeof t.error==`string`&&(e=t.error)}catch{}i(new l(e,a.status))}),a.addEventListener(`error`,()=>{i(new l(`Upload failed`,0))}),a.addEventListener(`abort`,()=>{i(new l(`Upload aborted`,0))}),t.signal){if(t.signal.aborted){a.abort();return}t.signal.addEventListener(`abort`,()=>a.abort(),{once:!0})}a.send(n)})}async function f(e,t,n){let r=`${e}/${encodeURIComponent(t)}`,i=await fetch(r,{credentials:`include`,...n!==void 0&&{signal:n}});if(i.status===404||i.status===403)return null;if(!i.ok)throw new l(await u(i,`Job lookup failed`),i.status);let a=(await i.json()).item??{},o=a.progress;return{status:typeof a.status==`string`?a.status:`unknown`,progress:p(o)?o:null,attempts:typeof a.attempts==`number`?a.attempts:0,lastError:typeof a.lastError==`string`?a.lastError:null}}function p(e){return!e||typeof e!=`object`?!1:typeof e.rowsRead==`number`}async function m(e,t=50,n){let r=`${e}/import_runs?${new URLSearchParams({limit:String(t),sortField:`createdAt`,sortDirection:`desc`}).toString()}`,i=await fetch(r,{credentials:`include`,...n!==void 0&&{signal:n}});if(!i.ok)throw new l(await u(i,`History fetch failed`),i.status);return(await i.json()).items.map(e=>h(e))}function h(e){return{id:typeof e.id==`string`?e.id:``,label:typeof e.label==`string`?e.label:``,status:typeof e.status==`string`?e.status:`pending`,createdAt:typeof e.createdAt==`string`?e.createdAt:``,startedAt:typeof e.startedAt==`string`?e.startedAt:null,finishedAt:typeof e.finishedAt==`string`?e.finishedAt:null,queueJobId:typeof e.queueJobId==`string`?e.queueJobId:null,totals:e.totals&&typeof e.totals==`object`?e.totals:null}}async function g(e,t,n){let r=new URLSearchParams({q:t.q,mode:t.mode,limit:String(t.limit??25),offset:String(t.offset??0)});for(let e of t.brandSlugs??[])r.append(`f.brand_slug`,e);let i=`${e}?${r.toString()}`,a=await fetch(i,{credentials:`include`,...n!==void 0&&{signal:n}});if(a.status===404)return null;if(!a.ok)throw new l(await u(a,`Parts search failed`),a.status);return await a.json()}const _=new Set([`succeeded`,`failed`,`dead`,`cancelled`,`completed`]),v=[`imports.run.**`];function y({initialData:n,canCreate:o,canPollProgress:u}){let[p,h]=a(``),[g,y]=a(``),[C,w]=a(null),[T,O]=a(!1),[k,A]=a(null),[j,M]=a(null),[N,P]=a(()=>n.activeJobs.map(x)),[F,I]=a(null);i(()=>{I(Date.now());let e=window.setInterval(()=>I(Date.now()),1e3);return()=>window.clearInterval(e)},[]);let L=n.uploadEndpoint.replace(/\/commerce\/imports\/run$/,``),{data:R,error:z,isLoading:B,refetch:V}=e({fetcher:r(async e=>m(L,void 0,e),[L]),topics:v,initialData:n.history}),H=R??n.history,U=z?z.message:null;async function W(e){if(e.preventDefault(),!C||!p||!g||T)return;O(!0),A(null),M(0);let t=n.brands.find(e=>e.id===p)?.label??p,r=n.suppliers.find(e=>e.id===g)?.label??g;try{let e=await d(n.uploadEndpoint,{file:C,brandId:p,supplierId:g,onProgress:e=>{e.percent!==null&&M(e.percent)}});P(n=>[{id:e.jobId,startedAt:Date.now(),importRunId:e.importRunId,brandLabel:t,supplierLabel:r,filename:C.name,status:`pending`,progress:null,finalError:null},...n]),w(null);let i=document.getElementById(`commerce-import-file`);i&&(i.value=``),V()}catch(e){A(e instanceof l||e instanceof Error?e.message:`Upload failed`)}finally{O(!1),M(null)}}return t(`queue.job.**`,e=>{let t=typeof e.payload?.jobId==`string`?e.payload.jobId:null;t&&P(n=>{let r=n.findIndex(e=>e.id===t);if(r<0)return n;let i=n[r];if(!i)return n;let a=b(i,e.topic,e.payload);if(a===i)return n;let o=n.slice();return o[r]=a,o})}),i(()=>{if(!u)return;let e=n.queueJobPath,t=new AbortController,r=N.filter(e=>!e.progress&&!_.has(e.status));if(r.length!==0)return Promise.all(r.map(async n=>{try{let r=await f(e,n.id,t.signal);return!r||t.signal.aborted?null:{jobId:n.id,result:r}}catch{return null}})).then(e=>{t.signal.aborted||P(t=>t.map(t=>{if(t.progress!==null)return t;let n=e.find(e=>e?.jobId===t.id);return n?{...t,status:n.result.status,progress:n.result.progress,finalError:n.result.lastError}:t}))}),()=>t.abort()},[]),c(`div`,{className:`space-y-6 p-6`,children:[c(`header`,{children:[s(`h1`,{className:`text-2xl font-semibold`,children:`Imports`}),s(`p`,{className:`mt-1 text-sm text-muted-foreground`,children:`Run a supplier feed through the carmaker-feed transform and into the parts index.`})]}),c(`section`,{className:`rounded-md border bg-card p-4`,children:[s(`h2`,{className:`text-base font-medium mb-3`,children:`Run import`}),c(`form`,{onSubmit:e=>void W(e),className:`grid gap-3 sm:grid-cols-4`,children:[c(`label`,{className:`flex flex-col gap-1 text-sm`,children:[s(`span`,{className:`text-muted-foreground`,children:`Brand`}),c(`select`,{className:`rounded border bg-background px-2 py-1.5`,value:p,onChange:e=>h(e.target.value),disabled:!o||T,required:!0,children:[s(`option`,{value:``,children:`Pick a brand…`}),n.brands.map(e=>s(`option`,{value:e.id,children:e.label},e.id))]})]}),c(`label`,{className:`flex flex-col gap-1 text-sm`,children:[s(`span`,{className:`text-muted-foreground`,children:`Supplier`}),c(`select`,{className:`rounded border bg-background px-2 py-1.5`,value:g,onChange:e=>y(e.target.value),disabled:!o||T,required:!0,children:[s(`option`,{value:``,children:`Pick a supplier…`}),n.suppliers.map(e=>s(`option`,{value:e.id,children:e.label},e.id))]})]}),c(`label`,{className:`flex flex-col gap-1 text-sm sm:col-span-2`,children:[s(`span`,{className:`text-muted-foreground`,children:`Feed file`}),s(`input`,{id:`commerce-import-file`,type:`file`,accept:`.txt,.csv,.tsv,text/plain,text/tab-separated-values,text/csv`,onChange:e=>w(e.target.files?.[0]??null),disabled:!o||T,required:!0,className:`text-sm`})]}),c(`div`,{className:`sm:col-span-4 flex items-center gap-3`,children:[s(`button`,{type:`submit`,className:`rounded bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50`,disabled:!o||T||!C||!p||!g,children:T?j!==null&&j<100?`Uploading… ${j.toFixed(0)}%`:`Enqueueing…`:`Run import`}),!o&&c(`span`,{className:`text-xs text-muted-foreground`,children:[`Read-only: needs `,s(`code`,{className:`font-mono`,children:`commerce_import:create`}),`.`]}),k&&s(`span`,{className:`text-sm text-destructive`,children:k})]}),T&&j!==null&&c(`div`,{className:`sm:col-span-4`,children:[s(`div`,{className:`h-2 w-full overflow-hidden rounded-full bg-muted`,children:s(`div`,{className:`h-full bg-primary transition-[width] duration-150 ease-out`,style:{width:`${j.toFixed(1)}%`}})}),C&&c(`p`,{className:`mt-1 text-xs text-muted-foreground font-mono`,children:[D(C.size*j/100),` /`,` `,D(C.size)]})]})]})]}),c(`section`,{className:`rounded-md border bg-card p-4`,children:[s(`h2`,{className:`text-base font-medium mb-3`,children:`Active jobs`}),u?N.length===0?s(`p`,{className:`text-sm text-muted-foreground`,children:`No imports running right now.`}):s(`ul`,{className:`space-y-2`,children:N.map(e=>s(S,{job:e,now:F},e.id))}):s(`p`,{className:`text-sm text-muted-foreground`,children:`Live updates not authorised — needs admin role.`})]}),c(`section`,{className:`rounded-md border bg-card p-4`,children:[c(`div`,{className:`mb-3 flex items-center justify-between`,children:[s(`h2`,{className:`text-base font-medium`,children:`Recent imports`}),s(`button`,{type:`button`,onClick:()=>void V(),disabled:B,className:`rounded border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50`,children:B?`Refreshing…`:`Refresh`})]}),U&&s(`p`,{className:`mb-3 text-sm text-destructive`,role:`alert`,children:U}),s(E,{rows:H})]})]})}function b(e,t,n){switch(t){case`queue.job.claimed`:return e.status===`running`?e:{...e,status:`running`};case`queue.job.progress`:return n.progress?{...e,progress:n.progress}:e;case`queue.job.completed`:return e.status===`completed`?e:{...e,status:`completed`};case`queue.job.dead`:return{...e,status:`dead`,finalError:n.error??e.finalError};case`queue.job.retrying`:return{...e,status:`pending`,finalError:null};case`queue.job.cancelled`:return e.status===`cancelled`?e:{...e,status:`cancelled`};default:return e}}function x(e){return{id:e.jobId,startedAt:new Date(e.startedAt).getTime(),importRunId:e.importRunId,brandLabel:e.brandLabel,supplierLabel:e.supplierLabel,filename:e.filename,status:e.status,progress:e.progress,finalError:e.finalError}}function S({job:e,now:t}){let n=t===null?null:Math.round((t-e.startedAt)/1e3),r=_.has(e.status),i=e.status===`pending`,a=e.status===`running`||e.status===`processing`;return c(`li`,{className:`rounded border p-3`,children:[c(`div`,{className:`flex flex-wrap items-baseline justify-between gap-2 text-sm`,children:[c(`div`,{className:`flex flex-wrap items-baseline gap-2`,children:[s(`span`,{className:`font-medium`,children:e.brandLabel}),s(`span`,{className:`text-muted-foreground`,children:`·`}),s(`span`,{children:e.supplierLabel}),s(`span`,{className:`text-muted-foreground`,children:`·`}),s(`code`,{className:`font-mono text-xs`,children:e.filename})]}),s(`span`,{className:`rounded px-2 py-0.5 text-xs font-medium ${A(e.status)}`,children:e.status})]}),e.progress?s(C,{progress:e.progress}):r?null:s(`p`,{className:`mt-2 text-xs text-muted-foreground`,children:i?`Queued — waiting for the worker to pick up…${n===null?``:` (${n}s)`}`:a?`Worker started — first batch in progress${n===null?``:` (${n}s)`}`:`Status: ${e.status}${n===null?``:` (${n}s)`}`}),e.finalError&&s(`p`,{className:`mt-2 text-xs text-destructive font-mono`,children:e.finalError})]})}function C({progress:e}){let t=e.totalRows,n=typeof t==`number`&&t>0,r=n?Math.min(100,e.rowsRead/t*100):null,i=n?Math.max(0,t-e.rowsRead):null,a=i!==null&&e.rowsPerSecond>0?i/e.rowsPerSecond:null;return c(`div`,{className:`mt-2 space-y-2`,children:[r!==null&&c(`div`,{children:[s(`div`,{className:`h-1.5 w-full overflow-hidden rounded-full bg-muted`,children:s(`div`,{className:`h-full bg-primary transition-[width] duration-150 ease-out`,style:{width:`${r.toFixed(1)}%`}})}),c(`p`,{className:`mt-1 text-xs text-muted-foreground font-mono`,children:[e.rowsRead.toLocaleString(),` / `,(t??0).toLocaleString(),` rows`,` · `,r.toFixed(1),`%`,a!==null&&` · ETA ${w(a)}`]})]}),c(`dl`,{className:`grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4`,children:[!n&&s(T,{label:`Read`,value:e.rowsRead.toLocaleString()}),s(T,{label:`Succeeded`,value:e.rowsSucceeded.toLocaleString()}),s(T,{label:`Failed`,value:e.rowsFailed.toLocaleString()}),s(T,{label:`Skipped`,value:e.rowsSkipped.toLocaleString()}),s(T,{label:`Batches`,value:e.batchesCompleted.toLocaleString()}),s(T,{label:`Elapsed`,value:`${e.elapsedSeconds.toFixed(1)}s`}),s(T,{label:`Rows/s`,value:e.rowsPerSecond.toFixed(1)}),s(T,{label:`Patterns`,value:e.distinctErrorPatterns.toLocaleString()})]})]})}function w(e){return e<60?`${e.toFixed(0)}s`:e<3600?`${Math.floor(e/60)}m ${Math.floor(e%60)}s`:`${Math.floor(e/3600)}h ${Math.floor(e%3600/60)}m`}function T({label:e,value:t}){return c(`div`,{children:[s(`dt`,{className:`text-muted-foreground`,children:e}),s(`dd`,{className:`font-mono`,children:t})]})}function E({rows:e}){let t=n();return e.length===0?s(`p`,{className:`text-sm text-muted-foreground`,children:`No imports yet. Pick a brand + supplier above and run one.`}):s(`div`,{className:`overflow-x-auto`,children:c(`table`,{className:`w-full text-sm`,children:[s(`thead`,{className:`border-b text-xs uppercase tracking-wide text-muted-foreground`,children:c(`tr`,{children:[s(`th`,{className:`px-2 py-1.5 text-left font-medium`,children:`Label`}),s(`th`,{className:`px-2 py-1.5 text-left font-medium`,children:`Status`}),s(`th`,{className:`px-2 py-1.5 text-right font-medium`,children:`Succeeded`}),s(`th`,{className:`px-2 py-1.5 text-right font-medium`,children:`Failed`}),s(`th`,{className:`px-2 py-1.5 text-left font-medium`,children:`Started`}),s(`th`,{className:`px-2 py-1.5 text-left font-medium`,children:`Finished`})]})}),s(`tbody`,{children:e.map(e=>c(`tr`,{className:`border-b last:border-0`,children:[s(`td`,{className:`px-2 py-1.5`,children:s(`a`,{href:`/admin/import_run/${e.id}`,className:`text-primary hover:underline`,children:e.label||e.id})}),s(`td`,{className:`px-2 py-1.5`,children:s(`span`,{className:`rounded px-2 py-0.5 text-xs font-medium ${A(e.status)}`,children:e.status})}),s(`td`,{className:`px-2 py-1.5 text-right font-mono`,children:O(t,e.totals?.succeeded)}),s(`td`,{className:`px-2 py-1.5 text-right font-mono`,children:O(t,e.totals?.failed)}),s(`td`,{className:`px-2 py-1.5 text-xs text-muted-foreground`,children:k(t,e.startedAt)}),s(`td`,{className:`px-2 py-1.5 text-xs text-muted-foreground`,children:k(t,e.finishedAt)})]},e.id))})]})})}function D(e){return e<1024?`${e.toFixed(0)} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/(1024*1024)).toFixed(1)} MB`:`${(e/(1024*1024*1024)).toFixed(2)} GB`}function O(e,t){return typeof t==`number`?e.number(t):`—`}function k(e,t){if(!t)return`—`;let n=Date.parse(t);return Number.isNaN(n)?t:e.dateTime(new Date(n),{year:`numeric`,month:`2-digit`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`,second:`2-digit`})}function A(e){switch(e){case`succeeded`:case`completed`:return`bg-emerald-500/10 text-emerald-700 dark:text-emerald-400`;case`failed`:case`dead`:return`bg-rose-500/10 text-rose-700 dark:text-rose-400`;case`running`:case`processing`:return`bg-sky-500/10 text-sky-700 dark:text-sky-400`;case`cancelled`:return`bg-amber-500/10 text-amber-700 dark:text-amber-400`;default:return`bg-muted text-muted-foreground`}}function j({endpoint:e}){let[t,n]=a(``),[r,i]=a(`prefix`),[u,d]=a([]),[f,p]=a(0),[m,h]=a(!1),[_,v]=a(null),[y,b]=a(!1),[x,S]=a(null);async function C(n=0){if(!(!t.trim()||m)){h(!0),S(null);try{let i=await g(e,{q:t.trim(),mode:r,brandSlugs:u,limit:25,offset:n});i===null?(b(!0),v(null)):(b(!1),v(i),p(n))}catch(e){S(e instanceof l||e instanceof Error?e.message:`Search failed`)}finally{h(!1)}}}function w(n){let i=u.includes(n)?u.filter(e=>e!==n):[...u,n];d(i),(async()=>{if(t.trim()){h(!0),S(null);try{let n=await g(e,{q:t.trim(),mode:r,brandSlugs:i,limit:25,offset:0});n===null?(b(!0),v(null)):(b(!1),v(n),p(0))}catch(e){S(e instanceof l||e instanceof Error?e.message:`Search failed`)}finally{h(!1)}}})()}return c(`div`,{className:`space-y-6 p-6`,children:[c(`header`,{children:[s(`h1`,{className:`text-2xl font-semibold`,children:`Parts search`}),s(`p`,{className:`mt-1 text-sm text-muted-foreground`,children:`Look up parts by code (prefix or exact). Filter by brand. Live data from the parts index.`})]}),y?c(`div`,{className:`rounded-md border border-amber-500/50 bg-amber-500/5 p-4 text-sm`,children:[s(`strong`,{children:`Parts search is not configured for this deployment.`}),c(`p`,{className:`mt-1 text-muted-foreground`,children:[`Wire an `,s(`code`,{className:`font-mono`,children:`ElasticsearchProvider`}),` via`,` `,c(`code`,{className:`font-mono`,children:[`commerce(`,`{ partsSearch: { provider } }`,`)`]}),` in your `,s(`code`,{className:`font-mono`,children:`lumi.config.ts`}),` to enable it.`]})]}):null,c(`section`,{className:`rounded-md border bg-card p-4`,children:[c(`form`,{onSubmit:e=>{e.preventDefault(),C(0)},className:`flex flex-wrap items-end gap-3`,children:[c(`label`,{className:`flex flex-col gap-1 text-sm flex-1 min-w-[240px]`,children:[s(`span`,{className:`text-muted-foreground`,children:`Code`}),s(`input`,{type:`text`,value:t,onChange:e=>n(e.target.value),placeholder:`e.g. ME-A0000000000 or A0000000000`,className:`rounded border bg-background px-2 py-1.5 font-mono`,disabled:m})]}),c(`fieldset`,{className:`flex flex-col gap-1 text-sm`,children:[s(`legend`,{className:`text-muted-foreground`,children:`Mode`}),c(`div`,{className:`flex rounded border`,children:[s(`button`,{type:`button`,onClick:()=>i(`prefix`),className:`px-3 py-1.5 text-xs ${r===`prefix`?`bg-primary text-primary-foreground`:`bg-background`}`,children:`Prefix`}),s(`button`,{type:`button`,onClick:()=>i(`term`),className:`px-3 py-1.5 text-xs border-l ${r===`term`?`bg-primary text-primary-foreground`:`bg-background`}`,children:`Exact`})]})]}),s(`button`,{type:`submit`,disabled:m||!t.trim(),className:`rounded bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50`,children:m?`Searching…`:`Search`})]}),x&&s(`p`,{className:`mt-3 text-sm text-destructive`,role:`alert`,children:x})]}),_&&c(o,{children:[s(M,{facets:_.facets.find(e=>e.field===`brand_slug`)?.buckets??[],selected:u,onToggle:w}),s(N,{rows:_.rows,total:_.total,offset:f,durationMs:_.durationMs,onPage:e=>void C(e),busy:m})]})]})}function M({facets:e,selected:t,onToggle:n}){return e.length===0?null:c(`section`,{className:`rounded-md border bg-card p-4`,children:[s(`h2`,{className:`mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground`,children:`Brand`}),s(`div`,{className:`flex flex-wrap gap-2`,children:e.map(e=>c(`button`,{type:`button`,onClick:()=>n(e.value),className:`rounded-full border px-3 py-1 text-xs transition-colors ${t.includes(e.value)?`border-primary bg-primary text-primary-foreground`:`border-border bg-background hover:bg-accent`}`,children:[e.value,s(`span`,{className:`ml-1.5 font-mono opacity-70`,children:e.count})]},e.value))})]})}function N({rows:e,total:t,offset:n,durationMs:r,onPage:i,busy:a}){let o=Math.floor(n/25)+1,l=Math.max(1,Math.ceil(t/25)),u=n>0,d=n+25<Math.min(t,1025);return c(`section`,{className:`rounded-md border bg-card p-4`,children:[c(`header`,{className:`mb-3 flex items-baseline justify-between`,children:[c(`h2`,{className:`text-base font-medium`,children:[t.toLocaleString(),` `,t===1?`result`:`results`]}),c(`span`,{className:`text-xs text-muted-foreground`,children:[r,`ms`]})]}),e.length===0?s(`p`,{className:`text-sm text-muted-foreground`,children:`No matches. Try the other mode or remove brand filters.`}):s(`div`,{className:`overflow-x-auto`,children:c(`table`,{className:`w-full text-sm`,children:[s(`thead`,{className:`border-b text-xs uppercase tracking-wide text-muted-foreground`,children:c(`tr`,{children:[s(`th`,{className:`px-2 py-1.5 text-left font-medium`,children:`Code`}),s(`th`,{className:`px-2 py-1.5 text-left font-medium`,children:`Brand`}),s(`th`,{className:`px-2 py-1.5 text-left font-medium`,children:`Supplier`}),s(`th`,{className:`px-2 py-1.5 text-left font-medium`,children:`Name`}),s(`th`,{className:`px-2 py-1.5 text-right font-medium`,children:`Net price (EUR)`}),s(`th`,{className:`px-2 py-1.5 text-left font-medium`,children:`Imported`})]})}),s(`tbody`,{children:e.map(e=>c(`tr`,{className:`border-b last:border-0`,children:[s(`td`,{className:`px-2 py-1.5 font-mono`,children:e.data.code}),s(`td`,{className:`px-2 py-1.5`,children:e.data.brand_slug}),s(`td`,{className:`px-2 py-1.5`,children:e.data.supplier_display_name}),s(`td`,{className:`px-2 py-1.5 text-muted-foreground`,children:e.data.name_en??`—`}),s(`td`,{className:`px-2 py-1.5 text-right font-mono`,children:e.data.base_price_eur==null?`—`:e.data.base_price_eur.toFixed(2)}),s(`td`,{className:`px-2 py-1.5 text-xs text-muted-foreground`,children:P(e.data.imported_at)})]},e.id))})]})}),l>1&&c(`nav`,{className:`mt-3 flex items-center justify-between text-xs`,children:[c(`span`,{className:`text-muted-foreground`,children:[`Page `,o,` of `,l]}),c(`div`,{className:`flex gap-2`,children:[s(`button`,{type:`button`,disabled:!u||a,onClick:()=>i(Math.max(0,n-25)),className:`rounded border px-2 py-1 hover:bg-accent disabled:opacity-50`,children:`Previous`}),s(`button`,{type:`button`,disabled:!d||a,onClick:()=>i(n+25),className:`rounded border px-2 py-1 hover:bg-accent disabled:opacity-50`,children:`Next`})]})]})]})}function P(e){try{return new Date(e).toLocaleDateString()}catch{return e}}export{l as CommerceApiError,y as CommerceImportsPageClient,j as CommercePartsSearchPageClient,m as fetchImportHistory,f as fetchJobProgress,g as searchParts,d as uploadImport};
3
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":["o"],"sources":["../src/lib/api.ts","../src/imports/imports-page-client.tsx","../src/parts-search/parts-search-page-client.tsx"],"sourcesContent":["/**\n * Client-side API helpers for the commerce admin pages.\n *\n * Every fetch goes through `credentials: 'include'` (admin shell relies\n * on the same-origin session cookie). All non-2xx responses are mapped\n * to `Error` instances carrying a clean message — pages render the\n * `.message` directly. The api-handler's CSRF check covers POST.\n */\n\nimport type {\n ImportProgressSnapshot,\n ImportRunHistoryRow,\n PartsSearchResponse,\n} from '../types.js'\n\n/** Thrown on any non-2xx response. `.status` is the HTTP code. */\nexport class CommerceApiError extends Error {\n readonly status: number\n constructor(message: string, status: number) {\n super(message)\n this.name = 'CommerceApiError'\n this.status = status\n }\n}\n\nasync function readError(res: Response, fallback: string): Promise<string> {\n try {\n const body = (await res.json()) as { error?: unknown }\n if (typeof body.error === 'string') return body.error\n } catch {\n // Non-JSON or empty body — fall through to the generic message.\n }\n return `${fallback} (${res.status})`\n}\n\n/** Bytes-uploaded snapshot pushed to the optional `onProgress` callback. */\nexport interface UploadProgress {\n loaded: number\n total: number\n /** 0–100 percent, or `null` when total isn't known (rare; keep callers defensive). */\n percent: number | null\n}\n\n/**\n * POST a multipart upload to the imports/run route. Returns the new\n * `import_run.id` and the linked `toolkit_jobs.id` so the caller can\n * start polling import progress.\n *\n * Uses `XMLHttpRequest` (NOT `fetch`) because `fetch()` doesn't expose\n * upload-progress events. For a 500 MB feed the operator otherwise sees\n * a frozen \"Uploading…\" with no signal. XHR's `xhr.upload.onprogress`\n * fires on every chunk transmitted by the browser, so the page can\n * render a live percent / byte counter while the file streams up.\n *\n * Errors map to `CommerceApiError` for parity with `fetch`-based\n * callers. Network failures → `Upload failed`. Aborts → `Upload\n * aborted` (so consumers can distinguish from a real error and skip\n * the toast).\n */\nexport async function uploadImport(\n endpoint: string,\n args: {\n file: File\n brandId: string\n supplierId: string\n onProgress?: (snapshot: UploadProgress) => void\n /** Allow the caller to abort an in-flight upload (e.g. on page unload). */\n signal?: AbortSignal\n },\n): Promise<{ importRunId: string; jobId: string; storageKey: string }> {\n const fd = new FormData()\n fd.append('file', args.file)\n fd.append('brandId', args.brandId)\n fd.append('supplierId', args.supplierId)\n\n return new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.open('POST', endpoint)\n // Match the `credentials: 'include'` semantics of the prior fetch\n // call so the api-handler's session cookie travels with the upload.\n xhr.withCredentials = true\n\n if (args.onProgress) {\n xhr.upload.addEventListener('progress', (e) => {\n args.onProgress?.({\n loaded: e.loaded,\n total: e.total,\n percent: e.lengthComputable && e.total > 0 ? (e.loaded / e.total) * 100 : null,\n })\n })\n }\n\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) {\n try {\n resolve(JSON.parse(xhr.responseText))\n } catch {\n reject(new CommerceApiError('Upload succeeded but response was unparseable', xhr.status))\n }\n return\n }\n // Mirror `readError` shape: pull `body.error` when present, else fall back.\n let message = `Upload failed (${xhr.status})`\n try {\n const parsed = JSON.parse(xhr.responseText) as { error?: unknown }\n if (typeof parsed.error === 'string') message = parsed.error\n } catch {\n // Non-JSON body — keep generic message.\n }\n reject(new CommerceApiError(message, xhr.status))\n })\n\n xhr.addEventListener('error', () => {\n reject(new CommerceApiError('Upload failed', 0))\n })\n xhr.addEventListener('abort', () => {\n reject(new CommerceApiError('Upload aborted', 0))\n })\n\n if (args.signal) {\n if (args.signal.aborted) {\n xhr.abort()\n return\n }\n args.signal.addEventListener('abort', () => xhr.abort(), { once: true })\n }\n\n xhr.send(fd)\n })\n}\n\n/**\n * Poll `GET /api/admin/queue/jobs/:id` for the current progress snapshot.\n *\n * Permission note: the queue route gates this on `queue:update` (NOT\n * `queue:view`) because the response includes the full job payload —\n * see the file header comment in `@murumets-ee/queue/admin.ts`. Roles\n * granting `commerce_import:view` therefore also need `queue:update`\n * for the live progress panel to populate. The default admin role\n * carries both; restricted operator roles need both granted in the\n * role editor.\n *\n * Uses `AbortSignal` so callers can cancel an in-flight request when the\n * component unmounts. Returns `null` on 404 (job purged after retention)\n * and on 403 (role missing `queue:update`) so the page can render a\n * neutral notice instead of an error toast on every poll tick.\n */\nexport async function fetchJobProgress(\n basePath: string,\n jobId: string,\n signal?: AbortSignal,\n): Promise<{\n status: string\n progress: ImportProgressSnapshot | null\n attempts: number\n lastError: string | null\n} | null> {\n const url = `${basePath}/${encodeURIComponent(jobId)}`\n const res = await fetch(url, { credentials: 'include', ...(signal !== undefined && { signal }) })\n if (res.status === 404 || res.status === 403) return null\n if (!res.ok) {\n throw new CommerceApiError(await readError(res, 'Job lookup failed'), res.status)\n }\n // Queue handler returns `{ item: row }`. Pull the fields the imports panel\n // actually displays — `payload`, `lockedBy`, etc. are present but unused.\n const body = (await res.json()) as { item?: Record<string, unknown> }\n const item = body.item ?? {}\n const progress = item.progress\n return {\n status: typeof item.status === 'string' ? item.status : 'unknown',\n progress: isProgressSnapshot(progress) ? progress : null,\n attempts: typeof item.attempts === 'number' ? item.attempts : 0,\n lastError: typeof item.lastError === 'string' ? item.lastError : null,\n }\n}\n\nfunction isProgressSnapshot(v: unknown): v is ImportProgressSnapshot {\n if (!v || typeof v !== 'object') return false\n const o = v as Record<string, unknown>\n // Fundamental required field: `rowsRead` is what the runner writes\n // first on every flush. The rest of the snapshot defaults to 0 if\n // missing — better to render a partial-but-honest stats grid than\n // throw the whole payload away because one optional counter wasn't\n // emitted yet. The `progress: {}` default the queue writes on insert\n // still fails this guard (no rowsRead key) → progress: null → UI\n // shows \"Worker started — first batch in progress\" which is right\n // for that state.\n return typeof o.rowsRead === 'number'\n}\n\n/**\n * GET the recent `import_run` history. Server pages ship the first page;\n * the client refetches when the operator triggers a new run.\n *\n * The endpoint is the auto-mounted entity-CRUD GET at\n * `${apiBasePath}/import_runs` (admin-ui registers one per entity using\n * the naive `name + 's'` pluralizer). Sort is fixed at `createdAt desc`\n * so the most recent run sits on top.\n */\nexport async function fetchImportHistory(\n apiBasePath: string,\n limit = 50,\n signal?: AbortSignal,\n): Promise<ImportRunHistoryRow[]> {\n const params = new URLSearchParams({\n limit: String(limit),\n sortField: 'createdAt',\n sortDirection: 'desc',\n })\n const url = `${apiBasePath}/import_runs?${params.toString()}`\n const res = await fetch(url, { credentials: 'include', ...(signal !== undefined && { signal }) })\n if (!res.ok) {\n throw new CommerceApiError(await readError(res, 'History fetch failed'), res.status)\n }\n const body = (await res.json()) as { items: Array<Record<string, unknown>> }\n return body.items.map((row) => narrowHistoryRow(row))\n}\n\nfunction narrowHistoryRow(row: Record<string, unknown>): ImportRunHistoryRow {\n // The auto-EntityListPage GET serialises Date columns to ISO strings,\n // and JSONB columns come through as plain objects. Narrow defensively\n // to the exact ImportRunHistoryRow shape so the UI never crashes when\n // a column is null mid-import (status=pending, finishedAt unset).\n return {\n id: typeof row.id === 'string' ? row.id : '',\n label: typeof row.label === 'string' ? row.label : '',\n status: typeof row.status === 'string' ? row.status : 'pending',\n createdAt: typeof row.createdAt === 'string' ? row.createdAt : '',\n startedAt: typeof row.startedAt === 'string' ? row.startedAt : null,\n finishedAt: typeof row.finishedAt === 'string' ? row.finishedAt : null,\n queueJobId: typeof row.queueJobId === 'string' ? row.queueJobId : null,\n totals:\n row.totals && typeof row.totals === 'object'\n ? (row.totals as Record<string, number>)\n : null,\n }\n}\n\n/**\n * GET `/api/admin/commerce/parts-search`. Forwards the query / mode /\n * brand-facet selections to the search proxy. Returns null when the\n * endpoint is not mounted (404 → ES not configured) so the page can\n * render a \"not configured\" notice without throwing.\n */\nexport async function searchParts(\n endpoint: string,\n args: { q: string; mode: 'prefix' | 'term'; brandSlugs?: string[]; limit?: number; offset?: number },\n signal?: AbortSignal,\n): Promise<PartsSearchResponse | null> {\n const params = new URLSearchParams({\n q: args.q,\n mode: args.mode,\n limit: String(args.limit ?? 25),\n offset: String(args.offset ?? 0),\n })\n for (const slug of args.brandSlugs ?? []) {\n params.append('f.brand_slug', slug)\n }\n const url = `${endpoint}?${params.toString()}`\n const res = await fetch(url, { credentials: 'include', ...(signal !== undefined && { signal }) })\n if (res.status === 404) return null\n if (!res.ok) {\n throw new CommerceApiError(await readError(res, 'Parts search failed'), res.status)\n }\n return (await res.json()) as PartsSearchResponse\n}\n","/**\n * Imports admin page — client component.\n *\n * Three sections:\n * 1. **Run import** — brand picker + supplier picker + file picker + submit.\n * POSTs multipart to `/api/admin/commerce/imports/run`. On success,\n * adds the returned `jobId` to the active-jobs panel and clears the\n * form so the next batch is one click away.\n * 2. **Active jobs** — subscribes to `queue.job.**` realtime topics and\n * renders the queue's progress payload (rowsRead / rowsSucceeded /\n * rowsFailed / rowsPerSecond) inline. Seed comes from the\n * server-rendered `initialData.activeJobs` so the panel survives a\n * navigate-away-and-back; new uploads optimistically append a\n * row that the worker's `queue.job.claimed` event resolves.\n * Pre-realtime this surface polled `GET /api/admin/queue/jobs/:id`\n * every 1.5s — that's now a one-shot mount-time seed for jobs\n * without server-rendered progress.\n * 3. **History** — the recent `import_run` rows from the server-rendered\n * initial payload + `useLiveSnapshot` over `imports.run.**` for\n * event-driven refetch. The \"Refresh\" button still works (calls the\n * hook's `refetch` directly) and serves as a fallback when the SSE\n * connection is unavailable. Drilling into a row jumps to\n * `/admin/import_run/[id]` (the auto-mounted entity edit page) for\n * the full error-pattern view.\n *\n * Permissions: this page is reachable for any role with\n * `commerce_import:view`. The upload requires `commerce_import:create`\n * (the route handler enforces); the active-jobs panel additionally\n * requires `queue:update` because the queue route gates job lookup on\n * that action — see {@link fetchJobProgress} JSDoc. Operators with only\n * `commerce_import:view` see the upload form disabled and the active-\n * jobs panel showing \"Polling not authorised\" rather than spinning.\n */\n\nimport { useLiveSnapshot, useRealtimeEffect } from '@murumets-ee/admin-ui/realtime'\nimport { useFormatter } from 'next-intl'\nimport { useCallback, useEffect, useState } from 'react'\nimport {\n fetchImportHistory,\n fetchJobProgress,\n uploadImport,\n CommerceApiError,\n} from '../lib/api.js'\nimport type {\n ActiveImportJobInitial,\n ImportProgressSnapshot,\n ImportRunHistoryRow,\n ImportsPageInitialData,\n} from '../types.js'\n\ninterface ActiveJob {\n id: string\n /** ms since epoch — drives the elapsed-time display. */\n startedAt: number\n importRunId: string\n brandLabel: string\n supplierLabel: string\n filename: string\n status: string\n progress: ImportProgressSnapshot | null\n finalError: string | null\n}\n\nconst TERMINAL = new Set(['succeeded', 'failed', 'dead', 'cancelled', 'completed'])\n\n/**\n * Topics the history slice re-fetches on. Stable reference so\n * `useLiveSnapshot` doesn't re-subscribe each render.\n *\n * `queue.job.**` drives the per-job Active panel (claim/progress/\n * completed/dead transitions); subscribed via `useRealtimeEffect` below.\n * `imports.run.**` drives the Recent imports table.\n */\nconst HISTORY_REFETCH_TOPICS: readonly string[] = ['imports.run.**'] as const\n\nexport interface CommerceImportsPageClientProps {\n initialData: ImportsPageInitialData\n /** Optional: disables the upload form when the operator lacks `commerce_import:create`. */\n canCreate: boolean\n /** Optional: hides the active-jobs panel when the operator lacks `queue:update`. */\n canPollProgress: boolean\n}\n\n/**\n * Public component. Subscribes to `queue.job.**` (active-jobs panel) and\n * `imports.run.**` (history slice) for live revalidation. Must be\n * mounted inside a `<RealtimeProvider>` (the admin shell does this for\n * you); without one the live updates fall back to the no-op subscriber\n * and only the Refresh button revalidates.\n */\nexport function CommerceImportsPageClient({\n initialData,\n canCreate,\n canPollProgress,\n}: CommerceImportsPageClientProps): React.ReactElement {\n const [brandId, setBrandId] = useState('')\n const [supplierId, setSupplierId] = useState('')\n const [file, setFile] = useState<File | null>(null)\n const [busy, setBusy] = useState(false)\n const [formError, setFormError] = useState<string | null>(null)\n // Upload progress: 0–100 (or null on indeterminate / not started).\n // Reset back to null after the upload settles so the bar disappears.\n const [uploadPercent, setUploadPercent] = useState<number | null>(null)\n\n // Seed active list from the server-rendered `initialData.activeJobs`\n // so a navigate-away-and-back keeps the panel populated. The\n // server's query covers `import_run.status IN ('pending', 'running')`\n // — anything terminal lives in History instead.\n const [active, setActive] = useState<ActiveJob[]>(() =>\n initialData.activeJobs.map(toActiveJob),\n )\n\n // Wall-clock tick that drives every Active row's \"elapsed (Xs)\"\n // display. Initial value is `null` — both server SSR and the\n // client's first render see `null` and render the rows WITHOUT\n // elapsed seconds, which is what fixes the hydration mismatch\n // (server rendering `Date.now()` at T1 and client rendering at\n // T1+1ms produced different \"(Xs)\" text). The mount-effect kicks\n // off the ticker on the client side only.\n //\n // One shared `now` for all rows: re-renders fire every second\n // regardless of how many rows are active. Cheaper + simpler than\n // per-row timers.\n const [now, setNow] = useState<number | null>(null)\n useEffect(() => {\n setNow(Date.now())\n const id = window.setInterval(() => setNow(Date.now()), 1000)\n return () => window.clearInterval(id)\n }, [])\n\n // Live Recent imports table. `useLiveSnapshot` debounces a burst\n // of `imports.run.**` events into a single refetch (default\n // 250ms) and re-fetches on the realtime layer's reconnect hint.\n // The fetcher is a stable callback so the hook doesn't re-\n // subscribe on each render.\n const apiBase = initialData.uploadEndpoint.replace(/\\/commerce\\/imports\\/run$/, '')\n const fetchHistory = useCallback(\n async (signal: AbortSignal): Promise<ImportRunHistoryRow[]> =>\n fetchImportHistory(apiBase, undefined, signal),\n [apiBase],\n )\n const {\n data: liveHistory,\n error: liveHistoryError,\n isLoading: historyLoading,\n refetch: refreshHistory,\n } = useLiveSnapshot<ImportRunHistoryRow[]>({\n fetcher: fetchHistory,\n topics: HISTORY_REFETCH_TOPICS,\n initialData: initialData.history,\n })\n const history: ImportRunHistoryRow[] = liveHistory ?? initialData.history\n const historyError = liveHistoryError ? liveHistoryError.message : null\n\n async function onSubmit(e: React.FormEvent): Promise<void> {\n e.preventDefault()\n if (!file || !brandId || !supplierId || busy) return\n setBusy(true)\n setFormError(null)\n setUploadPercent(0)\n const brandLabel = initialData.brands.find((b) => b.id === brandId)?.label ?? brandId\n const supplierLabel =\n initialData.suppliers.find((s) => s.id === supplierId)?.label ?? supplierId\n try {\n const result = await uploadImport(initialData.uploadEndpoint, {\n file,\n brandId,\n supplierId,\n onProgress: (snapshot) => {\n // `percent` is null when the browser can't determine total\n // length (rare for file uploads). Leave the bar at its last\n // known value rather than flickering to 0.\n if (snapshot.percent !== null) setUploadPercent(snapshot.percent)\n },\n })\n // Optimistically add to active jobs — `queue.job.claimed` from\n // the worker fills in the real status within ~250ms (queue's\n // realtime publish runs the moment the worker locks the row).\n setActive((prev) => [\n {\n id: result.jobId,\n startedAt: Date.now(),\n importRunId: result.importRunId,\n brandLabel,\n supplierLabel,\n filename: file.name,\n status: 'pending',\n progress: null,\n finalError: null,\n },\n ...prev,\n ])\n // Reset form so chained uploads are one click each. brand+supplier\n // stay set — common case is \"the same operator running 17 brands\n // back to back\" who'd otherwise re-pick from the top each time.\n setFile(null)\n // <input type=\"file\"> doesn't track via React; reset its DOM value.\n const fileInput = document.getElementById(\n 'commerce-import-file',\n ) as HTMLInputElement | null\n if (fileInput) fileInput.value = ''\n // Refresh history in the background — don't await, so the user can\n // start the next upload immediately.\n void refreshHistory()\n } catch (err) {\n const msg =\n err instanceof CommerceApiError\n ? err.message\n : err instanceof Error\n ? err.message\n : 'Upload failed'\n setFormError(msg)\n } finally {\n setBusy(false)\n setUploadPercent(null)\n }\n }\n\n // Realtime: each `queue.job.**` event mutates `active` for the\n // matching jobId. The pattern is `queue.job.<event>` — we route on\n // the topic suffix rather than parsing per-event payload shapes.\n // `useRealtimeEffect` holds the callback in a ref so passing an\n // inline arrow doesn't re-subscribe each render.\n useRealtimeEffect<RealtimeJobPayload>('queue.job.**', (msg) => {\n const jobId = typeof msg.payload?.jobId === 'string' ? msg.payload.jobId : null\n if (!jobId) return\n setActive((prev) => {\n const idx = prev.findIndex((j) => j.id === jobId)\n if (idx < 0) return prev\n const job = prev[idx]\n if (!job) return prev\n const updated = applyEvent(job, msg.topic, msg.payload)\n if (updated === job) return prev\n const next = prev.slice()\n next[idx] = updated\n return next\n })\n })\n\n // One-shot seed: server query returns active rows but doesn't\n // include `toolkit_jobs.progress` (would cross a package boundary).\n // For each row that arrived without progress, fetch once on mount\n // so the percent bar paints immediately. Subsequent updates flow\n // over realtime — no interval, no recurring polling.\n //\n // Effect runs on mount only — the seed list comes from\n // server-rendered `initialData`. New uploads added via `onSubmit`\n // start with `progress: null` and get filled in by the\n // `queue.job.claimed` + `queue.job.progress` events the worker\n // emits within ~250ms of claim.\n // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only seed; later additions are handled via onSubmit + realtime.\n useEffect(() => {\n if (!canPollProgress) return\n const queueJobBase = initialData.queueJobPath\n const controller = new AbortController()\n const seeds = active.filter((j) => !j.progress && !TERMINAL.has(j.status))\n if (seeds.length === 0) return\n\n void Promise.all(\n seeds.map(async (job) => {\n try {\n const result = await fetchJobProgress(queueJobBase, job.id, controller.signal)\n if (!result || controller.signal.aborted) return null\n return { jobId: job.id, result }\n } catch {\n return null\n }\n }),\n ).then((results) => {\n if (controller.signal.aborted) return\n setActive((prev) =>\n prev.map((j) => {\n // PR #267 review (M-2): if a `queue.job.progress` event has\n // already filled in `progress` between mount and now, the\n // seed result is older — refuse to overwrite. Window is\n // narrow (~250ms) but real on a busy worker.\n if (j.progress !== null) return j\n const seed = results.find((r) => r?.jobId === j.id)\n if (!seed) return j\n return {\n ...j,\n status: seed.result.status,\n progress: seed.result.progress,\n finalError: seed.result.lastError,\n }\n }),\n )\n })\n return () => controller.abort()\n }, [])\n\n return (\n <div className=\"space-y-6 p-6\">\n <header>\n <h1 className=\"text-2xl font-semibold\">Imports</h1>\n <p className=\"mt-1 text-sm text-muted-foreground\">\n Run a supplier feed through the carmaker-feed transform and into the parts index.\n </p>\n </header>\n\n <section className=\"rounded-md border bg-card p-4\">\n <h2 className=\"text-base font-medium mb-3\">Run import</h2>\n <form onSubmit={(e) => void onSubmit(e)} className=\"grid gap-3 sm:grid-cols-4\">\n <label className=\"flex flex-col gap-1 text-sm\">\n <span className=\"text-muted-foreground\">Brand</span>\n <select\n className=\"rounded border bg-background px-2 py-1.5\"\n value={brandId}\n onChange={(e) => setBrandId(e.target.value)}\n disabled={!canCreate || busy}\n required\n >\n <option value=\"\">Pick a brand…</option>\n {initialData.brands.map((b) => (\n <option key={b.id} value={b.id}>\n {b.label}\n </option>\n ))}\n </select>\n </label>\n <label className=\"flex flex-col gap-1 text-sm\">\n <span className=\"text-muted-foreground\">Supplier</span>\n <select\n className=\"rounded border bg-background px-2 py-1.5\"\n value={supplierId}\n onChange={(e) => setSupplierId(e.target.value)}\n disabled={!canCreate || busy}\n required\n >\n <option value=\"\">Pick a supplier…</option>\n {initialData.suppliers.map((s) => (\n <option key={s.id} value={s.id}>\n {s.label}\n </option>\n ))}\n </select>\n </label>\n <label className=\"flex flex-col gap-1 text-sm sm:col-span-2\">\n <span className=\"text-muted-foreground\">Feed file</span>\n <input\n id=\"commerce-import-file\"\n type=\"file\"\n accept=\".txt,.csv,.tsv,text/plain,text/tab-separated-values,text/csv\"\n onChange={(e) => setFile(e.target.files?.[0] ?? null)}\n disabled={!canCreate || busy}\n required\n className=\"text-sm\"\n />\n </label>\n <div className=\"sm:col-span-4 flex items-center gap-3\">\n <button\n type=\"submit\"\n className=\"rounded bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50\"\n disabled={!canCreate || busy || !file || !brandId || !supplierId}\n >\n {busy\n ? uploadPercent !== null && uploadPercent < 100\n ? `Uploading… ${uploadPercent.toFixed(0)}%`\n : 'Enqueueing…'\n : 'Run import'}\n </button>\n {!canCreate && (\n <span className=\"text-xs text-muted-foreground\">\n Read-only: needs <code className=\"font-mono\">commerce_import:create</code>.\n </span>\n )}\n {formError && <span className=\"text-sm text-destructive\">{formError}</span>}\n </div>\n {busy && uploadPercent !== null && (\n // Bytes-uploaded bar. Once the upload reaches 100% the\n // server still has to MIME-sniff + persist + insert + enqueue\n // before responding — that gap is what the \"Enqueueing…\"\n // button text covers above.\n <div className=\"sm:col-span-4\">\n <div className=\"h-2 w-full overflow-hidden rounded-full bg-muted\">\n <div\n className=\"h-full bg-primary transition-[width] duration-150 ease-out\"\n style={{ width: `${uploadPercent.toFixed(1)}%` }}\n />\n </div>\n {file && (\n <p className=\"mt-1 text-xs text-muted-foreground font-mono\">\n {formatBytes((file.size * uploadPercent) / 100)} /{' '}\n {formatBytes(file.size)}\n </p>\n )}\n </div>\n )}\n </form>\n </section>\n\n <section className=\"rounded-md border bg-card p-4\">\n <h2 className=\"text-base font-medium mb-3\">Active jobs</h2>\n {!canPollProgress ? (\n <p className=\"text-sm text-muted-foreground\">\n Live updates not authorised — needs admin role.\n </p>\n ) : active.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">No imports running right now.</p>\n ) : (\n <ul className=\"space-y-2\">\n {active.map((job) => (\n <ActiveJobRow key={job.id} job={job} now={now} />\n ))}\n </ul>\n )}\n </section>\n\n <section className=\"rounded-md border bg-card p-4\">\n <div className=\"mb-3 flex items-center justify-between\">\n <h2 className=\"text-base font-medium\">Recent imports</h2>\n <button\n type=\"button\"\n onClick={() => void refreshHistory()}\n disabled={historyLoading}\n className=\"rounded border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50\"\n >\n {historyLoading ? 'Refreshing…' : 'Refresh'}\n </button>\n </div>\n {historyError && (\n <p className=\"mb-3 text-sm text-destructive\" role=\"alert\">\n {historyError}\n </p>\n )}\n <HistoryTable rows={history} />\n </section>\n </div>\n )\n}\n\n/**\n * Realtime payload shape for `queue.job.**` topics. Fields are typed\n * loosely because each topic carries a different subset (`progress`\n * only on `queue.job.progress`, `error` only on `queue.job.dead`,\n * etc.); we narrow per-event in `applyEvent`.\n */\ninterface RealtimeJobPayload {\n jobId?: string\n jobType?: string\n progress?: ImportProgressSnapshot\n error?: string\n attempts?: number\n durationMs?: number\n}\n\n/**\n * Apply a `queue.job.<event>` to an existing `ActiveJob`. Returns the\n * SAME reference when the event isn't actionable (no diff) so the\n * setter can early-return without an extra render.\n */\nfunction applyEvent(\n job: ActiveJob,\n topic: string,\n payload: RealtimeJobPayload,\n): ActiveJob {\n switch (topic) {\n case 'queue.job.claimed':\n return job.status === 'running' ? job : { ...job, status: 'running' }\n case 'queue.job.progress':\n return payload.progress ? { ...job, progress: payload.progress } : job\n case 'queue.job.completed':\n return job.status === 'completed' ? job : { ...job, status: 'completed' }\n case 'queue.job.dead':\n return {\n ...job,\n status: 'dead',\n finalError: payload.error ?? job.finalError,\n }\n case 'queue.job.retrying':\n // Worker scheduled another attempt — go back to \"queued\".\n return { ...job, status: 'pending', finalError: null }\n case 'queue.job.cancelled':\n return job.status === 'cancelled' ? job : { ...job, status: 'cancelled' }\n default:\n return job\n }\n}\n\n/** Hydrate the JSON-safe initial-data shape into the runtime ActiveJob. */\nfunction toActiveJob(seed: ActiveImportJobInitial): ActiveJob {\n return {\n id: seed.jobId,\n startedAt: new Date(seed.startedAt).getTime(),\n importRunId: seed.importRunId,\n brandLabel: seed.brandLabel,\n supplierLabel: seed.supplierLabel,\n filename: seed.filename,\n status: seed.status,\n progress: seed.progress,\n finalError: seed.finalError,\n }\n}\n\nfunction ActiveJobRow({\n job,\n now,\n}: {\n job: ActiveJob\n /**\n * Wall-clock from the parent's ticker, or `null` during the very\n * first render (both server SSR and the client hydration pass).\n * When `null` the row omits the elapsed \"(Xs)\" suffix entirely so\n * server + client render IDENTICAL HTML — the elapsed display\n * fades in once the parent's mount-effect populates the ticker.\n */\n now: number | null\n}): React.ReactElement {\n const elapsed = now !== null ? Math.round((now - job.startedAt) / 1000) : null\n const isTerminal = TERMINAL.has(job.status)\n const isPending = job.status === 'pending'\n const isRunning = job.status === 'running' || job.status === 'processing'\n return (\n <li className=\"rounded border p-3\">\n <div className=\"flex flex-wrap items-baseline justify-between gap-2 text-sm\">\n <div className=\"flex flex-wrap items-baseline gap-2\">\n <span className=\"font-medium\">{job.brandLabel}</span>\n <span className=\"text-muted-foreground\">·</span>\n <span>{job.supplierLabel}</span>\n <span className=\"text-muted-foreground\">·</span>\n <code className=\"font-mono text-xs\">{job.filename}</code>\n </div>\n <span\n className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(job.status)}`}\n >\n {job.status}\n </span>\n </div>\n {job.progress ? (\n <ProgressBar progress={job.progress} />\n ) : !isTerminal ? (\n <p className=\"mt-2 text-xs text-muted-foreground\">\n {isPending\n ? `Queued — waiting for the worker to pick up…${elapsed !== null ? ` (${elapsed}s)` : ''}`\n : isRunning\n ? `Worker started — first batch in progress${elapsed !== null ? ` (${elapsed}s)` : ''}`\n : `Status: ${job.status}${elapsed !== null ? ` (${elapsed}s)` : ''}`}\n </p>\n ) : null}\n {job.finalError && (\n <p className=\"mt-2 text-xs text-destructive font-mono\">{job.finalError}</p>\n )}\n </li>\n )\n}\n\nfunction ProgressBar({ progress }: { progress: ImportProgressSnapshot }): React.ReactElement {\n // When the worker emitted a totalRows pre-count, render a real percent\n // bar + ETA. Otherwise fall back to the raw counts only — operator\n // still sees throughput, just not a denominator.\n const total = progress.totalRows\n const hasTotal = typeof total === 'number' && total > 0\n const percent = hasTotal ? Math.min(100, (progress.rowsRead / total) * 100) : null\n const remainingRows = hasTotal ? Math.max(0, total - progress.rowsRead) : null\n const etaSeconds =\n remainingRows !== null && progress.rowsPerSecond > 0\n ? remainingRows / progress.rowsPerSecond\n : null\n\n return (\n <div className=\"mt-2 space-y-2\">\n {percent !== null && (\n <div>\n <div className=\"h-1.5 w-full overflow-hidden rounded-full bg-muted\">\n <div\n className=\"h-full bg-primary transition-[width] duration-150 ease-out\"\n style={{ width: `${percent.toFixed(1)}%` }}\n />\n </div>\n <p className=\"mt-1 text-xs text-muted-foreground font-mono\">\n {progress.rowsRead.toLocaleString()} / {(total ?? 0).toLocaleString()} rows\n {' · '}\n {percent.toFixed(1)}%\n {etaSeconds !== null && ` · ETA ${formatDuration(etaSeconds)}`}\n </p>\n </div>\n )}\n <dl className=\"grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4\">\n {!hasTotal && <Stat label=\"Read\" value={progress.rowsRead.toLocaleString()} />}\n <Stat label=\"Succeeded\" value={progress.rowsSucceeded.toLocaleString()} />\n <Stat label=\"Failed\" value={progress.rowsFailed.toLocaleString()} />\n <Stat label=\"Skipped\" value={progress.rowsSkipped.toLocaleString()} />\n <Stat label=\"Batches\" value={progress.batchesCompleted.toLocaleString()} />\n <Stat label=\"Elapsed\" value={`${progress.elapsedSeconds.toFixed(1)}s`} />\n <Stat label=\"Rows/s\" value={progress.rowsPerSecond.toFixed(1)} />\n <Stat label=\"Patterns\" value={progress.distinctErrorPatterns.toLocaleString()} />\n </dl>\n </div>\n )\n}\n\nfunction formatDuration(seconds: number): string {\n if (seconds < 60) return `${seconds.toFixed(0)}s`\n if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`\n return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): React.ReactElement {\n return (\n <div>\n <dt className=\"text-muted-foreground\">{label}</dt>\n <dd className=\"font-mono\">{value}</dd>\n </div>\n )\n}\n\nfunction HistoryTable({ rows }: { rows: ImportRunHistoryRow[] }): React.ReactElement {\n // `useFormatter` reads the active locale + timezone from the\n // surrounding `NextIntlClientProvider`, which the [locale] route\n // populates from `setRequestLocale(locale)` server-side. That\n // guarantees the SAME locale is in scope for the SSR pass and the\n // client hydration pass — the prior `Date#toLocaleString()` and\n // `Number#toLocaleString()` calls used the host runtime's default\n // (server's `LANG` env vs. browser's user locale), which produced\n // hydration mismatches whenever the two diverged.\n //\n // The hook is safe to call here even when the rows array is empty —\n // React allows hooks at the top of a component body, and the early\n // return below comes after.\n const format = useFormatter()\n if (rows.length === 0) {\n return (\n <p className=\"text-sm text-muted-foreground\">\n No imports yet. Pick a brand + supplier above and run one.\n </p>\n )\n }\n return (\n <div className=\"overflow-x-auto\">\n <table className=\"w-full text-sm\">\n <thead className=\"border-b text-xs uppercase tracking-wide text-muted-foreground\">\n <tr>\n <th className=\"px-2 py-1.5 text-left font-medium\">Label</th>\n <th className=\"px-2 py-1.5 text-left font-medium\">Status</th>\n <th className=\"px-2 py-1.5 text-right font-medium\">Succeeded</th>\n <th className=\"px-2 py-1.5 text-right font-medium\">Failed</th>\n <th className=\"px-2 py-1.5 text-left font-medium\">Started</th>\n <th className=\"px-2 py-1.5 text-left font-medium\">Finished</th>\n </tr>\n </thead>\n <tbody>\n {rows.map((r) => (\n <tr key={r.id} className=\"border-b last:border-0\">\n <td className=\"px-2 py-1.5\">\n <a\n href={`/admin/import_run/${r.id}`}\n className=\"text-primary hover:underline\"\n >\n {r.label || r.id}\n </a>\n </td>\n <td className=\"px-2 py-1.5\">\n <span\n className={`rounded px-2 py-0.5 text-xs font-medium ${statusColor(r.status)}`}\n >\n {r.status}\n </span>\n </td>\n <td className=\"px-2 py-1.5 text-right font-mono\">\n {formatTotal(format, r.totals?.succeeded)}\n </td>\n <td className=\"px-2 py-1.5 text-right font-mono\">\n {formatTotal(format, r.totals?.failed)}\n </td>\n <td className=\"px-2 py-1.5 text-xs text-muted-foreground\">\n {formatTime(format, r.startedAt)}\n </td>\n <td className=\"px-2 py-1.5 text-xs text-muted-foreground\">\n {formatTime(format, r.finishedAt)}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )\n}\n\nfunction formatBytes(bytes: number): string {\n if (bytes < 1024) return `${bytes.toFixed(0)} B`\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`\n}\n\n/** next-intl formatter handle. Typed via `ReturnType` so we don't have\n * to surface an internal next-intl type in the helpers' signatures. */\ntype IntlFormatter = ReturnType<typeof useFormatter>\n\nfunction formatTotal(format: IntlFormatter, n: number | undefined): string {\n return typeof n === 'number' ? format.number(n) : '—'\n}\n\nfunction formatTime(format: IntlFormatter, iso: string | null): string {\n if (!iso) return '—'\n const ms = Date.parse(iso)\n if (Number.isNaN(ms)) return iso\n // Explicit `numeric` parts (NOT `dateStyle: 'short'` + `timeStyle:\n // 'short'`) so the rendered timestamp keeps SECONDS — operators\n // routinely correlate this column with worker log entries that\n // include `:ss`, and the `'short'` styles drop them.\n return format.dateTime(new Date(ms), {\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n })\n}\n\nfunction statusColor(status: string): string {\n switch (status) {\n case 'succeeded':\n case 'completed':\n return 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'\n case 'failed':\n case 'dead':\n return 'bg-rose-500/10 text-rose-700 dark:text-rose-400'\n case 'running':\n case 'processing':\n return 'bg-sky-500/10 text-sky-700 dark:text-sky-400'\n case 'cancelled':\n return 'bg-amber-500/10 text-amber-700 dark:text-amber-400'\n default:\n return 'bg-muted text-muted-foreground'\n }\n}\n","/**\n * Parts search admin page — client component.\n *\n * Bespoke search box (per PR 8a-2 plan: NOT the generic <SearchBox> from\n * PR 3 — that supersedes this when it ships). Single text input + mode\n * toggle + brand-facet chip group + paged result list. Talks to\n * `GET /api/admin/commerce/parts-search` (the proxy registered by\n * `commerceRoutes` when the consumer wires a SearchProvider).\n *\n * The endpoint returns `null` when the proxy isn't mounted — meaning\n * the operator's deployment doesn't have ES configured. The page then\n * renders a \"not configured\" notice rather than throwing.\n *\n * Search runs on form submit + on facet toggle. Pagination is offset-\n * based against the server's `total`; mechanics caps at 50 rows / page\n * and 1000 rows of offset, so deep pagination redirects to \"narrow your\n * query\" rather than letting the operator skip past the cap.\n */\n\nimport { useState } from 'react'\nimport { CommerceApiError, searchParts } from '../lib/api.js'\nimport type { PartsBrandFacet, PartsSearchResponse, PartsSearchRow } from '../types.js'\n\nexport interface CommercePartsSearchPageClientProps {\n /** GET endpoint for the parts-search proxy. */\n endpoint: string\n}\n\nconst PAGE_SIZE = 25\nconst MAX_OFFSET = 1_000\n\nexport function CommercePartsSearchPageClient({\n endpoint,\n}: CommercePartsSearchPageClientProps): React.ReactElement {\n const [query, setQuery] = useState('')\n const [mode, setMode] = useState<'prefix' | 'term'>('prefix')\n const [brands, setBrands] = useState<string[]>([])\n const [offset, setOffset] = useState(0)\n const [busy, setBusy] = useState(false)\n const [response, setResponse] = useState<PartsSearchResponse | null>(null)\n const [notConfigured, setNotConfigured] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n async function runSearch(nextOffset = 0): Promise<void> {\n if (!query.trim() || busy) return\n setBusy(true)\n setError(null)\n try {\n const result = await searchParts(endpoint, {\n q: query.trim(),\n mode,\n brandSlugs: brands,\n limit: PAGE_SIZE,\n offset: nextOffset,\n })\n if (result === null) {\n setNotConfigured(true)\n setResponse(null)\n } else {\n setNotConfigured(false)\n setResponse(result)\n setOffset(nextOffset)\n }\n } catch (err) {\n const msg =\n err instanceof CommerceApiError\n ? err.message\n : err instanceof Error\n ? err.message\n : 'Search failed'\n setError(msg)\n } finally {\n setBusy(false)\n }\n }\n\n function toggleBrand(slug: string): void {\n // Toggle then re-run from offset 0 — facet changes invalidate the\n // current page (rows on page 2 may not match the new filter).\n const next = brands.includes(slug)\n ? brands.filter((b) => b !== slug)\n : [...brands, slug]\n setBrands(next)\n // Use the *new* brand list, not the stale-closure-captured `brands`.\n void (async () => {\n if (!query.trim()) return\n setBusy(true)\n setError(null)\n try {\n const result = await searchParts(endpoint, {\n q: query.trim(),\n mode,\n brandSlugs: next,\n limit: PAGE_SIZE,\n offset: 0,\n })\n if (result === null) {\n setNotConfigured(true)\n setResponse(null)\n } else {\n setNotConfigured(false)\n setResponse(result)\n setOffset(0)\n }\n } catch (err) {\n const msg =\n err instanceof CommerceApiError\n ? err.message\n : err instanceof Error\n ? err.message\n : 'Search failed'\n setError(msg)\n } finally {\n setBusy(false)\n }\n })()\n }\n\n return (\n <div className=\"space-y-6 p-6\">\n <header>\n <h1 className=\"text-2xl font-semibold\">Parts search</h1>\n <p className=\"mt-1 text-sm text-muted-foreground\">\n Look up parts by code (prefix or exact). Filter by brand. Live data from the parts\n index.\n </p>\n </header>\n\n {notConfigured ? (\n <div className=\"rounded-md border border-amber-500/50 bg-amber-500/5 p-4 text-sm\">\n <strong>Parts search is not configured for this deployment.</strong>\n <p className=\"mt-1 text-muted-foreground\">\n Wire an <code className=\"font-mono\">ElasticsearchProvider</code> via{' '}\n <code className=\"font-mono\">commerce({'{ partsSearch: { provider } }'})</code> in\n your <code className=\"font-mono\">lumi.config.ts</code> to enable it.\n </p>\n </div>\n ) : null}\n\n <section className=\"rounded-md border bg-card p-4\">\n <form\n onSubmit={(e) => {\n e.preventDefault()\n void runSearch(0)\n }}\n className=\"flex flex-wrap items-end gap-3\"\n >\n <label className=\"flex flex-col gap-1 text-sm flex-1 min-w-[240px]\">\n <span className=\"text-muted-foreground\">Code</span>\n <input\n type=\"text\"\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder=\"e.g. ME-A0000000000 or A0000000000\"\n className=\"rounded border bg-background px-2 py-1.5 font-mono\"\n disabled={busy}\n />\n </label>\n <fieldset className=\"flex flex-col gap-1 text-sm\">\n <legend className=\"text-muted-foreground\">Mode</legend>\n <div className=\"flex rounded border\">\n <button\n type=\"button\"\n onClick={() => setMode('prefix')}\n className={`px-3 py-1.5 text-xs ${mode === 'prefix' ? 'bg-primary text-primary-foreground' : 'bg-background'}`}\n >\n Prefix\n </button>\n <button\n type=\"button\"\n onClick={() => setMode('term')}\n className={`px-3 py-1.5 text-xs border-l ${mode === 'term' ? 'bg-primary text-primary-foreground' : 'bg-background'}`}\n >\n Exact\n </button>\n </div>\n </fieldset>\n <button\n type=\"submit\"\n disabled={busy || !query.trim()}\n className=\"rounded bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50\"\n >\n {busy ? 'Searching…' : 'Search'}\n </button>\n </form>\n {error && (\n <p className=\"mt-3 text-sm text-destructive\" role=\"alert\">\n {error}\n </p>\n )}\n </section>\n\n {response && (\n <>\n <BrandFacets\n facets={response.facets.find((f) => f.field === 'brand_slug')?.buckets ?? []}\n selected={brands}\n onToggle={toggleBrand}\n />\n <ResultList\n rows={response.rows}\n total={response.total}\n offset={offset}\n durationMs={response.durationMs}\n onPage={(next) => void runSearch(next)}\n busy={busy}\n />\n </>\n )}\n </div>\n )\n}\n\nfunction BrandFacets({\n facets,\n selected,\n onToggle,\n}: {\n facets: readonly PartsBrandFacet[]\n selected: readonly string[]\n onToggle: (slug: string) => void\n}): React.ReactElement | null {\n if (facets.length === 0) return null\n return (\n <section className=\"rounded-md border bg-card p-4\">\n <h2 className=\"mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n Brand\n </h2>\n <div className=\"flex flex-wrap gap-2\">\n {facets.map((f) => {\n const isOn = selected.includes(f.value)\n return (\n <button\n key={f.value}\n type=\"button\"\n onClick={() => onToggle(f.value)}\n className={`rounded-full border px-3 py-1 text-xs transition-colors ${\n isOn\n ? 'border-primary bg-primary text-primary-foreground'\n : 'border-border bg-background hover:bg-accent'\n }`}\n >\n {f.value}\n <span className=\"ml-1.5 font-mono opacity-70\">{f.count}</span>\n </button>\n )\n })}\n </div>\n </section>\n )\n}\n\nfunction ResultList({\n rows,\n total,\n offset,\n durationMs,\n onPage,\n busy,\n}: {\n rows: readonly PartsSearchRow[]\n total: number\n offset: number\n durationMs: number\n onPage: (offset: number) => void\n busy: boolean\n}): React.ReactElement {\n const page = Math.floor(offset / PAGE_SIZE) + 1\n const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))\n const canPrev = offset > 0\n // Mechanics caps offset at 1000; protect the UI from offering an invalid jump.\n const canNext = offset + PAGE_SIZE < Math.min(total, MAX_OFFSET + PAGE_SIZE)\n\n return (\n <section className=\"rounded-md border bg-card p-4\">\n <header className=\"mb-3 flex items-baseline justify-between\">\n <h2 className=\"text-base font-medium\">\n {total.toLocaleString()} {total === 1 ? 'result' : 'results'}\n </h2>\n <span className=\"text-xs text-muted-foreground\">{durationMs}ms</span>\n </header>\n {rows.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">\n No matches. Try the other mode or remove brand filters.\n </p>\n ) : (\n <div className=\"overflow-x-auto\">\n <table className=\"w-full text-sm\">\n <thead className=\"border-b text-xs uppercase tracking-wide text-muted-foreground\">\n <tr>\n <th className=\"px-2 py-1.5 text-left font-medium\">Code</th>\n <th className=\"px-2 py-1.5 text-left font-medium\">Brand</th>\n <th className=\"px-2 py-1.5 text-left font-medium\">Supplier</th>\n <th className=\"px-2 py-1.5 text-left font-medium\">Name</th>\n <th className=\"px-2 py-1.5 text-right font-medium\">Net price (EUR)</th>\n <th className=\"px-2 py-1.5 text-left font-medium\">Imported</th>\n </tr>\n </thead>\n <tbody>\n {rows.map((r) => (\n <tr key={r.id} className=\"border-b last:border-0\">\n <td className=\"px-2 py-1.5 font-mono\">{r.data.code}</td>\n <td className=\"px-2 py-1.5\">{r.data.brand_slug}</td>\n <td className=\"px-2 py-1.5\">{r.data.supplier_display_name}</td>\n <td className=\"px-2 py-1.5 text-muted-foreground\">{r.data.name_en ?? '—'}</td>\n <td className=\"px-2 py-1.5 text-right font-mono\">\n {r.data.base_price_eur != null ? r.data.base_price_eur.toFixed(2) : '—'}\n </td>\n <td className=\"px-2 py-1.5 text-xs text-muted-foreground\">\n {formatDate(r.data.imported_at)}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n {totalPages > 1 && (\n <nav className=\"mt-3 flex items-center justify-between text-xs\">\n <span className=\"text-muted-foreground\">\n Page {page} of {totalPages}\n </span>\n <div className=\"flex gap-2\">\n <button\n type=\"button\"\n disabled={!canPrev || busy}\n onClick={() => onPage(Math.max(0, offset - PAGE_SIZE))}\n className=\"rounded border px-2 py-1 hover:bg-accent disabled:opacity-50\"\n >\n Previous\n </button>\n <button\n type=\"button\"\n disabled={!canNext || busy}\n onClick={() => onPage(offset + PAGE_SIZE)}\n className=\"rounded border px-2 py-1 hover:bg-accent disabled:opacity-50\"\n >\n Next\n </button>\n </div>\n </nav>\n )}\n </section>\n )\n}\n\nfunction formatDate(iso: string): string {\n try {\n return new Date(iso).toLocaleDateString()\n } catch {\n return iso\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import { PageProps } from "@murumets-ee/core";
2
+ import { ReactNode } from "react";
3
+
4
+ //#region src/pages/imports-page.d.ts
5
+ declare function CommerceImportsPage({
6
+ params
7
+ }: PageProps<{
8
+ locale: string;
9
+ }>): Promise<ReactNode>;
10
+ //#endregion
11
+ //#region src/pages/parts-search-page.d.ts
12
+ declare function CommercePartsSearchPage({
13
+ params
14
+ }: PageProps<{
15
+ locale: string;
16
+ }>): Promise<ReactNode>;
17
+ //#endregion
18
+ export { CommerceImportsPage, CommercePartsSearchPage };
19
+ //# sourceMappingURL=pages.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pages.d.mts","names":[],"sources":["../src/pages/imports-page.tsx","../src/pages/parts-search-page.tsx"],"mappings":";;;;iBAoCsB,mBAAA,CAAA;EACpB;AAAA,GACC,SAAA;EAAY,MAAA;AAAA,KAAoB,OAAA,CAAQ,SAAA;;;iBCrBrB,uBAAA,CAAA;EACpB;AAAA,GACC,SAAA;EAAY,MAAA;AAAA,KAAoB,OAAA,CAAQ,SAAA"}
package/dist/pages.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import{getRegisteredAdminConfig as e}from"@murumets-ee/admin-ui/config-registry";import{fetchEntityList as t,fetchReferenceOptions as n}from"@murumets-ee/admin-ui/server";import{buildPermissionChecker as r}from"@murumets-ee/auth";import{Brand as i,Supplier as a}from"@murumets-ee/commerce";import{getApp as o}from"@murumets-ee/core";import{createAdminClient as s}from"@murumets-ee/core/clients";import{ImportRun as c}from"@murumets-ee/imports";import{inArray as l}from"drizzle-orm";import{headers as u}from"next/headers";import{redirect as d}from"next/navigation";import{setRequestLocale as f}from"next-intl/server";import{CommerceImportsPageClient as p,CommercePartsSearchPageClient as m}from"@murumets-ee/commerce-ui";import{jsx as h}from"react/jsx-runtime";async function g({params:m}){let g=e(),{locale:y}=await m;f(y);let b=await u(),x=await g.auth.getSession(b);if(!x?.user){let e=g.signInPath.replace(`:locale`,y),t=(g.adminBasePath??`/admin`).replace(`:locale`,y)+`/commerce/imports`;d(`${e}?callbackUrl=${encodeURIComponent(t)}`)}let S=g.apiBasePath??`/api/admin`,C=x.user.role??`viewer`;return g.withAdminContext(y,async()=>{let e=r(await g.loadRoles(o())),u=e(C,`commerce_import`,`create`),f=C===`admin`;if(!e(C,`commerce_import`,`view`)){let e=g.signInPath.replace(`:locale`,y),t=(g.adminBasePath??`/admin`).replace(`:locale`,y)+`/commerce/imports`;d(`${e}?callbackUrl=${encodeURIComponent(t)}`)}let m=s(c),b=m.getTable(),[x,w,T,E]=await Promise.all([n(i),n(a,{labelField:`displayName`}),t(c,{sortField:`createdAt`,sortDirection:`desc`,limit:50}),m.findMany({where:l(b.status,[`pending`,`running`]),limit:50})]);return h(p,{initialData:{brands:x,suppliers:w,history:T.items.map(e=>{let t=e;return{id:typeof t.id==`string`?t.id:``,label:typeof t.label==`string`?t.label:``,status:typeof t.status==`string`?t.status:`pending`,createdAt:_(t.createdAt)??``,startedAt:_(t.startedAt),finishedAt:_(t.finishedAt),queueJobId:typeof t.queueJobId==`string`?t.queueJobId:null,totals:t.totals&&typeof t.totals==`object`?t.totals:null}}),activeJobs:E.map(e=>v(e)).filter(e=>e!==null),uploadEndpoint:`${S}/commerce/imports/run`,queueJobPath:`${S}/queue/jobs`,partsSearchEndpoint:`${S}/commerce/parts-search`},canCreate:u,canPollProgress:f})})}function _(e){return e instanceof Date?e.toISOString():typeof e==`string`?e:null}function v(e){let t=typeof e.queueJobId==`string`?e.queueJobId:null;if(!t)return null;let n=typeof e.id==`string`?e.id:null;if(!n)return null;let r=typeof e.status==`string`?e.status:`pending`,i=_(e.startedAt)??_(e.createdAt)??new Date().toISOString(),a=e.params&&typeof e.params==`object`&&!Array.isArray(e.params)?e.params:{};return{jobId:t,importRunId:n,brandLabel:typeof a.brandSlug==`string`&&a.brandSlug.length>0?a.brandSlug:typeof e.label==`string`?e.label:`—`,supplierLabel:typeof a.supplierDisplayName==`string`&&a.supplierDisplayName.length>0?a.supplierDisplayName:`—`,filename:typeof e.label==`string`&&e.label.includes(` — `)?e.label.split(` — `)[1]??e.label:typeof e.label==`string`?e.label:``,status:r,startedAt:i,progress:null,finalError:null}}async function y({params:t}){let n=e(),{locale:r}=await t;f(r);let i=await u();if(!(await n.auth.getSession(i))?.user){let e=n.signInPath.replace(`:locale`,r),t=(n.adminBasePath??`/admin`).replace(`:locale`,r)+`/commerce/parts-search`;d(`${e}?callbackUrl=${encodeURIComponent(t)}`)}return h(m,{endpoint:`${n.apiBasePath??`/api/admin`}/commerce/parts-search`})}export{g as CommerceImportsPage,y as CommercePartsSearchPage};
2
+ //# sourceMappingURL=pages.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pages.mjs","names":[],"sources":["../src/pages/imports-page.tsx","../src/pages/parts-search-page.tsx"],"sourcesContent":["/**\n * Auto-admin imports page (RSC).\n *\n * Renders at `/admin/commerce/imports`. Fetches the brand + supplier\n * picker options + the recent `import_run` history server-side so the\n * first paint contains the full payload — no client-side waterfall on\n * mount.\n *\n * Page-tier auth guard mirrors the ticketing inbox pattern (CLAUDE.md\n * §L \"Defense in Depth\"): even though the admin shell's\n * `AdminGuardLayout` redirects unauthenticated callers above this\n * page, this check is the second of the four-tier defense (proxy →\n * page → DAL → DB row-level). If the layout ever stops gating, the\n * page still refuses to leak.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { fetchEntityList, fetchReferenceOptions } from '@murumets-ee/admin-ui/server'\nimport { buildPermissionChecker } from '@murumets-ee/auth'\nimport { Brand, Supplier } from '@murumets-ee/commerce'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport { ImportRun } from '@murumets-ee/imports'\nimport { inArray } from 'drizzle-orm'\nimport { headers } from 'next/headers'\nimport { redirect } from 'next/navigation'\nimport { setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\nimport { CommerceImportsPageClient } from '@murumets-ee/commerce-ui'\nimport type {\n ActiveImportJobInitial,\n ImportsPageInitialData,\n} from '@murumets-ee/commerce-ui'\n\nconst HISTORY_LIMIT = 50\n\nexport async function CommerceImportsPage({\n params,\n}: PageProps<{ locale: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale } = await params\n setRequestLocale(locale)\n\n const h = await headers()\n const session = await config.auth.getSession(h)\n if (!session?.user) {\n const signIn = config.signInPath.replace(':locale', locale)\n const callback =\n (config.adminBasePath ?? '/admin').replace(':locale', locale) + '/commerce/imports'\n redirect(`${signIn}?callbackUrl=${encodeURIComponent(callback)}`)\n }\n\n const apiBasePath = config.apiBasePath ?? '/api/admin'\n // The shell's role provider is the single source of truth for the\n // current user's role. Falling back to `viewer` is safe: viewer has\n // neither `commerce_import:create` nor `queue:update`, so the form\n // and active-jobs panel both render in their disabled state.\n const role: string = (session.user as { role?: string }).role ?? 'viewer'\n\n return config.withAdminContext(locale, async () => {\n // Permissions matrix for the page-rendering tier. The ROUTE handler\n // re-checks server-side on every POST; this client-side flag only\n // controls whether the form is enabled.\n const roleDefinitions = await config.loadRoles(getApp())\n const checker = buildPermissionChecker(roleDefinitions)\n const canCreate = checker(role, 'commerce_import', 'create')\n // Live updates: the realtime layer scopes queue events to\n // `{ admin: true }` (`packages/queue/src/realtime/publish.ts:32`),\n // and the SSE handler's default isAdmin predicate is\n // `user.role === 'admin'`. Mirror that here so an `editor` granted\n // `queue:update` doesn't see a populated panel that never updates\n // (PR #267 review H-2). When the queue scope evolves to permission-\n // based, widen this back to `checker(role, 'queue', 'update')`.\n const canPollProgress = role === 'admin'\n\n // Defense-in-depth: refuse to render the page for a role without\n // `commerce_import:view`. The ROUTE handler enforces this on the\n // history fetch + active-jobs query individually — without the\n // page-tier check, a misconfigured role with `create` but not\n // `view` blows up server-side with a ForbiddenError (PR #267\n // review M-5).\n if (!checker(role, 'commerce_import', 'view')) {\n const signIn = config.signInPath.replace(':locale', locale)\n const callback =\n (config.adminBasePath ?? '/admin').replace(':locale', locale) + '/commerce/imports'\n redirect(`${signIn}?callbackUrl=${encodeURIComponent(callback)}`)\n }\n\n // In-flight runs: server-rendered so the Active jobs panel\n // survives a navigate-away-and-back. PR-A of the realtime track\n // (the client used to start with an empty `active` list and\n // discover running jobs only via optimistic-add at upload time —\n // refreshing the page mid-import wiped the panel even though the\n // job was still running on the worker). The client falls back to\n // a one-shot `fetchJobProgress` for any active row that arrives\n // here without `progress` populated; subsequent updates flow over\n // realtime.\n const importRunsClient = createAdminClient(ImportRun)\n const importRunsTable = importRunsClient.getTable()\n const [brands, suppliers, history, activeRuns] = await Promise.all([\n fetchReferenceOptions(Brand),\n fetchReferenceOptions(Supplier, { labelField: 'displayName' }),\n fetchEntityList(ImportRun, {\n sortField: 'createdAt',\n sortDirection: 'desc',\n limit: HISTORY_LIMIT,\n }),\n importRunsClient.findMany({\n // `findMany` takes a Drizzle SQL fragment for `where`. We\n // can't build operator-object filters here — `inArray` from\n // drizzle-orm is the canonical idiom for status filtering.\n where: inArray(importRunsTable.status, ['pending', 'running']),\n // Most-recent first; cap defensively. A healthy system has\n // at most worker.concurrency jobs in `running`, plus a small\n // queue of `pending` ones — 50 is comfortable headroom.\n limit: 50,\n }),\n ])\n\n const initialData: ImportsPageInitialData = {\n brands,\n suppliers,\n history: history.items.map((row) => {\n // ImportRun's typed row shape gives us Date + JSONB columns. Flatten\n // to JSON-safe strings before crossing the RSC boundary.\n const r = row as Record<string, unknown>\n return {\n id: typeof r.id === 'string' ? r.id : '',\n label: typeof r.label === 'string' ? r.label : '',\n status: typeof r.status === 'string' ? r.status : 'pending',\n createdAt: toIso(r.createdAt) ?? '',\n startedAt: toIso(r.startedAt),\n finishedAt: toIso(r.finishedAt),\n queueJobId: typeof r.queueJobId === 'string' ? r.queueJobId : null,\n totals:\n r.totals && typeof r.totals === 'object'\n ? (r.totals as Record<string, number>)\n : null,\n }\n }),\n activeJobs: activeRuns\n .map((row) => toActiveJobInitial(row as Record<string, unknown>))\n .filter((j): j is ActiveImportJobInitial => j !== null),\n uploadEndpoint: `${apiBasePath}/commerce/imports/run`,\n queueJobPath: `${apiBasePath}/queue/jobs`,\n partsSearchEndpoint: `${apiBasePath}/commerce/parts-search`,\n }\n\n return (\n <CommerceImportsPageClient\n initialData={initialData}\n canCreate={canCreate}\n canPollProgress={canPollProgress}\n />\n )\n })\n}\n\nfunction toIso(v: unknown): string | null {\n if (v instanceof Date) return v.toISOString()\n if (typeof v === 'string') return v\n return null\n}\n\n/**\n * Map an `import_run` row (status pending/running) to the\n * `ActiveImportJobInitial` shape the client seeds its Active panel\n * from. Returns null when the row is missing the queue job link or\n * the params block we need to render labels — the panel just skips\n * those rows.\n */\nfunction toActiveJobInitial(row: Record<string, unknown>): ActiveImportJobInitial | null {\n const queueJobId = typeof row.queueJobId === 'string' ? row.queueJobId : null\n if (!queueJobId) return null\n const importRunId = typeof row.id === 'string' ? row.id : null\n if (!importRunId) return null\n const status = typeof row.status === 'string' ? row.status : 'pending'\n const startedAt = toIso(row.startedAt) ?? toIso(row.createdAt) ?? new Date().toISOString()\n\n // Pull labels from `import_run.params` (the upload route writes\n // `{ brandSlug, supplierDisplayName, ... }` into this JSONB column).\n // Falling back to the `label` string so a row without a fully\n // populated params block still renders something useful.\n const params =\n row.params && typeof row.params === 'object' && !Array.isArray(row.params)\n ? (row.params as Record<string, unknown>)\n : {}\n const brandLabel =\n typeof params.brandSlug === 'string' && params.brandSlug.length > 0\n ? params.brandSlug\n : (typeof row.label === 'string' ? row.label : '—')\n const supplierLabel =\n typeof params.supplierDisplayName === 'string' && params.supplierDisplayName.length > 0\n ? params.supplierDisplayName\n : '—'\n // Filename was stripped into the `label` (\"brand_slug — filename\")\n // — pull the trailing portion when present.\n const filename =\n typeof row.label === 'string' && row.label.includes(' — ')\n ? (row.label.split(' — ')[1] ?? row.label)\n : (typeof row.label === 'string' ? row.label : '')\n\n return {\n jobId: queueJobId,\n importRunId,\n brandLabel,\n supplierLabel,\n filename,\n status,\n startedAt,\n // Server doesn't read `toolkit_jobs.progress` directly (would\n // cross a package boundary). The client does a one-shot fetch\n // via `queueJobPath` after mount to seed, then realtime takes\n // over.\n progress: null,\n finalError: null,\n }\n}\n","/**\n * Auto-admin parts search page (RSC).\n *\n * Renders at `/admin/commerce/parts-search`. Server side: just the\n * auth gate + a tiny config payload (the API endpoint). All search is\n * client-driven against the `commerce/parts-search` proxy — no initial\n * fetch needed because the page renders an empty input state.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport type { PageProps } from '@murumets-ee/core'\nimport { headers } from 'next/headers'\nimport { redirect } from 'next/navigation'\nimport { setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\nimport { CommercePartsSearchPageClient } from '@murumets-ee/commerce-ui'\n\nexport async function CommercePartsSearchPage({\n params,\n}: PageProps<{ locale: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale } = await params\n setRequestLocale(locale)\n\n const h = await headers()\n const session = await config.auth.getSession(h)\n if (!session?.user) {\n const signIn = config.signInPath.replace(':locale', locale)\n const callback =\n (config.adminBasePath ?? '/admin').replace(':locale', locale) + '/commerce/parts-search'\n redirect(`${signIn}?callbackUrl=${encodeURIComponent(callback)}`)\n }\n\n const apiBasePath = config.apiBasePath ?? '/api/admin'\n const endpoint = `${apiBasePath}/commerce/parts-search`\n\n return <CommercePartsSearchPageClient endpoint={endpoint} />\n}\n"],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ import { CommercePluginOptions, CommercePluginOptions as CommercePluginOptions$1 } from "@murumets-ee/commerce/plugin";
2
+ import { Plugin } from "@murumets-ee/core";
3
+
4
+ //#region src/plugin.d.ts
5
+ declare function commerce(options?: CommercePluginOptions$1): Plugin;
6
+ //#endregion
7
+ export { type CommercePluginOptions, commerce };
8
+ //# sourceMappingURL=plugin.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;iBAmDgB,QAAA,CAAS,OAAA,GAAU,uBAAA,GAAwB,MAAA"}
@@ -0,0 +1,2 @@
1
+ import{commerce as e}from"@murumets-ee/commerce/plugin";const t=async e=>(await import(`@murumets-ee/commerce-ui/pages`)).CommerceImportsPage(e),n=async e=>(await import(`@murumets-ee/commerce-ui/pages`)).CommercePartsSearchPage(e);function r(r){let i=e(r),a=i.server??{},o=i.adminUi??{};return{name:`@murumets-ee/commerce`,server:a,...i.shared!==void 0&&{shared:i.shared},adminUi:{...o,pages:{...o.pages??{},CommerceImportsPage:t,CommercePartsSearchPage:n},sidebar:[...o.sidebar??[],{id:`commerce:imports`,group:`commerce`,label:`Imports`,href:`/admin/commerce/imports`,iconName:`upload`},{id:`commerce:parts-search`,group:`commerce`,label:`Parts search`,href:`/admin/commerce/parts-search`,iconName:`search`}],defaultRoutes:[...o.defaultRoutes??[],{path:`commerce/imports`,factory:`CommerceImportsPage`,nav:{label:`Imports`,iconName:`upload`,group:`commerce`}},{path:`commerce/parts-search`,factory:`CommercePartsSearchPage`,nav:{label:`Parts search`,iconName:`search`,group:`commerce`}}]}}}export{r as commerce};
2
+ //# sourceMappingURL=plugin.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.mjs","names":["serverCommerce"],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * Commerce plugin — assembles the server commerce plugin\n * (`@murumets-ee/commerce`) with the admin UI surface (imports +\n * parts-search pages, sidebar entries) so consumers get the full\n * commerce experience from a single plugin entry:\n *\n * ```ts\n * import { defineLumiConfig } from '@murumets-ee/core'\n * import { commerce } from '@murumets-ee/commerce-ui/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * commerce({\n * partsSearch: {\n * provider: new ElasticsearchProvider({ ... }),\n * getClientIp: proxyClientIp, // when behind a trusted proxy\n * },\n * }),\n * ],\n * })\n * ```\n *\n * Location note: this factory lives in `@murumets-ee/commerce-ui`, not\n * in `@murumets-ee/commerce`, because the UI half depends on the\n * server half (one-directional). Hosting the assembled factory here\n * keeps the dependency graph clean.\n */\n\nimport type { PageFactory, Plugin } from '@murumets-ee/core'\nimport { commerce as serverCommerce } from '@murumets-ee/commerce/plugin'\nimport type { CommercePluginOptions } from '@murumets-ee/commerce/plugin'\n\n// Lazy page factories — see `@murumets-ee/ticketing-ui/src/plugin.ts` and\n// `@murumets-ee/queue-ui/src/plugin.ts` for the rationale. The plugin\n// factory is evaluated at `lumi.config.ts` load time (jiti / tsx for the\n// CLI + worker), neither of which applies the `react-server` export\n// condition. Eager top-level imports of the page module pull in\n// `next-intl/server` + `@murumets-ee/admin-ui/server` (which is\n// `server-only`-tagged) and throw under jiti. Wrapping as thunks defers\n// the import until Next.js RSC render where the conditions resolve.\nconst CommerceImportsPage: PageFactory<{ locale: string }> = async (props) => {\n const mod = await import('@murumets-ee/commerce-ui/pages')\n return mod.CommerceImportsPage(props)\n}\nconst CommercePartsSearchPage: PageFactory<{ locale: string }> = async (props) => {\n const mod = await import('@murumets-ee/commerce-ui/pages')\n return mod.CommercePartsSearchPage(props)\n}\n\nexport type { CommercePluginOptions } from '@murumets-ee/commerce/plugin'\n\nexport function commerce(options?: CommercePluginOptions): Plugin {\n // Compose the server plugin first to inherit its `init` hook, entity\n // registrations, route group, and shared contributions. Layer the\n // admin UI surface on top.\n const serverPlugin = serverCommerce(options)\n const serverContrib = serverPlugin.server ?? {}\n const adminUiContrib = serverPlugin.adminUi ?? {}\n\n return {\n name: '@murumets-ee/commerce',\n server: serverContrib,\n ...(serverPlugin.shared !== undefined && { shared: serverPlugin.shared }),\n adminUi: {\n ...adminUiContrib,\n pages: {\n ...(adminUiContrib.pages ?? {}),\n CommerceImportsPage,\n CommercePartsSearchPage,\n },\n sidebar: [\n ...(adminUiContrib.sidebar ?? []),\n {\n id: 'commerce:imports',\n group: 'commerce',\n label: 'Imports',\n href: '/admin/commerce/imports',\n iconName: 'upload',\n },\n {\n id: 'commerce:parts-search',\n group: 'commerce',\n label: 'Parts search',\n href: '/admin/commerce/parts-search',\n iconName: 'search',\n },\n ],\n defaultRoutes: [\n ...(adminUiContrib.defaultRoutes ?? []),\n {\n path: 'commerce/imports',\n factory: 'CommerceImportsPage',\n nav: { label: 'Imports', iconName: 'upload', group: 'commerce' },\n },\n {\n path: 'commerce/parts-search',\n factory: 'CommercePartsSearchPage',\n nav: { label: 'Parts search', iconName: 'search', group: 'commerce' },\n },\n ],\n },\n }\n}\n"],"mappings":"wDAwCA,MAAM,EAAuD,KAAO,KAE3D,MADW,OAAO,mCACd,oBAAoB,EAAM,CAEjC,EAA2D,KAAO,KAE/D,MADW,OAAO,mCACd,wBAAwB,EAAM,CAK3C,SAAgB,EAAS,EAAyC,CAIhE,IAAM,EAAeA,EAAe,EAAQ,CACtC,EAAgB,EAAa,QAAU,EAAE,CACzC,EAAiB,EAAa,SAAW,EAAE,CAEjD,MAAO,CACL,KAAM,wBACN,OAAQ,EACR,GAAI,EAAa,SAAW,IAAA,IAAa,CAAE,OAAQ,EAAa,OAAQ,CACxE,QAAS,CACP,GAAG,EACH,MAAO,CACL,GAAI,EAAe,OAAS,EAAE,CAC9B,sBACA,0BACD,CACD,QAAS,CACP,GAAI,EAAe,SAAW,EAAE,CAChC,CACE,GAAI,mBACJ,MAAO,WACP,MAAO,UACP,KAAM,0BACN,SAAU,SACX,CACD,CACE,GAAI,wBACJ,MAAO,WACP,MAAO,eACP,KAAM,+BACN,SAAU,SACX,CACF,CACD,cAAe,CACb,GAAI,EAAe,eAAiB,EAAE,CACtC,CACE,KAAM,mBACN,QAAS,sBACT,IAAK,CAAE,MAAO,UAAW,SAAU,SAAU,MAAO,WAAY,CACjE,CACD,CACE,KAAM,wBACN,QAAS,0BACT,IAAK,CAAE,MAAO,eAAgB,SAAU,SAAU,MAAO,WAAY,CACtE,CACF,CACF,CACF"}
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@murumets-ee/commerce-ui",
3
+ "version": "0.13.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.mts",
9
+ "import": "./dist/index.mjs"
10
+ },
11
+ "./pages": {
12
+ "types": "./dist/pages.d.mts",
13
+ "import": "./dist/pages.mjs"
14
+ },
15
+ "./plugin": {
16
+ "types": "./dist/plugin.d.mts",
17
+ "import": "./dist/plugin.mjs"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "dependencies": {
24
+ "@murumets-ee/admin-ui": "0.13.0",
25
+ "@murumets-ee/commerce": "0.13.0",
26
+ "@murumets-ee/core": "0.13.0",
27
+ "@murumets-ee/entity": "0.13.0",
28
+ "@murumets-ee/imports": "0.13.0",
29
+ "@murumets-ee/queue": "0.13.0",
30
+ "@murumets-ee/search": "0.13.0"
31
+ },
32
+ "peerDependencies": {
33
+ "lucide-react": ">=0.400.0",
34
+ "next": ">=15.0.0",
35
+ "next-intl": ">=4.0.0",
36
+ "react": ">=19.0.0",
37
+ "react-dom": ">=19.0.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "next": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@testing-library/react": "^16.3.0",
46
+ "@types/react": "^19",
47
+ "@types/react-dom": "^19",
48
+ "happy-dom": "^15.11.7",
49
+ "lucide-react": "^0.577.0",
50
+ "next": "16.2.4",
51
+ "next-intl": "^4.11.0",
52
+ "react": "19.2.5",
53
+ "react-dom": "19.2.5",
54
+ "tsdown": "^0.21.10",
55
+ "typescript": "^5.7.3",
56
+ "vitest": "^2.1.8",
57
+ "@murumets-ee/admin-ui": "0.13.0",
58
+ "@murumets-ee/commerce": "0.13.0",
59
+ "@murumets-ee/imports": "0.13.0",
60
+ "@murumets-ee/queue": "0.13.0",
61
+ "@murumets-ee/search": "0.13.0"
62
+ },
63
+ "typeCoverage": {
64
+ "atLeast": 99
65
+ },
66
+ "bundleBudget": {
67
+ "dist/pages.mjs": 6144,
68
+ "dist/plugin.mjs": 4096
69
+ },
70
+ "scripts": {
71
+ "build": "tsdown",
72
+ "dev": "tsdown --watch",
73
+ "test": "vitest"
74
+ }
75
+ }