@parhelia/localization 0.1.12601 → 0.1.12602

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/LocalizeItemDialog.d.ts.map +1 -1
  2. package/dist/LocalizeItemDialog.js +17 -13
  3. package/dist/api/discovery.d.ts +4 -1
  4. package/dist/api/discovery.d.ts.map +1 -1
  5. package/dist/api/discovery.js +4 -3
  6. package/dist/hooks/useTranslationWizard.d.ts.map +1 -1
  7. package/dist/hooks/useTranslationWizard.js +2 -3
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +28 -13
  10. package/dist/services/translationService.d.ts +7 -48
  11. package/dist/services/translationService.d.ts.map +1 -1
  12. package/dist/services/translationService.js +0 -3
  13. package/dist/settings/TranslationServicesPanel.d.ts.map +1 -1
  14. package/dist/settings/TranslationServicesPanel.js +42 -111
  15. package/dist/setup/LocalizationSetupStep.d.ts.map +1 -1
  16. package/dist/setup/LocalizationSetupStep.js +7 -8
  17. package/dist/steps/ServiceLanguageSelectionStep.d.ts.map +1 -1
  18. package/dist/steps/ServiceLanguageSelectionStep.js +1 -18
  19. package/dist/steps/SubitemDiscoveryStep.d.ts.map +1 -1
  20. package/dist/steps/SubitemDiscoveryStep.js +181 -95
  21. package/dist/translation-center/BatchTranslationView.d.ts +1 -1
  22. package/dist/translation-center/BatchTranslationView.d.ts.map +1 -1
  23. package/dist/translation-center/BatchTranslationView.js +98 -356
  24. package/dist/translation-center/RecentTranslations.d.ts.map +1 -1
  25. package/dist/translation-center/RecentTranslations.js +13 -20
  26. package/dist/translation-center/TranslationManagement.d.ts.map +1 -1
  27. package/dist/translation-center/TranslationManagement.js +4 -2
  28. package/package.json +11 -10
@@ -1,9 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useEffect, useState, useMemo, useRef, useCallback, memo, } from "react";
3
- import { useEditContext, SimpleTable, Progress as CoreProgress, Button, SimpleIconButton, CopyButton, usePathname, useRouter, useSearchParams, } from "@parhelia/core";
4
- import { listBatchTranslationJobs, retryBatchTranslation, subscribeToBatch, unsubscribeFromBatch, getBatchInfo, getTranslationProviders, } from "../services/translationService";
5
- import { ChevronDown, ChevronUp, RefreshCw, ArrowLeft, Info, Loader2, Languages, ExternalLink, } from "lucide-react";
6
- import { toast } from "sonner";
3
+ import { useEditContext, SimpleTable, Progress as CoreProgress, Button, } from "@parhelia/core";
4
+ import { listBatchTranslationJobs, subscribeToBatch, unsubscribeFromBatch, getBatchInfo, getTranslationProviders } from "../services/translationService";
5
+ import { ChevronDown, ChevronUp } from "lucide-react";
7
6
  import { JsonView, defaultStyles } from "react-json-view-lite";
8
7
  import "react-json-view-lite/dist/index.css";
9
8
  // Wrapper so React 19 sees a plain function component instead of a forwardRef exotic component.
@@ -11,12 +10,6 @@ const Progress = (props) => React.createElement(CoreProgress, props);
11
10
  // Wrappers for lucide-react icons to work with React 19
12
11
  const ChevronUpIcon = (props) => React.createElement(ChevronUp, props);
13
12
  const ChevronDownIcon = (props) => React.createElement(ChevronDown, props);
14
- const RefreshIcon = (props) => React.createElement(RefreshCw, props);
15
- const ArrowLeftIcon = (props) => React.createElement(ArrowLeft, props);
16
- const InfoIcon = (props) => React.createElement(Info, props);
17
- const LoaderIcon = (props) => React.createElement(Loader2, props);
18
- const LanguagesIcon = (props) => React.createElement(Languages, props);
19
- const ExternalLinkIcon = (props) => React.createElement(ExternalLink, props);
20
13
  const MemoizedJsonView = memo(({ data }) => (_jsx(JsonView, { data: data, shouldExpandNode: (level) => level < 2, style: defaultStyles })), (prevProps, nextProps) => {
21
14
  return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data);
22
15
  });
