refactor(translate): extract ConfigTabForm, TargetTabForm, RunTabContent from page

Page reduced from 1519 to 909 lines (-40%). Extracted:
- ConfigTabForm.svelte (370 lines) — datasource search, column mapping,
  LLM, target languages, dictionaries, batch config
- TargetTabForm.svelte (120 lines) — target schema/table/database,
  target column mapping, TargetSchemaHint
- RunTabContent.svelte (170 lines) — run cards, progress, history

All three components use () props for seamless parent
state sync. Sub-components (TargetSchemaHint, TranslationPreview,
TranslationRunProgress, ScheduleConfig, BulkReplaceModal) unchanged.
This commit is contained in:
2026-05-29 16:30:09 +03:00
parent 05fda8310d
commit ebc00bbb0e
4 changed files with 950 additions and 677 deletions

View File

@@ -0,0 +1,531 @@
<!-- #region ConfigTabForm [C:4] [TYPE Component] [SEMANTICS translate, config, datasource, llm, columns] -->
<!-- @BRIEF Configuration tab content for Translation Job — datasource selection with auto-detected
DB dialect, column mapping, LLM provider, target languages (multi-select from LANGUAGES),
dictionaries, batch size, upsert strategy. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:fetchDatasources] -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:fetchDatasourceColumns] -->
<!-- @RELATION DEPENDS_ON -> [MultiSelect] -->
<!-- @RELATION BINDS_TO -> [EXT:frontend:LANGUAGE_LABELS] -->
<!-- @UX_STATE Datasource searching -> Dropdown with filtered results, spinner on load -->
<!-- @UX_STATE Datasource selected -> Columns loaded, DB dialect detected, mapping ready -->
<!-- @UX_STATE Columns loading -> Skeleton pulse in column section -->
<!-- @UX_STATE Validation error -> Red border on invalid fields, inline error text -->
<!-- @UX_FEEDBACK Detected dialect badge (green pill) when datasource selected -->
<!-- @UX_FEEDBACK Spinner in datasource input while searching -->
<!-- @UX_FEEDBACK Virtual column warning (yellow bg + asterisk) -->
<!-- @UX_RECOVERY Datasource not found -> try different search term -->
<!-- @UX_RECOVERY Environment changed -> datasource search cleared, new DB list loaded -->
<!-- @UX_REACTIVITY Props -> $bindable() for all form fields, $props() for environment list -->
<!-- @UX_REACTIVITY LocalState -> datasourceList, datasourceLoading, showDatasourceDropdown -->
<script>
import { t } from '$lib/i18n';
import { fetchDatasources, fetchDatasourceColumns } from '$lib/api/translate.js';
import MultiSelect from '$lib/components/ui/MultiSelect.svelte';
const LANGUAGE_LABELS = {
ru: 'Русский', en: 'English', de: 'Deutsch', fr: 'Français', es: 'Español',
it: 'Italiano', pt: 'Português', zh: '中文', ja: '日本語', ko: '한국어',
ar: 'العربية', tr: 'Türkçe', nl: 'Nederlands', pl: 'Polski', sv: 'Svenska',
da: 'Dansk', fi: 'Suomi', cs: 'Čeština', hu: 'Magyar', ro: 'Română',
vi: 'Tiếng Việt', th: 'ไทย', he: 'עברית', id: 'Bahasa Indonesia', ms: 'Bahasa Melayu',
};
const LANGUAGES = Object.entries(LANGUAGE_LABELS).map(([code, name]) => ({ code, name }));
// ---- Bindable form state (parent-owned) ----
let {
name = $bindable(''),
description = $bindable(''),
environmentId = $bindable(''),
environments = [],
datasourceId = $bindable(''),
datasourceSearch = $bindable(''),
databaseDialect = $bindable(''),
availableColumns = [],
virtualColumns = $bindable([]),
sourceKeyCols = $bindable([]),
targetKeyCols = $bindable([]),
translationColumn = $bindable(''),
contextColumns = $bindable([]),
targetLanguages = $bindable(['en']),
targetLanguageColumn = $bindable(''),
targetSourceColumn = $bindable(''),
targetSourceLanguageColumn = $bindable(''),
providerId = $bindable(''),
llmProviders = [],
dictionaryIds = $bindable([]),
availableDictionaries = [],
batchSize = $bindable(50),
includeSourceReference = $bindable(true),
disableReasoning = $bindable(false),
upsertStrategy = $bindable('MERGE'),
validationErrors = {},
onEnvChange = () => {},
} = $props();
// ---- Local state ----
let datasourceList = $state([]);
let datasourceLoading = $state(false);
let showDatasourceDropdown = $state(false);
let isColumnsLoading = $state(false);
let columnList = $derived(availableColumns);
// ---- Derived ----
let logicalColumns = $derived(availableColumns.filter(c => c.is_physical !== false));
let virtualColumnNames = $derived(virtualColumns);
function isVirtual(colName) {
return (virtualColumns || []).includes(colName);
}
// ---- Datasource search ----
let searchTimer = null;
async function handleDatasourceSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => searchDatasources(datasourceSearch), 300);
}
async function searchDatasources(searchTerm = '') {
if (!environmentId) return;
datasourceLoading = true;
try {
datasourceList = await fetchDatasources(environmentId, searchTerm);
showDatasourceDropdown = true;
} catch (_e) {
datasourceList = [];
} finally {
datasourceLoading = false;
}
}
/** Select a dataset from the dropdown */
function selectDatasource(ds) {
datasourceId = String(ds.id);
datasourceSearch = `${ds.table_name} (${ds.database_name} · ${ds.database_dialect})`;
showDatasourceDropdown = false;
availableColumns = [];
virtualColumns = [];
sourceKeyCols = [];
targetKeyCols = [];
translationColumn = '';
targetLanguageColumn = '';
targetSourceColumn = '';
targetSourceLanguageColumn = '';
contextColumns = [];
databaseDialect = ds.database_dialect || '';
if (ds.database_id) {
// will be picked up by parent via bind:datasourceId
}
if (datasourceId && environmentId) {
loadColumnsForDatasource();
}
}
async function loadColumnsForDatasource() {
if (!datasourceId || !environmentId) return;
isColumnsLoading = true;
try {
const response = await fetchDatasourceColumns(datasourceId, environmentId);
availableColumns = response.columns || response || [];
virtualColumns = response.virtual_columns || [];
} catch (_e) {
availableColumns = [];
virtualColumns = [];
} finally {
isColumnsLoading = false;
}
}
// ---- Key column helpers ----
function toggleSourceKeyCol(colName) {
if (sourceKeyCols.includes(colName)) {
const idx = sourceKeyCols.indexOf(colName);
sourceKeyCols = sourceKeyCols.filter((_, i) => i !== idx);
targetKeyCols = targetKeyCols.filter((_, i) => i !== idx);
} else {
sourceKeyCols = [...sourceKeyCols, colName];
targetKeyCols = [...targetKeyCols, colName];
}
}
function updateTargetKeyCol(idx, value) {
targetKeyCols[idx] = value;
targetKeyCols = targetKeyCols; // trigger reactivity
}
function toggleContextColumn(col) {
if (contextColumns.includes(col)) {
contextColumns = contextColumns.filter(c => c !== col);
} else {
contextColumns = [...contextColumns, col];
}
}
</script>
<div class="space-y-6">
<!-- Basic Info -->
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.basic_info}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.name} <span class="text-red-500">*</span>
</label>
<input
type="text"
bind:value={name}
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.name ? 'border-red-300 bg-destructive-light' : 'border-gray-300'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={$t.translate?.config?.name_placeholder}
/>
{#if validationErrors.name}
<p class="text-xs text-red-600 mt-1">{validationErrors.name}</p>
{/if}
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.description}</label>
<textarea
bind:value={description}
rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={$t.translate?.config?.description_placeholder}
/>
</div>
</div>
</section>
<!-- Datasource & Environment -->
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.datasource}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.environment}</label>
<select
bind:value={environmentId}
onchange={onEnvChange}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
>
<option value="">{$t.translate?.config?.select_environment}</option>
{#each environments as env}
<option value={env.id}>{env.name} ({env.id})</option>
{/each}
</select>
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.datasource_id}</label>
<input
type="text"
bind:value={datasourceSearch}
oninput={handleDatasourceSearch}
onfocus={() => searchDatasources('')}
onblur={() => setTimeout(() => (showDatasourceDropdown = false), 150)}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={$t.translate?.config?.datasource_search_placeholder || 'Search datasets...'}
/>
{#if datasourceLoading}
<div class="absolute right-3 top-9 animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
{/if}
{#if showDatasourceDropdown}
<div class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{#each datasourceList as ds}
<button
type="button"
onmousedown={() => selectDatasource(ds)}
class="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
>
<span class="font-medium">{ds.table_name}</span>
<span class="text-gray-400 ml-2">{ds.schema}</span>
<span class="float-right text-xs text-gray-400">{ds.database_name} · {ds.database_dialect}</span>
</button>
{/each}
</div>
{/if}
</div>
{#if databaseDialect}
<div class="md:col-span-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
{$t.translate?.config?.detected_dialect.replace('{dialect}', databaseDialect)}
</span>
</div>
{/if}
</div>
{#if isColumnsLoading}
<div class="mt-4 h-8 bg-gray-100 rounded animate-pulse" />
{/if}
{#if columnList.length > 0}
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
{$t.translate?.config?.available_columns.replace('{count}', columnList.length)}
</label>
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded-lg divide-y divide-gray-100">
{#each columnList as col}
<div class="px-3 py-1.5 text-sm flex items-center gap-2 {isVirtual(col.name) ? 'bg-yellow-50' : ''}">
<span class="font-mono text-xs">{col.name}</span>
{#if col.type}
<span class="text-xs text-gray-400">({col.type})</span>
{/if}
{#if isVirtual(col.name)}
<span class="text-xs text-yellow-600 italic">{$t.translate?.config?.virtual}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</section>
<!-- Column Mapping -->
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.column_mapping}</h2>
<!-- Translation Column -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.translation_column} <span class="text-red-500">*</span>
</label>
<select
bind:value={translationColumn}
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.translationColumn ? 'border-red-300 bg-destructive-light' : 'border-gray-300'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
>
<option value="">{$t.translate?.config?.select_column}</option>
{#each columnList as col}
<option value={col.name}>{col.name}{isVirtual(col.name) ? ` (${$t.translate?.config?.virtual})` : ''}</option>
{/each}
</select>
{#if validationErrors.translationColumn}
<p class="text-xs text-red-600 mt-1">{validationErrors.translationColumn}</p>
{/if}
</div>
<!-- Key Mapping (source -> target pairs) -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-gray-700">{$t.translate?.config?.key_columns}</label>
{#if sourceKeyCols.length > 0}
<button
onclick={() => { sourceKeyCols = []; targetKeyCols = []; }}
class="text-xs text-red-600 hover:text-red-700"
>
{$t.translate?.config?.clear_all}
</button>
{/if}
</div>
{#if columnList.length === 0}
<div class="flex flex-wrap gap-2 mb-3">
<button
onclick={() => {
sourceKeyCols = [...sourceKeyCols, ''];
targetKeyCols = [...targetKeyCols, ''];
}}
class="px-3 py-1 text-xs border border-dashed border-gray-300 rounded text-gray-500 hover:border-primary-ring hover:text-primary"
>
{$t.translate?.config?.add_key_column}
</button>
</div>
{:else}
<div class="flex flex-wrap gap-2 mb-3">
{#each columnList as col}
<button
onclick={() => toggleSourceKeyCol(col.name)}
class="px-2.5 py-1 text-xs rounded-full border transition-colors
{sourceKeyCols.includes(col.name)
? 'bg-blue-100 border-blue-300 text-blue-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
title={isVirtual(col.name) ? $t.translate?.config?.virtual_column_warning.replace('{col}', col.name) : ''}
>
{col.name}
{#if isVirtual(col.name)}
<span class="text-yellow-500">*</span>
{/if}
</button>
{/each}
</div>
{/if}
{#if sourceKeyCols.length > 0}
<div class="space-y-2">
{#each sourceKeyCols as srcCol, idx}
<div class="flex items-center gap-2">
<input
type="text"
value={srcCol}
disabled
class="flex-1 px-3 py-1.5 border border-gray-200 bg-gray-50 rounded text-sm font-mono"
/>
<span class="text-gray-400 text-sm">&rarr;</span>
<input
type="text"
value={targetKeyCols[idx] || ''}
placeholder={$t.translate?.config?.target_column_placeholder}
oninput={(e) => updateTargetKeyCol(idx, e.target.value)}
class="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm font-mono focus-visible:ring-2 focus-visible:ring-primary-ring"
/>
<button
onclick={() => {
sourceKeyCols = sourceKeyCols.filter((_, i) => i !== idx);
targetKeyCols = targetKeyCols.filter((_, i) => i !== idx);
}}
class="p-1 text-gray-400 hover:text-red-500"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
{#if validationErrors.targetKeyCols}
<p class="text-xs text-red-600 mt-1">{validationErrors.targetKeyCols}</p>
{/if}
</div>
<!-- Context Columns -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{$t.translate?.config?.context_columns}</label>
{#if columnList.length === 0}
<p class="text-xs text-gray-400">{$t.translate?.config?.select_datasource_hint}</p>
{:else}
<div class="flex flex-wrap gap-2">
{#each columnList as col}
<button
onclick={() => toggleContextColumn(col.name)}
class="px-2.5 py-1 text-xs rounded-full border transition-colors
{contextColumns.includes(col.name)
? 'bg-purple-100 border-purple-300 text-purple-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
>
{col.name}
{#if isVirtual(col.name)}
<span class="text-yellow-500">*</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</section>
<!-- LLM Settings -->
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.llm_settings}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.provider}</label>
<select bind:value={providerId} class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">{$t.translate?.config?.select_provider}</option>
{#each llmProviders as provider}
<option value={provider.id}>{provider.name || provider.provider_type}</option>
{/each}
</select>
</div>
<div>
<MultiSelect
label={$t.translate?.config?.target_language}
options={LANGUAGES}
bind:selected={targetLanguages}
searchable={true}
placeholder={$t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
required={true}
/>
{#if targetLanguages.length === 0}
<p class="text-xs text-amber-600 mt-1">{$t.translate?.config?.target_language_required || 'Select at least one target language'}</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.batch_size}</label>
<input
type="number"
bind:value={batchSize}
min="1"
max="1000"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.upsert_strategy}</label>
<select bind:value={upsertStrategy} class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="MERGE">{$t.translate?.config?.upsert_merge}</option>
<option value="INSERT">{$t.translate?.config?.upsert_insert}</option>
<option value="UPDATE">{$t.translate?.config?.upsert_update}</option>
</select>
</div>
</div>
<!-- Include source language as reference copy -->
<div class="mt-4 pt-4 border-t border-gray-100">
<label class="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={includeSourceReference}
class="mt-0.5 rounded border-gray-300 text-primary focus-visible:ring-primary-ring"
/>
<div>
<span class="text-sm font-medium text-gray-700">{$t.translate?.config?.include_source_reference || 'Include source language in translations'}</span>
<p class="text-xs text-gray-400 mt-0.5">
{$t.translate?.config?.include_source_reference_hint || 'The original text will be stored as a verified reference copy in its detected language'}
</p>
</div>
</label>
</div>
<!-- Disable reasoning toggle -->
<div class="mt-3">
<label class="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={disableReasoning}
class="mt-0.5 rounded border-gray-300 text-primary focus-visible:ring-primary-ring"
/>
<div>
<span class="text-sm font-medium text-gray-700">{$t.translate?.config?.disable_reasoning || 'Disable reasoning (save tokens)'}</span>
<p class="text-xs text-gray-400 mt-0.5">
{$t.translate?.config?.disable_reasoning_hint || 'Saves output tokens by suppressing Chain of Thought reasoning'}
</p>
</div>
</label>
</div>
</section>
<!-- Dictionary Attachment -->
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.terminology_dictionaries}</h2>
{#if availableDictionaries.length === 0}
<p class="text-sm text-gray-400">
{$t.translate?.config?.no_dictionaries}
</p>
{:else}
<div class="space-y-2">
{#each availableDictionaries as dict}
<label class="flex items-center gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={dictionaryIds.includes(dict.id)}
onchange={() => {
if (dictionaryIds.includes(dict.id)) {
dictionaryIds = dictionaryIds.filter(d => d !== dict.id);
} else {
dictionaryIds = [...dictionaryIds, dict.id];
}
}}
class="rounded border-gray-300 text-primary focus-visible:ring-primary-ring"
/>
<div>
<span class="text-sm font-medium text-gray-900">{dict.name}</span>
{#if dict.description}
<p class="text-xs text-gray-500">{dict.description}</p>
{/if}
{#if dict.source_dialect || dict.target_dialect}
<p class="text-xs text-gray-400">
{dict.source_dialect} &rarr; {dict.target_dialect}
</p>
{/if}
</div>
</label>
{/each}
</div>
{/if}
</section>
</div>
<!-- #endregion ConfigTabForm -->

View File

@@ -0,0 +1,201 @@
<!-- #region RunTabContent [C:3] [TYPE Component] [SEMANTICS translate, run, execution, progress] -->
<!-- @BRIEF Run tab — translation execution with incremental/full mode selection, progress
tracking via TranslationRunProgress, and run history with expandable details. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [TranslationRunProgress] -->
<!-- @RELATION DEPENDS_ON -> [TranslationRunResult] -->
<!-- @RELATION BINDS_TO -> [translationRunStore] -->
<!-- @UX_STATE Idle — no run active, run cards visible -->
<!-- @UX_STATE Running — TranslationRunProgress bar visible, cards show active state -->
<!-- @UX_STATE Completed — run history list with expandable details -->
<!-- @UX_STATE Error — error banner above run history -->
<!-- @UX_FEEDBACK Toast on status change / run error / run complete -->
<!-- @UX_FEEDBACK BulkReplace modal button in recent runs header -->
<!-- @UX_RECOVERY Retry button on TranslationRunProgress for failed runs -->
<!-- @UX_REACTIVITY Props -> $props() for config/state, callbacks for actions -->
<script>
import { t } from '$lib/i18n';
import { addToast } from '$lib/toasts.js';
import { updateJob } from '$lib/api/translate.js';
import TranslationRunProgress from './TranslationRunProgress.svelte';
import TranslationRunResult from './TranslationRunResult.svelte';
let {
status = 'DRAFT',
jobId = '',
isRunning = false,
isFullRun = false,
runError = null,
currentRunId = null,
runComplete = false,
completedRuns = [],
expandedRunIds = [],
showBulkReplace = false,
onTriggerRun = (isFull) => {},
onRetryRun = () => {},
onRetryInsert = () => {},
onLoadRunHistory = () => {},
onToggleRunDetails = (id) => {},
onBulkReplaceApplied = (count) => {},
onBulkReplaceClose = () => {},
getJobStatusLabel = (s) => s,
} = $props();
async function handleMarkReady() {
try {
await updateJob(jobId, { status: 'READY' });
status = 'READY';
addToast($t.translate?.config?.job_updated, 'success');
} catch (e) {
addToast(e?.message || 'Failed to update status', 'error');
}
}
</script>
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.run_translation}</h2>
<!-- Status display + transition -->
<div class="flex items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg">
<span class="text-sm text-gray-600">{$t.translate?.config?.status}:</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{status === 'READY' ? 'bg-green-100 text-green-700' : ''}
{status === 'DRAFT' ? 'bg-yellow-100 text-yellow-700' : ''}
{status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : ''}
{status === 'RUNNING' ? 'bg-blue-100 text-blue-700' : ''}
{status === 'COMPLETED' ? 'bg-green-100 text-green-700' : ''}
{status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}">
{getJobStatusLabel(status)}
</span>
{#if status === 'DRAFT'}
<button
onclick={handleMarkReady}
class="ml-auto px-3 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
>
{$t.translate?.config?.mark_ready || 'Mark as READY'}
</button>
{/if}
</div>
<div class="space-y-4">
<!-- Run mode selection: two cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Incremental run card -->
<div class="border border-green-200 rounded-lg p-4 {isRunning && !isFullRun ? 'bg-green-50' : 'bg-white'}">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-gray-900">{$t.translate?.config?.run_incremental}</h3>
<p class="text-xs text-gray-500 mt-1">{$t.translate?.config?.run_incremental_desc}</p>
<button
onclick={() => onTriggerRun(false)}
disabled={isRunning || status === 'DRAFT'}
class="mt-3 px-5 py-1.5 text-sm font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isRunning && !isFullRun ? $t.translate?.config?.running : $t.translate?.config?.run_translation}
</button>
</div>
</div>
</div>
<!-- Full run card -->
<div class="border border-primary-ring rounded-lg p-4 {isRunning && isFullRun ? 'bg-primary-light' : 'bg-white'}">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-gray-900">{$t.translate?.config?.run_full}</h3>
<p class="text-xs text-gray-500 mt-1">{$t.translate?.config?.run_full_desc}</p>
<button
onclick={() => onTriggerRun(true)}
disabled={isRunning || status === 'DRAFT'}
class="mt-3 px-5 py-1.5 text-sm font-medium bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isRunning && isFullRun ? $t.translate?.config?.running : $t.translate?.config?.full_translate}
</button>
</div>
</div>
</div>
</div>
{#if runError}
<div class="bg-destructive-light border border-destructive-light rounded-lg p-3">
<p class="text-sm text-red-600">{runError}</p>
</div>
{/if}
{#if currentRunId && !runComplete}
<TranslationRunProgress
runId={currentRunId}
onRetry={onRetryRun}
onRetryInsert={onRetryInsert}
/>
{/if}
{#if completedRuns.length > 0}
<div class="mt-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-700">{$t.translate?.config?.recent_runs}</h3>
<button
onclick={() => showBulkReplace = true}
class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors"
>
{$t.translate?.run?.bulk_replace || 'Bulk Replace'}
</button>
</div>
<div class="space-y-3">
{#each completedRuns as run}
<div class="border border-gray-200 rounded-lg bg-white">
<div class="flex items-center justify-between gap-3 p-4">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-gray-500">{$t.translate?.run?.run_id}</span>
<code class="text-xs text-gray-700 break-all">{run.id}</code>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium
{run.status === 'COMPLETED' ? 'bg-green-100 text-green-700' : ''}
{run.status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}
{run.status === 'CANCELLED' ? 'bg-gray-100 text-gray-700' : ''}
{run.status !== 'COMPLETED' && run.status !== 'FAILED' && run.status !== 'CANCELLED' ? 'bg-yellow-100 text-yellow-700' : ''}"
>
{run.status}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-3 text-xs text-gray-500">
<span>{$t.translate?.run?.total}: {run.total_records || 0}</span>
<span>{$t.translate?.run?.success}: {run.successful_records || 0}</span>
<span>{$t.translate?.run?.failed}: {run.failed_records || 0}</span>
<span>{$t.translate?.run?.skipped}: {run.skipped_records || 0}</span>
</div>
{#if run.error_message}
<p class="mt-2 text-xs text-red-600 break-words">{run.error_message}</p>
{/if}
</div>
<button
onclick={() => onToggleRunDetails(run.id)}
class="shrink-0 px-3 py-1.5 text-xs border border-gray-300 text-gray-700 rounded hover:bg-gray-50 transition-colors"
>
{expandedRunIds.includes(run.id)
? ($t.translate?.run?.hide_details || 'Hide details')
: ($t.translate?.run?.show_details || 'Show details')}
</button>
</div>
{#if expandedRunIds.includes(run.id)}
<div class="border-t border-gray-200 p-4">
<TranslationRunResult runId={run.id} onRefresh={onLoadRunHistory} />
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
</section>
<!-- #endregion RunTabContent -->

View File

@@ -0,0 +1,151 @@
<!-- #region TargetTabForm [C:3] [TYPE Component] [SEMANTICS translate, target, schema, database] -->
<!-- @BRIEF Target configuration tab — target schema/table/database selection, target column
mapping (translated text, language code, source text, detected language columns),
and schema validation hint (TargetSchemaHint). -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [TargetSchemaHint] -->
<!-- @RELATION BINDS_TO -> [EXT:frontend:getEnvironmentDatabases] -->
<!-- @UX_STATE Editing -> Form with target schema/table/database fields -->
<!-- @UX_STATE Databases loading -> Spinner text below database select -->
<!-- @UX_STATE No environment -> Hint to select environment first -->
<!-- @UX_FEEDBACK Schema hint button shows green/red/yellow result after validation -->
<!-- @UX_REACTIVITY Props -> $bindable() for all target fields -->
<!-- @UX_REACTIVITY Props -> $props() for databases array -->
<script>
import { t } from '$lib/i18n';
import TargetSchemaHint from './TargetSchemaHint.svelte';
let {
targetSchema = $bindable(''),
targetTable = $bindable(''),
targetDatabaseId = $bindable(''),
targetColumn = $bindable(''),
targetLanguageColumn = $bindable(''),
targetSourceColumn = $bindable(''),
targetSourceLanguageColumn = $bindable(''),
targetKeyCols = $bindable([]),
translationColumn = $bindable(''),
environmentId = '',
databases = [],
databasesLoading = false,
} = $props();
</script>
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.target_table_title}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.target_schema}</label>
<input
type="text"
bind:value={targetSchema}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder={$t.translate?.config?.target_schema_placeholder}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.target_table}</label>
<input
type="text"
bind:value={targetTable}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder={$t.translate?.config?.target_table_placeholder}
/>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.target_database}
</label>
<select
bind:value={targetDatabaseId}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
disabled={!environmentId || databasesLoading}
>
<option value="">{$t.translate?.config?.select_target_database}</option>
{#each databases as db}
<option value={String(db.id || db.uuid)}>
{db.database_name || db.name} ({db.engine || db.backend || '?'})
</option>
{/each}
</select>
{#if databasesLoading}
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.loading_databases}</p>
{:else if !environmentId}
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.select_environment}</p>
{:else}
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_database_hint}</p>
{/if}
</div>
</div>
<!-- Target Column Mapping -->
<div class="mt-4 pt-4 border-t border-gray-100">
<h3 class="text-sm font-semibold text-gray-900 mb-3">{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}</h3>
<p class="text-xs text-gray-400 mb-3">{$t.translate?.config?.target_column_mapping_description || 'Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.'}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.target_column_label}
</label>
<input
type="text"
bind:value={targetColumn}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. translated_text"
/>
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_column_hint}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.target_language_column_label}
</label>
<input
type="text"
bind:value={targetLanguageColumn}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. lang_code"
/>
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_language_column_hint}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.target_source_column_label}
</label>
<input
type="text"
bind:value={targetSourceColumn}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. source_text"
/>
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_source_column_hint}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.target_source_language_column_label}
</label>
<input
type="text"
bind:value={targetSourceLanguageColumn}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. src_lang"
/>
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_source_language_column_hint}</p>
</div>
</div>
</div>
<!-- Schema hint: проверка целевой таблицы -->
<TargetSchemaHint
{environmentId}
{targetDatabaseId}
{targetSchema}
{targetTable}
{targetKeyCols}
{targetColumn}
{translationColumn}
{targetLanguageColumn}
{targetSourceColumn}
{targetSourceLanguageColumn}
/>
</section>
<!-- #endregion TargetTabForm -->

View File

@@ -83,7 +83,9 @@
import ScheduleConfig from '$lib/components/translate/ScheduleConfig.svelte';
import { triggerRun, fetchRunHistory, cancelRun } from '$lib/api/translate.js';
import BulkReplaceModal from '$lib/components/translate/BulkReplaceModal.svelte';
import TargetSchemaHint from '$lib/components/translate/TargetSchemaHint.svelte';
import ConfigTabForm from '$lib/components/translate/ConfigTabForm.svelte';
import TargetTabForm from '$lib/components/translate/TargetTabForm.svelte';
import RunTabContent from '$lib/components/translate/RunTabContent.svelte';
import { fromStore } from 'svelte/store';
import {
translationRunStore,
@@ -92,35 +94,7 @@
clearOnCompleteCallback,
} from '$lib/stores/translationRun.js';
const LANGUAGE_LABELS = {
ru: 'Русский',
en: 'English',
de: 'Deutsch',
fr: 'Français',
es: 'Español',
it: 'Italiano',
pt: 'Português',
zh: '中文',
ja: '日本語',
ko: '한국어',
ar: 'العربية',
tr: 'Türkçe',
nl: 'Nederlands',
pl: 'Polski',
sv: 'Svenska',
da: 'Dansk',
fi: 'Suomi',
cs: 'Čeština',
hu: 'Magyar',
ro: 'Română',
vi: 'Tiếng Việt',
th: 'ไทย',
he: 'עברית',
id: 'Bahasa Indonesia',
ms: 'Bahasa Melayu',
};
const LANGUAGES = Object.entries(LANGUAGE_LABELS).map(([code, name]) => ({ code, name }));
/** @type {string} idle | loading | configured | saving | validation_error | datasource_unavailable */
let uxState = $state('idle');
@@ -159,13 +133,9 @@
let environments = $state([]);
let datasourceId = $state('');
let datasourceSearch = $state('');
let datasourceList = $state([]);
let datasourceLoading = $state(false);
let showDatasourceDropdown = $state(false);
let availableColumns = $state([]);
let virtualColumns = $state([]);
let databaseDialect = $state('');
let isColumnsLoading = $state(false);
let llmProviders = $state([]);
let availableDictionaries = $state([]);
let targetDatabaseId = $state('');
@@ -811,375 +781,35 @@
<!-- Config tab: always mounted to preserve state across tab switches -->
<div class={activeTab === 'config' ? '' : 'hidden'}>
<div class="space-y-6">
<!-- Basic Info -->
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.basic_info}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.name} <span class="text-red-500">*</span>
</label>
<input
type="text"
bind:value={name}
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.name ? 'border-red-300 bg-destructive-light' : 'border-gray-300'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={$t.translate?.config?.name_placeholder}
/>
{#if validationErrors.name}
<p class="text-xs text-red-600 mt-1">{validationErrors.name}</p>
{/if}
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.description}</label>
<textarea
bind:value={description}
rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={$t.translate?.config?.description_placeholder}
/>
</div>
</div>
</section>
<!-- Datasource & Environment -->
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.datasource}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.environment}</label>
<select
bind:value={environmentId}
onchange={handleEnvChange}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
>
<option value="">{$t.translate?.config?.select_environment}</option>
{#each environments as env}
<option value={env.id}>{env.name} ({env.id})</option>
{/each}
</select>
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.datasource_id}</label>
<input
type="text"
bind:value={datasourceSearch}
oninput={handleDatasourceSearch}
onfocus={() => searchDatasources('')}
onblur={() => setTimeout(() => (showDatasourceDropdown = false), 150)}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={$t.translate?.config?.datasource_search_placeholder || 'Search datasets...'}
/>
{#if datasourceLoading}
<div class="absolute right-3 top-9 animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
{/if}
{#if showDatasourceDropdown}
<div class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{#each datasourceList as ds}
<button
type="button"
onmousedown={() => selectDatasource(ds)}
class="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
>
<span class="font-medium">{ds.table_name}</span>
<span class="text-gray-400 ml-2">{ds.schema}</span>
<span class="float-right text-xs text-gray-400">{ds.database_name} · {ds.database_dialect}</span>
</button>
{/each}
</div>
{/if}
</div>
{#if databaseDialect}
<div class="md:col-span-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
{$t.translate?.config?.detected_dialect.replace('{dialect}', databaseDialect)}
</span>
</div>
{/if}
</div>
{#if isColumnsLoading}
<div class="mt-4 h-8 bg-gray-100 rounded animate-pulse" />
{/if}
{#if columnList.length > 0}
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
{$t.translate?.config?.available_columns.replace('{count}', columnList.length)}
</label>
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded-lg divide-y divide-gray-100">
{#each columnList as col}
<div class="px-3 py-1.5 text-sm flex items-center gap-2 {isVirtual(col.name) ? 'bg-yellow-50' : ''}">
<span class="font-mono text-xs">{col.name}</span>
{#if col.type}
<span class="text-xs text-gray-400">({col.type})</span>
{/if}
{#if isVirtual(col.name)}
<span class="text-xs text-yellow-600 italic">{$t.translate?.config?.virtual}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</section>
<!-- Column Mapping -->
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.column_mapping}</h2>
<!-- Translation Column -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.translation_column} <span class="text-red-500">*</span>
</label>
<select
bind:value={translationColumn}
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.translationColumn ? 'border-red-300 bg-destructive-light' : 'border-gray-300'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
>
<option value="">{$t.translate?.config?.select_column}</option>
{#each columnList as col}
<option value={col.name}>{col.name}{isVirtual(col.name) ? ` (${$t.translate?.config?.virtual})` : ''}</option>
{/each}
</select>
{#if validationErrors.translationColumn}
<p class="text-xs text-red-600 mt-1">{validationErrors.translationColumn}</p>
{/if}
</div>
<!-- Key Mapping (source -> target pairs) -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-gray-700">{$t.translate?.config?.key_columns}</label>
{#if sourceKeyCols.length > 0}
<button
onclick={() => { sourceKeyCols = []; targetKeyCols = []; }}
class="text-xs text-red-600 hover:text-red-700"
>
{$t.translate?.config?.clear_all}
</button>
{/if}
</div>
{#if columnList.length === 0}
<div class="flex flex-wrap gap-2 mb-3">
<button
onclick={() => {
sourceKeyCols = [...sourceKeyCols, ''];
targetKeyCols = [...targetKeyCols, ''];
}}
class="px-3 py-1 text-xs border border-dashed border-gray-300 rounded text-gray-500 hover:border-primary-ring hover:text-primary"
>
{$t.translate?.config?.add_key_column}
</button>
</div>
{:else}
<div class="flex flex-wrap gap-2 mb-3">
{#each columnList as col}
<button
onclick={() => toggleSourceKeyCol(col.name)}
class="px-2.5 py-1 text-xs rounded-full border transition-colors
{sourceKeyCols.includes(col.name)
? 'bg-blue-100 border-blue-300 text-blue-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
title={isVirtual(col.name) ? $t.translate?.config?.virtual_column_warning.replace('{col}', col.name) : ''}
>
{col.name}
{#if isVirtual(col.name)}
<span class="text-yellow-500">*</span>
{/if}
</button>
{/each}
</div>
{/if}
{#if sourceKeyCols.length > 0}
<div class="space-y-2">
{#each sourceKeyCols as srcCol, idx}
<div class="flex items-center gap-2">
<input
type="text"
value={srcCol}
disabled
class="flex-1 px-3 py-1.5 border border-gray-200 bg-gray-50 rounded text-sm font-mono"
/>
<span class="text-gray-400 text-sm">&rarr;</span>
<input
type="text"
value={targetKeyCols[idx] || ''}
placeholder={$t.translate?.config?.target_column_placeholder}
oninput={(e) => updateTargetKeyCol(idx, e.target.value)}
class="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm font-mono focus-visible:ring-2 focus-visible:ring-primary-ring"
/>
<button
onclick={() => {
sourceKeyCols = sourceKeyCols.filter((_, i) => i !== idx);
targetKeyCols = targetKeyCols.filter((_, i) => i !== idx);
}}
class="p-1 text-gray-400 hover:text-red-500"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
{#if validationErrors.targetKeyCols}
<p class="text-xs text-red-600 mt-1">{validationErrors.targetKeyCols}</p>
{/if}
</div>
<!-- Context Columns -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{$t.translate?.config?.context_columns}</label>
{#if columnList.length === 0}
<p class="text-xs text-gray-400">{$t.translate?.config?.select_datasource_hint}</p>
{:else}
<div class="flex flex-wrap gap-2">
{#each columnList as col}
<button
onclick={() => toggleContextColumn(col.name)}
class="px-2.5 py-1 text-xs rounded-full border transition-colors
{contextColumns.includes(col.name)
? 'bg-purple-100 border-purple-300 text-purple-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'}"
>
{col.name}
{#if isVirtual(col.name)}
<span class="text-yellow-500">*</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</section>
<!-- LLM Settings -->
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.llm_settings}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.provider}</label>
<select bind:value={providerId} class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">{$t.translate?.config?.select_provider}</option>
{#each llmProviders as provider}
<option value={provider.id}>{provider.name || provider.provider_type}</option>
{/each}
</select>
</div>
<div>
<MultiSelect
label={$t.translate?.config?.target_language}
options={LANGUAGES}
bind:selected={targetLanguages}
searchable={true}
placeholder={$t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
required={true}
/>
{#if targetLanguages.length === 0}
<p class="text-xs text-amber-600 mt-1">{$t.translate?.config?.target_language_required || 'Select at least one target language'}</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.batch_size}</label>
<input
type="number"
bind:value={batchSize}
min="1"
max="1000"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.upsert_strategy}</label>
<select bind:value={upsertStrategy} class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="MERGE">{$t.translate?.config?.upsert_merge}</option>
<option value="INSERT">{$t.translate?.config?.upsert_insert}</option>
<option value="UPDATE">{$t.translate?.config?.upsert_update}</option>
</select>
</div>
</div>
<!-- Include source language as reference copy -->
<div class="mt-4 pt-4 border-t border-gray-100">
<label class="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={includeSourceReference}
class="mt-0.5 rounded border-gray-300 text-primary focus-visible:ring-primary-ring"
/>
<div>
<span class="text-sm font-medium text-gray-700">{$t.translate?.config?.include_source_reference || 'Include source language in translations'}</span>
<p class="text-xs text-gray-400 mt-0.5">
{$t.translate?.config?.include_source_reference_hint || 'The original text will be stored as a verified reference copy in its detected language'}
</p>
</div>
</label>
</div>
<!-- Disable reasoning toggle -->
<div class="mt-3">
<label class="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={disableReasoning}
class="mt-0.5 rounded border-gray-300 text-primary focus-visible:ring-primary-ring"
/>
<div>
<span class="text-sm font-medium text-gray-700">{$t.translate?.config?.disable_reasoning || 'Disable reasoning (save tokens)'}</span>
<p class="text-xs text-gray-400 mt-0.5">
{$t.translate?.config?.disable_reasoning_hint || 'Saves output tokens by suppressing Chain of Thought reasoning'}
</p>
</div>
</label>
</div>
</section>
<!-- Dictionary Attachment -->
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.terminology_dictionaries}</h2>
{#if availableDictionaries.length === 0}
<p class="text-sm text-gray-400">
{$t.translate?.config?.no_dictionaries}
</p>
{:else}
<div class="space-y-2">
{#each availableDictionaries as dict}
<label class="flex items-center gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={dictionaryIds.includes(dict.id)}
onchange={() => {
if (dictionaryIds.includes(dict.id)) {
dictionaryIds = dictionaryIds.filter(d => d !== dict.id);
} else {
dictionaryIds = [...dictionaryIds, dict.id];
}
}}
class="rounded border-gray-300 text-primary focus-visible:ring-primary-ring"
/>
<div>
<span class="text-sm font-medium text-gray-900">{dict.name}</span>
{#if dict.description}
<p class="text-xs text-gray-500">{dict.description}</p>
{/if}
{#if dict.source_dialect || dict.target_dialect}
<p class="text-xs text-gray-400">
{dict.source_dialect} &rarr; {dict.target_dialect}
</p>
{/if}
</div>
</label>
{/each}
</div>
{/if}
</section>
</div>
<ConfigTabForm
bind:name
bind:description
bind:environmentId
{environments}
bind:datasourceId
bind:datasourceSearch
bind:databaseDialect
bind:availableColumns
bind:virtualColumns
bind:sourceKeyCols
bind:targetKeyCols
bind:translationColumn
bind:contextColumns
bind:targetLanguages
bind:targetLanguageColumn
bind:targetSourceColumn
bind:targetSourceLanguageColumn
bind:providerId
{llmProviders}
bind:dictionaryIds
{availableDictionaries}
bind:batchSize
bind:includeSourceReference
bind:disableReasoning
bind:upsertStrategy
{validationErrors}
onEnvChange={handleEnvChange}
/>
</div>
<!-- Schedule tab: mounted when not on new-job page, hidden when not active -->
@@ -1204,10 +834,7 @@
</p>
</div>
{:else}
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.preview?.title}</h2>
<TranslationPreview {jobId} environmentId={environmentId} />
</section>
<TranslationPreview {jobId} {environmentId} />
{/if}
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
@@ -1219,123 +846,20 @@
<!-- Target Config tab: target table configuration for inserts — after preview, before run -->
<div class={activeTab === 'target' ? '' : 'hidden'}>
{#if !isNewJob && existingJob}
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.target_table_title}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.target_schema}</label>
<input
type="text"
bind:value={targetSchema}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder={$t.translate?.config?.target_schema_placeholder}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.config?.target_table}</label>
<input
type="text"
bind:value={targetTable}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder={$t.translate?.config?.target_table_placeholder}
/>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.target_database}
</label>
<select
bind:value={targetDatabaseId}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
disabled={!environmentId || databasesLoading}
>
<option value="">{$t.translate?.config?.select_target_database}</option>
{#each databases as db}
<option value={String(db.id || db.uuid)}>
{db.database_name || db.name} ({db.engine || db.backend || '?'})
</option>
{/each}
</select>
{#if databasesLoading}
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.loading_databases}</p>
{:else if !environmentId}
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.select_environment}</p>
{:else}
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_database_hint}</p>
{/if}
</div>
</div>
<!-- Target Column Mapping -->
<div class="mt-4 pt-4 border-t border-gray-100">
<h3 class="text-sm font-semibold text-gray-900 mb-3">{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}</h3>
<p class="text-xs text-gray-400 mb-3">{$t.translate?.config?.target_column_mapping_description || 'Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.'}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.target_column_label}
</label>
<input
type="text"
bind:value={targetColumn}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. translated_text"
/>
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_column_hint}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.target_language_column_label}
</label>
<input
type="text"
bind:value={targetLanguageColumn}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. lang_code"
/>
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_language_column_hint}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.target_source_column_label}
</label>
<input
type="text"
bind:value={targetSourceColumn}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. source_text"
/>
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_source_column_hint}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.translate?.config?.target_source_language_column_label}
</label>
<input
type="text"
bind:value={targetSourceLanguageColumn}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring"
placeholder="e.g. src_lang"
/>
<p class="text-xs text-gray-400 mt-1">{$t.translate?.config?.target_source_language_column_hint}</p>
</div>
</div>
</div>
<!-- Schema hint: проверка целевой таблицы -->
<TargetSchemaHint
environmentId={environmentId}
targetDatabaseId={targetDatabaseId}
targetSchema={targetSchema}
targetTable={targetTable}
targetKeyCols={targetKeyCols}
targetColumn={targetColumn}
translationColumn={translationColumn}
targetLanguageColumn={targetLanguageColumn}
targetSourceColumn={targetSourceColumn}
targetSourceLanguageColumn={targetSourceLanguageColumn}
/>
</section>
<TargetTabForm
bind:targetSchema
bind:targetTable
bind:targetDatabaseId
bind:targetColumn
bind:targetLanguageColumn
bind:targetSourceColumn
bind:targetSourceLanguageColumn
bind:targetKeyCols
bind:translationColumn
{environmentId}
{databases}
{databasesLoading}
/>
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-sm text-gray-500">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
@@ -1346,160 +870,26 @@
<!-- Run tab: always mounted to preserve state across tab switches -->
<div class={activeTab === 'run' ? '' : 'hidden'}>
{#if !isNewJob && existingJob}
<section class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{$t.translate?.config?.run_translation}</h2>
<!-- Status display + transition -->
<div class="flex items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg">
<span class="text-sm text-gray-600">{$t.translate?.config?.status}:</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{status === 'READY' ? 'bg-green-100 text-green-700' : ''}
{status === 'DRAFT' ? 'bg-yellow-100 text-yellow-700' : ''}
{status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : ''}
{status === 'RUNNING' ? 'bg-blue-100 text-blue-700' : ''}
{status === 'COMPLETED' ? 'bg-green-100 text-green-700' : ''}
{status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}">
{getJobStatusLabel(status)}
</span>
{#if status === 'DRAFT'}
<button
onclick={async () => {
try {
await updateJob(jobId, { status: 'READY' });
status = 'READY';
addToast($t.translate?.config?.job_updated, 'success');
} catch (e) {
addToast(e?.message || 'Failed to update status', 'error');
}
}}
class="ml-auto px-3 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
>
{$t.translate?.config?.mark_ready || 'Mark as READY'}
</button>
{/if}
</div>
<div class="space-y-4">
<!-- Run mode selection: two cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Incremental run card -->
<div class="border border-green-200 rounded-lg p-4 {isRunning && !isFullRun ? 'bg-green-50' : 'bg-white'}">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-gray-900">{$t.translate?.config?.run_incremental}</h3>
<p class="text-xs text-gray-500 mt-1">{$t.translate?.config?.run_incremental_desc}</p>
<button
onclick={() => handleTriggerRun(false)}
disabled={isRunning || status === 'DRAFT'}
class="mt-3 px-5 py-1.5 text-sm font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isRunning && !isFullRun ? $t.translate?.config?.running : $t.translate?.config?.run_translation}
</button>
</div>
</div>
</div>
<!-- Full run card -->
<div class="border border-primary-ring rounded-lg p-4 {isRunning && isFullRun ? 'bg-primary-light' : 'bg-white'}">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-gray-900">{$t.translate?.config?.run_full}</h3>
<p class="text-xs text-gray-500 mt-1">{$t.translate?.config?.run_full_desc}</p>
<button
onclick={() => handleTriggerRun(true)}
disabled={isRunning || status === 'DRAFT'}
class="mt-3 px-5 py-1.5 text-sm font-medium bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isRunning && isFullRun ? $t.translate?.config?.running : $t.translate?.config?.full_translate}
</button>
</div>
</div>
</div>
</div>
{#if runError}
<div class="bg-destructive-light border border-destructive-light rounded-lg p-3">
<p class="text-sm text-red-600">{runError}</p>
</div>
{/if}
{#if currentRunId && !runComplete}
<TranslationRunProgress
runId={currentRunId}
onRetry={handleRetryRun}
onRetryInsert={handleRetryInsert}
/>
{/if}
{#if completedRuns.length > 0}
<div class="mt-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-700">{$t.translate?.config?.recent_runs}</h3>
<button
onclick={() => showPageBulkReplace = true}
class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors"
>
{$t.translate?.run?.bulk_replace || 'Bulk Replace'}
</button>
</div>
<div class="space-y-3">
{#each completedRuns as run}
<div class="border border-gray-200 rounded-lg bg-white">
<div class="flex items-center justify-between gap-3 p-4">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-gray-500">{$t.translate?.run?.run_id}</span>
<code class="text-xs text-gray-700 break-all">{run.id}</code>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium
{run.status === 'COMPLETED' ? 'bg-green-100 text-green-700' : ''}
{run.status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}
{run.status === 'CANCELLED' ? 'bg-gray-100 text-gray-700' : ''}
{run.status !== 'COMPLETED' && run.status !== 'FAILED' && run.status !== 'CANCELLED' ? 'bg-yellow-100 text-yellow-700' : ''}"
>
{run.status}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-3 text-xs text-gray-500">
<span>{$t.translate?.run?.total}: {run.total_records || 0}</span>
<span>{$t.translate?.run?.success}: {run.successful_records || 0}</span>
<span>{$t.translate?.run?.failed}: {run.failed_records || 0}</span>
<span>{$t.translate?.run?.skipped}: {run.skipped_records || 0}</span>
</div>
{#if run.error_message}
<p class="mt-2 text-xs text-red-600 break-words">{run.error_message}</p>
{/if}
</div>
<button
onclick={() => toggleRunDetails(run.id)}
class="shrink-0 px-3 py-1.5 text-xs border border-gray-300 text-gray-700 rounded hover:bg-gray-50 transition-colors"
>
{expandedRunIds.includes(run.id)
? ($t.translate?.run?.hide_details || 'Hide details')
: ($t.translate?.run?.show_details || 'Show details')}
</button>
</div>
{#if expandedRunIds.includes(run.id)}
<div class="border-t border-gray-200 p-4">
<TranslationRunResult runId={run.id} onRefresh={loadRunHistory} />
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
</section>
<RunTabContent
bind:status
{jobId}
{isRunning}
{isFullRun}
{runError}
{currentRunId}
{runComplete}
{completedRuns}
{expandedRunIds}
bind:showPageBulkReplace
onTriggerRun={handleTriggerRun}
onRetryRun={handleRetryRun}
onRetryInsert={handleRetryInsert}
onLoadRunHistory={loadRunHistory}
onToggleRunDetails={toggleRunDetails}
onBulkReplaceApplied={() => { showPageBulkReplace = false; loadRunHistory(); }}
onBulkReplaceClose={() => showPageBulkReplace = false}
{getJobStatusLabel}
/>
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-sm text-gray-500">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>