@parhelia/localization 0.1.12534 → 0.1.12555

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