116 lines
10 KiB
Svelte
116 lines
10 KiB
Svelte
<script lang="ts">
|
||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||
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';
|
||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||
let { data, form } = $props();
|
||
let reviewing = $state<any>(null);
|
||
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
|
||
let correctionEntries = $state<Array<{ index: number; name: string; value: string; kind: string; fileName?: string }>>([]);
|
||
let handledToken = $state('');
|
||
const date = (value: string) => new Date(value).toLocaleString();
|
||
const evidence = (payload: string) => (JSON.parse(payload) as any[]).map((entry) =>
|
||
entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry
|
||
);
|
||
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
|
||
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value === 'weaponmods' ? 'WEAPON MODS' : value === 'gearmods' ? 'GEAR MODS' : value.toUpperCase();
|
||
const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) })));
|
||
const openReview = (proposal: any) => {
|
||
reviewing = proposal;
|
||
decision = 'approve';
|
||
correctionEntries = (JSON.parse(proposal.payload_json) as any[]).map((entry, index) => ({
|
||
index, name: entry.name, value: entry.kind === 'text' ? entry.value : '', kind: entry.kind, fileName: entry.fileName
|
||
}));
|
||
};
|
||
const correctionPayload = () => JSON.stringify(correctionEntries.filter((entry) => entry.kind === 'text').map(({ index, value }) => ({ index, value })));
|
||
const longField = (entry: { name: string; value: string }) => entry.value.length > 80 || ['description', 'descriptionTemplate', 'effects', 'tiers', 'augmentEffects', 'baseBonuses', 'qualityValues'].includes(entry.name) || entry.value.startsWith('[') || entry.value.startsWith('{');
|
||
$effect(() => {
|
||
if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) {
|
||
handledToken = form.moderationToken;
|
||
reviewing = null;
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<svelte:head><title>Data Oversight — ISAC</title></svelte:head>
|
||
<div class="module-toolbar moderation-toolbar">
|
||
<div><p class="eyebrow">DATA OVERSIGHT / CONTRIBUTION CONTROL</p><h2>DATA OVERSIGHT</h2><p>Review, correct, and decide contributor intelligence before deployment to the live builder registry.</p></div>
|
||
<div class="queue-readout"><span>PENDING REVIEW</span><strong>{data.summary.pending ?? 0}</strong><small>CHANGE REQUESTS</small></div>
|
||
</div>
|
||
<KpiFilters parameter="status" active={data.status} items={[
|
||
{value:'pending',label:'PENDING',count:data.summary.pending ?? 0,description:'Awaiting operator decision'},
|
||
{value:'approved',label:'APPROVED',count:data.summary.approved ?? 0,description:'Applied to live data'},
|
||
{value:'rejected',label:'REJECTED',count:data.summary.rejected ?? 0,description:'Declined by oversight'},
|
||
{value:'failed',label:'FAILED',count:data.summary.failed ?? 0,description:'Blocked by validation'}
|
||
]}/>
|
||
<form class="audit-filter moderation-filter">
|
||
<input name="search" value={data.search} placeholder="CHANGE, RECORD, OR CONTRIBUTOR"/>
|
||
<SearchableAssignment items={moduleOptions} mode="single" compact fieldName="module" value={data.module} subjectLabel="DATA MODULE" searchPlaceholder="Search data modules…" emptySelectionLabel="ALL DATA MODULES"/>
|
||
<input type="hidden" name="status" value={data.status}/>
|
||
<button class="secondary">SEARCH QUEUE →</button>
|
||
</form>
|
||
<RegistryPagination pagination={data.pagination}/>
|
||
<div class="dashboard-table moderation-queue">
|
||
<div class="dashboard-row moderation-row table-heading"><SortableColumnHeader label="CHANGE" column="change" sort={data.sort}/><SortableColumnHeader label="CONTRIBUTOR" column="contributor" sort={data.sort}/><SortableColumnHeader label="SUBMITTED" column="submitted" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATUS" column="status" sort={data.sort}/><span></span></div>
|
||
{#each data.proposals as proposal}
|
||
<div class="dashboard-row moderation-row mobile-detail-row">
|
||
<div class="change-identity"><span>{proposal.module === 'skillchips' ? 'CHIP' : proposal.module === 'specializations' ? 'CLASS' : proposal.module.slice(0,4).toUpperCase()}</span><div><strong>{proposal.summary}</strong><small>{moduleLabel(proposal.module)} / {operationLabel(proposal.action)}</small></div></div>
|
||
<div><strong>{proposal.proposer_name}</strong><small>AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}</small></div>
|
||
<span>{date(proposal.created_at)}</span>
|
||
<span class="proposal-state" class:pending={proposal.status === 'pending'} class:approved={proposal.status === 'approved'} class:declined={proposal.status === 'rejected' || proposal.status === 'failed'}><i></i>{proposal.status.toUpperCase()}</span>
|
||
{#if proposal.status === 'pending'}<button onclick={() => openReview(proposal)}>REVIEW →</button>{:else}<button onclick={() => openReview(proposal)}>EVIDENCE →</button>{/if}
|
||
<button class="mobile-row-open" aria-label={`${proposal.status === 'pending' ? 'Review' : 'View evidence for'} ${proposal.summary}`} onclick={() => openReview(proposal)}></button>
|
||
</div>
|
||
{:else}<div class="empty-dashboard"><strong>NO {data.status.toUpperCase()} CHANGE REQUESTS</strong><span>Adjust the module or search filters to inspect another part of the queue.</span></div>{/each}
|
||
</div>
|
||
|
||
{#if reviewing}
|
||
<div class="isac-modal-backdrop" role="presentation">
|
||
<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>
|
||
<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>
|
||
{#if decision === 'correct'}
|
||
<PendingForm action="?/correct">
|
||
{#snippet children(pending)}
|
||
<input type="hidden" name="id" value={reviewing.id}/><input type="hidden" name="corrections" value={correctionPayload()}/>
|
||
<div class="proposal-correction-head"><div><span>OVERSIGHT CORRECTIONS</span><small>FILES REMAIN IMMUTABLE · CHANGES ARE AUDIT LOGGED</small></div><b>{correctionEntries.filter((entry) => entry.kind === 'text').length} EDITABLE FIELDS</b></div>
|
||
<div class="proposal-correction-grid">
|
||
{#each correctionEntries as entry}
|
||
{#if entry.kind === 'text'}
|
||
<label class="field"><span>{operationLabel(entry.name)} <small>FIELD {entry.index + 1}</small></span>{#if longField(entry)}<textarea bind:value={entry.value}></textarea>{:else}<input bind:value={entry.value}/>{/if}</label>
|
||
{:else}
|
||
<div class="immutable-proposal-file"><span>{operationLabel(entry.name)}</span><b>{entry.fileName}</b><small>ORIGINAL FILE EVIDENCE · NOT EDITABLE</small></div>
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}
|
||
<div class="moderation-warning"><b>!</b><span>SAVE ONLY MINOR CORRECTIONS. MATERIAL CHANGES SHOULD BE REJECTED FOR CONTRIBUTOR REVISION.</span></div>
|
||
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE CORRECTIONS →'}</button></div>
|
||
{/snippet}
|
||
</PendingForm>
|
||
{:else}
|
||
<PendingForm action={decision === 'approve' ? '?/approve' : '?/reject'}>
|
||
{#snippet children(pending)}
|
||
<input type="hidden" name="id" value={reviewing.id}/>
|
||
<label class="field">{decision === 'approve' ? 'OPTIONAL OVERSIGHT NOTE' : 'REJECTION REASON — REQUIRED'}<textarea name="note" required={decision === 'reject'} placeholder={decision === 'approve' ? 'Record context for the audit trail…' : 'Explain what the contributor should revise…'}></textarea></label>
|
||
{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}
|
||
<div class="moderation-warning" class:rejecting={decision === 'reject'}><b>{decision === 'approve' ? '!' : '×'}</b><span>{decision === 'approve' ? 'APPROVAL WILL RUN VALIDATION AND MUTATE THE LIVE BUILDER DATA SET.' : 'REJECTION WILL PRESERVE THIS REQUEST AND YOUR REASON IN THE AUDIT TRAIL.'}</span></div>
|
||
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class={decision === 'approve' ? 'primary' : 'danger'} disabled={pending} aria-busy={pending}>{pending ? 'PROCESSING…' : decision === 'approve' ? 'CONFIRM & DEPLOY →' : 'CONFIRM REJECTION →'}</button></div>
|
||
{/snippet}
|
||
</PendingForm>
|
||
{/if}
|
||
{:else}<div class="form-actions"><button class="primary" onclick={() => reviewing = null}>CLOSE EVIDENCE</button></div>{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|