344 lines
14 KiB
Svelte
344 lines
14 KiB
Svelte
<!-- [DEF:ValidationFindingsPanel:Component] -->
|
|
<!-- @COMPLEXITY: 3 -->
|
|
<!-- @SEMANTICS: dataset-review, findings, severity, readiness, actionability -->
|
|
<!-- @PURPOSE: Present validation findings grouped by severity with explicit resolution and actionability signals. -->
|
|
<!-- @LAYER: UI -->
|
|
<!-- @RELATION: [BINDS_TO] ->[DatasetReviewWorkspace] -->
|
|
<!-- @UX_STATE: Blocking -> Blocking findings are visually dominant and block launch flow. -->
|
|
<!-- @UX_STATE: Warning -> Warnings remain visible with explicit approval or defer actions. -->
|
|
<!-- @UX_STATE: Informational -> Low-priority findings are collapsed or secondary. -->
|
|
<!-- @UX_FEEDBACK: Resolving or approving an item updates readiness state immediately. -->
|
|
<!-- @UX_RECOVERY: Users can jump from a finding directly to the relevant remediation area. -->
|
|
<!-- @UX_REACTIVITY: Uses $props and $derived only; no legacy reactive syntax. -->
|
|
|
|
<script>
|
|
import { t } from "$lib/i18n";
|
|
|
|
let {
|
|
findings = [],
|
|
recommendedAction = "",
|
|
onjump = () => {},
|
|
} = $props();
|
|
|
|
const groupedFindings = $derived(groupFindingsBySeverity(findings || []));
|
|
const blockingFindings = $derived(groupedFindings.blocking);
|
|
const warningFindings = $derived(groupedFindings.warning);
|
|
const informationalFindings = $derived(groupedFindings.informational);
|
|
const totalFindings = $derived(
|
|
blockingFindings.length + warningFindings.length + informationalFindings.length,
|
|
);
|
|
|
|
function groupFindingsBySeverity(items) {
|
|
return (items || []).reduce(
|
|
(accumulator, finding) => {
|
|
const severity = String(finding?.severity || "informational");
|
|
if (severity === "blocking") {
|
|
accumulator.blocking.push(finding);
|
|
return accumulator;
|
|
}
|
|
if (severity === "warning") {
|
|
accumulator.warning.push(finding);
|
|
return accumulator;
|
|
}
|
|
accumulator.informational.push(finding);
|
|
return accumulator;
|
|
},
|
|
{ blocking: [], warning: [], informational: [] },
|
|
);
|
|
}
|
|
|
|
function buildTargetFromFinding(finding) {
|
|
const area = String(finding?.area || "");
|
|
if (area === "source_intake") return "intake";
|
|
if (area === "dataset_profile") return "summary";
|
|
if (area === "semantic_enrichment") return "semantics";
|
|
if (area === "clarification") return "clarification";
|
|
if (area === "filter_recovery") return "filters";
|
|
if (area === "template_mapping") return "mappings";
|
|
if (area === "compiled_preview") return "preview";
|
|
if (area === "launch") return "launch";
|
|
return "summary";
|
|
}
|
|
|
|
function jumpToFindingTarget(finding) {
|
|
onjump({
|
|
target: buildTargetFromFinding(finding),
|
|
finding,
|
|
});
|
|
}
|
|
|
|
function getResolutionLabel(resolutionState) {
|
|
const normalized = String(resolutionState || "open");
|
|
return $t.dataset_review?.findings?.resolution?.[normalized] || normalized;
|
|
}
|
|
|
|
function getAreaLabel(area) {
|
|
const normalized = String(area || "dataset_profile");
|
|
return $t.dataset_review?.findings?.areas?.[normalized] || normalized;
|
|
}
|
|
|
|
function getRecommendedActionLabel(action) {
|
|
const normalized = String(action || "");
|
|
return $t.dataset_review?.workspace?.actions?.[normalized] || normalized;
|
|
}
|
|
|
|
function getFindingMessage(finding) {
|
|
return String(finding?.message || "").trim();
|
|
}
|
|
|
|
function getFindingTechnicalReference(finding) {
|
|
return String(finding?.caused_by_ref || "").trim();
|
|
}
|
|
|
|
function getFindingResolutionNote(finding) {
|
|
return String(finding?.resolution_note || "").trim();
|
|
}
|
|
</script>
|
|
|
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
|
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
{$t.dataset_review?.findings?.eyebrow}
|
|
</p>
|
|
<h2 class="text-xl font-semibold text-slate-900">
|
|
{$t.dataset_review?.findings?.title}
|
|
</h2>
|
|
<p class="mt-1 max-w-3xl text-sm text-slate-600">
|
|
{$t.dataset_review?.findings?.description}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-right">
|
|
<div class="text-xs uppercase tracking-wide text-slate-500">
|
|
{$t.dataset_review?.findings?.next_action_label}
|
|
</div>
|
|
<div class="mt-1 text-sm font-medium text-slate-900">
|
|
{getRecommendedActionLabel(recommendedAction)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if totalFindings === 0}
|
|
<div class="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-800">
|
|
{$t.dataset_review?.findings?.empty}
|
|
</div>
|
|
{:else}
|
|
<div class="mt-5 space-y-4">
|
|
<section class="rounded-2xl border border-red-200 bg-red-50 p-4">
|
|
<div class="flex items-center gap-3">
|
|
<h3 class="text-sm font-semibold text-slate-900">
|
|
{$t.dataset_review?.findings?.blocking_title}
|
|
</h3>
|
|
<span class="rounded-full bg-red-100 px-2.5 py-1 text-xs font-medium text-red-700">
|
|
{blockingFindings.length}
|
|
</span>
|
|
</div>
|
|
|
|
{#if blockingFindings.length === 0}
|
|
<p class="mt-3 text-sm text-slate-600">
|
|
{$t.dataset_review?.findings?.blocking_empty}
|
|
</p>
|
|
{:else}
|
|
<div class="mt-3 space-y-3">
|
|
{#each blockingFindings as finding}
|
|
<article class="rounded-xl border border-white/70 bg-white/80 p-4 shadow-sm">
|
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<h4 class="text-sm font-semibold text-slate-900">
|
|
{finding.title}
|
|
</h4>
|
|
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
|
{finding.code}
|
|
</span>
|
|
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
|
|
{getAreaLabel(finding.area)}
|
|
</span>
|
|
</div>
|
|
|
|
<p class="mt-2 text-sm leading-6 text-slate-700">
|
|
{finding.message}
|
|
</p>
|
|
|
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
|
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
|
|
{$t.dataset_review?.findings?.resolution_label}:
|
|
{getResolutionLabel(finding.resolution_state)}
|
|
</span>
|
|
|
|
{#if finding.caused_by_ref}
|
|
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
|
|
{$t.dataset_review?.findings?.reference_label}:
|
|
{finding.caused_by_ref}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if finding.resolution_note}
|
|
<p class="mt-2 text-xs text-slate-500">{finding.resolution_note}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
|
|
onclick={() => jumpToFindingTarget(finding)}
|
|
>
|
|
{$t.dataset_review?.findings?.jump_action}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="rounded-2xl border border-amber-200 bg-amber-50 p-4">
|
|
<div class="flex items-center gap-3">
|
|
<h3 class="text-sm font-semibold text-slate-900">
|
|
{$t.dataset_review?.findings?.warning_title}
|
|
</h3>
|
|
<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700">
|
|
{warningFindings.length}
|
|
</span>
|
|
</div>
|
|
|
|
{#if warningFindings.length === 0}
|
|
<p class="mt-3 text-sm text-slate-600">
|
|
{$t.dataset_review?.findings?.warning_empty}
|
|
</p>
|
|
{:else}
|
|
<div class="mt-3 space-y-3">
|
|
{#each warningFindings as finding}
|
|
<article class="rounded-xl border border-white/70 bg-white/80 p-4 shadow-sm">
|
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<h4 class="text-sm font-semibold text-slate-900">
|
|
{finding.title}
|
|
</h4>
|
|
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
|
{finding.code}
|
|
</span>
|
|
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
|
|
{getAreaLabel(finding.area)}
|
|
</span>
|
|
</div>
|
|
|
|
<p class="mt-2 text-sm leading-6 text-slate-700">
|
|
{finding.message}
|
|
</p>
|
|
|
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
|
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
|
|
{$t.dataset_review?.findings?.resolution_label}:
|
|
{getResolutionLabel(finding.resolution_state)}
|
|
</span>
|
|
|
|
{#if finding.caused_by_ref}
|
|
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
|
|
{$t.dataset_review?.findings?.reference_label}:
|
|
{finding.caused_by_ref}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if finding.resolution_note}
|
|
<p class="mt-2 text-xs text-slate-500">{finding.resolution_note}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
|
|
onclick={() => jumpToFindingTarget(finding)}
|
|
>
|
|
{$t.dataset_review?.findings?.jump_action}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
|
<div class="flex items-center gap-3">
|
|
<h3 class="text-sm font-semibold text-slate-900">
|
|
{$t.dataset_review?.findings?.informational_title}
|
|
</h3>
|
|
<span class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-medium text-slate-700">
|
|
{informationalFindings.length}
|
|
</span>
|
|
</div>
|
|
|
|
{#if informationalFindings.length === 0}
|
|
<p class="mt-3 text-sm text-slate-600">
|
|
{$t.dataset_review?.findings?.informational_empty}
|
|
</p>
|
|
{:else}
|
|
<div class="mt-3 space-y-3">
|
|
{#each informationalFindings as finding}
|
|
<article class="rounded-xl border border-white/70 bg-white/80 p-4 shadow-sm">
|
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<h4 class="text-sm font-semibold text-slate-900">
|
|
{finding.title}
|
|
</h4>
|
|
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
|
{finding.code}
|
|
</span>
|
|
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
|
|
{getAreaLabel(finding.area)}
|
|
</span>
|
|
</div>
|
|
|
|
<p class="mt-2 text-sm leading-6 text-slate-700">
|
|
{finding.message}
|
|
</p>
|
|
|
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
|
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
|
|
{$t.dataset_review?.findings?.resolution_label}:
|
|
{getResolutionLabel(finding.resolution_state)}
|
|
</span>
|
|
|
|
{#if finding.caused_by_ref}
|
|
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
|
|
{$t.dataset_review?.findings?.reference_label}:
|
|
{finding.caused_by_ref}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if finding.resolution_note}
|
|
<p class="mt-2 text-xs text-slate-500">{finding.resolution_note}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
|
|
onclick={() => jumpToFindingTarget(finding)}
|
|
>
|
|
{$t.dataset_review?.findings?.jump_action}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- [/DEF:ValidationFindingsPanel:Component] --> |