261 lines
11 KiB
JavaScript
261 lines
11 KiB
JavaScript
import { apiPost } from '../api.js';
|
||
import { getRole, showToast } from '../app.js';
|
||
import { navigate } from '../router.js';
|
||
|
||
export function devPage() {
|
||
if (getRole() !== 'admin') {
|
||
navigate('#/');
|
||
return;
|
||
}
|
||
|
||
const app = document.getElementById('app');
|
||
app.innerHTML = `
|
||
<div class="page-header">
|
||
<h1>Admin Settings</h1>
|
||
</div>
|
||
<div class="card" style="max-width:720px;margin-bottom:16px">
|
||
<h2 style="margin:0 0 8px;font-size:1.1rem">Export all response data (ZIP)</h2>
|
||
<p class="field-hint" style="margin:0 0 14px">
|
||
Download one CSV per questionnaire in a single ZIP (full server data).
|
||
<strong>Current data</strong> uses live answers;
|
||
<strong>All versions</strong> includes every upload (one row per submission).
|
||
</p>
|
||
<div style="display:flex;flex-wrap:wrap;gap:10px">
|
||
<button type="button" class="btn btn-primary" id="exportAllCurrentZipBtn">Export all — current data (ZIP)</button>
|
||
<button type="button" class="btn" id="exportAllVersionsZipBtn">Export all — all versions (ZIP)</button>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="max-width:720px">
|
||
<h2 style="margin:0 0 8px;font-size:1.1rem">Dev import</h2>
|
||
<p class="field-hint callout-warn">
|
||
<strong>Local / test only.</strong> Imports prefixed dev users (<code>dev_*</code>),
|
||
clients (<code>DEV-CL-*</code>), and completed questionnaire runs.
|
||
Existing real users are not modified. Conflicts are skipped (usernames, client codes, completions).
|
||
Default password: <code>socialvrlab</code>. Questionnaires must already exist in the database.
|
||
</p>
|
||
<div class="form-group">
|
||
<label for="devFixtureFile">Fixture JSON</label>
|
||
<input type="file" id="devFixtureFile" accept=".json,application/json">
|
||
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the repo root (generate via <code>nat-as-server/scripts/generate_dev_fixture.py</code>; reads <code>questionnaires_bundle_2026-05-28.json</code>, 5× scale).</span>
|
||
</div>
|
||
<div id="devFixtureSummary" class="field-hint" style="margin:12px 0;display:none"></div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
||
<button type="button" class="btn btn-primary" id="devImportBtn" disabled>Import fixture</button>
|
||
<button type="button" class="btn btn-danger" id="devRemoveBtn">Remove test data</button>
|
||
</div>
|
||
<pre id="devImportResult" style="margin-top:16px;font-size:.8rem;max-height:320px;overflow:auto;display:none"></pre>
|
||
</div>
|
||
<div class="card" style="max-width:720px;margin-top:16px">
|
||
<h2 style="margin:0 0 8px;font-size:1.1rem">Reset database</h2>
|
||
<p class="field-hint callout-danger">
|
||
<strong>Destructive.</strong> Deletes every client, completion, coach, and supervisor
|
||
(including non-dev accounts). Admin logins and questionnaire definitions are kept.
|
||
</p>
|
||
<button type="button" class="btn btn-danger" id="devWipeBtn">Remove all data (keep admins)</button>
|
||
</div>
|
||
`;
|
||
|
||
let parsedFixture = null;
|
||
|
||
document.getElementById('devFixtureFile').addEventListener('change', async (e) => {
|
||
const file = e.target.files?.[0];
|
||
const summary = document.getElementById('devFixtureSummary');
|
||
const btn = document.getElementById('devImportBtn');
|
||
parsedFixture = null;
|
||
btn.disabled = true;
|
||
summary.style.display = 'none';
|
||
|
||
if (!file) return;
|
||
|
||
try {
|
||
const text = await file.text();
|
||
parsedFixture = JSON.parse(text);
|
||
const st = parsedFixture.stats || {};
|
||
summary.innerHTML = `
|
||
<strong>Preview:</strong>
|
||
${parsedFixture.admins?.length ?? st.admins ?? 0} admins,
|
||
${parsedFixture.supervisors?.length ?? st.supervisors ?? 0} supervisors,
|
||
${parsedFixture.coaches?.length ?? st.coaches ?? 0} coaches,
|
||
${parsedFixture.clients?.length ?? st.clients ?? 0} clients,
|
||
${parsedFixture.completions?.length ?? st.completions ?? 0} completions
|
||
`;
|
||
summary.style.display = '';
|
||
btn.disabled = false;
|
||
} catch (err) {
|
||
showToast('Invalid JSON: ' + err.message, 'error');
|
||
}
|
||
});
|
||
|
||
document.getElementById('devRemoveBtn').addEventListener('click', async () => {
|
||
if (!confirm(
|
||
'Remove all dev test data?\n\n'
|
||
+ 'Deletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\n'
|
||
+ 'Real accounts are not affected.'
|
||
)) {
|
||
return;
|
||
}
|
||
const btn = document.getElementById('devRemoveBtn');
|
||
const pre = document.getElementById('devImportResult');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Removing…';
|
||
pre.style.display = 'none';
|
||
|
||
try {
|
||
const res = await apiPost('dev', {
|
||
action: 'remove',
|
||
usernamePrefix: 'dev_',
|
||
clientCodePrefix: 'DEV-CL-',
|
||
});
|
||
if (!res.success) {
|
||
showToast(res.error || res.message || 'Remove failed', 'error');
|
||
return;
|
||
}
|
||
pre.textContent = JSON.stringify(res, null, 2);
|
||
pre.style.display = '';
|
||
const d = res.deleted || {};
|
||
showToast(
|
||
`Removed ${d.clients ?? 0} clients, ${d.users ?? 0} users, `
|
||
+ `${d.completed_questionnaires ?? 0} completions`,
|
||
'success'
|
||
);
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Remove test data';
|
||
}
|
||
});
|
||
|
||
document.getElementById('devWipeBtn').addEventListener('click', async () => {
|
||
if (!confirm(
|
||
'Remove ALL clients, completions, coaches, and supervisors?\n\n'
|
||
+ 'Admin accounts and questionnaires will NOT be deleted.\n'
|
||
+ 'This cannot be undone.'
|
||
)) {
|
||
return;
|
||
}
|
||
const btn = document.getElementById('devWipeBtn');
|
||
const pre = document.getElementById('devImportResult');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Removing…';
|
||
pre.style.display = 'none';
|
||
|
||
try {
|
||
const res = await apiPost('dev', { action: 'wipeExceptAdmins' });
|
||
if (!res.success) {
|
||
showToast(res.error || res.message || 'Wipe failed', 'error');
|
||
return;
|
||
}
|
||
pre.textContent = JSON.stringify(res, null, 2);
|
||
pre.style.display = '';
|
||
const d = res.deleted || {};
|
||
const kept = res.kept?.admins ?? '?';
|
||
showToast(
|
||
`Wiped ${d.clients ?? 0} clients, ${d.users ?? 0} users, `
|
||
+ `${d.completed_questionnaires ?? 0} completions (${kept} admins kept)`,
|
||
'success'
|
||
);
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Remove all data (keep admins)';
|
||
}
|
||
});
|
||
|
||
wireAdminZipExport();
|
||
|
||
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
||
if (!parsedFixture) return;
|
||
const btn = document.getElementById('devImportBtn');
|
||
const pre = document.getElementById('devImportResult');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Importing…';
|
||
pre.style.display = 'none';
|
||
|
||
try {
|
||
const res = await apiPost('dev', { fixture: parsedFixture });
|
||
if (!res.success) {
|
||
showToast(res.error || 'Import failed', 'error');
|
||
return;
|
||
}
|
||
pre.textContent = JSON.stringify(res, null, 2);
|
||
pre.style.display = '';
|
||
const imp = res.imported || {};
|
||
const sk = res.skipped || {};
|
||
showToast(
|
||
`Imported: ${imp.completions ?? 0} completions, ${imp.clients ?? 0} clients, `
|
||
+ `${imp.coaches ?? 0} coaches (skipped ${sk.completions ?? 0} existing)`,
|
||
'success'
|
||
);
|
||
if (res.errors?.length) {
|
||
showToast(`${res.errors.length} warning(s) — see log below`, 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Import fixture';
|
||
}
|
||
});
|
||
}
|
||
|
||
function filenameFromContentDisposition(res, fallback) {
|
||
const disp = res.headers.get('Content-Disposition');
|
||
if (!disp) return fallback;
|
||
const m = /filename="([^"]+)"/i.exec(disp);
|
||
return m ? m[1] : fallback;
|
||
}
|
||
|
||
async function downloadExportZip(url, defaultFilename) {
|
||
const token = localStorage.getItem('qdb_token');
|
||
const res = await fetch(url, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
||
throw new Error(err.error?.message || err.error || 'Download failed');
|
||
}
|
||
const blob = await res.blob();
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = blobUrl;
|
||
a.download = filenameFromContentDisposition(res, defaultFilename);
|
||
a.click();
|
||
URL.revokeObjectURL(blobUrl);
|
||
}
|
||
|
||
function wireAdminZipExport() {
|
||
document.getElementById('exportAllCurrentZipBtn')?.addEventListener('click', async () => {
|
||
const btn = document.getElementById('exportAllCurrentZipBtn');
|
||
btn.disabled = true;
|
||
const prev = btn.textContent;
|
||
btn.textContent = 'Preparing ZIP…';
|
||
try {
|
||
await downloadExportZip('../api/export?exportAll=1', 'responses_export_current.zip');
|
||
showToast('ZIP download started (current data)', 'success');
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = prev;
|
||
}
|
||
});
|
||
|
||
document.getElementById('exportAllVersionsZipBtn')?.addEventListener('click', async () => {
|
||
const btn = document.getElementById('exportAllVersionsZipBtn');
|
||
btn.disabled = true;
|
||
const prev = btn.textContent;
|
||
btn.textContent = 'Preparing ZIP…';
|
||
try {
|
||
await downloadExportZip('../api/export?exportAll=1&allVersions=1', 'responses_export_all_versions.zip');
|
||
showToast('ZIP download started (all versions)', 'success');
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = prev;
|
||
}
|
||
});
|
||
}
|