@@ -24,66 +17,32 @@ const MemoizedJsonView = memo(({ data }) => (_jsx(JsonView, { data: data, should
24
17
  function normalizeBatchInfo(raw) {
25
18
  if (!raw)
26
19
  return null;
27
- const toNum = (v) => typeof v === "number" ? v : v != null ? parseInt(v, 10) : undefined;
20
+ const pick = (a, b) => (a !== undefined ? a : b);
21
+ const toNum = (v) => (typeof v === "number" ? v : v != null ? parseInt(v, 10) : undefined);
28
22
  const info = {
29
- batchId: raw?.batchId,
30
- createdAtUtc: raw?.createdAtUtc,
31
- startedAtUtc: raw?.startedAtUtc,
32
- completedAtUtc: raw?.completedAtUtc,
33
- initiatedByUser: raw?.initiatedByUser,
34
- initiatorSessionId: raw?.initiatorSessionId,
35
- provider: raw?.provider,
36
- status: raw?.status,
37
- expectedJobs: toNum(raw?.expectedJobs),
38
- completedJobs: toNum(raw?.completedJobs),
39
- errorJobs: toNum(raw?.errorJobs),
40
- metadata: raw?.metadata,
41
- lastUpdatedUtc: raw?.lastUpdatedUtc,
42
- exists: raw?.exists,
23
+ batchId: pick(raw?.batchId, raw?.BatchId),
24
+ createdAtUtc: pick(raw?.createdAtUtc, raw?.CreatedAtUtc),
25
+ startedAtUtc: pick(raw?.startedAtUtc, raw?.StartedAtUtc),
26
+ completedAtUtc: pick(raw?.completedAtUtc, raw?.CompletedAtUtc),
27
+ initiatedByUser: pick(raw?.initiatedByUser, raw?.InitiatedByUser),
28
+ initiatorSessionId: pick(raw?.initiatorSessionId, raw?.InitiatorSessionId),
29
+ provider: pick(raw?.provider, raw?.Provider),
30
+ status: pick(raw?.status, raw?.Status),
31
+ expectedJobs: toNum(pick(raw?.expectedJobs, raw?.ExpectedJobs)),
32
+ completedJobs: toNum(pick(raw?.completedJobs, raw?.CompletedJobs)),
33
+ errorJobs: toNum(pick(raw?.errorJobs, raw?.ErrorJobs)),
34
+ metadata: pick(raw?.metadata, raw?.Metadata),
35
+ lastUpdatedUtc: pick(raw?.lastUpdatedUtc, raw?.LastUpdatedUtc),
36
+ exists: pick(raw?.exists, raw?.Exists),
43
37
  };
44
38
  return info.batchId ? info : null;
45
39
  }
46
- function normalizeJobItemId(itemId) {
47
- return (itemId ?? "").toString().toLowerCase();
48
- }
49
- function getJobKey(itemId, language) {
50
- return `${normalizeJobItemId(itemId)}-${(language ?? "").toString()}`;
51
- }
52
- function getSocketPayloadMessage(payload) {
53
- return payload?.message || payload?.error || "";
54
- }
55
- function normalizeJobRecord(raw) {
56
- return {
57
- id: typeof raw?.id === "number"
58
- ? raw.id
59
- : typeof raw?.Id === "number"
60
- ? raw.Id
61
- : undefined,
62
- itemId: normalizeJobItemId(raw?.itemId ?? raw?.ItemId),
63
- targetLanguage: raw?.targetLanguage ?? raw?.TargetLanguage ?? "",
64
- sourceLanguage: raw?.sourceLanguage ?? raw?.SourceLanguage ?? "",
65
- status: raw?.status ?? raw?.Status ?? "",
66
- timestamp: raw?.timestamp ?? raw?.Timestamp ?? "",
67
- hash: raw?.hash ?? raw?.Hash,
68
- message: raw?.message ?? raw?.Message,
69
- batchId: raw?.batchId ?? raw?.BatchId,
70
- metadata: raw?.metadata ?? raw?.Metadata ?? null,
71
- };
72
- }
73
- function getRetryJobKey(job) {
74
- return `${job.id ?? "no-id"}|${normalizeJobItemId(job.itemId)}|${job.sourceLanguage}|${job.targetLanguage}`;
75
- }
76
- export function BatchTranslationView({ batchId, onBack, }) {
40
+ export function BatchTranslationView({ batchId, onBack }) {
77
41
  const editContext = useEditContext();
78
- const router = useRouter();
79
- const pathname = usePathname();
80
- const searchParams = useSearchParams();
81
42
  const [batchJobs, setBatchJobs] = useState([]);
82
43
  const [batchInfo, setBatchInfo] = useState(null);
83
44
  const [providers, setProviders] = useState([]);
84
45
  const [isLoading, setIsLoading] = useState(false);
85
- const [isRetryingAll, setIsRetryingAll] = useState(false);
86
- const [retryingJobKeys, setRetryingJobKeys] = useState(new Set());
87
46
  const [translationProgress, setTranslationProgress] = useState(new Map());
88
47
  const [itemNames, setItemNames] = useState(new Map());
89
48
  const [isMetadataExpanded, setIsMetadataExpanded] = useState(false);
@@ -97,94 +56,10 @@ export function BatchTranslationView({ batchId, onBack, }) {
97
56
  // Helper function to get display name from service name
98
57
  const getProviderDisplayName = useCallback((serviceName) => {
99
58
  if (!serviceName)
100
- return "Unknown";
101
- const provider = providers.find((p) => p.name === serviceName);
59
+ return 'Unknown';
60
+ const provider = providers.find(p => p.name === serviceName);
102
61
  return provider?.displayName || serviceName;
103
62
  }, [providers]);
104
- const openItemInEditor = useCallback((itemId, language) => {
105
- editContext?.loadItem({
106
- id: normalizeJobItemId(itemId),
107
- language,
108
- version: 0,
109
- });
110
- editContext?.switchWorkspace?.("editor");
111
- }, [editContext]);
112
- const openBatchInView = useCallback((nextBatchId) => {
113
- const current = new URLSearchParams(searchParams.toString());
114
- current.set("batchId", nextBatchId);
115
- router.push(`${pathname}?${current.toString()}`, { scroll: false });
116
- }, [pathname, router, searchParams]);
117
- const buildRetryJobs = useCallback((jobs) => {
118
- const uniqueJobs = new Map();
119
- for (const job of jobs) {
120
- if (job.status !== "Error")
121
- continue;
122
- const key = getRetryJobKey(job);
123
- if (uniqueJobs.has(key))
124
- continue;
125
- uniqueJobs.set(key, {
126
- sourceTranslationId: job.id,
127
- itemId: normalizeJobItemId(job.itemId),
128
- sourceLanguage: job.sourceLanguage,
129
- targetLanguage: job.targetLanguage,
130
- metadata: job.metadata ?? undefined,
131
- });
132
- }
133
- return Array.from(uniqueJobs.values());
134
- }, []);
135
- const handleRetryJobs = useCallback(async (jobs, retryAll) => {
136
- const retryJobs = buildRetryJobs(jobs);
137
- if (retryJobs.length === 0) {
138
- toast.error("No failed translations available to retry.");
139
- return;
140
- }
141
- if (!editContext?.sessionId) {
142
- toast.error("Cannot retry without an active session.");
143
- return;
144
- }
145
- if (!batchInfo?.provider) {
146
- toast.error("Retry provider could not be resolved for this batch.");
147
- return;
148
- }
149
- if (retryAll) {
150
- setIsRetryingAll(true);
151
- }
152
- else {
153
- setRetryingJobKeys(new Set(jobs.map((job) => getRetryJobKey(job)).filter((key) => retryJobs.some((retryJob) => key ===
154
- `${retryJob.sourceTranslationId ?? "no-id"}|${retryJob.itemId}|${retryJob.sourceLanguage}|${retryJob.targetLanguage}`))));
155
- }
156
- try {
157
- const result = await retryBatchTranslation({
158
- sessionId: editContext.sessionId,
159
- sourceBatchId: batchId,
160
- provider: batchInfo.provider,
161
- jobs: retryJobs,
162
- });
163
- if (result.type !== "success" || !result.data) {
164
- toast.error(result.details ||
165
- result.summary ||
166
- "Failed to start retry translations.");
167
- return;
168
- }
169
- const response = result.data;
170
- const startedCount = response.started?.length ?? 0;
171
- const skippedCount = response.skipped?.length ?? 0;
172
- if (startedCount === 0) {
173
- toast.error(skippedCount > 0
174
- ? response.skipped[0]?.error || "No retries were started."
175
- : "No retries were started.");
176
- return;
177
- }
178
- toast.success(retryAll
179
- ? `Started retry batch with ${startedCount} translation${startedCount === 1 ? "" : "s"}.`
180
- : "Started retry batch for failed translation.");
181
- openBatchInView(response.retryBatchId);
182
- }
183
- finally {
184
- setIsRetryingAll(false);
185
- setRetryingJobKeys(new Set());
186
- }
187
- }, [batchId, batchInfo?.provider, buildRetryJobs, editContext?.sessionId, openBatchInView]);
188
63
  // Parse metadata for display
189
64
  const parsedMetadata = useMemo(() => {
190
65
  if (!batchInfo?.metadata)
@@ -203,9 +78,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
203
78
  const loadProviders = useCallback(async () => {
204
79
  try {
205
80
  const res = await getTranslationProviders();
206
- const providerData = (res?.data ??
207
- res ??
208
- []);
81
+ const providerData = (res?.data ?? res ?? []);
209
82
  setProviders(providerData);
210
83
  }
211
84
  catch (error) {
@@ -217,45 +90,36 @@ export function BatchTranslationView({ batchId, onBack, }) {
217
90
  try {
218
91
  // Use efficient batch-specific endpoint
219
92
  const res = await listBatchTranslationJobs(batchId);
220
- const apiJobsRaw = (res?.data ?? res ?? []);
221
- const apiJobs = apiJobsRaw.map(normalizeJobRecord);
93
+ const apiJobs = (res?.data ?? res ?? []);
222
94
  // Merge API response with existing state to prevent flickering
223
- setBatchJobs((prevJobs) => {
95
+ setBatchJobs(prevJobs => {
224
96
  // Create a map of existing jobs keyed by itemId-targetLanguage
225
97
  const existingMap = new Map();
226
98
  for (const job of prevJobs) {
227
- const key = getJobKey(job.itemId, job.targetLanguage);
99
+ const key = `${job.itemId}-${job.targetLanguage}`;
228
100
  existingMap.set(key, job);
229
101
  }
230
102
  // Merge API jobs into the map (API data takes precedence)
231
103
  for (const apiJob of apiJobs) {
232
- const normalizedJob = normalizeJobRecord(apiJob);
233
- const key = getJobKey(normalizedJob.itemId, normalizedJob.targetLanguage);
104
+ const key = `${apiJob.itemId}-${apiJob.targetLanguage}`;
234
105
  const existingJob = existingMap.get(key);
235
106
  if (existingJob) {
236
107
  // Prefer API data if timestamp is newer or status changed
237
- const apiTimestamp = normalizedJob.timestamp
238
- ? new Date(normalizedJob.timestamp).getTime()
239
- : 0;
240
- const existingTimestamp = existingJob.timestamp
241
- ? new Date(existingJob.timestamp).getTime()
242
- : 0;
243
- if (apiTimestamp >= existingTimestamp ||
244
- normalizedJob.status !== existingJob.status) {
108
+ const apiTimestamp = apiJob.timestamp ? new Date(apiJob.timestamp).getTime() : 0;
109
+ const existingTimestamp = existingJob.timestamp ? new Date(existingJob.timestamp).getTime() : 0;
110
+ if (apiTimestamp >= existingTimestamp || apiJob.status !== existingJob.status) {
245
111
  // Preserve sourceLanguage from API if available, otherwise keep existing
246
112
  existingMap.set(key, {
247
- ...normalizedJob,
248
- sourceLanguage: normalizedJob.sourceLanguage ||
249
- existingJob.sourceLanguage ||
250
- "",
113
+ ...apiJob,
114
+ sourceLanguage: apiJob.sourceLanguage || existingJob.sourceLanguage || ""
251
115
  });
252
116
  }
253
117
  }
254
118
  else {
255
119
  // New job from API - ensure sourceLanguage is set
256
120
  existingMap.set(key, {
257
- ...normalizedJob,
258
- sourceLanguage: normalizedJob.sourceLanguage || "",
121
+ ...apiJob,
122
+ sourceLanguage: apiJob.sourceLanguage || ""
259
123
  });
260
124
  }
261
125
  }
@@ -267,7 +131,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
267
131
  return mergedJobs;
268
132
  }
269
133
  // Deep comparison: check if any job changed
270
- const prevMap = new Map(prevJobs.map((j) => [`${j.itemId}-${j.targetLanguage}`, j]));
134
+ const prevMap = new Map(prevJobs.map(j => [`${j.itemId}-${j.targetLanguage}`, j]));
271
135
  let hasChanges = false;
272
136
  for (const mergedJob of mergedJobs) {
273
137
  const key = `${mergedJob.itemId}-${mergedJob.targetLanguage}`;
@@ -317,7 +181,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
317
181
  const info = normalizeBatchInfo(raw);
318
182
  if (!cancelled && info)
319
183
  setBatchInfo(info);
320
- const isTerminal = info?.status === "Completed" || info?.status === "Error";
184
+ const isTerminal = info?.status === 'Completed' || info?.status === 'Error';
321
185
  // Conditionally subscribe only for non-terminal batches
322
186
  if (!isTerminal && editContext?.sessionId) {
323
187
  await subscribeToBatch(batchId, editContext.sessionId);
@@ -340,10 +204,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
340
204
  init();
341
205
  return () => {
342
206
  // Cleanup: unsubscribe when component unmounts or batch changes
343
- if (isSubscribedRef.current &&
344
- editContext &&
345
- editContext.sessionId &&
346
- batchId) {
207
+ if (isSubscribedRef.current && editContext && editContext.sessionId && batchId) {
347
208
  unsubscribeFromBatch(batchId, editContext.sessionId).catch(console.error);
348
209
  }
349
210
  };
@@ -365,10 +226,8 @@ export function BatchTranslationView({ batchId, onBack, }) {
365
226
  // Only update if this message is for our batch
366
227
  if (message.payload.batchId === batchId) {
367
228
  // Update the specific job in our local state instead of refetching all jobs
368
- setBatchJobs((prevJobs) => {
369
- const normalizedItemId = normalizeJobItemId(message.payload.itemId);
370
- const payloadMessage = getSocketPayloadMessage(message.payload);
371
- const jobIndex = prevJobs.findIndex((job) => normalizeJobItemId(job.itemId) === normalizedItemId &&
229
+ setBatchJobs(prevJobs => {
230
+ const jobIndex = prevJobs.findIndex(job => job.itemId === message.payload.itemId &&
372
231
  job.targetLanguage === message.payload.language);
373
232
  if (jobIndex >= 0) {
374
233
  // Update existing job
@@ -377,44 +236,41 @@ export function BatchTranslationView({ batchId, onBack, }) {
377
236
  if (existingJob) {
378
237
  updatedJobs[jobIndex] = {
379
238
  ...existingJob,
380
- itemId: normalizedItemId,
381
239
  status: message.payload.status,
382
240
  timestamp: message.payload.timestamp || existingJob.timestamp,
383
- message: payloadMessage || existingJob.message,
241
+ message: message.payload.message || existingJob.message,
384
242
  // Preserve sourceLanguage from existing job if not provided in message
385
- sourceLanguage: message.payload.sourceLanguage ||
386
- existingJob.sourceLanguage ||
387
- "",
243
+ sourceLanguage: message.payload.sourceLanguage || existingJob.sourceLanguage || ""
388
244
  };
389
245
  }
390
246
  return updatedJobs;
391
247
  }
392
- else {
393
- // Add new job if it doesn't exist yet so terminal updates are still visible.
248
+ else if (message.type === "translation-started") {
249
+ // Add new job if it doesn't exist (shouldn't happen with batch subscriptions, but safe fallback)
394
250
  const newJob = {
395
- itemId: normalizedItemId,
251
+ itemId: message.payload.itemId,
396
252
  targetLanguage: message.payload.language,
397
253
  sourceLanguage: message.payload.sourceLanguage || "", // Keep this, but we'll try to populate from API
398
254
  status: message.payload.status,
399
255
  timestamp: message.payload.timestamp || new Date().toISOString(),
400
256
  batchId: batchId,
401
- message: payloadMessage,
257
+ message: message.payload.message || ""
402
258
  };
403
259
  return [...prevJobs, newJob];
404
260
  }
261
+ return prevJobs;
405
262
  });
406
263
  }
407
264
  }
408
265
  if (message.type === "translation-progress") {
409
266
  // Only track progress for jobs in this batch
410
267
  if (message.payload.batchId === batchId) {
411
- const itemId = normalizeJobItemId(message.payload.itemId);
268
+ const itemId = (message.payload.itemId ?? "").toString().toLowerCase();
412
269
  const language = message.payload.language;
413
- const progressKey = getJobKey(itemId, language);
270
+ const progressKey = `${itemId}-${language}`;
414
271
  // Ensure a row exists; if not, add a placeholder "In Progress" job
415
- setBatchJobs((prevJobs) => {
416
- const idx = prevJobs.findIndex((j) => normalizeJobItemId(j.itemId) === itemId &&
417
- j.targetLanguage === language);
272
+ setBatchJobs(prevJobs => {
273
+ const idx = prevJobs.findIndex(j => j.itemId === itemId && j.targetLanguage === language);
418
274
  if (idx >= 0)
419
275
  return prevJobs;
420
276
  const newJob = {
@@ -424,7 +280,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
424
280
  status: "In Progress",
425
281
  timestamp: new Date().toISOString(),
426
282
  batchId: batchId,
427
- message: message.payload.message || "",
283
+ message: message.payload.message || ""
428
284
  };
429
285
  return [...prevJobs, newJob];
430
286
  });
@@ -432,15 +288,14 @@ export function BatchTranslationView({ batchId, onBack, }) {
432
288
  const next = new Map(prev);
433
289
  next.set(progressKey, {
434
290
  progress: message.payload.progress || 0,
435
- message: message.payload.message || "",
291
+ message: message.payload.message || ""
436
292
  });
437
293
  return next;
438
294
  });
439
295
  }
440
296
  }
441
297
  // Handle batch-completed messages for this specific batch
442
- if (message.type === "batch-completed" &&
443
- message.payload.batchId === batchId) {
298
+ if (message.type === "batch-completed" && message.payload.batchId === batchId) {
444
299
  // Refresh header info only (jobs are already updated via websocket)
445
300
  (async () => {
446
301
  try {
@@ -463,7 +318,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
463
318
  }, [editContext, batchId]);
464
319
  // Adaptive polling while batch is in progress - only as fallback when websockets are silent
465
320
  useEffect(() => {
466
- const isRunning = batchInfo?.status === "In Progress";
321
+ const isRunning = batchInfo?.status === 'In Progress';
467
322
  if (!isRunning)
468
323
  return;
469
324
  const pollBatchInfo = async () => {
@@ -501,7 +356,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
501
356
  }
502
357
  const fetchItemNames = async () => {
503
358
  // Extract unique item IDs from batchJobs
504
- const uniqueItemIds = Array.from(new Set(batchJobs.map((job) => job.itemId)));
359
+ const uniqueItemIds = Array.from(new Set(batchJobs.map(job => job.itemId)));
505
360
  if (uniqueItemIds.length === 0) {
506
361
  return;
507
362
  }
@@ -509,7 +364,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
509
364
  const language = editContext.currentItemDescriptor?.language || "en";
510
365
  try {
511
366
  // Create ItemDescriptors for fetching
512
- const itemDescriptors = uniqueItemIds.map((itemId) => ({
367
+ const itemDescriptors = uniqueItemIds.map(itemId => ({
513
368
  id: itemId,
514
369
  language: language,
515
370
  version: 0, // Latest version
@@ -523,7 +378,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
523
378
  namesMap.set(stub.id.toLowerCase(), stub.name);
524
379
  }
525
380
  }
526
- setItemNames((prevNames) => {
381
+ setItemNames(prevNames => {
527
382
  // Merge with existing names, only update if there are changes
528
383
  let hasChanges = false;
529
384
  const merged = new Map(prevNames);
@@ -542,11 +397,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
542
397
  }
543
398
  };
544
399
  fetchItemNames();
545
- }, [
546
- batchJobs,
547
- editContext?.itemsRepository,
548
- editContext?.currentItemDescriptor?.language,
549
- ]);
400
+ }, [batchJobs, editContext?.itemsRepository, editContext?.currentItemDescriptor?.language]);
550
401
  // Group jobs by item for better organization
551
402
  const itemGroups = useMemo(() => {
552
403
  const groups = {};
@@ -564,9 +415,9 @@ export function BatchTranslationView({ batchId, onBack, }) {
564
415
  return entries.sort(([, jobsA], [, jobsB]) => {
565
416
  // Calculate status priority for each item group
566
417
  const getStatusPriority = (jobs) => {
567
- const hasInProgress = jobs.some((j) => j.status === "In Progress");
568
- const hasError = jobs.some((j) => j.status === "Error");
569
- const allCompleted = jobs.every((j) => j.status === "Completed");
418
+ const hasInProgress = jobs.some(j => j.status === "In Progress");
419
+ const hasError = jobs.some(j => j.status === "Error");
420
+ const allCompleted = jobs.every(j => j.status === "Completed");
570
421
  if (hasInProgress)
571
422
  return 1; // Highest priority - show first
572
423
  if (hasError)
@@ -583,7 +434,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
583
434
  // Calculate overall progress segments including in-progress job progress.
584
435
  // Incorporate server-side expected/completed/error counts so pending server jobs are reflected.
585
436
  const progressSegments = useMemo(() => {
586
- const expectedTotal = typeof batchInfo?.expectedJobs === "number" && batchInfo.expectedJobs > 0
437
+ const expectedTotal = typeof batchInfo?.expectedJobs === 'number' && batchInfo.expectedJobs > 0
587
438
  ? batchInfo.expectedJobs
588
439
  : batchJobs.length;
589
440
  if (expectedTotal === 0) {
@@ -597,22 +448,18 @@ export function BatchTranslationView({ batchId, onBack, }) {
597
448
  errorCount: 0,
598
449
  pendingCount: 0,
599
450
  total: 0,
600
- actualProgress: 0,
451
+ actualProgress: 0
601
452
  };
602
453
  }
603
- const localCompleted = batchJobs.filter((job) => job.status === "Completed").length;
604
- const localErrors = batchJobs.filter((job) => job.status === "Error").length;
605
- const serverCompleted = typeof batchInfo?.completedJobs === "number"
606
- ? batchInfo.completedJobs
607
- : undefined;
608
- const serverErrors = typeof batchInfo?.errorJobs === "number"
609
- ? batchInfo.errorJobs
610
- : undefined;
454
+ const localCompleted = batchJobs.filter(job => job.status === 'Completed').length;
455
+ const localErrors = batchJobs.filter(job => job.status === 'Error').length;
456
+ const serverCompleted = typeof batchInfo?.completedJobs === 'number' ? batchInfo.completedJobs : undefined;
457
+ const serverErrors = typeof batchInfo?.errorJobs === 'number' ? batchInfo.errorJobs : undefined;
611
458
  // Prefer the larger of local/server so UI can progress smoothly with local updates
612
459
  const completedCount = Math.max(0, Math.min(expectedTotal, Math.max(localCompleted, serverCompleted ?? 0)));
613
460
  const errorCount = Math.max(0, Math.min(expectedTotal - completedCount, Math.max(localErrors, serverErrors ?? 0)));
614
461
  // Known in-progress from client state
615
- const knownInProgressCount = batchJobs.filter((job) => job.status === "In Progress").length;
462
+ const knownInProgressCount = batchJobs.filter(job => job.status === 'In Progress').length;
616
463
  // Cap to not exceed remaining after completed+error
617
464
  const inProgressCount = Math.max(0, Math.min(knownInProgressCount, expectedTotal - completedCount - errorCount));
618
465
  const pendingCount = Math.max(0, expectedTotal - completedCount - errorCount - inProgressCount);
@@ -620,8 +467,8 @@ export function BatchTranslationView({ batchId, onBack, }) {
620
467
  // Actual progress: completed=100 each, in-progress use live progress if available, others 0
621
468
  let totalActualProgress = completedCount * 100;
622
469
  for (const job of batchJobs) {
623
- if (job.status === "In Progress") {
624
- const progressKey = getJobKey(job.itemId, job.targetLanguage);
470
+ if (job.status === 'In Progress') {
471
+ const progressKey = `${job.itemId}-${job.targetLanguage}`;
625
472
  const jobProgress = translationProgress.get(progressKey);
626
473
  totalActualProgress += jobProgress?.progress || 0;
627
474
  }
@@ -642,7 +489,6 @@ export function BatchTranslationView({ batchId, onBack, }) {
642
489
  return segments;
643
490
  }, [batchJobs, batchInfo, translationProgress]);
644
491
  const overallProgress = progressSegments.actualProgress;
645
- const isMobile = editContext?.isMobile ?? false;
646
492
  // Treat the batch as Completed if all jobs are completed and there are no errors/in-progress,
647
493
  // even if the backend status is still "In Progress".
648
494
  const effectiveStatus = batchInfo?.status === "In Progress" &&
@@ -671,38 +517,14 @@ export function BatchTranslationView({ batchId, onBack, }) {
671
517
  catch { }
672
518
  })();
673
519
  }, [effectiveStatus, batchId, loadBatchJobs]);
674
- return (_jsxs("div", { className: "flex h-full flex-col min-h-0 bg-gray-5", "data-testid": "batch-translation-view", children: [_jsxs("div", { className: "shrink-0 border-b border-gray-3 bg-background p-4 md:p-6", children: [_jsxs("div", { className: `flex ${isMobile ? "flex-col items-start gap-4" : "items-center justify-between"}`, children: [_jsxs("div", { className: `flex ${isMobile ? "w-full" : "items-center"} gap-3`, children: [onBack && (_jsxs(Button, { size: "sm", variant: "outline", onClick: onBack, className: isMobile ? "shrink-0 self-start p-0 w-8 h-8" : "", children: [_jsx(ArrowLeftIcon, { className: "h-4 w-4" }), isMobile ? "" : "Back"] })), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h1", { className: "text-lg md:text-xl font-semibold text-(--color-dark) truncate", children: "Translation Batch" }), _jsxs("p", { className: "text-xs md:text-sm text-gray-2 mt-0.5 md:mt-1", children: ["Batch ID:", " ", _jsx("span", { className: "font-mono text-[10px] md:text-xs bg-gray-4 px-2 py-0.5 rounded-md break-all", children: batchId })] }), batchInfo && !isMobile && (_jsxs("div", { className: "mt-2 flex flex-wrap gap-2 items-center text-xs", children: [effectiveStatus && (_jsx("span", { "data-testid": "translation-job-status", className: `inline-flex items-center rounded-full px-2.5 py-1 font-medium ${effectiveStatus === "Completed"
675
- ? "bg-green-100 text-green-700"
676
- : effectiveStatus === "In Progress"
677
- ? ""
678
- : effectiveStatus === "Error"
679
- ? "bg-red-100 text-red-600"
680
- : "bg-gray-4 text-(--color-gray-1)"}`, style: effectiveStatus === "In Progress"
681
- ? { backgroundColor: "#f6eeff", color: "#9650fb" }
682
- : undefined, children: effectiveStatus })), batchInfo.provider && (_jsxs("span", { className: "text-(--color-gray-1)", children: ["Provider:", " ", _jsx("span", { className: "font-medium", children: getProviderDisplayName(batchInfo.provider) })] })), batchInfo.initiatedByUser && (_jsxs("span", { className: "text-(--color-gray-1)", children: ["By:", " ", _jsx("span", { className: "font-medium", children: batchInfo.initiatedByUser })] })), (batchInfo.startedAtUtc ||
683
- batchInfo.completedAtUtc ||
684
- batchInfo.lastUpdatedUtc) && (_jsxs("span", { className: "text-gray-2", children: [batchInfo.startedAtUtc && (_jsxs(_Fragment, { children: ["Started:", " ", new Date(batchInfo.startedAtUtc).toLocaleString()] })), batchInfo.completedAtUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? " • " : "", "Completed:", " ", new Date(batchInfo.completedAtUtc).toLocaleString()] })), !batchInfo.completedAtUtc &&
685
- batchInfo.lastUpdatedUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? " • " : "", "Updated:", " ", new Date(batchInfo.lastUpdatedUtc).toLocaleString()] }))] }))] }))] })] }), isMobile && batchInfo && (_jsxs("div", { className: "grid grid-cols-2 gap-x-4 gap-y-3 w-full mt-1 border-t border-gray-3 pt-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Status" }), effectiveStatus && (_jsx("span", { "data-testid": "translation-job-status", className: `inline-flex items-center self-start rounded-full px-2 py-0.5 text-[10px] font-medium ${effectiveStatus === "Completed"
686
- ? "bg-green-100 text-green-700"
687
- : effectiveStatus === "In Progress"
688
- ? ""
689
- : effectiveStatus === "Error"
690
- ? "bg-red-100 text-red-600"
691
- : "bg-gray-4 text-(--color-gray-1)"}`, style: effectiveStatus === "In Progress"
692
- ? { backgroundColor: "#f6eeff", color: "#9650fb" }
693
- : undefined, children: effectiveStatus }))] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Provider" }), _jsx("span", { className: "text-xs font-medium text-(--color-gray-1) truncate", children: getProviderDisplayName(batchInfo.provider) })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Initiated By" }), _jsx("span", { className: "text-xs font-medium text-(--color-gray-1) truncate", children: batchInfo.initiatedByUser || "System" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Activity" }), _jsx("span", { className: "text-[10px] text-gray-2 leading-tight", children: batchInfo.completedAtUtc ? (_jsxs(_Fragment, { children: ["Done:", " ", new Date(batchInfo.completedAtUtc).toLocaleTimeString()] })) : batchInfo.startedAtUtc ? (_jsxs(_Fragment, { children: ["Started:", " ", new Date(batchInfo.startedAtUtc).toLocaleTimeString()] })) : batchInfo.lastUpdatedUtc ? (_jsxs(_Fragment, { children: ["Updated:", " ", new Date(batchInfo.lastUpdatedUtc).toLocaleTimeString()] })) : (_jsx(_Fragment, { children: "Created" })) })] })] })), _jsxs("div", { className: `flex items-center ${isMobile ? "w-full justify-between border-t border-gray-3 pt-3 mt-1" : "gap-3"}`, children: [_jsxs("div", { className: "text-xs md:text-sm text-gray-2", children: [_jsx("span", { className: "font-semibold text-(--color-dark)", children: progressSegments.completedCount }), "/", progressSegments.total, " completed", progressSegments.errorCount
694
- ? `, ${progressSegments.errorCount} errors`
695
- : ""] }), _jsxs("div", { className: "flex items-center gap-2", children: [progressSegments.errorCount > 0 && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => void handleRetryJobs(batchJobs.filter((job) => job.status === "Error"), true), disabled: isRetryingAll || isLoading, className: "md:w-auto h-8 px-3", children: [_jsx(RefreshIcon, { className: `h-4 w-4 ${isRetryingAll ? "animate-spin" : ""}` }), _jsx("span", { children: "Retry All Failed" })] })), _jsxs(Button, { size: "sm", variant: "outline", onClick: loadBatchJobs, disabled: isLoading || isRetryingAll, title: "Manual refresh - normally updates come through websocket subscriptions", className: "md:w-auto w-8 h-8 md:h-8 p-0 md:px-3", children: [_jsx(RefreshIcon, { className: `h-4 w-4 ${isLoading ? "animate-spin" : ""}` }), _jsx("span", { className: "hidden md:inline", children: "Refresh" })] })] })] })] }), parsedMetadata && (_jsxs("div", { className: `border-t border-gray-3 bg-gray-5 mt-4 rounded-b-lg ${isMobile ? "-mx-4 border-x-0" : ""}`, children: [_jsxs("button", { onClick: () => setIsMetadataExpanded(!isMetadataExpanded), className: `flex w-full cursor-pointer items-center justify-between ${isMobile ? "px-4 py-3" : "px-4 py-2.5"} text-left transition-colors hover:bg-gray-4`, children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(InfoIcon, { className: "h-4 w-4 text-gray-2" }), _jsx("span", { className: "text-xs font-medium text-(--color-gray-1)", children: "Batch Metadata" })] }), isMetadataExpanded ? (_jsx(ChevronUpIcon, { className: "h-4 w-4 text-gray-2", strokeWidth: 1 })) : (_jsx(ChevronDownIcon, { className: "h-4 w-4 text-gray-2", strokeWidth: 1 }))] }), isMetadataExpanded && (_jsx("div", { className: "max-h-96 overflow-y-auto px-4 pb-3", children: _jsx("div", { className: "rounded-lg border border-gray-3 bg-background p-3 text-xs shadow-sm mt-2", children: _jsx(MemoizedJsonView, { data: parsedMetadata }) }) }))] })), _jsxs("div", { className: "mt-6 md:mt-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("span", { className: "text-sm font-medium text-(--color-dark)", children: "Overall Progress" }), _jsxs("span", { className: "text-sm text-gray-2", children: [_jsx("span", { className: "font-semibold text-(--color-dark)", children: progressSegments.completedCount }), " ", "/ ", progressSegments.total, " completed"] })] }), _jsxs("div", { className: `relative ${isMobile ? "h-5" : "h-4"} rounded-full overflow-hidden bg-gray-3`, children: [_jsxs("div", { className: "absolute inset-0 flex", children: [progressSegments.completed > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: {
696
- width: `${progressSegments.completed}%`,
697
- backgroundColor: "#8ae048",
698
- } })), progressSegments.inProgress > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: {
699
- width: `${progressSegments.inProgress}%`,
700
- backgroundColor: "#9650fb",
701
- } })), progressSegments.error > 0 && (_jsx("div", { className: "bg-destructive h-full transition-all duration-300", style: { width: `${progressSegments.error}%` } })), progressSegments.pending > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: {
702
- width: `${progressSegments.pending}%`,
703
- backgroundColor: "var(--color-gray-4)",
704
- } }))] }), _jsx("div", { className: "absolute inset-0 flex items-center justify-center pointer-events-none", children: _jsxs("span", { className: `text-xs font-bold text-white drop-shadow-sm ${isMobile ? "-mt-1" : ""}`, children: [overallProgress, "%"] }) })] }), _jsxs("div", { className: `flex flex-wrap ${isMobile ? "gap-x-4 gap-y-2" : "gap-3"} mt-3 md:mt-2 text-[10px] md:text-xs`, children: [progressSegments.completedCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 rounded-sm", style: { backgroundColor: "#8ae048" } }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.completedCount, " completed"] })] })), progressSegments.inProgressCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 rounded-sm", style: { backgroundColor: "#9650fb" } }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.inProgressCount, " in progress"] })] })), progressSegments.errorCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 bg-destructive rounded-sm" }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.errorCount, " errors"] })] })), progressSegments.pendingCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 bg-gray-4 rounded-sm" }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.pendingCount, " pending"] })] }))] })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 md:p-6 min-h-0", children: isLoading && batchJobs.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "flex items-center gap-2 text-gray-2", children: [_jsx(LoaderIcon, { className: "h-5 w-5 animate-spin text-theme-secondary" }), "Loading batch translations..."] }) })) : batchJobs.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "text-center text-gray-2", children: [_jsx(LanguagesIcon, { className: "h-8 w-8 block mx-auto mb-2 text-gray-2" }), _jsx("p", { className: "font-medium text-(--color-gray-1)", children: "No translations found for this batch" }), _jsx("p", { className: "text-sm mt-1", children: "The batch may not exist or no translation jobs have started yet" })] }) })) : (_jsx("div", { className: "space-y-4", children: sortedItemGroups.map(([itemId, jobs]) => {
705
- const itemCompleted = jobs.filter((j) => j.status === "Completed").length;
520
+ return (_jsxs("div", { className: "flex h-full flex-col min-h-0 bg-[var(--color-gray-5)]", "data-testid": "batch-translation-view", children: [_jsxs("div", { className: "flex-shrink-0 border-b border-[var(--color-gray-3)] bg-background p-4 md:p-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-3", children: [onBack && (_jsxs(Button, { size: "sm", variant: "outline", onClick: onBack, children: [_jsx("i", { className: "pi pi-arrow-left" }), "Back"] })), _jsxs("div", { children: [_jsx("h1", { className: "text-xl font-semibold text-[var(--color-dark)]", children: "Translation Batch" }), _jsxs("p", { className: "text-sm text-[var(--color-gray-2)] mt-1", children: ["Batch ID: ", _jsx("span", { className: "font-mono text-xs bg-[var(--color-gray-4)] px-2 py-0.5 rounded-md", children: batchId })] }), batchInfo && (_jsxs("div", { className: "mt-2 flex flex-wrap gap-2 items-center text-xs", children: [effectiveStatus && (_jsx("span", { "data-testid": "translation-job-status", className: `inline-flex items-center rounded-full px-2.5 py-1 font-medium ${effectiveStatus === 'Completed'
521
+ ? 'bg-green-100 text-green-700'
522
+ : effectiveStatus === 'In Progress'
523
+ ? ''
524
+ : effectiveStatus === 'Error'
525
+ ? 'bg-red-100 text-red-600'
526
+ : 'bg-[var(--color-gray-4)] text-[var(--color-gray-1)]'}`, style: effectiveStatus === 'In Progress' ? { backgroundColor: '#f6eeff', color: '#9650fb' } : undefined, children: effectiveStatus })), batchInfo.provider && (_jsxs("span", { className: "text-[var(--color-gray-1)]", children: ["Provider: ", _jsx("span", { className: "font-medium", children: getProviderDisplayName(batchInfo.provider) })] })), batchInfo.initiatedByUser && (_jsxs("span", { className: "text-[var(--color-gray-1)]", children: ["By: ", _jsx("span", { className: "font-medium", children: batchInfo.initiatedByUser })] })), (batchInfo.startedAtUtc || batchInfo.completedAtUtc || batchInfo.lastUpdatedUtc) && (_jsxs("span", { className: "text-[var(--color-gray-2)]", children: [batchInfo.startedAtUtc && (_jsxs(_Fragment, { children: ["Started: ", new Date(batchInfo.startedAtUtc).toLocaleString()] })), batchInfo.completedAtUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? ' • ' : '', "Completed: ", new Date(batchInfo.completedAtUtc).toLocaleString()] })), !batchInfo.completedAtUtc && batchInfo.lastUpdatedUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? ' • ' : '', "Updated: ", new Date(batchInfo.lastUpdatedUtc).toLocaleString()] }))] }))] }))] })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("div", { className: "text-sm text-[var(--color-gray-2)]", children: [`${progressSegments.completedCount}/${progressSegments.total} completed`, progressSegments.errorCount ? `, ${progressSegments.errorCount} errors` : ''] }), _jsxs(Button, { size: "sm", variant: "outline", onClick: loadBatchJobs, disabled: isLoading, title: "Manual refresh - normally updates come through websocket subscriptions", children: [_jsx("i", { className: `pi pi-refresh ${isLoading ? 'pi-spin' : ''}` }), "Refresh"] })] })] }), parsedMetadata && (_jsxs("div", { className: "border-t border-[var(--color-gray-3)] bg-[var(--color-gray-5)] mt-4 rounded-b-lg", children: [_jsxs("button", { onClick: () => setIsMetadataExpanded(!isMetadataExpanded), className: "flex w-full cursor-pointer items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-gray-4)]", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("i", { className: "pi pi-info-circle text-[var(--color-gray-2)]" }), _jsx("span", { className: "text-xs font-medium text-[var(--color-gray-1)]", children: "Batch Metadata" })] }), isMetadataExpanded ? (_jsx(ChevronUpIcon, { className: "h-4 w-4 text-[var(--color-gray-2)]", strokeWidth: 1 })) : (_jsx(ChevronDownIcon, { className: "h-4 w-4 text-[var(--color-gray-2)]", strokeWidth: 1 }))] }), isMetadataExpanded && (_jsx("div", { className: "max-h-96 overflow-y-auto px-4 pb-3", children: _jsx("div", { className: "rounded-lg border border-[var(--color-gray-3)] bg-background p-3 text-xs shadow-sm mt-2", children: _jsx(MemoizedJsonView, { data: parsedMetadata }) }) }))] })), _jsxs("div", { className: "mt-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("span", { className: "text-sm font-medium text-[var(--color-dark)]", children: "Overall Progress" }), _jsxs("span", { className: "text-sm text-[var(--color-gray-2)]", children: [progressSegments.completedCount, " / ", progressSegments.total, " completed"] })] }), _jsxs("div", { className: "relative h-4 rounded-full overflow-hidden", style: { backgroundColor: 'var(--color-gray-3)' }, children: [_jsxs("div", { className: "absolute inset-0 flex", children: [progressSegments.completed > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: { width: `${progressSegments.completed}%`, backgroundColor: '#8ae048' } })), progressSegments.inProgress > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: { width: `${progressSegments.inProgress}%`, backgroundColor: '#9650fb' } })), progressSegments.error > 0 && (_jsx("div", { className: "bg-destructive h-full transition-all duration-300", style: { width: `${progressSegments.error}%` } })), progressSegments.pending > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: { width: `${progressSegments.pending}%`, backgroundColor: 'var(--color-gray-4)' } }))] }), _jsx("div", { className: "absolute inset-0 flex items-center justify-center", children: _jsxs("span", { className: "text-xs font-medium text-white drop-shadow-sm", children: [overallProgress, "%"] }) })] }), _jsxs("div", { className: "flex flex-wrap gap-3 mt-2 text-xs", children: [progressSegments.completedCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-3 h-3 rounded-sm", style: { backgroundColor: '#8ae048' } }), _jsxs("span", { className: "text-[var(--color-gray-1)]", children: [progressSegments.completedCount, " completed"] })] })), progressSegments.inProgressCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-3 h-3 rounded-sm", style: { backgroundColor: '#9650fb' } }), _jsxs("span", { className: "text-[var(--color-gray-1)]", children: [progressSegments.inProgressCount, " in progress"] })] })), progressSegments.errorCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-3 h-3 bg-destructive rounded-sm" }), _jsxs("span", { className: "text-[var(--color-gray-1)]", children: [progressSegments.errorCount, " errors"] })] })), progressSegments.pendingCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-3 h-3 bg-[var(--color-gray-4)] rounded-sm" }), _jsxs("span", { className: "text-[var(--color-gray-1)]", children: [progressSegments.pendingCount, " pending"] })] })), typeof batchInfo?.expectedJobs === 'number' && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-3 h-3 bg-[var(--color-gray-4)] rounded-sm" }), _jsxs("span", { className: "text-[var(--color-gray-1)]", children: [(batchInfo.expectedJobs || 0) - (batchInfo.completedJobs || 0) - (batchInfo.errorJobs || 0), " pending (server)"] })] }))] })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 md:p-6 min-h-0", children: isLoading && batchJobs.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "flex items-center gap-2 text-[var(--color-gray-2)]", children: [_jsx("i", { className: "pi pi-spin pi-spinner text-[#9650fb]" }), "Loading batch translations..."] }) })) : batchJobs.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "text-center text-[var(--color-gray-2)]", children: [_jsx("i", { className: "pi pi-language text-2xl block mb-2 text-[var(--color-gray-3)]" }), _jsx("p", { className: "font-medium text-[var(--color-gray-1)]", children: "No translations found for this batch" }), _jsx("p", { className: "text-sm mt-1", children: "The batch may not exist or no translation jobs have started yet" })] }) })) : (_jsx("div", { className: "space-y-4", children: sortedItemGroups.map(([itemId, jobs]) => {
527
+ const itemCompleted = jobs.filter(j => j.status === "Completed").length;
706
528
  // Calculate item progress including in-progress job progress
707
529
  // Performance: item-level calculation is always fast (typically 2-10 jobs per item)
708
530
  let totalProgress = 0;
@@ -712,7 +534,7 @@ export function BatchTranslationView({ batchId, onBack, }) {
712
534
  }
713
535
  else if (job.status === "In Progress") {
714
536
  // Get progress for this specific job
715
- const progressKey = getJobKey(job.itemId, job.targetLanguage);
537
+ const progressKey = `${job.itemId}-${job.targetLanguage}`;
716
538
  const jobProgress = translationProgress.get(progressKey);
717
539
  totalProgress += jobProgress?.progress || 0; // Use actual progress or 0
718
540
  }
@@ -721,131 +543,51 @@ export function BatchTranslationView({ batchId, onBack, }) {
721
543
  }
722
544
  }
723
545
  const itemProgress = Math.round(totalProgress / jobs.length);
724
- const itemInProgress = jobs.some((j) => j.status === "In Progress");
725
- const itemError = jobs.some((j) => j.status === "Error");
726
- const itemTerminalError = itemError && !itemInProgress;
727
- const itemVisualProgress = itemTerminalError ? 100 : itemProgress;
728
- return (_jsxs("div", { className: `border border-gray-3 rounded-lg bg-background ${isMobile ? "p-4" : "p-6"} shadow-sm`, children: [_jsxs("div", { className: "mb-4", children: [_jsx("div", { className: "mb-3", children: (() => {
546
+ const itemInProgress = jobs.some(j => j.status === "In Progress");
547
+ const itemError = jobs.some(j => j.status === "Error");
548
+ return (_jsxs("div", { className: "border border-[var(--color-gray-3)] rounded-lg bg-background p-4 md:p-6 shadow-sm", children: [_jsxs("div", { className: "mb-4", children: [_jsx("div", { className: "mb-3", children: (() => {
729
549
  const itemName = itemNames.get(itemId.toLowerCase());
730
- return itemName ? (_jsxs(_Fragment, { children: [_jsx("div", { className: `${isMobile ? "text-sm" : "text-base"} font-bold text-(--color-dark) mb-1`, children: itemName }), _jsxs("div", { className: `flex items-center gap-1.5 text-[10px] md:text-xs text-gray-2 font-mono break-all`, children: [_jsxs("span", { className: "break-all", children: ["Item ID: ", itemId] }), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(itemId, "en"), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: "Open item in English", className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] })] })) : (_jsxs("div", { className: `flex items-center gap-1.5 text-[10px] md:text-xs text-gray-2 font-mono break-all`, children: [_jsxs("span", { className: "break-all", children: ["Item ID: ", itemId] }), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(itemId, "en"), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: "Open item in English", className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] }));
731
- })() }), _jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: `${isMobile ? "text-xs" : "text-sm"} text-gray-2`, children: [itemCompleted, " / ", jobs.length, " languages"] }), itemInProgress && (_jsx("span", { className: `${isMobile ? "text-[10px]" : "text-xs"} px-2 py-0.5 md:px-2.5 md:py-1 rounded-full font-medium`, style: {
732
- backgroundColor: "#f6eeff",
733
- color: "#9650fb",
734
- }, children: "In Progress" })), itemError && (_jsx("span", { className: `${isMobile ? "text-[10px]" : "text-xs"} bg-red-100 text-red-600 px-2 py-0.5 md:px-2.5 md:py-1 rounded-full font-medium`, children: "Error" }))] }), _jsxs("span", { className: `${isMobile ? "text-xs" : "text-sm"} font-bold text-(--color-dark)`, children: [itemVisualProgress, "%"] })] }), _jsx("div", { className: "relative", children: _jsx(Progress, { value: itemVisualProgress, className: `${isMobile ? "h-2" : "h-3"} mb-2`, indicatorClassName: itemError ? "bg-destructive" : undefined, indicatorStyle: itemError
735
- ? undefined
736
- : itemProgress === 100
737
- ? { backgroundColor: "#8ae048" }
738
- : { backgroundColor: "#9650fb" }, showValue: false }) })] }), isMobile ? (_jsx("div", { className: "space-y-3", children: jobs
739
- .sort((a, b) => {
740
- const getJobPriority = (job) => {
741
- if (job.status === "In Progress")
742
- return 1;
743
- if (job.status === "Error")
744
- return 2;
745
- if (job.status === "Completed")
746
- return 4;
747
- return 3;
748
- };
749
- const priorityA = getJobPriority(a);
750
- const priorityB = getJobPriority(b);
751
- if (priorityA === priorityB) {
752
- return a.targetLanguage.localeCompare(b.targetLanguage);
753
- }
754
- return priorityA - priorityB;
755
- })
756
- .map((job) => {
757
- const progressKey = getJobKey(job.itemId, job.targetLanguage);
758
- const progress = translationProgress.get(progressKey);
759
- const date = new Date(job.timestamp);
760
- const displayMessage = job.message ||
761
- (job.status === "Error"
762
- ? progress?.message || "Translation failed"
763
- : "");
764
- const jobRetryKey = getRetryJobKey(job);
765
- const isRetryingJob = isRetryingAll || retryingJobKeys.has(jobRetryKey);
766
- return (_jsxs("div", { className: "rounded-md border border-gray-4 bg-gray-5 p-3", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-bold text-(--color-dark)", children: job.targetLanguage }), _jsx("span", { className: "text-gray-2", children: "\u2190" }), _jsx("span", { className: "text-xs text-(--color-gray-1)", children: job.sourceLanguage || "—" })] }), _jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-bold ${job.status === "Completed"
767
- ? "bg-green-100 text-green-700"
768
- : job.status === "In Progress"
769
- ? ""
770
- : job.status === "Error"
771
- ? "bg-red-100 text-red-600"
772
- : "bg-gray-4 text-(--color-gray-1)"}`, style: job.status === "In Progress"
773
- ? {
774
- backgroundColor: "#f6eeff",
775
- color: "#9650fb",
776
- }
777
- : undefined, children: job.status })] }), displayMessage && (_jsxs("div", { className: "mb-2 flex items-start gap-1.5", children: [_jsx("div", { className: "min-w-0 flex-1 text-xs text-gray-2 break-words [overflow-wrap:anywhere] whitespace-normal leading-relaxed line-clamp-3", title: displayMessage, children: displayMessage }), _jsx(CopyButton, { textToCopy: displayMessage, iconOnly: true, className: "mt-0.5 shrink-0 text-gray-2 hover:text-(--color-gray-1)" })] })), job.status === "In Progress" && progress && (_jsxs("div", { className: "mb-2", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 truncate pr-2", children: progress.message }), _jsxs("span", { className: "text-[10px] font-bold text-(--color-gray-1) shrink-0", children: [Math.round(progress.progress), "%"] })] }), _jsx(Progress, { value: progress.progress, className: "h-2", indicatorStyle: {
778
- backgroundColor: "#9650fb",
779
- }, showValue: false })] })), job.status === "Error" && (_jsx("div", { className: "mb-2", children: _jsx(Progress, { value: 100, className: "h-2", indicatorClassName: "bg-destructive", showValue: false }) })), _jsx("div", { className: "text-[10px] text-gray-2 text-right mt-1", children: date.toLocaleTimeString([], {
780
- hour: "2-digit",
781
- minute: "2-digit",
782
- }) }), _jsx("div", { className: "mt-2 flex justify-end", children: _jsxs("div", { className: "flex items-center gap-2", children: [job.status === "Error" && (_jsx(SimpleIconButton, { onClick: () => void handleRetryJobs([job], false), icon: _jsx(RefreshIcon, { className: `h-3.5 w-3.5 ${isRetryingJob ? "animate-spin" : ""}`, strokeWidth: 1 }), label: "Retry translation as new batch", disabled: isRetryingJob, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(job.itemId, job.targetLanguage), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: `Open item in ${job.targetLanguage}`, disabled: isRetryingAll, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] }) })] }, job.targetLanguage));
783
- }) })) : (_jsx(SimpleTable, { columns: [
550
+ return itemName ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-base font-semibold text-[var(--color-dark)] mb-1", children: itemName }), _jsxs("div", { className: "text-xs text-[var(--color-gray-2)] font-mono", children: ["Item ID: ", itemId] })] })) : (_jsxs("div", { className: "text-xs text-[var(--color-gray-2)] font-mono", children: ["Item ID: ", itemId] }));
551
+ })() }), _jsx("div", { className: "flex items-center justify-between mb-2", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-sm text-[var(--color-gray-2)]", children: [itemCompleted, " / ", jobs.length, " languages"] }), itemInProgress && (_jsx("span", { className: "text-xs px-2.5 py-1 rounded-full font-medium", style: { backgroundColor: '#f6eeff', color: '#9650fb' }, children: "In Progress" })), itemError && (_jsx("span", { className: "text-xs bg-red-100 text-red-600 px-2.5 py-1 rounded-full font-medium", children: "Error" }))] }) }), _jsx("div", { className: "relative", children: _jsx(Progress, { value: itemProgress, className: "h-3 mb-2", indicatorClassName: itemError ? "bg-destructive" : undefined, indicatorStyle: itemError ? undefined :
552
+ itemProgress === 100 ? { backgroundColor: '#8ae048' } :
553
+ { backgroundColor: '#9650fb' }, showValue: true }) })] }), _jsx(SimpleTable, { columns: [
784
554
  {
785
555
  header: "Language",
786
556
  className: "w-16", // Fixed width for language column
787
- body: (job) => (_jsx("div", { className: "font-medium text-(--color-dark)", children: job.targetLanguage })),
557
+ body: (job) => (_jsx("div", { className: "font-medium text-[var(--color-dark)]", children: job.targetLanguage }))
788
558
  },
789
559
  {
790
560
  header: "Status",
791
- className: "w-[14rem] min-w-[14rem]", // Keep status column width stable as content changes
561
+ className: "w-32", // Fixed width for status column
792
562
  body: (job) => {
793
- const progressKey = getJobKey(job.itemId, job.targetLanguage);
563
+ const progressKey = `${job.itemId}-${job.targetLanguage}`;
794
564
  const progress = translationProgress.get(progressKey);
795
- const displayMessage = job.message ||
796
- (job.status === "Error"
797
- ? progress?.message || "Translation failed"
798
- : "");
799
- return (_jsxs("div", { className: "w-[14rem] min-w-[14rem]", children: [_jsx("span", { className: `inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${job.status === "Completed"
565
+ return (_jsxs("div", { children: [_jsx("span", { className: `inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${job.status === "Completed"
800
566
  ? "bg-green-100 text-green-700"
801
567
  : job.status === "In Progress"
802
568
  ? ""
803
569
  : job.status === "Error"
804
570
  ? "bg-red-100 text-red-600"
805
- : "bg-gray-4 text-(--color-gray-1)"}`, style: job.status === "In Progress"
806
- ? {
807
- backgroundColor: "#f6eeff",
808
- color: "#9650fb",
809
- }
810
- : undefined, children: job.status }), job.status === "In Progress" &&
811
- progress &&
812
- Math.round(progress.progress || 0) === 0 && (_jsx("div", { className: "mt-1 text-xs text-gray-2", children: "Version created" })), progress && job.status === "In Progress" && (_jsxs("div", { className: "mt-1.5", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("span", { className: "text-xs text-gray-2 truncate", "data-testid": "translation-progress-text", children: progress.message }), _jsxs("span", { className: "text-xs font-medium text-(--color-gray-1) ml-1", children: [Math.round(progress.progress), "%"] })] }), _jsx(Progress, { value: progress.progress, className: "h-2", indicatorStyle: {
813
- backgroundColor: "#9650fb",
814
- }, showValue: false, "data-testid": "translation-progress-bar" })] })), job.status === "Error" && (_jsx("div", { className: "mt-1.5", children: _jsx(Progress, { value: 100, className: "h-2", indicatorClassName: "bg-destructive", showValue: false, "data-testid": "translation-progress-bar" }) }))] }));
815
- },
571
+ : "bg-[var(--color-gray-4)] text-[var(--color-gray-1)]"}`, style: job.status === "In Progress" ? { backgroundColor: '#f6eeff', color: '#9650fb' } : undefined, children: job.status }), job.status === "In Progress" && progress && Math.round(progress.progress || 0) === 0 && (_jsx("div", { className: "mt-1 text-xs text-[var(--color-gray-2)]", children: "Version created" })), progress && job.status === "In Progress" && (_jsxs("div", { className: "mt-1.5", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("span", { className: "text-xs text-[var(--color-gray-2)] truncate", "data-testid": "translation-progress-text", children: progress.message }), _jsxs("span", { className: "text-xs font-medium text-[var(--color-gray-1)] ml-1", children: [Math.round(progress.progress), "%"] })] }), _jsx(Progress, { value: progress.progress, className: "h-2", indicatorStyle: { backgroundColor: '#9650fb' }, showValue: false, "data-testid": "translation-progress-bar" })] }))] }));
572
+ }
816
573
  },
817
574
  {
818
575
  header: "Source",
819
576
  className: "w-16", // Fixed width for source column
820
- body: (job) => (_jsx("div", { className: "text-sm text-(--color-gray-1)", children: job.sourceLanguage || "—" })),
577
+ body: (job) => (_jsx("div", { className: "text-sm text-[var(--color-gray-1)]", children: job.sourceLanguage || "—" }))
821
578
  },
822
579
  {
823
580
  header: "Message",
824
- className: "w-full min-w-0",
825
- body: (job) => {
826
- const message = job.message ||
827
- (job.status === "Error"
828
- ? "Translation failed"
829
- : "—");
830
- return (_jsxs("div", { className: "flex items-start gap-1.5", children: [_jsx("div", { className: "min-w-0 w-full flex-1 text-sm text-gray-2 break-words [overflow-wrap:anywhere] whitespace-normal leading-relaxed line-clamp-3", title: message, children: message }), message !== "—" && (_jsx(CopyButton, { textToCopy: message, iconOnly: true, className: "mt-0.5 shrink-0 text-gray-2 hover:text-(--color-gray-1)" }))] }));
831
- },
832
- },
833
- {
834
- header: "Actions",
835
- className: "w-24",
836
- body: (job) => {
837
- const jobRetryKey = getRetryJobKey(job);
838
- const isRetryingJob = isRetryingAll || retryingJobKeys.has(jobRetryKey);
839
- return (_jsxs("div", { className: "flex items-center justify-center gap-2", children: [job.status === "Error" && (_jsx(SimpleIconButton, { onClick: () => void handleRetryJobs([job], false), icon: _jsx(RefreshIcon, { className: `h-3.5 w-3.5 ${isRetryingJob ? "animate-spin" : ""}`, strokeWidth: 1 }), label: "Retry translation as new batch", disabled: isRetryingJob, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(job.itemId, job.targetLanguage), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: `Open item in ${job.targetLanguage}`, disabled: isRetryingAll, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] }));
840
- },
581
+ className: "w-64", // Fixed width with proper wrapping for message column
582
+ body: (job) => (_jsx("div", { className: "text-sm text-[var(--color-gray-2)] break-words whitespace-normal leading-relaxed", children: job.message || "—" }))
841
583
  },
842
584
  {
843
585
  header: "Updated",
844
586
  className: "w-24", // Fixed width for updated column
845
587
  body: (job) => {
846
588
  const date = new Date(job.timestamp);
847
- return (_jsx("div", { className: "text-sm text-gray-2 whitespace-nowrap", children: date.toLocaleTimeString() }));
848
- },
589
+ return (_jsx("div", { className: "text-sm text-[var(--color-gray-2)] whitespace-nowrap", children: date.toLocaleTimeString() }));
590
+ }
849
591
  },
850
592
  ], items: jobs.sort((a, b) => {
851
593
  // Sort by status first (in-progress first, then errors, then completed)
@@ -865,6 +607,6 @@ export function BatchTranslationView({ batchId, onBack, }) {
865
607
  return a.targetLanguage.localeCompare(b.targetLanguage);
866
608
  }
867
609
  return priorityA - priorityB;
868
- }) }))] }, itemId));
610
+ }) })] }, itemId));
869
611
  }) })) })] }));
870
612
  }