@parhelia/localization 0.1.12789 → 0.1.12791
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/LocalizeItemDialog.d.ts.map +1 -1
- package/dist/LocalizeItemDialog.js +92 -34
- package/dist/LocalizeItemUtils.d.ts +1 -2
- package/dist/LocalizeItemUtils.d.ts.map +1 -1
- package/dist/LocalizeItemUtils.js +44 -12
- package/dist/api/discovery.d.ts +25 -0
- package/dist/api/discovery.d.ts.map +1 -1
- package/dist/api/discovery.js +87 -0
- package/dist/index.d.ts +8 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -30
- package/dist/services/translationService.d.ts +40 -9
- package/dist/services/translationService.d.ts.map +1 -1
- package/dist/services/translationService.js +30 -4
- package/dist/settings/TranslationServicesPanel.d.ts.map +1 -1
- package/dist/settings/TranslationServicesPanel.js +18 -36
- package/dist/sidebar/TranslationSidebar.d.ts.map +1 -1
- package/dist/sidebar/TranslationSidebar.js +4 -1
- package/dist/steps/ItemSelectionStep.d.ts +3 -0
- package/dist/steps/ItemSelectionStep.d.ts.map +1 -0
- package/dist/steps/ItemSelectionStep.js +23 -0
- package/dist/steps/ItemSelectionTree.d.ts +13 -0
- package/dist/steps/ItemSelectionTree.d.ts.map +1 -0
- package/dist/steps/ItemSelectionTree.js +326 -0
- package/dist/steps/MetadataInputStep.d.ts.map +1 -1
- package/dist/steps/MetadataInputStep.js +8 -1
- package/dist/steps/PromptCustomizationStep.d.ts +1 -1
- package/dist/steps/PromptCustomizationStep.d.ts.map +1 -1
- package/dist/steps/PromptCustomizationStep.js +161 -56
- package/dist/steps/ServiceLanguageSelectionStep.d.ts +6 -1
- package/dist/steps/ServiceLanguageSelectionStep.d.ts.map +1 -1
- package/dist/steps/ServiceLanguageSelectionStep.js +53 -163
- package/dist/steps/WizardStepShell.d.ts +17 -0
- package/dist/steps/WizardStepShell.d.ts.map +1 -0
- package/dist/steps/WizardStepShell.js +11 -0
- package/dist/steps/index.d.ts +1 -0
- package/dist/steps/index.d.ts.map +1 -1
- package/dist/steps/index.js +1 -0
- package/dist/steps/types.d.ts +17 -1
- package/dist/steps/types.d.ts.map +1 -1
- package/dist/translation-center/TranslationBatches.d.ts +2 -0
- package/dist/translation-center/TranslationBatches.d.ts.map +1 -0
- package/dist/translation-center/TranslationBatches.js +995 -0
- package/dist/translation-center/TranslationManagement.d.ts.map +1 -1
- package/dist/translation-center/TranslationManagement.js +6 -23
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/translation-center/BatchTranslationView.d.ts +0 -8
- package/dist/translation-center/BatchTranslationView.d.ts.map +0 -1
- package/dist/translation-center/BatchTranslationView.js +0 -890
- package/dist/translation-center/RecentTranslations.d.ts +0 -2
- package/dist/translation-center/RecentTranslations.d.ts.map +0 -1
- package/dist/translation-center/RecentTranslations.js +0 -309
|
@@ -1,890 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
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";
|
|
7
|
-
import { JsonView, defaultStyles } from "react-json-view-lite";
|
|
8
|
-
import "react-json-view-lite/dist/index.css";
|
|
9
|
-
// Wrapper so React 19 sees a plain function component instead of a forwardRef exotic component.
|
|
10
|
-
const Progress = (props) => React.createElement(CoreProgress, props);
|
|
11
|
-
// Wrappers for lucide-react icons to work with React 19
|
|
12
|
-
const ChevronUpIcon = (props) => React.createElement(ChevronUp, props);
|
|
13
|
-
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
|
-
const MemoizedJsonView = memo(({ data }) => (_jsx(JsonView, { data: data, shouldExpandNode: (level) => level < 2, style: defaultStyles })), (prevProps, nextProps) => {
|
|
21
|
-
return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data);
|
|
22
|
-
});
|
|
23
|
-
// Normalize API shape/casing to what the UI expects
|
|
24
|
-
function normalizeBatchInfo(raw) {
|
|
25
|
-
if (!raw)
|
|
26
|
-
return null;
|
|
27
|
-
const toNum = (v) => typeof v === "number" ? v : v != null ? parseInt(v, 10) : undefined;
|
|
28
|
-
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,
|
|
43
|
-
};
|
|
44
|
-
return info.batchId ? info : null;
|
|
45
|
-
}
|
|
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, }) {
|
|
77
|
-
const editContext = useEditContext();
|
|
78
|
-
const router = useRouter();
|
|
79
|
-
const pathname = usePathname();
|
|
80
|
-
const searchParams = useSearchParams();
|
|
81
|
-
const [batchJobs, setBatchJobs] = useState([]);
|
|
82
|
-
const [batchInfo, setBatchInfo] = useState(null);
|
|
83
|
-
const [providers, setProviders] = useState([]);
|
|
84
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
85
|
-
const [isRetryingAll, setIsRetryingAll] = useState(false);
|
|
86
|
-
const [retryingJobKeys, setRetryingJobKeys] = useState(new Set());
|
|
87
|
-
const [translationProgress, setTranslationProgress] = useState(new Map());
|
|
88
|
-
const [itemNames, setItemNames] = useState(new Map());
|
|
89
|
-
const [isMetadataExpanded, setIsMetadataExpanded] = useState(false);
|
|
90
|
-
const lastWsAtRef = useRef(0);
|
|
91
|
-
const batchJobsRef = useRef([]);
|
|
92
|
-
const hasLoadedRef = useRef(false);
|
|
93
|
-
const isSubscribedRef = useRef(false);
|
|
94
|
-
const initGuardRef = useRef(false);
|
|
95
|
-
const lastBatchIdRef = useRef(null);
|
|
96
|
-
const completionRefreshRef = useRef(null);
|
|
97
|
-
// Helper function to get display name from service name
|
|
98
|
-
const getProviderDisplayName = useCallback((serviceName) => {
|
|
99
|
-
if (!serviceName)
|
|
100
|
-
return "Unknown";
|
|
101
|
-
const provider = providers.find((p) => p.name === serviceName);
|
|
102
|
-
return provider?.displayName || serviceName;
|
|
103
|
-
}, [providers]);
|
|
104
|
-
const openItemInEditor = useCallback(async (itemId, language) => {
|
|
105
|
-
editContext?.setShowAgentsWorkspaceEditor(true);
|
|
106
|
-
await editContext?.loadItem({
|
|
107
|
-
id: normalizeJobItemId(itemId),
|
|
108
|
-
language,
|
|
109
|
-
version: 0,
|
|
110
|
-
});
|
|
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
|
-
// Parse metadata for display
|
|
189
|
-
const parsedMetadata = useMemo(() => {
|
|
190
|
-
if (!batchInfo?.metadata)
|
|
191
|
-
return null;
|
|
192
|
-
try {
|
|
193
|
-
return JSON.parse(batchInfo.metadata);
|
|
194
|
-
}
|
|
195
|
-
catch {
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
}, [batchInfo?.metadata]);
|
|
199
|
-
// Keep refs in sync with state
|
|
200
|
-
useEffect(() => {
|
|
201
|
-
batchJobsRef.current = batchJobs;
|
|
202
|
-
}, [batchJobs]);
|
|
203
|
-
const loadProviders = useCallback(async () => {
|
|
204
|
-
try {
|
|
205
|
-
const res = await getTranslationProviders();
|
|
206
|
-
const providerData = (res?.data ??
|
|
207
|
-
res ??
|
|
208
|
-
[]);
|
|
209
|
-
setProviders(providerData);
|
|
210
|
-
}
|
|
211
|
-
catch (error) {
|
|
212
|
-
console.error("Failed to load translation providers:", error);
|
|
213
|
-
}
|
|
214
|
-
}, []);
|
|
215
|
-
const loadBatchJobs = useCallback(async () => {
|
|
216
|
-
setIsLoading(true);
|
|
217
|
-
try {
|
|
218
|
-
// Use efficient batch-specific endpoint
|
|
219
|
-
const res = await listBatchTranslationJobs(batchId);
|
|
220
|
-
const apiJobsRaw = (res?.data ?? res ?? []);
|
|
221
|
-
const apiJobs = apiJobsRaw.map(normalizeJobRecord);
|
|
222
|
-
// Merge API response with existing state to prevent flickering
|
|
223
|
-
setBatchJobs((prevJobs) => {
|
|
224
|
-
// Create a map of existing jobs keyed by itemId-targetLanguage
|
|
225
|
-
const existingMap = new Map();
|
|
226
|
-
for (const job of prevJobs) {
|
|
227
|
-
const key = getJobKey(job.itemId, job.targetLanguage);
|
|
228
|
-
existingMap.set(key, job);
|
|
229
|
-
}
|
|
230
|
-
// Merge API jobs into the map (API data takes precedence)
|
|
231
|
-
for (const apiJob of apiJobs) {
|
|
232
|
-
const normalizedJob = normalizeJobRecord(apiJob);
|
|
233
|
-
const key = getJobKey(normalizedJob.itemId, normalizedJob.targetLanguage);
|
|
234
|
-
const existingJob = existingMap.get(key);
|
|
235
|
-
if (existingJob) {
|
|
236
|
-
// 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) {
|
|
245
|
-
// Preserve sourceLanguage from API if available, otherwise keep existing
|
|
246
|
-
existingMap.set(key, {
|
|
247
|
-
...normalizedJob,
|
|
248
|
-
sourceLanguage: normalizedJob.sourceLanguage ||
|
|
249
|
-
existingJob.sourceLanguage ||
|
|
250
|
-
"",
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
else {
|
|
255
|
-
// New job from API - ensure sourceLanguage is set
|
|
256
|
-
existingMap.set(key, {
|
|
257
|
-
...normalizedJob,
|
|
258
|
-
sourceLanguage: normalizedJob.sourceLanguage || "",
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
// Convert map back to array
|
|
263
|
-
const mergedJobs = Array.from(existingMap.values());
|
|
264
|
-
// Only update if the merged result differs from current state
|
|
265
|
-
// Check if arrays are different (length or content)
|
|
266
|
-
if (mergedJobs.length !== prevJobs.length) {
|
|
267
|
-
return mergedJobs;
|
|
268
|
-
}
|
|
269
|
-
// Deep comparison: check if any job changed
|
|
270
|
-
const prevMap = new Map(prevJobs.map((j) => [`${j.itemId}-${j.targetLanguage}`, j]));
|
|
271
|
-
let hasChanges = false;
|
|
272
|
-
for (const mergedJob of mergedJobs) {
|
|
273
|
-
const key = `${mergedJob.itemId}-${mergedJob.targetLanguage}`;
|
|
274
|
-
const prevJob = prevMap.get(key);
|
|
275
|
-
if (!prevJob ||
|
|
276
|
-
prevJob.status !== mergedJob.status ||
|
|
277
|
-
prevJob.timestamp !== mergedJob.timestamp ||
|
|
278
|
-
prevJob.message !== mergedJob.message) {
|
|
279
|
-
hasChanges = true;
|
|
280
|
-
break;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
if (hasChanges) {
|
|
284
|
-
return mergedJobs;
|
|
285
|
-
}
|
|
286
|
-
// No changes detected, return previous state to avoid unnecessary re-render
|
|
287
|
-
return prevJobs;
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
catch (error) {
|
|
291
|
-
console.error("Failed to load batch jobs:", error);
|
|
292
|
-
}
|
|
293
|
-
finally {
|
|
294
|
-
setIsLoading(false);
|
|
295
|
-
}
|
|
296
|
-
}, [batchId, editContext]);
|
|
297
|
-
// Load initial data and conditionally subscribe to batch updates
|
|
298
|
-
useEffect(() => {
|
|
299
|
-
// Reset guards if batchId changed
|
|
300
|
-
if (lastBatchIdRef.current !== batchId) {
|
|
301
|
-
lastBatchIdRef.current = batchId;
|
|
302
|
-
hasLoadedRef.current = false;
|
|
303
|
-
isSubscribedRef.current = false;
|
|
304
|
-
initGuardRef.current = false;
|
|
305
|
-
completionRefreshRef.current = null;
|
|
306
|
-
}
|
|
307
|
-
let cancelled = false;
|
|
308
|
-
const init = async () => {
|
|
309
|
-
if (initGuardRef.current)
|
|
310
|
-
return; // prevent duplicate init under StrictMode
|
|
311
|
-
initGuardRef.current = true;
|
|
312
|
-
try {
|
|
313
|
-
// Always load batch info first to decide on subscriptions
|
|
314
|
-
try {
|
|
315
|
-
const resInfo = await getBatchInfo(batchId);
|
|
316
|
-
const raw = resInfo?.data ?? resInfo;
|
|
317
|
-
const info = normalizeBatchInfo(raw);
|
|
318
|
-
if (!cancelled && info)
|
|
319
|
-
setBatchInfo(info);
|
|
320
|
-
const isTerminal = info?.status === "Completed" || info?.status === "Error";
|
|
321
|
-
// Conditionally subscribe only for non-terminal batches
|
|
322
|
-
if (!isTerminal && editContext?.sessionId) {
|
|
323
|
-
await subscribeToBatch(batchId, editContext.sessionId);
|
|
324
|
-
isSubscribedRef.current = true;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
catch { }
|
|
328
|
-
// Load providers for display name mapping
|
|
329
|
-
await loadProviders();
|
|
330
|
-
// Load jobs once per mount/batch change
|
|
331
|
-
if (!hasLoadedRef.current) {
|
|
332
|
-
await loadBatchJobs();
|
|
333
|
-
hasLoadedRef.current = true;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
catch (e) {
|
|
337
|
-
// no-op
|
|
338
|
-
}
|
|
339
|
-
};
|
|
340
|
-
init();
|
|
341
|
-
return () => {
|
|
342
|
-
// Cleanup: unsubscribe when component unmounts or batch changes
|
|
343
|
-
if (isSubscribedRef.current &&
|
|
344
|
-
editContext &&
|
|
345
|
-
editContext.sessionId &&
|
|
346
|
-
batchId) {
|
|
347
|
-
unsubscribeFromBatch(batchId, editContext.sessionId).catch(console.error);
|
|
348
|
-
}
|
|
349
|
-
};
|
|
350
|
-
}, [batchId, editContext?.sessionId]);
|
|
351
|
-
// Listen for translation events - only for this batch
|
|
352
|
-
useEffect(() => {
|
|
353
|
-
const removeSocketMessageListener = editContext?.addSocketMessageListener((message) => {
|
|
354
|
-
// Mark last websocket activity time for adaptive polling
|
|
355
|
-
if (message.type === "translation-started" ||
|
|
356
|
-
message.type === "translation-finished" ||
|
|
357
|
-
message.type === "translation-error" ||
|
|
358
|
-
message.type === "translation-progress" ||
|
|
359
|
-
message.type === "batch-completed") {
|
|
360
|
-
lastWsAtRef.current = Date.now();
|
|
361
|
-
}
|
|
362
|
-
if (message.type === "translation-started" ||
|
|
363
|
-
message.type === "translation-finished" ||
|
|
364
|
-
message.type === "translation-error") {
|
|
365
|
-
// Only update if this message is for our batch
|
|
366
|
-
if (message.payload.batchId === batchId) {
|
|
367
|
-
// 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 &&
|
|
372
|
-
job.targetLanguage === message.payload.language);
|
|
373
|
-
if (jobIndex >= 0) {
|
|
374
|
-
// Update existing job
|
|
375
|
-
const updatedJobs = [...prevJobs];
|
|
376
|
-
const existingJob = updatedJobs[jobIndex];
|
|
377
|
-
if (existingJob) {
|
|
378
|
-
updatedJobs[jobIndex] = {
|
|
379
|
-
...existingJob,
|
|
380
|
-
itemId: normalizedItemId,
|
|
381
|
-
status: message.payload.status,
|
|
382
|
-
timestamp: message.payload.timestamp || existingJob.timestamp,
|
|
383
|
-
message: payloadMessage || existingJob.message,
|
|
384
|
-
// Preserve sourceLanguage from existing job if not provided in message
|
|
385
|
-
sourceLanguage: message.payload.sourceLanguage ||
|
|
386
|
-
existingJob.sourceLanguage ||
|
|
387
|
-
"",
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
return updatedJobs;
|
|
391
|
-
}
|
|
392
|
-
else {
|
|
393
|
-
// Add new job if it doesn't exist yet so terminal updates are still visible.
|
|
394
|
-
const newJob = {
|
|
395
|
-
itemId: normalizedItemId,
|
|
396
|
-
targetLanguage: message.payload.language,
|
|
397
|
-
sourceLanguage: message.payload.sourceLanguage || "", // Keep this, but we'll try to populate from API
|
|
398
|
-
status: message.payload.status,
|
|
399
|
-
timestamp: message.payload.timestamp || new Date().toISOString(),
|
|
400
|
-
batchId: batchId,
|
|
401
|
-
message: payloadMessage,
|
|
402
|
-
};
|
|
403
|
-
return [...prevJobs, newJob];
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
if (message.type === "translation-progress") {
|
|
409
|
-
// Only track progress for jobs in this batch
|
|
410
|
-
if (message.payload.batchId === batchId) {
|
|
411
|
-
const itemId = normalizeJobItemId(message.payload.itemId);
|
|
412
|
-
const language = message.payload.language;
|
|
413
|
-
const progressKey = getJobKey(itemId, language);
|
|
414
|
-
// 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);
|
|
418
|
-
if (idx >= 0)
|
|
419
|
-
return prevJobs;
|
|
420
|
-
const newJob = {
|
|
421
|
-
itemId,
|
|
422
|
-
targetLanguage: language,
|
|
423
|
-
sourceLanguage: message.payload.sourceLanguage || "", // Keep this
|
|
424
|
-
status: "In Progress",
|
|
425
|
-
timestamp: new Date().toISOString(),
|
|
426
|
-
batchId: batchId,
|
|
427
|
-
message: message.payload.message || "",
|
|
428
|
-
};
|
|
429
|
-
return [...prevJobs, newJob];
|
|
430
|
-
});
|
|
431
|
-
setTranslationProgress((prev) => {
|
|
432
|
-
const next = new Map(prev);
|
|
433
|
-
next.set(progressKey, {
|
|
434
|
-
progress: message.payload.progress || 0,
|
|
435
|
-
message: message.payload.message || "",
|
|
436
|
-
});
|
|
437
|
-
return next;
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
// Handle batch-completed messages for this specific batch
|
|
442
|
-
if (message.type === "batch-completed" &&
|
|
443
|
-
message.payload.batchId === batchId) {
|
|
444
|
-
// Refresh header info only (jobs are already updated via websocket)
|
|
445
|
-
(async () => {
|
|
446
|
-
try {
|
|
447
|
-
const resInfo = await getBatchInfo(batchId);
|
|
448
|
-
const raw = resInfo?.data ?? resInfo;
|
|
449
|
-
const info = normalizeBatchInfo(raw);
|
|
450
|
-
if (info)
|
|
451
|
-
setBatchInfo(info);
|
|
452
|
-
await loadBatchJobs();
|
|
453
|
-
}
|
|
454
|
-
catch { }
|
|
455
|
-
})();
|
|
456
|
-
}
|
|
457
|
-
});
|
|
458
|
-
return () => {
|
|
459
|
-
if (removeSocketMessageListener) {
|
|
460
|
-
removeSocketMessageListener();
|
|
461
|
-
}
|
|
462
|
-
};
|
|
463
|
-
}, [editContext, batchId]);
|
|
464
|
-
// Adaptive polling while batch is in progress - only as fallback when websockets are silent
|
|
465
|
-
useEffect(() => {
|
|
466
|
-
const isRunning = batchInfo?.status === "In Progress";
|
|
467
|
-
if (!isRunning)
|
|
468
|
-
return;
|
|
469
|
-
const pollBatchInfo = async () => {
|
|
470
|
-
try {
|
|
471
|
-
const resInfo = await getBatchInfo(batchId);
|
|
472
|
-
const raw = resInfo?.data ?? resInfo;
|
|
473
|
-
const info = normalizeBatchInfo(raw);
|
|
474
|
-
if (info)
|
|
475
|
-
setBatchInfo(info);
|
|
476
|
-
}
|
|
477
|
-
catch (error) {
|
|
478
|
-
console.error("Failed to refresh batch info:", error);
|
|
479
|
-
}
|
|
480
|
-
};
|
|
481
|
-
// Don't poll immediately - rely on websocket updates and initial load
|
|
482
|
-
// Only poll as a fallback if websockets are completely silent
|
|
483
|
-
const id = setInterval(() => {
|
|
484
|
-
const timeSinceLastWs = Date.now() - lastWsAtRef.current;
|
|
485
|
-
// Only poll if websockets have been completely silent for 30+ seconds
|
|
486
|
-
// This is a fallback mechanism - websockets should handle all updates
|
|
487
|
-
if (timeSinceLastWs > 30000) {
|
|
488
|
-
void pollBatchInfo();
|
|
489
|
-
// Only poll for jobs if websockets are silent for even longer
|
|
490
|
-
if (timeSinceLastWs > 45000) {
|
|
491
|
-
void loadBatchJobs();
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
}, 30000); // Check every 30 seconds, but only poll if WS silent for 30+ seconds
|
|
495
|
-
return () => clearInterval(id);
|
|
496
|
-
}, [batchInfo?.status, loadBatchJobs, batchId]);
|
|
497
|
-
// Fetch item names when batchJobs change
|
|
498
|
-
useEffect(() => {
|
|
499
|
-
if (!editContext?.itemsRepository || batchJobs.length === 0) {
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
const fetchItemNames = async () => {
|
|
503
|
-
// Extract unique item IDs from batchJobs
|
|
504
|
-
const uniqueItemIds = Array.from(new Set(batchJobs.map((job) => job.itemId)));
|
|
505
|
-
if (uniqueItemIds.length === 0) {
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
// Get language from editContext or fallback to "en"
|
|
509
|
-
const language = editContext.currentItemDescriptor?.language || "en";
|
|
510
|
-
try {
|
|
511
|
-
// Create ItemDescriptors for fetching
|
|
512
|
-
const itemDescriptors = uniqueItemIds.map((itemId) => ({
|
|
513
|
-
id: itemId,
|
|
514
|
-
language: language,
|
|
515
|
-
version: 0, // Latest version
|
|
516
|
-
}));
|
|
517
|
-
// Fetch item stubs
|
|
518
|
-
const stubs = await editContext.itemsRepository.getItemsStubs(itemDescriptors);
|
|
519
|
-
// Store names in Map
|
|
520
|
-
const namesMap = new Map();
|
|
521
|
-
for (const stub of stubs) {
|
|
522
|
-
if (stub.name) {
|
|
523
|
-
namesMap.set(stub.id.toLowerCase(), stub.name);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
setItemNames((prevNames) => {
|
|
527
|
-
// Merge with existing names, only update if there are changes
|
|
528
|
-
let hasChanges = false;
|
|
529
|
-
const merged = new Map(prevNames);
|
|
530
|
-
for (const [id, name] of namesMap) {
|
|
531
|
-
if (merged.get(id) !== name) {
|
|
532
|
-
merged.set(id, name);
|
|
533
|
-
hasChanges = true;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
return hasChanges ? merged : prevNames;
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
catch (error) {
|
|
540
|
-
console.error("Failed to fetch item names:", error);
|
|
541
|
-
// On error, keep existing names (don't clear them)
|
|
542
|
-
}
|
|
543
|
-
};
|
|
544
|
-
fetchItemNames();
|
|
545
|
-
}, [
|
|
546
|
-
batchJobs,
|
|
547
|
-
editContext?.itemsRepository,
|
|
548
|
-
editContext?.currentItemDescriptor?.language,
|
|
549
|
-
]);
|
|
550
|
-
// Group jobs by item for better organization
|
|
551
|
-
const itemGroups = useMemo(() => {
|
|
552
|
-
const groups = {};
|
|
553
|
-
for (const job of batchJobs) {
|
|
554
|
-
if (!groups[job.itemId]) {
|
|
555
|
-
groups[job.itemId] = [];
|
|
556
|
-
}
|
|
557
|
-
groups[job.itemId].push(job);
|
|
558
|
-
}
|
|
559
|
-
return groups;
|
|
560
|
-
}, [batchJobs]);
|
|
561
|
-
// Sort items by status priority: in-progress first, then errors, then completed
|
|
562
|
-
const sortedItemGroups = useMemo(() => {
|
|
563
|
-
const entries = Object.entries(itemGroups);
|
|
564
|
-
return entries.sort(([, jobsA], [, jobsB]) => {
|
|
565
|
-
// Calculate status priority for each item group
|
|
566
|
-
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");
|
|
570
|
-
if (hasInProgress)
|
|
571
|
-
return 1; // Highest priority - show first
|
|
572
|
-
if (hasError)
|
|
573
|
-
return 2; // Second priority
|
|
574
|
-
if (allCompleted)
|
|
575
|
-
return 4; // Lowest priority - show last
|
|
576
|
-
return 3; // Pending/other statuses
|
|
577
|
-
};
|
|
578
|
-
const priorityA = getStatusPriority(jobsA);
|
|
579
|
-
const priorityB = getStatusPriority(jobsB);
|
|
580
|
-
return priorityA - priorityB;
|
|
581
|
-
});
|
|
582
|
-
}, [itemGroups]);
|
|
583
|
-
// Calculate overall progress segments including in-progress job progress.
|
|
584
|
-
// Incorporate server-side expected/completed/error counts so pending server jobs are reflected.
|
|
585
|
-
const progressSegments = useMemo(() => {
|
|
586
|
-
const expectedTotal = typeof batchInfo?.expectedJobs === "number" && batchInfo.expectedJobs > 0
|
|
587
|
-
? batchInfo.expectedJobs
|
|
588
|
-
: batchJobs.length;
|
|
589
|
-
if (expectedTotal === 0) {
|
|
590
|
-
return {
|
|
591
|
-
completed: 0,
|
|
592
|
-
inProgress: 0,
|
|
593
|
-
error: 0,
|
|
594
|
-
pending: 0,
|
|
595
|
-
completedCount: 0,
|
|
596
|
-
inProgressCount: 0,
|
|
597
|
-
errorCount: 0,
|
|
598
|
-
pendingCount: 0,
|
|
599
|
-
total: 0,
|
|
600
|
-
actualProgress: 0,
|
|
601
|
-
};
|
|
602
|
-
}
|
|
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;
|
|
611
|
-
// Prefer the larger of local/server so UI can progress smoothly with local updates
|
|
612
|
-
const completedCount = Math.max(0, Math.min(expectedTotal, Math.max(localCompleted, serverCompleted ?? 0)));
|
|
613
|
-
const errorCount = Math.max(0, Math.min(expectedTotal - completedCount, Math.max(localErrors, serverErrors ?? 0)));
|
|
614
|
-
// Known in-progress from client state
|
|
615
|
-
const knownInProgressCount = batchJobs.filter((job) => job.status === "In Progress").length;
|
|
616
|
-
// Cap to not exceed remaining after completed+error
|
|
617
|
-
const inProgressCount = Math.max(0, Math.min(knownInProgressCount, expectedTotal - completedCount - errorCount));
|
|
618
|
-
const pendingCount = Math.max(0, expectedTotal - completedCount - errorCount - inProgressCount);
|
|
619
|
-
const pct = (count) => Math.max(0, Math.min(100, Math.round((count / expectedTotal) * 100)));
|
|
620
|
-
// Actual progress: completed=100 each, in-progress use live progress if available, others 0
|
|
621
|
-
let totalActualProgress = completedCount * 100;
|
|
622
|
-
for (const job of batchJobs) {
|
|
623
|
-
if (job.status === "In Progress") {
|
|
624
|
-
const progressKey = getJobKey(job.itemId, job.targetLanguage);
|
|
625
|
-
const jobProgress = translationProgress.get(progressKey);
|
|
626
|
-
totalActualProgress += jobProgress?.progress || 0;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
const actualProgress = Math.max(0, Math.min(100, Math.round(totalActualProgress / expectedTotal)));
|
|
630
|
-
const segments = {
|
|
631
|
-
completed: pct(completedCount),
|
|
632
|
-
inProgress: pct(inProgressCount),
|
|
633
|
-
error: pct(errorCount),
|
|
634
|
-
pending: pct(pendingCount),
|
|
635
|
-
completedCount,
|
|
636
|
-
inProgressCount,
|
|
637
|
-
errorCount,
|
|
638
|
-
pendingCount,
|
|
639
|
-
total: expectedTotal,
|
|
640
|
-
actualProgress,
|
|
641
|
-
};
|
|
642
|
-
return segments;
|
|
643
|
-
}, [batchJobs, batchInfo, translationProgress]);
|
|
644
|
-
const overallProgress = progressSegments.actualProgress;
|
|
645
|
-
const isMobile = editContext?.isMobile ?? false;
|
|
646
|
-
// Treat the batch as Completed if all jobs are completed and there are no errors/in-progress,
|
|
647
|
-
// even if the backend status is still "In Progress".
|
|
648
|
-
const effectiveStatus = batchInfo?.status === "In Progress" &&
|
|
649
|
-
progressSegments.total > 0 &&
|
|
650
|
-
progressSegments.completedCount === progressSegments.total &&
|
|
651
|
-
progressSegments.errorCount === 0 &&
|
|
652
|
-
progressSegments.inProgressCount === 0
|
|
653
|
-
? "Completed"
|
|
654
|
-
: batchInfo?.status;
|
|
655
|
-
// Ensure terminal state is reflected in UI even if a websocket message was missed.
|
|
656
|
-
useEffect(() => {
|
|
657
|
-
if (effectiveStatus !== "Completed" && effectiveStatus !== "Error")
|
|
658
|
-
return;
|
|
659
|
-
if (completionRefreshRef.current === batchId)
|
|
660
|
-
return;
|
|
661
|
-
completionRefreshRef.current = batchId;
|
|
662
|
-
(async () => {
|
|
663
|
-
try {
|
|
664
|
-
const resInfo = await getBatchInfo(batchId);
|
|
665
|
-
const raw = resInfo?.data ?? resInfo;
|
|
666
|
-
const info = normalizeBatchInfo(raw);
|
|
667
|
-
if (info)
|
|
668
|
-
setBatchInfo(info);
|
|
669
|
-
await loadBatchJobs();
|
|
670
|
-
}
|
|
671
|
-
catch { }
|
|
672
|
-
})();
|
|
673
|
-
}, [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;
|
|
706
|
-
// Calculate item progress including in-progress job progress
|
|
707
|
-
// Performance: item-level calculation is always fast (typically 2-10 jobs per item)
|
|
708
|
-
let totalProgress = 0;
|
|
709
|
-
for (const job of jobs) {
|
|
710
|
-
if (job.status === "Completed") {
|
|
711
|
-
totalProgress += 100; // Full credit for completed jobs
|
|
712
|
-
}
|
|
713
|
-
else if (job.status === "In Progress") {
|
|
714
|
-
// Get progress for this specific job
|
|
715
|
-
const progressKey = getJobKey(job.itemId, job.targetLanguage);
|
|
716
|
-
const jobProgress = translationProgress.get(progressKey);
|
|
717
|
-
totalProgress += jobProgress?.progress || 0; // Use actual progress or 0
|
|
718
|
-
}
|
|
719
|
-
else {
|
|
720
|
-
totalProgress += 0; // Pending/Error jobs contribute 0
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
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
|
-
const defaultItemLanguage = jobs.find((job) => job.sourceLanguage)?.sourceLanguage ||
|
|
729
|
-
jobs[0]?.targetLanguage ||
|
|
730
|
-
editContext?.currentItemDescriptor?.language ||
|
|
731
|
-
"en";
|
|
732
|
-
const normalizedItemId = normalizeJobItemId(itemId);
|
|
733
|
-
const isSelectedItem = normalizeJobItemId(editContext?.currentItemDescriptor?.id) ===
|
|
734
|
-
normalizedItemId;
|
|
735
|
-
return (_jsxs("div", { className: `rounded-lg border bg-background ${isSelectedItem ? "border-[#9650fb] ring-2 ring-[#9650fb]/20" : "border-gray-3"} ${isMobile ? "p-4" : "p-6"} shadow-sm`, children: [_jsxs("div", { className: "mb-4", children: [_jsx("div", { className: "mb-3", children: (() => {
|
|
736
|
-
const itemName = itemNames.get(itemId.toLowerCase());
|
|
737
|
-
return (_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("button", { type: "button", onClick: () => void openItemInEditor(itemId, defaultItemLanguage), className: "min-w-0 flex-1 rounded-md text-left transition-colors hover:bg-gray-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#9650fb]/40", title: `Load item in ${defaultItemLanguage}`, children: [itemName && (_jsx("div", { className: `${isMobile ? "text-sm" : "text-base"} mb-1 font-bold text-(--color-dark)`, children: itemName })), _jsxs("div", { className: "font-mono text-[10px] text-gray-2 break-all md:text-xs", children: ["Item ID: ", itemId] })] }), _jsx(SimpleIconButton, { onClick: () => void openItemInEditor(itemId, defaultItemLanguage), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: `Load item in ${defaultItemLanguage}`, className: "mt-0.5 shrink-0 p-0! text-gray-2 hover:text-(--color-gray-1)" })] }));
|
|
738
|
-
})() }), _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: {
|
|
739
|
-
backgroundColor: "#f6eeff",
|
|
740
|
-
color: "#9650fb",
|
|
741
|
-
}, 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
|
|
742
|
-
? undefined
|
|
743
|
-
: itemProgress === 100
|
|
744
|
-
? { backgroundColor: "#8ae048" }
|
|
745
|
-
: { backgroundColor: "#9650fb" }, showValue: false }) })] }), isMobile ? (_jsx("div", { className: "space-y-3", children: jobs
|
|
746
|
-
.sort((a, b) => {
|
|
747
|
-
const getJobPriority = (job) => {
|
|
748
|
-
if (job.status === "In Progress")
|
|
749
|
-
return 1;
|
|
750
|
-
if (job.status === "Error")
|
|
751
|
-
return 2;
|
|
752
|
-
if (job.status === "Completed")
|
|
753
|
-
return 4;
|
|
754
|
-
return 3;
|
|
755
|
-
};
|
|
756
|
-
const priorityA = getJobPriority(a);
|
|
757
|
-
const priorityB = getJobPriority(b);
|
|
758
|
-
if (priorityA === priorityB) {
|
|
759
|
-
return a.targetLanguage.localeCompare(b.targetLanguage);
|
|
760
|
-
}
|
|
761
|
-
return priorityA - priorityB;
|
|
762
|
-
})
|
|
763
|
-
.map((job) => {
|
|
764
|
-
const progressKey = getJobKey(job.itemId, job.targetLanguage);
|
|
765
|
-
const progress = translationProgress.get(progressKey);
|
|
766
|
-
const date = new Date(job.timestamp);
|
|
767
|
-
const displayMessage = job.message ||
|
|
768
|
-
(job.status === "Error"
|
|
769
|
-
? progress?.message || "Translation failed"
|
|
770
|
-
: "");
|
|
771
|
-
const jobRetryKey = getRetryJobKey(job);
|
|
772
|
-
const isRetryingJob = isRetryingAll || retryingJobKeys.has(jobRetryKey);
|
|
773
|
-
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"
|
|
774
|
-
? "bg-green-100 text-green-700"
|
|
775
|
-
: job.status === "In Progress"
|
|
776
|
-
? ""
|
|
777
|
-
: job.status === "Error"
|
|
778
|
-
? "bg-red-100 text-red-600"
|
|
779
|
-
: "bg-gray-4 text-(--color-gray-1)"}`, style: job.status === "In Progress"
|
|
780
|
-
? {
|
|
781
|
-
backgroundColor: "#f6eeff",
|
|
782
|
-
color: "#9650fb",
|
|
783
|
-
}
|
|
784
|
-
: 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: {
|
|
785
|
-
backgroundColor: "#9650fb",
|
|
786
|
-
}, 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([], {
|
|
787
|
-
hour: "2-digit",
|
|
788
|
-
minute: "2-digit",
|
|
789
|
-
}) }), _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: (event) => {
|
|
790
|
-
event.stopPropagation();
|
|
791
|
-
void openItemInEditor(job.itemId, job.targetLanguage);
|
|
792
|
-
}, 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));
|
|
793
|
-
}) })) : (_jsx(SimpleTable, { onRowClick: ({ item }) => void openItemInEditor(item.itemId, item.targetLanguage), rowClassName: (job) => normalizeJobItemId(editContext?.currentItemDescriptor?.id) === normalizeJobItemId(job.itemId) &&
|
|
794
|
-
editContext?.currentItemDescriptor?.language ===
|
|
795
|
-
job.targetLanguage
|
|
796
|
-
? "bg-[#9650fb]/5"
|
|
797
|
-
: "", columns: [
|
|
798
|
-
{
|
|
799
|
-
header: "Language",
|
|
800
|
-
className: "w-16", // Fixed width for language column
|
|
801
|
-
body: (job) => (_jsx("div", { className: "font-medium text-(--color-dark)", children: job.targetLanguage })),
|
|
802
|
-
},
|
|
803
|
-
{
|
|
804
|
-
header: "Status",
|
|
805
|
-
className: "w-[14rem] min-w-[14rem]", // Keep status column width stable as content changes
|
|
806
|
-
body: (job) => {
|
|
807
|
-
const progressKey = getJobKey(job.itemId, job.targetLanguage);
|
|
808
|
-
const progress = translationProgress.get(progressKey);
|
|
809
|
-
const displayMessage = job.message ||
|
|
810
|
-
(job.status === "Error"
|
|
811
|
-
? progress?.message || "Translation failed"
|
|
812
|
-
: "");
|
|
813
|
-
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"
|
|
814
|
-
? "bg-green-100 text-green-700"
|
|
815
|
-
: job.status === "In Progress"
|
|
816
|
-
? ""
|
|
817
|
-
: job.status === "Error"
|
|
818
|
-
? "bg-red-100 text-red-600"
|
|
819
|
-
: "bg-gray-4 text-(--color-gray-1)"}`, style: job.status === "In Progress"
|
|
820
|
-
? {
|
|
821
|
-
backgroundColor: "#f6eeff",
|
|
822
|
-
color: "#9650fb",
|
|
823
|
-
}
|
|
824
|
-
: undefined, children: job.status }), job.status === "In Progress" &&
|
|
825
|
-
progress &&
|
|
826
|
-
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: {
|
|
827
|
-
backgroundColor: "#9650fb",
|
|
828
|
-
}, 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" }) }))] }));
|
|
829
|
-
},
|
|
830
|
-
},
|
|
831
|
-
{
|
|
832
|
-
header: "Source",
|
|
833
|
-
className: "w-16", // Fixed width for source column
|
|
834
|
-
body: (job) => (_jsx("div", { className: "text-sm text-(--color-gray-1)", children: job.sourceLanguage || "—" })),
|
|
835
|
-
},
|
|
836
|
-
{
|
|
837
|
-
header: "Message",
|
|
838
|
-
className: "w-full min-w-0",
|
|
839
|
-
body: (job) => {
|
|
840
|
-
const message = job.message ||
|
|
841
|
-
(job.status === "Error"
|
|
842
|
-
? "Translation failed"
|
|
843
|
-
: "—");
|
|
844
|
-
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)" }))] }));
|
|
845
|
-
},
|
|
846
|
-
},
|
|
847
|
-
{
|
|
848
|
-
header: "Actions",
|
|
849
|
-
className: "w-24",
|
|
850
|
-
body: (job) => {
|
|
851
|
-
const jobRetryKey = getRetryJobKey(job);
|
|
852
|
-
const isRetryingJob = isRetryingAll || retryingJobKeys.has(jobRetryKey);
|
|
853
|
-
return (_jsxs("div", { className: "flex items-center justify-center gap-2", children: [job.status === "Error" && (_jsx(SimpleIconButton, { onClick: (event) => {
|
|
854
|
-
event.stopPropagation();
|
|
855
|
-
void handleRetryJobs([job], false);
|
|
856
|
-
}, 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: (event) => {
|
|
857
|
-
event.stopPropagation();
|
|
858
|
-
void openItemInEditor(job.itemId, job.targetLanguage);
|
|
859
|
-
}, 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)" })] }));
|
|
860
|
-
},
|
|
861
|
-
},
|
|
862
|
-
{
|
|
863
|
-
header: "Updated",
|
|
864
|
-
className: "w-24", // Fixed width for updated column
|
|
865
|
-
body: (job) => {
|
|
866
|
-
const date = new Date(job.timestamp);
|
|
867
|
-
return (_jsx("div", { className: "text-sm text-gray-2 whitespace-nowrap", children: date.toLocaleTimeString() }));
|
|
868
|
-
},
|
|
869
|
-
},
|
|
870
|
-
], items: jobs.sort((a, b) => {
|
|
871
|
-
// Sort by status first (in-progress first, then errors, then completed)
|
|
872
|
-
const getJobPriority = (job) => {
|
|
873
|
-
if (job.status === "In Progress")
|
|
874
|
-
return 1;
|
|
875
|
-
if (job.status === "Error")
|
|
876
|
-
return 2;
|
|
877
|
-
if (job.status === "Completed")
|
|
878
|
-
return 4;
|
|
879
|
-
return 3; // Pending/other statuses
|
|
880
|
-
};
|
|
881
|
-
const priorityA = getJobPriority(a);
|
|
882
|
-
const priorityB = getJobPriority(b);
|
|
883
|
-
// If same priority, sort by language alphabetically
|
|
884
|
-
if (priorityA === priorityB) {
|
|
885
|
-
return a.targetLanguage.localeCompare(b.targetLanguage);
|
|
886
|
-
}
|
|
887
|
-
return priorityA - priorityB;
|
|
888
|
-
}) }))] }, itemId));
|
|
889
|
-
}) })) })] }));
|
|
890
|
-
}
|