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
|
should be rejected for contributor revision. Collapse the decision controls and
|
||||||
correction fields to one column on narrow screens.
|
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:
|
Data-navigation links with pending proposals use a dedicated four-column layout:
|
||||||
module code, flexible label, square orange count cell, then navigation arrow.
|
module code, flexible label, square orange count cell, then navigation arrow.
|
||||||
Never insert a count into the ordinary three-column link without changing its
|
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 users reviewer ON reviewer.id=p.reviewed_by
|
||||||
LEFT JOIN user_profiles reviewer_profile ON reviewer_profile.user_id=reviewer.id
|
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);
|
${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();
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||||
|
import ProposalChangePreview from '$lib/components/ProposalChangePreview.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let reviewing = $state<any>(null);
|
let reviewing = $state<any>(null);
|
||||||
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
|
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="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="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="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>
|
<ProposalChangePreview module={reviewing.module} action={reviewing.action} payload={reviewing.payload_json} currentSnapshot={reviewing.current_snapshot}/>
|
||||||
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
|
<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.review_note}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{reviewing.review_note}</p></div>{/if}
|
||||||
{#if reviewing.status === 'pending'}
|
{#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>
|
<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