Improve oversight change previews
This commit is contained in:
@@ -717,6 +717,17 @@ must be separately saved and audit logged before approval, and material changes
|
||||
should be rejected for contributor revision. Collapse the decision controls and
|
||||
correction fields to one column on narrow screens.
|
||||
|
||||
Proposal evidence is reviewer-facing product data, not JSON-first diagnostics.
|
||||
Open review in a wide, scroll-contained confirmation surface and present live
|
||||
and proposed values side by side using the visual structure and terminology of
|
||||
the source module's editor. Label the two states explicitly as `CURRENT LIVE /
|
||||
BEFORE` and `PROPOSED / AFTER`; for additions, replace the empty live panel with
|
||||
an explicit `NEW RECORD` state. Stack comparisons on narrow screens. Raw stored
|
||||
payloads remain available in a collapsed technical-evidence disclosure for
|
||||
troubleshooting and audit support, but must not be the primary review surface.
|
||||
Each module should add a purpose-built preview as its editor matures; until then,
|
||||
use compact labeled field cards rather than an exposed JSON document.
|
||||
|
||||
Data-navigation links with pending proposals use a dedicated four-column layout:
|
||||
module code, flexible label, square orange count cell, then navigation arrow.
|
||||
Never insert a count into the ordinary three-column link without changing its
|
||||
|
||||
+1
-1
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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user