Files
ss-tools/frontend/src/lib/components/dataset-review/ValidationFindingsPanel.svelte
2026-03-20 17:20:24 +03:00

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] -->