Improve oversight change previews

This commit is contained in:
JB
2026-07-25 21:25:15 -04:00
parent 65aa6361a6
commit c5036e7bf0
5 changed files with 124 additions and 4 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,99 @@
<script lang="ts">
type Entry = { name: string; kind: 'text' | 'file'; value?: string; fileName?: string; type?: string };
let {
module,
action,
payload,
currentSnapshot = null
}: {
module: string;
action: string;
payload: string;
currentSnapshot?: Record<string, unknown> | null;
} = $props();
const entries = $derived(JSON.parse(payload) as Entry[]);
const proposed = $derived(Object.fromEntries(entries.filter((entry) => entry.kind === 'text').map((entry) => [entry.name, entry.value ?? ''])));
const label = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
const shown = (value: unknown) => value === null || value === undefined || value === '' ? 'NOT SET' : String(value);
const jsonValue = (value: unknown) => {
if (typeof value !== 'string' || (!value.startsWith('[') && !value.startsWith('{'))) return null;
try { return JSON.parse(value); } catch { return null; }
};
const attributeValue = (side: 'current' | 'proposed', key: string) => {
if (side === 'current') return currentSnapshot?.[key];
const names: Record<string, string> = {
calculation_type: 'calculationType',
gear_min: 'gearMin',
gear_max: 'gearMax',
weapon_min: 'weaponMin',
weapon_max: 'weaponMax',
skill_chip_min: 'chipMin',
skill_chip_max: 'chipMax',
cap_value: 'capValue'
};
return proposed[names[key] ?? key];
};
</script>
<section class="proposal-change-preview" aria-label="Proposed data comparison">
<div class="proposal-preview-head">
<div><span>CHANGE PREVIEW</span><small>LIVE REGISTRY COMPARED WITH CONTRIBUTOR SUBMISSION</small></div>
<b>{currentSnapshot ? 'BEFORE / AFTER' : 'NEW RECORD'}</b>
</div>
{#if module === 'attributes' && action === 'save'}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE</span><b>{currentSnapshot ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if currentSnapshot}
<div class="proposal-attribute-grid">
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('current', 'name'))}</strong></div>
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('current', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('current', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
</div>
{:else}
<div class="proposal-empty-state"><b>NEW ATTRIBUTE DEFINITION</b><span>No live registry record will be replaced.</span></div>
{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED DEFINITION</span><b>AFTER</b></header>
<div class="proposal-attribute-grid">
<div class="preview-field identity"><span>NAME</span><strong>{shown(attributeValue('proposed', 'name'))}</strong></div>
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('proposed', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('proposed', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
</div>
</article>
</div>
{:else}
<div class="proposal-editor-preview generic after">
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each entries as entry}
<div>
<span>{label(entry.name)}</span>
{#if entry.kind === 'file'}
<strong>{entry.fileName || 'UPLOADED FILE'}</strong><small>{entry.type || 'FILE EVIDENCE'}</small>
{:else if jsonValue(entry.value)}
<strong>{Array.isArray(jsonValue(entry.value)) ? `${jsonValue(entry.value).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
<small>{entry.value}</small>
{:else}
<strong>{shown(entry.value)}</strong>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</section>
@@ -36,8 +36,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
LEFT JOIN users reviewer ON reviewer.id=p.reviewed_by
LEFT JOIN user_profiles reviewer_profile ON reviewer_profile.user_id=reviewer.id
${where} ORDER BY p.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const proposalsWithSnapshots = await Promise.all(proposals.map(async (proposal) => ({
...proposal,
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null
: null
})));
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all();
return { proposals, pagination, status, module, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
return { proposals: proposalsWithSnapshots, pagination, status, module, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
};
export const actions: Actions = {
+6 -2
View File
@@ -3,6 +3,7 @@
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import ProposalChangePreview from '$lib/components/ProposalChangePreview.svelte';
let { data, form } = $props();
let reviewing = $state<any>(null);
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
@@ -69,8 +70,11 @@
<div class="isac-confirm-modal moderation-review" role="dialog" aria-modal="true" aria-labelledby="moderation-title">
<div class="editor-title"><div><p class="eyebrow">CHANGE CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="moderation-title">{reviewing.summary}</h3></div><button aria-label="Close review" onclick={() => reviewing = null}>×</button></div>
<div class="moderation-metadata"><span>DATA MODULE <b>{moduleLabel(reviewing.module)}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
<div class="evidence-header"><div><span>PROPOSED FORM PAYLOAD</span><small>CANONICAL VALIDATION RUNS WHEN APPROVED</small></div><b>{evidence(reviewing.payload_json).length} FIELDS</b></div>
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
<ProposalChangePreview module={reviewing.module} action={reviewing.action} payload={reviewing.payload_json} currentSnapshot={reviewing.current_snapshot}/>
<details>
<summary>RAW SUBMISSION EVIDENCE · {evidence(reviewing.payload_json).length} FIELDS</summary>
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
</details>
{#if reviewing.review_note}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{reviewing.review_note}</p></div>{/if}
{#if reviewing.status === 'pending'}
<div class="decision-tabs oversight-decision-tabs"><button class:active={decision === 'approve'} onclick={() => decision = 'approve'}><b>✓</b><span>APPROVE<small>APPLY TO LIVE DATA</small></span></button><button class:active={decision === 'correct'} onclick={() => decision = 'correct'}><b>✎</b><span>CORRECT<small>FIX MINOR ERRORS</small></span></button><button class:active={decision === 'reject'} onclick={() => decision = 'reject'}><b>×</b><span>REJECT<small>RETURN FOR REVISION</small></span></button></div>