changes to translation system
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-07-09 14:59:56 +02:00
parent 4afab336ee
commit f04388e0ec
24 changed files with 1882 additions and 415 deletions

View File

@ -34,15 +34,19 @@ export async function confirmAndPatchClientArchive(clientCode, archived) {
* @param {string} opts.clientCode Escaped client code for data-code attributes
* @param {boolean} opts.archived
* @param {boolean} [opts.showDelete]
* @param {boolean} [opts.showReset]
* @param {string} [opts.rawCode] Unescaped code for button labels (optional)
*/
export function clientTableActionsHTML({ clientCode, archived, showDelete = false }) {
export function clientTableActionsHTML({ clientCode, archived, showDelete = false, showReset = false }) {
const archiveBtn = archived
? `<button type="button" class="btn btn-sm restore-client-btn" data-code="${clientCode}" title="Restore to active lists">Restore</button>`
: `<button type="button" class="btn btn-sm archive-client-btn" data-code="${clientCode}" title="Mark as finished and hide from lists">Archive</button>`;
const resetBtn = showReset
? `<button type="button" class="btn btn-sm reset-client-btn" data-code="${clientCode}" title="Clear answers, uploads, and scores; keep client code and counselor">Reset</button>`
: '';
const deleteBtn = showDelete
? `<button type="button" class="btn btn-sm btn-danger delete-client-btn" data-code="${clientCode}" title="Permanently delete client and all data">Delete</button>`
: '';
return `<div class="table-row-actions" role="group" aria-label="Client actions">${archiveBtn}${deleteBtn}</div>`;
return `<div class="table-row-actions" role="group" aria-label="Client actions">${archiveBtn}${resetBtn}${deleteBtn}</div>`;
}

31
website/js/datetime.js Normal file
View File

@ -0,0 +1,31 @@
/** App timezone for display (Germany: CET/CEST). */
export const APP_TIMEZONE = 'Europe/Berlin';
/** YYYY-MM-DD in app timezone (date inputs, log filters, filename stamps). */
export function berlinDateInputValue(date = new Date()) {
return new Intl.DateTimeFormat('en-CA', { timeZone: APP_TIMEZONE }).format(date);
}
/** YYYY-MM-DD HH:mm:ss in app timezone. */
export function formatBerlinDateTime(value) {
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return '';
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: APP_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(d);
const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
}
/** YYYY-MM-DD HH:mm in app timezone. */
export function formatBerlinDateTimeShort(value) {
const full = formatBerlinDateTime(value);
return full ? full.slice(0, 16) : '';
}

View File

@ -197,6 +197,9 @@ function renderClientTableBody() {
body.querySelectorAll('.delete-client-btn').forEach(btn => {
btn.addEventListener('click', () => deleteClient(btn.dataset.code));
});
body.querySelectorAll('.reset-client-btn').forEach(btn => {
btn.addEventListener('click', () => resetClient(btn.dataset.code));
});
body.querySelectorAll('.archive-client-btn').forEach(btn => {
btn.addEventListener('click', () => setClientArchived(btn.dataset.code, true));
});
@ -413,6 +416,9 @@ function profileScoreBlockHTML(p, clientCode = '') {
<span class="client-profile-score-label">Counselor</span>
${coachControl}
${coachDiffers ? '<span class="client-profile-override-hint">override</span>' : ''}
${coachDiffers && p.coachReviewedByUsername
? `<span class="client-profile-override-meta">by ${esc(p.coachReviewedByUsername)}${p.coachReviewedAt ? ` · ${esc(p.coachReviewedAt)}` : ''}</span>`
: ''}
</div>
</div>`;
}
@ -431,7 +437,10 @@ function renderClientScoringProfiles(profiles, clientCode = '') {
const coachPending = p.pendingReview !== false && !p.coachBand;
const computedAt = p.computedAt ? `Last computed ${esc(p.computedAt)}` : '';
const reviewedAt = p.coachReviewedAt ? `Counselor reviewed ${esc(p.coachReviewedAt)}` : '';
const meta = [computedAt, reviewedAt].filter(Boolean).join(' · ');
const overrideMeta = p.coachOverride && p.coachReviewedByUsername
? `Overridden by ${esc(p.coachReviewedByUsername)}${p.coachReviewedAt ? ` · ${esc(p.coachReviewedAt)}` : ''}`
: '';
const meta = [computedAt, reviewedAt, overrideMeta].filter(Boolean).join(' · ');
return `
<div class="scoring-profile-detail-card">
${profileScoreBlockHTML(p, clientCode)}
@ -732,6 +741,7 @@ function clientRowHTML(c) {
clientCode: esc(c.clientCode),
archived,
showDelete: true,
showReset: clientHasResponseData(c),
});
return `
@ -818,6 +828,44 @@ async function setClientArchived(clientCode, archived) {
else renderClientTableBody();
}
async function resetClient(clientCode) {
if (!(await confirmAction({
title: 'Reset client',
message: `Reset client "${clientCode}"? This clears all questionnaire answers, upload history, and scoring results. The client code and counselor assignment are kept.`,
confirmLabel: 'Reset',
variant: 'danger',
}))) return;
try {
await apiPost('clients.php', { action: 'reset', clientCode });
const row = clientsList.find(c => c.clientCode === clientCode);
if (row) {
row.hasResponseData = 0;
row.scoringProfiles = (row.scoringProfiles || []).map(p => ({
...p,
band: null,
calculatedBand: null,
coachBand: null,
effectiveBand: null,
pendingReview: false,
coachOverride: false,
coachReviewedAt: '',
coachReviewedByUsername: '',
weightedTotal: null,
}));
}
delete detailByClient[clientCode];
if (expandedClientCode === clientCode) {
expandedClientCode = null;
expandedQnKey = null;
expandedVersionKey = null;
}
showToast(`Client "${clientCode}" reset`, 'success');
renderClientTableBody();
} catch (e) {
showToast(e.message, 'error');
}
}
async function deleteClient(clientCode) {
if (!(await confirmAction({
title: 'Delete client',

View File

@ -2,6 +2,7 @@ import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin, apiUrl } fr
import { getRole, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js';
import { berlinDateInputValue, formatBerlinDateTime } from '../datetime.js';
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
@ -20,7 +21,7 @@ export function devPage() {
}
const app = document.getElementById('app');
const today = new Date().toISOString().slice(0, 10);
const today = berlinDateInputValue();
app.innerHTML = `
${pageHeaderHTML('Admin Settings')}
<div class="card security-settings-card" style="max-width:720px;margin-bottom:16px">
@ -115,7 +116,7 @@ export function devPage() {
<table class="data-table activity-log-table">
<thead>
<tr>
<th>Time (UTC)</th>
<th>Time</th>
<th>Kind</th>
<th>Subject</th>
<th>Method</th>
@ -326,7 +327,7 @@ async function loadActivityLog() {
const meta = document.getElementById('activityLogMeta');
if (!tbody) return;
const date = document.getElementById('activityLogDate')?.value || new Date().toISOString().slice(0, 10);
const date = document.getElementById('activityLogDate')?.value || berlinDateInputValue();
const activity = document.getElementById('activityLogKind')?.value || '';
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px">Loading…</td></tr>';
@ -450,13 +451,8 @@ function formatActivityChangeValue(value) {
function formatActivityTime(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toISOString().replace('T', ' ').slice(0, 19);
} catch {
return iso;
}
const formatted = formatBerlinDateTime(iso);
return formatted || iso;
}
function escHtml(s) {

View File

@ -1,5 +1,6 @@
import { apiGet, apiDownloadFetch, apiUrl } from '../api.js';
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
import { berlinDateInputValue } from '../datetime.js';
let questionnairesList = [];
let filterSearch = '';
@ -162,7 +163,7 @@ function wireExportActions() {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const stamp = new Date().toISOString().slice(0, 10);
const stamp = berlinDateInputValue();
a.download = `questionnaires_bundle_${stamp}.json`;
a.click();
URL.revokeObjectURL(url);
@ -215,7 +216,7 @@ async function onExportAllVersionsClick(ev) {
btn.textContent = 'Downloading…';
try {
const url = apiUrl(`export?questionnaireID=${encodeURIComponent(btn.dataset.id)}&allVersions=1`);
const stamp = new Date().toISOString().slice(0, 10);
const stamp = berlinDateInputValue();
await downloadExportCsv(btn, url, `${btn.dataset.name}_all_versions_${stamp}.csv`);
showToast('All versions download started', 'success');
} catch (e) {

View File

@ -1,6 +1,7 @@
import { apiGet } from '../api.js';
import { homeNavButton, showToast } from '../app.js';
import { buildResultColumns, resultColumnCellValue } from '../test-data.js';
import { formatBerlinDateTimeShort } from '../datetime.js';
const PAGE_SIZE = 50;
@ -9,6 +10,7 @@ let sortDir = 'asc';
let allClients = [];
let questionsDef = [];
let resultColumns = [];
let scoringProfileCatalog = [];
let questionnaireMeta = null;
let filterCoach = '';
let filterSupervisor = '';
@ -52,6 +54,7 @@ export async function resultsPage(params) {
const data = await apiGet(`results.php?questionnaireID=${encodeURIComponent(params.id)}`);
questionnaireMeta = data.questionnaire;
questionsDef = data.questions || [];
scoringProfileCatalog = data.scoringProfiles || [];
allClients = data.clients || [];
document.getElementById('resultsTitle').textContent = `Results: ${questionnaireMeta.name} v${questionnaireMeta.version}`;
renderResults();
@ -181,6 +184,40 @@ function renderResults() {
renderTableRows();
}
function scoringTableColumns() {
const cols = [];
for (const profile of scoringProfileCatalog) {
const name = profile.name || profile.profileID;
cols.push(
{ key: `score_${profile.profileID}_calc`, label: `${name} calculated`, profileID: profile.profileID, field: 'calculated', title: `${name} — server-calculated category` },
{ key: `score_${profile.profileID}_coach`, label: `${name} counselor`, profileID: profile.profileID, field: 'counselor', title: `${name} — counselor category` },
{ key: `score_${profile.profileID}_by`, label: `${name} override by`, profileID: profile.profileID, field: 'override_by', title: `${name} — who overrode the calculated category` },
{ key: `score_${profile.profileID}_at`, label: `${name} override at`, profileID: profile.profileID, field: 'override_at', title: `${name} — when the override was set` },
);
}
return cols;
}
function scoringCellValue(client, profileID, field) {
const p = (client.scoringProfiles || []).find(x => x.profileID === profileID);
if (!p) return '';
const calc = p.calculatedBand || p.band || '';
const coach = p.coachBand || '';
const differs = Boolean(p.coachOverride) || (coach && coach !== calc);
switch (field) {
case 'calculated':
return calc;
case 'counselor':
return coach;
case 'override_by':
return differs ? (p.coachReviewedByUsername || '') : '';
case 'override_at':
return differs ? (p.coachReviewedAt || '') : '';
default:
return '';
}
}
function renderTableHead() {
const head = document.getElementById('resultsHead');
const fixedCols = [
@ -191,7 +228,8 @@ function renderTableHead() {
{ key: 'sumPoints', label: 'Score' },
{ key: 'completedAt', label: 'Completed' },
];
const allCols = [...fixedCols, ...resultColumns.map(col => ({
const scoringCols = scoringTableColumns();
const allCols = [...fixedCols, ...scoringCols, ...resultColumns.map(col => ({
key: col.key,
label: col.label,
title: col.title,
@ -381,6 +419,11 @@ function renderTableRows() {
return `<td>${esc(String(val))}</td>`;
}).join('');
const scoringCells = scoringTableColumns().map(col => {
const val = scoringCellValue(c, col.profileID, col.field);
return `<td>${val ? esc(String(val)) : '—'}</td>`;
}).join('');
return `<tr>
<td class="sticky-col">${esc(c.clientCode)}</td>
<td>${esc(coachDisplayName(c))}</td>
@ -388,6 +431,7 @@ function renderTableRows() {
<td>${statusBadge}</td>
<td>${c.sumPoints !== null ? c.sumPoints : '—'}</td>
<td>${completedDate}</td>
${scoringCells}
${questionCells}
</tr>`;
}).join('');
@ -400,6 +444,10 @@ function getCellValue(client, key) {
if (key === 'status') return client.status;
if (key === 'sumPoints') return client.sumPoints;
if (key === 'completedAt') return client.completedAt;
const scoringCol = scoringTableColumns().find(c => c.key === key);
if (scoringCol) {
return scoringCellValue(client, scoringCol.profileID, scoringCol.field);
}
const col = resultColumns.find(c => c.key === key);
if (col) {
const ans = client.answers && client.answers[col.questionID];
@ -424,19 +472,23 @@ function exportCSV() {
});
const headers = ['clientCode', 'Counselor', 'supervisor', 'status', 'sumPoints', 'completedAt',
...scoringTableColumns().map(col => col.label),
...resultColumns.map(col => col.label)];
const rows = clients.map(c => {
const base = [
c.clientCode, coachDisplayName(c), c.supervisorUsername || '', c.status, c.sumPoints ?? '',
c.completedAt ? new Date(c.completedAt * 1000).toISOString() : ''
c.completedAt ? formatBerlinDateTimeShort(c.completedAt * 1000) : ''
];
const scoringCols = scoringTableColumns().map(col =>
scoringCellValue(c, col.profileID, col.field)
);
const answerCols = resultColumns.map(col => {
const ans = c.answers && c.answers[col.questionID];
const val = resultColumnCellValue(col, ans, optionMap);
return val != null ? val : '';
});
return [...base, ...answerCols];
return [...base, ...scoringCols, ...answerCols];
});
let csv = '\uFEFF'; // BOM

View File

@ -6,6 +6,7 @@ import {
normalizeEntry,
translationFor,
targetLanguages,
isLanguageGloballyEnabled,
sourceLanguageLabel,
translationListHeaderHTML,
buildTranslationGroups,
@ -32,15 +33,19 @@ let savesInFlight = 0;
export async function translationsPage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Translations', '<span id="transHeaderActions"></span>')}
${pageHeaderHTML(
'Translations',
'<span id="transHeaderActions"></span>',
{ subtitle: 'Manage languages, availability, and translated text for the coach app.' },
)}
<div id="transPageRoot"><div class="spinner"></div></div>
`;
if (canEdit()) {
document.getElementById('transHeaderActions').innerHTML = `
<input type="file" id="importTransBundleFile" accept=".json,application/json" hidden>
<button type="button" class="btn" id="importTransBundleBtn">Import translations</button>
<button type="button" class="btn btn-primary" id="exportTransBundleBtn">Export translations</button>
<button type="button" class="btn" id="importTransBundleBtn" title="Import JSON bundle">Import</button>
<button type="button" class="btn btn-primary" id="exportTransBundleBtn" title="Download JSON bundle">Export</button>
`;
bindTranslationBundleActions();
}
@ -56,47 +61,49 @@ export async function translationsPage() {
function renderShell() {
document.getElementById('transPageRoot').innerHTML = `
<div class="card trans-page">
<div class="trans-controls">
<label class="trans-control">
<span>Translate into</span>
<select id="transLangSelect" class="trans-select" disabled>
<option value="">—</option>
</select>
</label>
</div>
<div class="trans-layout">
<aside class="trans-sidebar" id="transSidebar">
<section class="trans-sidebar-section">
<div class="trans-sidebar-head">
<h2 class="trans-sidebar-title">Languages</h2>
<p class="trans-sidebar-hint">Select a language to translate into. Toggle to show or hide in the app.</p>
</div>
<div id="transLangSidebar" class="trans-lang-list">
<div class="spinner spinner-sm"></div>
</div>
<div id="transAddLang" class="trans-add-lang"></div>
</section>
<details class="trans-lang-panel">
<summary>More languages</summary>
<div id="transLangPanel"><span class="text-muted">Loading…</span></div>
</details>
<section class="trans-sidebar-section">
<div class="trans-sidebar-head">
<h2 class="trans-sidebar-title">Content</h2>
<p class="trans-sidebar-hint">Choose what you are translating.</p>
</div>
<nav id="transContentNav" class="trans-content-nav" aria-label="Translation content">
<div class="spinner spinner-sm"></div>
</nav>
</section>
<div class="trans-toolbar">
<label class="trans-control trans-toolbar-field">
<span>Content</span>
<select id="transScopeSelect" class="trans-select" disabled>
<option value="">Loading…</option>
</select>
</label>
<label class="trans-control trans-toolbar-field">
<span>Show</span>
<select id="transShowMode" class="trans-select trans-type-select" disabled>
<option value="missing">Missing only</option>
<option value="all">All</option>
<option value="complete">Complete only</option>
</select>
</label>
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search in scope…" disabled>
<select id="transTypeFilter" class="trans-select trans-type-select" disabled>
<option value="">All types</option>
<option value="app_string">App strings</option>
<option value="string">Questionnaire UI strings</option>
<option value="question">Questions</option>
<option value="answer_option">Answer options</option>
</select>
</div>
<section class="trans-sidebar-section trans-sidebar-advanced">
<details class="trans-advanced-panel" id="transAdvancedPanel">
<summary class="trans-advanced-summary">Advanced</summary>
<div id="transAdvancedBody" class="trans-advanced-body">
<p class="text-muted trans-advanced-note">
Bulk-delete translated text. German source is never removed.
</p>
<div id="transDeleteForm"></div>
</div>
</details>
</section>
</aside>
<div id="transListArea"><div class="spinner"></div></div>
<main class="trans-main" id="transMain">
<div id="transWorkspaceHeader" class="trans-workspace-header"></div>
<div id="transQnAvailability" class="trans-qn-availability" hidden></div>
<div id="transListArea" class="trans-list-area">
<div class="spinner"></div>
</div>
</main>
</div>
`;
}
@ -105,72 +112,72 @@ function pickDefaultTargetLang(languages) {
const targets = targetLanguages(languages);
if (!targets.length) return '';
const stored = localStorage.getItem(STORAGE_LANG);
if (stored && targets.some(l => l.languageCode === stored)) {
return stored;
}
if (selectedLang && targets.some(l => l.languageCode === selectedLang)) {
return selectedLang;
}
if (stored && targets.some(l => l.languageCode === stored)) return stored;
if (selectedLang && targets.some(l => l.languageCode === selectedLang)) return selectedLang;
return targets[0].languageCode;
}
function pickDefaultShowMode() {
const stored = localStorage.getItem(STORAGE_SHOW);
if (stored === 'missing' || stored === 'all' || stored === 'complete') {
return stored;
}
if (stored === 'missing' || stored === 'all' || stored === 'complete') return stored;
return 'missing';
}
function pickDefaultScope(sections, entries, lang) {
const stored = localStorage.getItem(STORAGE_SCOPE);
if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) {
return stored;
}
if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) return stored;
for (const sec of sections) {
const slice = sectionEntries(entries, sec);
if (countMissing(slice, lang) > 0) {
return sec.questionnaireID;
}
if (countMissing(sectionEntries(entries, sec), lang) > 0) return sec.questionnaireID;
}
return sections[0]?.questionnaireID || '__app__';
}
function scopeLabel(sec, entries, lang) {
const slice = sectionEntries(entries, sec);
const missing = countMissing(slice, lang);
const suffix = missing ? ` (${missing} missing)` : ' (complete)';
return `${sec.name}${suffix}`;
const missing = countMissing(sectionEntries(entries, sec), lang);
return { name: sec.name, missing, complete: missing === 0 };
}
function scopedSections() {
const sections = transData?.sections || [];
if (!selectedScope || selectedScope === '__all__') {
return sections;
}
if (!selectedScope || selectedScope === '__all__') return sections;
return sections.filter(s => s.questionnaireID === selectedScope);
}
function entriesInScope() {
const sections = scopedSections();
const out = [];
for (const sec of sections) {
out.push(...sectionEntries(allEntries, sec));
}
for (const sec of sections) out.push(...sectionEntries(allEntries, sec));
return out;
}
function currentScopeMeta() {
const sections = transData?.sections || [];
if (selectedScope === '__all__') return { id: '__all__', name: 'All content', isApp: false, isQuestionnaire: false };
const sec = sections.find(s => s.questionnaireID === selectedScope);
if (!sec) return { id: selectedScope, name: 'Content', isApp: false, isQuestionnaire: false };
return {
id: sec.questionnaireID,
name: sec.name,
isApp: !!sec.isApp,
isQuestionnaire: !sec.isApp && sec.questionnaireID !== '__app__',
};
}
function currentLangMeta() {
const languages = transData?.languages || [];
const row = languages.find(l => l.languageCode === selectedLang);
return {
code: selectedLang,
name: row?.name || selectedLang.toUpperCase(),
enabled: row ? isLanguageGloballyEnabled(row) : true,
};
}
function setSaveStatus(state) {
const el = document.getElementById('transSaveStatus');
if (!el) return;
el.dataset.state = state;
if (state === 'saving') {
el.textContent = 'Saving…';
} else if (state === 'error') {
el.textContent = 'Save failed';
} else {
el.textContent = 'All changes saved';
}
el.textContent = state === 'saving' ? 'Saving…' : state === 'error' ? 'Save failed' : 'All changes saved';
}
async function ensureGermanLanguage() {
@ -181,10 +188,6 @@ async function ensureGermanLanguage() {
async function loadTranslations() {
const listArea = document.getElementById('transListArea');
const langSelect = document.getElementById('transLangSelect');
const filter = document.getElementById('transFilter');
const typeFilter = document.getElementById('transTypeFilter');
if (listArea) listArea.innerHTML = '<div class="spinner"></div>';
saveTimers = {};
@ -203,93 +206,137 @@ async function loadTranslations() {
showMode = pickDefaultShowMode();
selectedScope = pickDefaultScope(flat.sections, allEntries, selectedLang);
if (langSelect) {
const targets = targetLanguages(languages);
langSelect.disabled = !targets.length;
langSelect.innerHTML = targets.length
? targets.map(l => {
const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode;
return `<option value="${esc(l.languageCode)}" ${l.languageCode === selectedLang ? 'selected' : ''}>${esc(label)}</option>`;
}).join('')
: '<option value="">Add a language below</option>';
langSelect.onchange = () => {
selectedLang = langSelect.value;
localStorage.setItem(STORAGE_LANG, selectedLang);
populateScopeSelect();
renderEntryList();
};
}
const scopeSelect = document.getElementById('transScopeSelect');
const showSelect = document.getElementById('transShowMode');
if (scopeSelect) {
scopeSelect.disabled = false;
populateScopeSelect();
scopeSelect.onchange = () => {
selectedScope = scopeSelect.value;
localStorage.setItem(STORAGE_SCOPE, selectedScope);
renderEntryList();
};
}
if (showSelect) {
showSelect.disabled = false;
showSelect.value = showMode;
showSelect.onchange = () => {
showMode = showSelect.value;
localStorage.setItem(STORAGE_SHOW, showMode);
applyFilters();
};
}
if (filter) filter.disabled = false;
if (typeFilter) typeFilter.disabled = false;
renderLanguagePanel(languages);
renderLanguageSidebar(languages);
renderContentNav();
renderDeleteForm();
renderWorkspaceHeader();
renderQnAvailabilityStrip();
renderEntryList();
bindFilters();
setSaveStatus('saved');
} catch (e) {
if (listArea) listArea.innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
if (listArea) listArea.innerHTML = `<p class="error-text trans-hint">${esc(e.message)}</p>`;
showToast(e.message, 'error');
}
}
function renderLanguagePanel(languages) {
const panel = document.getElementById('transLangPanel');
if (!panel) return;
function renderLanguageSidebar(languages) {
const list = document.getElementById('transLangSidebar');
const addBox = document.getElementById('transAddLang');
if (!list || !addBox) return;
const others = targetLanguages(languages);
const source = languages.find(l => l.languageCode === SOURCE_LANG);
panel.innerHTML = `
<p class="text-muted" style="font-size:.85rem;margin:0 0 10px">
German source text is edited in the questionnaire editor. Add other languages here.
</p>
<div class="lang-chips">
<span class="lang-chip lang-chip-fixed">
<strong>DE</strong>
<span class="lang-chip-name">German (source)</span>
list.innerHTML = `
<div class="trans-lang-item trans-lang-item-source" aria-disabled="true">
<span class="trans-lang-code">DE</span>
<span class="trans-lang-meta">
<span class="trans-lang-name">${esc(source?.name || 'German')}</span>
<span class="trans-lang-sub">Source language</span>
</span>
${others.map(l => `
<span class="lang-chip">
<strong>${esc(l.languageCode.toUpperCase())}</strong>
${l.name ? `<span class="lang-chip-name">${esc(l.name)}</span>` : ''}
<button type="button" class="lang-chip-remove" data-lc="${esc(l.languageCode)}" title="Remove language">&times;</button>
</span>
`).join('')}
</div>
<div class="lang-add-row">
<input type="text" id="newLangCode" placeholder="Code (en)" class="lang-add-code" maxlength="8">
<input type="text" id="newLangName" placeholder="Name (English)" class="lang-add-name">
<button type="button" class="btn btn-sm btn-primary" id="addLangBtn">Add</button>
<span class="trans-lang-badge trans-lang-badge-source">Source</span>
</div>
${others.length ? others.map(lang => {
const selected = lang.languageCode === selectedLang;
const enabled = isLanguageGloballyEnabled(lang);
const missing = countMissing(allEntries, lang.languageCode);
return `
<button type="button"
class="trans-lang-item${selected ? ' is-selected' : ''}${!enabled ? ' is-disabled' : ''}"
data-lc="${esc(lang.languageCode)}"
aria-pressed="${selected}">
<span class="trans-lang-code">${esc(lang.languageCode.toUpperCase())}</span>
<span class="trans-lang-meta">
<span class="trans-lang-name">${esc(lang.name || lang.languageCode)}</span>
<span class="trans-lang-sub">${missing ? `${missing} missing overall` : 'Fully translated'}</span>
</span>
<span class="trans-lang-actions" data-stop-select>
<label class="trans-toggle" title="Show in app language picker">
<input type="checkbox" class="trans-global-lang-toggle" data-lc="${esc(lang.languageCode)}"
${enabled ? 'checked' : ''}>
<span class="trans-toggle-ui" aria-hidden="true"></span>
</label>
${canEdit() ? `<button type="button" class="trans-lang-remove" data-lc="${esc(lang.languageCode)}" title="Remove language">&times;</button>` : ''}
</span>
</button>`;
}).join('') : `
<p class="trans-empty-sidebar">No target languages yet. Add one below to start translating.</p>
`}
`;
addBox.innerHTML = canEdit() ? `
<div class="trans-add-lang-form">
<div class="trans-add-lang-fields">
<input type="text" id="newLangCode" placeholder="Code (en)" class="trans-add-input" maxlength="8" aria-label="Language code">
<input type="text" id="newLangName" placeholder="Name (English)" class="trans-add-input" aria-label="Language name">
</div>
<button type="button" class="btn btn-sm btn-primary trans-add-lang-btn" id="addLangBtn">Add language</button>
</div>
` : '';
list.querySelectorAll('.trans-lang-item[data-lc]').forEach(btn => {
btn.addEventListener('click', e => {
if (e.target.closest('[data-stop-select]')) return;
selectedLang = btn.dataset.lc;
localStorage.setItem(STORAGE_LANG, selectedLang);
renderLanguageSidebar(languages);
renderContentNav();
renderWorkspaceHeader();
renderQnAvailabilityStrip();
renderEntryList();
});
});
list.querySelectorAll('.trans-global-lang-toggle').forEach(input => {
input.addEventListener('change', async e => {
e.stopPropagation();
const lc = input.dataset.lc;
const enabled = input.checked;
input.disabled = true;
try {
await apiPut('translations.php', { type: 'language_enabled', languageCode: lc, enabled });
const lang = languages.find(l => l.languageCode === lc);
if (lang) lang.enabled = enabled ? 1 : 0;
showToast(enabled ? `${lc} enabled in app` : `${lc} hidden from app`, 'success');
renderLanguageSidebar(languages);
renderQnAvailabilityStrip();
} catch (err) {
input.checked = !enabled;
showToast(err.message, 'error');
} finally {
input.disabled = false;
}
});
});
list.querySelectorAll('.trans-lang-remove').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const lc = btn.dataset.lc;
if (!(await confirmAction({
title: 'Remove language',
message: `Remove "${lc}" and delete all of its translations?`,
confirmLabel: 'Remove',
variant: 'danger',
}))) return;
try {
await apiDelete('translations.php', { type: 'language', languageCode: lc });
showToast(`Language "${lc}" removed`, 'success');
if (selectedLang === lc) selectedLang = '';
await loadTranslations();
} catch (err) {
showToast(err.message, 'error');
}
});
});
document.getElementById('addLangBtn')?.addEventListener('click', async () => {
const code = document.getElementById('newLangCode').value.trim().toLowerCase();
const name = document.getElementById('newLangName').value.trim();
if (!code) { showToast('Language code is required', 'error'); return; }
if (code === SOURCE_LANG) {
showToast('German (de) is always available as the source language', 'error');
showToast('German (de) is always the source language', 'error');
return;
}
if (languages.some(l => l.languageCode === code)) {
@ -301,48 +348,285 @@ function renderLanguagePanel(languages) {
showToast(`Language "${code}" added`, 'success');
selectedLang = code;
await loadTranslations();
} catch (e) {
showToast(e.message, 'error');
} catch (err) {
showToast(err.message, 'error');
}
});
}
panel.querySelectorAll('.lang-chip-remove').forEach(btn => {
btn.addEventListener('click', async () => {
const lc = btn.dataset.lc;
if (!(await confirmAction({
title: 'Remove language',
message: `Remove "${lc}" and all its translations?`,
confirmLabel: 'Remove',
variant: 'danger',
}))) return;
function renderContentNav() {
const nav = document.getElementById('transContentNav');
if (!nav || !transData) return;
const sections = transData.sections || [];
const items = sections.map(sec => {
const { name, missing, complete } = scopeLabel(sec, allEntries, selectedLang);
const active = sec.questionnaireID === selectedScope;
const badge = missing
? `<span class="trans-nav-badge trans-nav-badge-warn">${missing}</span>`
: `<span class="trans-nav-badge trans-nav-badge-ok" title="Complete">✓</span>`;
const icon = sec.isApp ? '◇' : '▢';
return `
<button type="button"
class="trans-content-item${active ? ' is-active' : ''}"
data-scope="${esc(sec.questionnaireID)}"
aria-current="${active ? 'page' : 'false'}">
<span class="trans-content-icon" aria-hidden="true">${icon}</span>
<span class="trans-content-label">${esc(name)}</span>
${selectedLang ? badge : ''}
</button>`;
});
items.push(`
<button type="button"
class="trans-content-item trans-content-item-all${selectedScope === '__all__' ? ' is-active' : ''}"
data-scope="__all__"
aria-current="${selectedScope === '__all__' ? 'page' : 'false'}">
<span class="trans-content-icon" aria-hidden="true">≡</span>
<span class="trans-content-label">All content</span>
</button>
`);
nav.innerHTML = items.join('');
nav.querySelectorAll('.trans-content-item').forEach(btn => {
btn.addEventListener('click', () => {
selectedScope = btn.dataset.scope;
localStorage.setItem(STORAGE_SCOPE, selectedScope);
renderContentNav();
renderWorkspaceHeader();
renderQnAvailabilityStrip();
renderDeleteForm();
renderEntryList();
});
});
}
function questionnaireLanguageDisabled(questionnaireID, languageCode) {
return (transData?.questionnaireLanguageDisables || []).some(
row => row.questionnaireID === questionnaireID && row.languageCode === languageCode
);
}
function setQuestionnaireLanguageDisabled(questionnaireID, languageCode, disabled) {
if (!transData) return;
const rows = transData.questionnaireLanguageDisables || [];
const idx = rows.findIndex(
row => row.questionnaireID === questionnaireID && row.languageCode === languageCode
);
if (disabled) {
if (idx < 0) rows.push({ questionnaireID, languageCode });
} else if (idx >= 0) {
rows.splice(idx, 1);
}
transData.questionnaireLanguageDisables = rows;
}
function renderQnAvailabilityStrip() {
const strip = document.getElementById('transQnAvailability');
if (!strip || !transData) return;
const scope = currentScopeMeta();
const languages = targetLanguages(transData.languages || []).filter(isLanguageGloballyEnabled);
if (!scope.isQuestionnaire || !languages.length) {
strip.hidden = true;
strip.innerHTML = '';
return;
}
strip.hidden = false;
strip.innerHTML = `
<div class="trans-qn-availability-inner">
<span class="trans-qn-availability-label">Available in app for this questionnaire</span>
<div class="trans-qn-availability-toggles">
${languages.map(lang => {
const on = !questionnaireLanguageDisabled(scope.id, lang.languageCode);
return `
<label class="trans-qn-pill${on ? ' is-on' : ''}">
<input type="checkbox" class="trans-qn-lang-toggle"
data-qid="${esc(scope.id)}"
data-lc="${esc(lang.languageCode)}"
${on ? 'checked' : ''}>
<span>${esc(lang.languageCode.toUpperCase())}</span>
</label>`;
}).join('')}
</div>
</div>
`;
strip.querySelectorAll('.trans-qn-lang-toggle').forEach(input => {
input.addEventListener('change', async () => {
const qid = input.dataset.qid;
const lc = input.dataset.lc;
const enabled = input.checked;
input.disabled = true;
try {
await apiDelete('translations.php', { type: 'language', languageCode: lc });
showToast(`Language "${lc}" removed`, 'success');
if (selectedLang === lc) selectedLang = '';
await loadTranslations();
} catch (e) {
showToast(e.message, 'error');
await apiPut('translations.php', {
type: 'questionnaire_language',
questionnaireID: qid,
languageCode: lc,
enabled,
});
setQuestionnaireLanguageDisabled(qid, lc, !enabled);
renderQnAvailabilityStrip();
showToast(
enabled ? `${lc} enabled for this questionnaire` : `${lc} hidden for this questionnaire`,
'success'
);
} catch (err) {
input.checked = !enabled;
showToast(err.message, 'error');
} finally {
input.disabled = false;
}
});
});
}
function populateScopeSelect() {
const scopeSelect = document.getElementById('transScopeSelect');
if (!scopeSelect || !transData) return;
const sections = transData.sections || [];
const options = sections.map(sec =>
`<option value="${esc(sec.questionnaireID)}"${sec.questionnaireID === selectedScope ? ' selected' : ''}>${esc(scopeLabel(sec, allEntries, selectedLang))}</option>`
);
options.push(`<option value="__all__"${selectedScope === '__all__' ? ' selected' : ''}>All content</option>`);
scopeSelect.innerHTML = options.join('');
if (!selectedScope || (selectedScope !== '__all__' && !sections.some(s => s.questionnaireID === selectedScope))) {
selectedScope = pickDefaultScope(sections, allEntries, selectedLang);
scopeSelect.value = selectedScope;
localStorage.setItem(STORAGE_SCOPE, selectedScope);
function renderDeleteForm() {
const form = document.getElementById('transDeleteForm');
if (!form || !transData || !canEdit()) {
if (form) form.innerHTML = '';
return;
}
const languages = targetLanguages(transData.languages || []);
const sections = (transData.sections || []).filter(sec => sec.questionnaireID !== '__app__');
const defaultScope = selectedScope && selectedScope !== '__all__' ? selectedScope : '__app__';
form.innerHTML = `
<label class="trans-field">
<span>Content area</span>
<select id="transDeleteScope" class="trans-select">
<option value="__app__"${defaultScope === '__app__' ? ' selected' : ''}>App UI strings</option>
${sections.map(sec => `
<option value="${esc(sec.questionnaireID)}"${defaultScope === sec.questionnaireID ? ' selected' : ''}>
${esc(sec.name)}
</option>
`).join('')}
<option value="__all__"${defaultScope === '__all__' ? ' selected' : ''}>All content</option>
</select>
</label>
<label class="trans-field">
<span>Language</span>
<select id="transDeleteLang" class="trans-select">
<option value="">All target languages</option>
${languages.map(l => `
<option value="${esc(l.languageCode)}">${esc(l.name || l.languageCode)} (${esc(l.languageCode)})</option>
`).join('')}
</select>
</label>
<button type="button" class="btn btn-danger btn-sm trans-delete-btn" id="transDeleteBtn">Delete translations</button>
`;
document.getElementById('transDeleteBtn')?.addEventListener('click', onDeleteTranslations);
}
async function onDeleteTranslations() {
const scopeVal = document.getElementById('transDeleteScope')?.value || '__app__';
const lang = document.getElementById('transDeleteLang')?.value || '';
const scope = scopeVal === '__app__' ? 'app' : (scopeVal === '__all__' ? 'all' : 'questionnaire');
const sections = (transData?.sections || []).filter(sec => sec.questionnaireID !== '__app__');
const scopeLabel = scopeVal === '__app__'
? 'App UI strings'
: (scopeVal === '__all__' ? 'all content' : (sections.find(s => s.questionnaireID === scopeVal)?.name || 'this questionnaire'));
const langLabel = lang ? lang.toUpperCase() : 'all target languages';
if (!(await confirmAction({
title: 'Delete translations',
message: `Delete ${langLabel} translations for ${scopeLabel}? This cannot be undone.`,
confirmLabel: 'Delete',
variant: 'danger',
}))) return;
const btn = document.getElementById('transDeleteBtn');
btn.disabled = true;
try {
const body = { action: 'deleteTranslationsScope', scope };
if (scope === 'questionnaire') body.questionnaireID = scopeVal;
if (lang) body.languageCode = lang;
const result = await apiPost('translations.php', body);
showToast(`Deleted ${result.deleted ?? 0} translation row(s)`, 'success');
await loadTranslations();
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
}
}
function renderWorkspaceHeader() {
const header = document.getElementById('transWorkspaceHeader');
if (!header || !transData) return;
const scope = currentScopeMeta();
const lang = currentLangMeta();
const scopeEntries = entriesInScope();
const missingCount = selectedLang ? countMissing(scopeEntries, selectedLang) : 0;
const pct = scopeEntries.length && selectedLang
? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100)
: 0;
header.innerHTML = `
<div class="trans-workspace-top">
<div class="trans-workspace-title-wrap">
<h2 class="trans-workspace-title">${esc(scope.name)}</h2>
<p class="trans-workspace-subtitle">
${selectedLang
? `Translating into <strong>${esc(lang.name)}</strong> (${esc(lang.code)})`
: 'Add a target language to begin'}
${!lang.enabled && selectedLang ? ' · <span class="trans-warn">Hidden from app</span>' : ''}
</p>
</div>
<div class="trans-workspace-status">
<span id="transSaveStatus" class="trans-save-status" data-state="saved">All changes saved</span>
<button type="button" class="btn btn-sm" id="transJumpMissingBtn"${!selectedLang ? ' disabled' : ''}>
Next missing
</button>
</div>
</div>
<div class="trans-workspace-progress"${!selectedLang ? ' hidden' : ''}>
<div class="trans-progress-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
<div class="trans-progress-fill" style="width:${pct}%"></div>
</div>
<span class="trans-progress-text" id="transVisibleCount">
${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} strings
<span class="trans-progress-pct">${pct}% complete</span>
</span>
</div>
<div class="trans-workspace-filters">
<div class="trans-filter-pills" role="group" aria-label="Show">
<button type="button" class="trans-pill${showMode === 'missing' ? ' is-active' : ''}" data-show="missing">Missing</button>
<button type="button" class="trans-pill${showMode === 'all' ? ' is-active' : ''}" data-show="all">All</button>
<button type="button" class="trans-pill${showMode === 'complete' ? ' is-active' : ''}" data-show="complete">Complete</button>
</div>
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search strings…" ${!selectedLang ? 'disabled' : ''}>
<select id="transTypeFilter" class="trans-select trans-type-select" ${!selectedLang ? 'disabled' : ''}>
<option value="">All types</option>
<option value="app_string">App strings</option>
<option value="string">Questionnaire UI</option>
<option value="question">Questions</option>
<option value="answer_option">Answer options</option>
</select>
</div>
`;
header.querySelectorAll('.trans-pill').forEach(btn => {
btn.addEventListener('click', () => {
showMode = btn.dataset.show;
localStorage.setItem(STORAGE_SHOW, showMode);
header.querySelectorAll('.trans-pill').forEach(p => p.classList.toggle('is-active', p.dataset.show === showMode));
applyFilters();
});
});
document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing);
filtersBound = false;
bindFilters();
}
function renderEntryList() {
@ -355,30 +639,33 @@ function renderEntryList() {
if (!targets.length) {
listArea.innerHTML = `
<p class="text-muted trans-hint">Add at least one language under <strong>More languages</strong> to translate into.</p>`;
<div class="trans-empty-state">
<p class="trans-empty-title">Add a language to get started</p>
<p class="text-muted">Use the form in the sidebar to add English, French, or any other target language.</p>
</div>`;
return;
}
if (!selectedLang) {
listArea.innerHTML = `<p class="text-muted trans-hint">Choose a language above.</p>`;
listArea.innerHTML = `
<div class="trans-empty-state">
<p class="trans-empty-title">Select a target language</p>
<p class="text-muted">Click a language in the sidebar to begin translating.</p>
</div>`;
return;
}
if (!transData.sections?.length) {
listArea.innerHTML = `
<p class="text-muted trans-hint">No translatable content yet. Add questionnaires and questions in the editor first.</p>`;
<div class="trans-empty-state">
<p class="trans-empty-title">No translatable content</p>
<p class="text-muted">Create questionnaires in the editor first. German source text is edited there.</p>
</div>`;
return;
}
const sourceLabel = sourceLanguageLabel(languages);
const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase();
const scopeEntries = entriesInScope();
const missingCount = countMissing(scopeEntries, selectedLang);
const pct = scopeEntries.length
? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100)
: 0;
const headerOnce = translationListHeaderHTML(langLabel, sourceLabel);
const useQnDetails = selectedScope === '__all__';
const sectionsHtml = sections.map(sec => {
@ -393,9 +680,7 @@ function renderEntryList() {
const open = secMissing > 0 && sections.filter(s =>
countMissing(sectionEntries(allEntries, s), selectedLang) > 0
)[0]?.questionnaireID === sec.questionnaireID;
const summary = secMissing
? `${sec.name} · ${secMissing} missing`
: `${sec.name} · complete`;
const summary = secMissing ? `${sec.name} · ${secMissing} missing` : `${sec.name} · complete`;
return `
<details class="trans-qn-section"${open ? ' open' : ''} data-qn="${esc(sec.questionnaireID)}">
<summary class="trans-qn-summary">${esc(summary)}</summary>
@ -407,27 +692,12 @@ function renderEntryList() {
}).join('');
listArea.innerHTML = `
<div class="trans-top-bar">
<div class="trans-top-stats">
<span id="transVisibleCount">${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} in scope</span>
<span class="trans-stats-progress">
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
<span class="trans-progress-label">${pct}% in ${esc(selectedLang.toUpperCase())}</span>
</span>
</div>
<div class="trans-top-actions">
<span id="transSaveStatus" class="trans-save-status" data-state="saved">All changes saved</span>
<button type="button" class="btn btn-sm" id="transJumpMissingBtn">Jump to next missing</button>
</div>
</div>
<div class="trans-sheet">
${headerOnce}
${translationListHeaderHTML(langLabel, sourceLabel)}
<div id="transGroupedRoot" class="trans-sheet-body${useQnDetails ? ' trans-sheet-body-stack' : ''}">${sectionsHtml}</div>
</div>
`;
document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing);
bindEntryEditing(allEntries);
applyFilters();
}
@ -458,8 +728,7 @@ function jumpToNextMissing() {
}
const target = rows[start] || rows[0];
openAncestors(target);
const inp = target.querySelector('[data-field="translation"]');
inp?.focus({ preventScroll: false });
target.querySelector('[data-field="translation"]')?.focus({ preventScroll: false });
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
@ -518,7 +787,8 @@ async function saveTranslation(entry, inp, isGerman = false) {
savesInFlight = Math.max(0, savesInFlight - 1);
if (savesInFlight === 0) {
setSaveStatus('saved');
populateScopeSelect();
renderContentNav();
renderLanguageSidebar(transData?.languages || []);
}
}
}
@ -531,8 +801,10 @@ function updateProgress() {
: 0;
const fill = document.querySelector('.trans-progress-fill');
if (fill) fill.style.width = `${pct}%`;
const label = document.querySelector('.trans-progress-label');
if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`;
const track = document.querySelector('.trans-progress-track');
if (track) track.setAttribute('aria-valuenow', String(pct));
const pctEl = document.querySelector('.trans-progress-pct');
if (pctEl) pctEl.textContent = `${pct}% complete`;
if (!document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) {
updateVisibleCountSummary();
}
@ -544,14 +816,20 @@ function updateVisibleCountSummary(visible = null, visibleMissing = null) {
const scopeEntries = entriesInScope();
const text = document.getElementById('transFilter')?.value || '';
const type = document.getElementById('transTypeFilter')?.value || '';
const missing = countMissing(scopeEntries, selectedLang);
const pct = scopeEntries.length
? Math.round(((scopeEntries.length - missing) / scopeEntries.length) * 100)
: 0;
if (text || type || showMode !== 'missing') {
if (visible != null) {
countEl.textContent = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}`;
countEl.innerHTML = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}
<span class="trans-progress-pct">${pct}% complete</span>`;
}
return;
}
const missing = countMissing(scopeEntries, selectedLang);
countEl.textContent = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} in scope`;
countEl.innerHTML = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} strings
<span class="trans-progress-pct">${pct}% complete</span>`;
}
let filtersBound = false;
@ -562,7 +840,6 @@ function bindFilters() {
return;
}
filtersBound = true;
document.getElementById('transFilter')?.addEventListener('input', applyFilters);
document.getElementById('transTypeFilter')?.addEventListener('change', applyFilters);
}
@ -590,13 +867,12 @@ function applyFilters() {
row.classList.toggle('trans-row-hidden', !show);
if (show) {
visible++;
if (row.dataset.missing === '1') visibleMissing++;
if (isMissing) visibleMissing++;
}
});
document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => {
const rows = group.querySelectorAll('.trans-list-item');
const anyVisible = [...rows].some(r => !r.classList.contains('trans-row-hidden'));
const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
group.classList.toggle('trans-group-hidden', !anyVisible);
});
@ -643,8 +919,7 @@ function bindTranslationBundleActions() {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const stamp = new Date().toISOString().slice(0, 10);
a.download = `translations_bundle_${stamp}.json`;
a.download = `translations_bundle_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('Translations bundle downloaded', 'success');
@ -676,10 +951,9 @@ function bindTranslationBundleActions() {
return;
}
const count = bundle.entries.length;
if (!(await confirmAction({
title: 'Import translations',
message: `Import translations for ${count} entries from this file?`,
message: `Import translations for ${bundle.entries.length} entries from this file?`,
confirmLabel: 'Import',
}))) return;
@ -687,10 +961,7 @@ function bindTranslationBundleActions() {
const prev = importBtn.textContent;
importBtn.textContent = 'Importing…';
try {
const result = await apiPost('translations.php', {
action: 'importBundle',
bundle,
});
const result = await apiPost('translations.php', { action: 'importBundle', bundle });
const imported = result.imported ?? 0;
const skipped = result.skipped ?? 0;
let msg = `Imported ${imported} translation(s)`;

View File

@ -2,6 +2,7 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
import { confirmAction } from '../confirm-modal.js';
import { openAdminResetPasswordModal } from '../password-modal.js';
import { berlinDateInputValue, formatBerlinDateTimeShort } from '../datetime.js';
// Roles an admin can create; supervisors can only create coaches
const CREATEABLE_ROLES = {
@ -529,7 +530,7 @@ function downloadUsersCSV() {
u.role === 'coach' ? '' : (u.location || ''),
u.role === 'coach' ? (u.supervisorUsername || u.supervisorID || '') : '',
u.mustChangePassword == 1 ? 'yes' : 'no',
u.createdAt ? new Date(parseInt(u.createdAt, 10) * 1000).toISOString() : '',
u.createdAt ? formatBerlinDateTimeShort(parseInt(u.createdAt, 10) * 1000) : '',
u.userID,
]);
@ -541,7 +542,7 @@ function downloadUsersCSV() {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `users_${new Date().toISOString().slice(0, 10)}.csv`;
a.download = `users_${berlinDateInputValue()}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast('Users exported', 'success');

View File

@ -1,4 +1,13 @@
const STABLE_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
const RESULTS_EXCLUDED_KEYS = new Set(['data_final_warning']);
const RESULTS_EXCLUDED_TYPES = new Set(['last_page']);
/** Questions hidden from results tables and CSV exports (e.g. last-page warnings). */
export function isResultsExcludedQuestion(q) {
if (!q) return false;
if (RESULTS_EXCLUDED_TYPES.has(q.type)) return true;
return RESULTS_EXCLUDED_KEYS.has(questionDisplayKey(q));
}
/** Local question id from full questionID (hash__q5 → q5). */
export function questionLocalKey(questionID, fallback = '') {
@ -62,6 +71,7 @@ export function glassSymptomAnswerValue(raw, symptomKey) {
export function buildResultColumns(questions) {
const cols = [];
for (const q of questions || []) {
if (isResultsExcludedQuestion(q)) continue;
const cfg = parseQuestionConfig(q);
const symptoms = cfg.symptoms;
if (q.type === 'glass_scale_question' && Array.isArray(symptoms) && symptoms.length > 0) {

View File

@ -69,6 +69,16 @@ export function targetLanguages(languages) {
return (languages || []).filter(l => l.languageCode !== SOURCE_LANG);
}
export function isLanguageGloballyEnabled(lang) {
if (!lang) return false;
if (lang.languageCode === SOURCE_LANG) return true;
return Number(lang.enabled ?? 1) === 1;
}
export function globallyEnabledLanguages(languages) {
return (languages || []).filter(isLanguageGloballyEnabled);
}
/** Sort rows within a group: missing translations first, then questionnaire order */
export function sortGroupItems(items, targetLang) {
return [...items].sort((a, b) => {