added prototype dev settings with test import and db wipe
This commit is contained in:
186
website/js/pages/dev.js
Normal file
186
website/js/pages/dev.js
Normal file
@ -0,0 +1,186 @@
|
||||
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>Dev import</h1>
|
||||
</div>
|
||||
<div class="card" style="max-width:720px">
|
||||
<p class="field-hint" style="margin-bottom:16px;padding:12px;background:#fff8e1;border-radius:8px;color:#7a5d00">
|
||||
<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 project root (generate via <code>scripts/generate_dev_fixture.py</code>).</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" style="margin-bottom:12px;padding:12px;background:#fdecea;border-radius:8px;color:#8a1f17">
|
||||
<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)';
|
||||
}
|
||||
});
|
||||
|
||||
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';
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -257,6 +257,10 @@ function renderTableRows() {
|
||||
const questionCells = questionsDef.map(q => {
|
||||
const ans = c.answers && c.answers[q.questionID];
|
||||
if (!ans) return '<td>—</td>';
|
||||
if (q.type === 'glass_scale_question') {
|
||||
const text = formatGlassAnswer(ans.freeTextValue);
|
||||
return `<td>${esc(text || '—')}</td>`;
|
||||
}
|
||||
if (ans.answerOptionID && optionMap[ans.answerOptionID]) {
|
||||
return `<td>${esc(optionMap[ans.answerOptionID])}</td>`;
|
||||
}
|
||||
@ -286,8 +290,12 @@ function getCellValue(client, key) {
|
||||
if (key === 'completedAt') return client.completedAt;
|
||||
if (key.startsWith('q_')) {
|
||||
const qid = key.substring(2);
|
||||
const q = questionsDef.find(qq => qq.questionID === qid);
|
||||
const ans = client.answers && client.answers[qid];
|
||||
if (!ans) return null;
|
||||
if (q?.type === 'glass_scale_question') {
|
||||
return formatGlassAnswer(ans.freeTextValue) || null;
|
||||
}
|
||||
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
|
||||
if (ans.freeTextValue) return ans.freeTextValue;
|
||||
return ans.answerOptionID || null;
|
||||
@ -315,6 +323,9 @@ function exportCSV() {
|
||||
const answerCols = questionsDef.map(q => {
|
||||
const ans = c.answers && c.answers[q.questionID];
|
||||
if (!ans) return '';
|
||||
if (q.type === 'glass_scale_question') {
|
||||
return formatGlassAnswer(ans.freeTextValue);
|
||||
}
|
||||
if (ans.answerOptionID && optionMap[ans.answerOptionID]) return optionMap[ans.answerOptionID];
|
||||
if (ans.freeTextValue) return ans.freeTextValue;
|
||||
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
|
||||
@ -345,6 +356,22 @@ function csvEsc(val) {
|
||||
return s;
|
||||
}
|
||||
|
||||
function formatGlassAnswer(raw) {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const o = JSON.parse(raw);
|
||||
if (o && typeof o === 'object' && !Array.isArray(o)) {
|
||||
return Object.entries(o)
|
||||
.filter(([, v]) => v != null && String(v).trim() !== '')
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('; ');
|
||||
}
|
||||
} catch {
|
||||
/* plain text fallback */
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s ?? '';
|
||||
|
||||
Reference in New Issue
Block a user