Files
atc-agents/ui/src/components/features/DataQualityView.tsx
T

868 lines
40 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useState, Fragment } from 'react'
import {
AlertTriangle,
CheckCircle2,
FileSearch,
FileText,
Image,
Layers,
Loader2,
RefreshCw,
Table2,
Upload,
XCircle,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Dimension = {
id: string
label: string
description: string
score: number
level: string
findings: string[]
recommended_actions?: string[]
}
type ColumnProfile = {
name: string
dtype: string
null_pct: number
unique_count: number
quality_flags: string[]
sample_values?: string[]
numeric?: { min: number; max: number; mean: number; outliers: number }
text?: { avg_length: number; empty_strings: number }
top_values?: { value: string; count: number }[]
}
type Violations = {
violation_count?: number
violation_pct?: number
sample_rows?: { row_index: number; values: Record<string, unknown> }[]
location_hint?: string
affected_columns?: string[]
}
type DqCheck = {
id?: string
suite: string
success?: boolean
status?: string
outcome?: string
expectation?: string
name?: string
metric?: string
check?: string
test?: string
rule?: string
monitor?: string
result?: string
detail?: string
column?: string
violations?: Violations
meta?: Record<string, unknown>
}
type GxCheck = DqCheck & { expectation: string; success: boolean; result: string }
type SodaCheck = DqCheck & { name: string; check: string; outcome: string; detail: string }
type DocStructure = {
pages: number
pictures: number
tables: number
text_blocks: number
headings: number
paragraphs: number
list_items?: number
form_items: number
key_value_pairs: number
label_counts?: Record<string, number>
table_details?: { index: number; rows: number; cols: number; cells: number; preview?: string }[]
picture_details?: { index: number; label: string; has_image: boolean; captions: number }[]
outline?: { type: string; text: string; level?: number }[]
}
type AssessResult = {
ok: boolean
report_id: string
overall_score: number
maturity_level: string
maturity_description?: string
rows: number
columns: number
dimensions: Dimension[]
column_profiles: ColumnProfile[]
action_items: { priority: string; dimension: string; score: number; action: string }[]
checks?: {
great_expectations: GxCheck[]
soda_core: SodaCheck[]
deequ?: DqCheck[]
pandera?: DqCheck[]
dbt_expectations?: DqCheck[]
monte_carlo?: DqCheck[]
affirm?: DqCheck[]
}
checks_summary: Record<string, { total: number; passed?: number; warnings?: number }>
executive_summary?: {
headline: string
dataset: string
checks_run: number
issues_found: number
flagged_columns: number
tools: string[]
recommendation: string
}
docling?: { used: boolean; parse_id?: string; document_structure?: DocStructure; stats?: Record<string, number>; images?: DocImage[] }
rag_ingest?: { ok: boolean; duplicate?: boolean; chunks?: number; message?: string; error?: string }
report_url: string
}
type DocImage = {
index: number
label: string
available: boolean
url?: string
width?: number
height?: number
mimetype?: string
dpi?: number
bytes?: number
captions?: string[]
}
type ParseResult = {
ok: boolean
parse_id: string
filename: string
status: string
processing_time_sec?: number
formats_available: string[]
document_structure: DocStructure
images?: DocImage[]
stats: Record<string, number>
content: { preview_markdown?: string; preview_html?: string; markdown?: string; html?: string }
table_preview?: string[]
errors?: string[]
parse_json_url?: string
}
type Capabilities = {
maturity_dimensions: { id: string; label: string; description: string }[]
maturity_levels: { min_score: number; label: string; description: string }[]
supported_data_formats: string[]
supported_document_formats: string[]
tools: Record<string, { status: string; capabilities?: string[] }>
docling_online: boolean
}
type ReportSummary = {
id: string
filename: string
ts: string
overall_score: number
maturity_level: string
rows: number
columns: number
}
type Tab = 'assess' | 'docling' | 'reports'
const SCORE_COLOR = (s: number) => (s >= 80 ? 'text-success' : s >= 60 ? 'text-warning' : 'text-danger')
const BAR_COLOR = (s: number) => (s >= 80 ? 'bg-success' : s >= 60 ? 'bg-warning' : 'bg-danger')
export function DataQualityView() {
const [tab, setTab] = useState<Tab>('assess')
const [caps, setCaps] = useState<Capabilities | null>(null)
const [loading, setLoading] = useState(false)
const [assess, setAssess] = useState<AssessResult | null>(null)
const [parse, setParse] = useState<ParseResult | null>(null)
const [parseFormat, setParseFormat] = useState<'markdown' | 'html'>('markdown')
const [reports, setReports] = useState<ReportSummary[]>([])
const [error, setError] = useState<string | null>(null)
const [expandedCol, setExpandedCol] = useState<string | null>(null)
const [expandedCheck, setExpandedCheck] = useState<string | null>(null)
const [expandedDim, setExpandedDim] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState<string>('great_expectations')
const loadMeta = useCallback(async () => {
try {
const [c, r] = await Promise.all([fetch('/dq/capabilities'), fetch('/dq/reports')])
if (c.ok) setCaps(await c.json())
if (r.ok) {
const j = await r.json()
setReports(j.reports || [])
}
} catch {
/* ignore */
}
}, [])
useEffect(() => {
loadMeta()
}, [loadMeta])
const onAssess = async (file: File) => {
setLoading(true)
setError(null)
setAssess(null)
const fd = new FormData()
fd.append('file', file)
try {
const r = await fetch('/dq/assess', { method: 'POST', body: fd })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || j.detail || 'Assessment failed')
return
}
setAssess(j as AssessResult)
loadMeta()
} catch {
setError('Connection failed — check DQ API')
} finally {
setLoading(false)
}
}
const onParse = async (file: File) => {
setLoading(true)
setError(null)
setParse(null)
const fd = new FormData()
fd.append('file', file)
fd.append('to_formats', 'md,html,json')
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 300000)
try {
const r = await fetch('/dq/parse', { method: 'POST', body: fd, signal: ctrl.signal })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(typeof j.error === 'string' ? j.error : JSON.stringify(j.error || j).slice(0, 200) || 'Docling parse failed')
return
}
setParse(j as ParseResult)
loadMeta()
} catch (e) {
setError(e instanceof Error && e.name === 'AbortError' ? 'Timeout — document too large or Docling overloaded' : 'Docling unavailable')
} finally {
clearTimeout(timer)
setLoading(false)
}
}
const tabs: { id: Tab; label: string; icon: typeof FileSearch }[] = [
{ id: 'assess', label: 'Maturity Assessment', icon: FileSearch },
{ id: 'docling', label: 'Docling Parser', icon: FileText },
{ id: 'reports', label: 'Reports', icon: CheckCircle2 },
]
return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Data Quality & Maturity Platform</h2>
<p className="text-[11px] text-foreground-muted">
Full data maturity assessment for customer data Docling, Great Expectations, Soda Core
</p>
</div>
<div className="flex items-center gap-2">
<span className={cn('rounded-full px-2.5 py-1 text-[10px] font-medium', caps?.docling_online ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
Docling {caps?.docling_online ? '● online' : '○ offline'}
</span>
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
<RefreshCw className="h-4 w-4" />
</button>
<a href={`http://${window.location.hostname}:5001/ui/`} target="_blank" rel="noreferrer" className="rounded border border-docker/40 bg-docker/15 px-2 py-1 text-[10px] text-docker">
Docling UI
</a>
</div>
</div>
{caps && (
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<CapCard title="Maturity Engine" items={caps.maturity_dimensions.map((d) => d.label)} icon={Layers} />
<CapCard title="Data Quality Tools" items={['Great Expectations', 'Soda Core', 'Deequ', 'Pandera', 'dbt', 'Monte Carlo', 'Affirm', 'Pandas']} icon={CheckCircle2} />
<CapCard title="Document Parsing" items={caps.tools.docling?.capabilities || ['PDF', 'PPTX', 'DOCX']} icon={FileText} />
<CapCard title="File formats" items={[...caps.supported_data_formats.slice(0, 4), ...caps.supported_document_formats.slice(0, 3)]} icon={Upload} />
</div>
)}
</header>
<div className="flex shrink-0 gap-1 border-b border-border bg-surface-overlay/20 px-3 py-2">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={cn('flex items-center gap-1.5 rounded-md px-3 py-2 text-[11px] font-medium transition-all', tab === id ? subTabActive : subTabIdle)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-[11px] text-danger">
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{tab === 'assess' && (
<div className="space-y-5">
<UploadZone
loading={loading}
label="Upload customer data for full maturity assessment"
hint="CSV · Excel · JSON · Parquet · PDF · PPTX · DOCX"
accept=".csv,.tsv,.xlsx,.xls,.json,.parquet,.pdf,.pptx,.ppt,.docx"
onFile={onAssess}
/>
{loading && <LoadingMsg text="Analyzing: 6 maturity dimensions · GE checks · Soda checks · column profiles…" />}
{assess && (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<StatCard label="Overall Score" value={`${assess.overall_score}`} sub="/100" accent />
<StatCard label="Maturity Level" value={assess.maturity_level} sub={assess.maturity_description} />
<StatCard label="Dataset" value={`${assess.rows.toLocaleString()}`} sub={`${assess.columns} columns`} />
<StatCard label="DQ Checks" value={String(assess.executive_summary?.checks_run || Object.values(assess.checks_summary).reduce((s, x) => s + (x.total || 0), 0))} sub="across 7 tools" />
<StatCard label="Issues" value={String(assess.executive_summary?.issues_found ?? 0)} sub="failed/warn checks" warn={(assess.executive_summary?.issues_found ?? 0) > 0} />
</div>
{assess.docling?.used && assess.docling.document_structure && (
<>
<DocStructurePanel structure={assess.docling.document_structure} title="Document structure (via Docling)" />
{assess.docling.images && assess.docling.images.length > 0 && assess.docling.parse_id && (
<ImageGallery images={assess.docling.images} parseId={assess.docling.parse_id} />
)}
</>
)}
{assess.rag_ingest && (
<div className={cn(
'rounded-lg border px-3 py-2 text-[11px]',
assess.rag_ingest.ok ? 'border-success/30 bg-success/10 text-success' : 'border-warning/30 bg-warning/10 text-warning',
)}>
<p className="font-medium">Knowledge Chat sync</p>
<p className="text-foreground-muted">
{assess.rag_ingest.ok
? (assess.rag_ingest.duplicate
? `Already in Knowledge Chat — ${assess.rag_ingest.message || 'you can chat immediately.'}`
: `Indexed for chat: ${assess.rag_ingest.chunks ?? '?'} text chunks. Open Knowledge Chat to ask questions.`)
: (assess.rag_ingest.error || 'Could not sync to Knowledge Chat')}
</p>
</div>
)}
{assess.executive_summary && (
<section className="rounded-lg border border-docker/30 bg-docker/5 p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-docker">Executive Summary Customer Report</h3>
<p className="text-[13px] font-semibold text-foreground">{assess.executive_summary.headline}</p>
<p className="mt-1 text-[11px] text-foreground-muted">{assess.executive_summary.dataset}</p>
<div className="mt-2 flex flex-wrap gap-3 text-[10px]">
<span>{assess.executive_summary.checks_run} checks run</span>
<span className={assess.executive_summary.issues_found > 0 ? 'text-warning' : 'text-success'}>{assess.executive_summary.issues_found} issues found</span>
<span>{assess.executive_summary.flagged_columns} flagged columns</span>
</div>
<p className="mt-2 border-t border-border/50 pt-2 text-[11px] text-foreground-muted">
<strong className="text-foreground">Recommendation:</strong> {assess.executive_summary.recommendation}
</p>
</section>
)}
<div className="flex flex-wrap gap-2">
<a href={assess.report_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
Full HTML report
</a>
<a href={assess.report_url + '/json'} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
JSON export
</a>
</div>
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">Data Quality Tools click any row for error details</h3>
<div className="mb-2 flex flex-wrap gap-1">
{([
['great_expectations', 'Great Expectations'],
['soda_core', 'Soda Core'],
['deequ', 'AWS Deequ'],
['pandera', 'Pandera'],
['dbt_expectations', 'dbt Expectations'],
['monte_carlo', 'Monte Carlo'],
['affirm', 'Affirm'],
] as const).map(([key, label]) => {
const items = assess.checks?.[key] || []
if (!items.length) return null
const summary = assess.checks_summary?.[key]
const badge = summary?.passed !== undefined ? `${summary.passed}/${summary.total}` : `${items.length}`
return (
<button key={key} type="button" onClick={() => setActiveTool(key)} className={cn('rounded-md px-2.5 py-1 text-[10px]', activeTool === key ? subTabActive : subTabIdle)}>
{label} ({badge})
</button>
)
})}
</div>
{assess.checks && (
<CheckTable
title={activeTool.replace(/_/g, ' ')}
checks={normalizeChecks(assess.checks[activeTool as keyof typeof assess.checks] || [])}
expandedId={expandedCheck}
onToggle={setExpandedCheck}
/>
)}
</section>
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">6 Maturity Dimensions</h3>
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
{assess.dimensions.map((d) => (
<DimensionCard key={d.id} dimension={d} expanded={expandedDim === d.id} onToggle={() => setExpandedDim(expandedDim === d.id ? null : d.id)} />
))}
</div>
</section>
{assess.action_items.length > 0 && (
<section>
<h3 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
<AlertTriangle className="h-3.5 w-3.5 text-warning" /> Remediation Roadmap
</h3>
<div className="space-y-1.5">
{assess.action_items.map((a, i) => (
<div key={i} className={cn('rounded-lg border px-3 py-2 text-[11px]', a.priority === 'high' ? 'border-danger/40 bg-danger/10' : a.priority === 'medium' ? 'border-warning/40 bg-warning/10' : 'border-border bg-surface-overlay/40')}>
<span className="font-bold uppercase text-foreground-faint">{a.priority}</span>
{' · '}<strong>{a.dimension}</strong> ({a.score}): {a.action}
</div>
))}
</div>
</section>
)}
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
Column profiles ({assess.column_profiles.length})
</h3>
<ColumnTable profiles={assess.column_profiles} expandedCol={expandedCol} onToggle={setExpandedCol} />
</section>
</div>
)}
</div>
)}
{tab === 'docling' && (
<div className="space-y-5">
<p className="text-[12px] leading-relaxed text-foreground-muted">
Docling extracts text, tables, images and document structure from PDF, PowerPoint, Word, Excel and images.
Output: Markdown, HTML, JSON with pages, images, tables and document outline.
</p>
<UploadZone
loading={loading}
label="Upload document for Docling parsing"
hint="PDF · PPTX · DOCX · XLSX · PNG · JPG · TIFF · MD · HTML"
accept=".pdf,.pptx,.ppt,.docx,.doc,.xlsx,.png,.jpg,.jpeg,.tiff,.txt,.md,.html"
onFile={onParse}
/>
{loading && <LoadingMsg text="Docling processing document — OCR, table detection, images (30180 sec)…" />}
{parse && (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6">
<StatCard label="File" value={parse.filename.length > 20 ? parse.filename.slice(0, 18) + '…' : parse.filename} sub={parse.status} />
<StatCard label="Processing" value={`${(parse.processing_time_sec || 0).toFixed(1)}s`} sub={`Formats: ${parse.formats_available.join(', ')}`} />
<StatCard label="Pages" value={String(parse.document_structure?.pages ?? parse.stats.pages ?? 0)} icon={Layers} />
<StatCard label="Images" value={String(parse.document_structure?.pictures ?? 0)} icon={Image} accent />
<StatCard label="Tables" value={String(parse.document_structure?.tables ?? 0)} icon={Table2} />
<StatCard label="Text blocks" value={String(parse.document_structure?.text_blocks ?? 0)} sub={`${parse.stats.words?.toLocaleString() ?? 0} words`} />
</div>
<DocStructurePanel structure={parse.document_structure} title="Document analysis" />
{parse.images && parse.images.filter((i) => i.available).length > 0 && (
<ImageGallery images={parse.images} parseId={parse.parse_id} />
)}
{parse.document_structure?.outline && parse.document_structure.outline.length > 0 && (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-3">
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Document outline</h3>
<ul className="space-y-1 text-[11px]">
{parse.document_structure.outline.map((o, i) => (
<li key={i} className="flex gap-2" style={{ paddingLeft: (o.level || 0) * 12 }}>
<span className="shrink-0 rounded bg-docker/20 px-1 font-mono text-[9px] text-docker">{o.type}</span>
<span className="text-foreground-muted">{o.text}</span>
</li>
))}
</ul>
</section>
)}
<div className="flex gap-1">
{(['markdown', 'html'] as const).map((f) => (
<button key={f} type="button" onClick={() => setParseFormat(f)} className={cn('rounded-md px-3 py-1.5 text-[11px] font-medium', parseFormat === f ? subTabActive : subTabIdle)}>
{f.toUpperCase()}
</button>
))}
{parse.parse_json_url && (
<a href={parse.parse_json_url} target="_blank" rel="noreferrer" className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
Full JSON
</a>
)}
</div>
{parse.table_preview && parse.table_preview.length > 0 && (
<section>
<h3 className="mb-1 text-[11px] font-semibold uppercase text-foreground-faint">Tables (markdown preview)</h3>
<pre className="scrollbar-thin max-h-40 overflow-auto rounded-lg border border-border bg-surface-overlay p-3 font-mono text-[10px]">
{parse.table_preview.join('\n')}
</pre>
</section>
)}
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase text-foreground-faint">Extracted content</h3>
{parseFormat === 'html' && (parse.content.preview_html || parse.content.html) ? (
<div className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-2">
<div className="rounded bg-white p-4 text-black" dangerouslySetInnerHTML={{ __html: parse.content.preview_html || parse.content.html || '' }} />
</div>
) : (
<pre className="scrollbar-thin max-h-[500px] overflow-auto rounded-lg border border-border bg-surface-overlay p-4 font-mono text-[11px] leading-relaxed text-foreground">
{parse.content.preview_markdown || parse.content.markdown || '(no content)'}
</pre>
)}
</section>
</div>
)}
</div>
)}
{tab === 'reports' && (
<div className="space-y-2">
{reports.length === 0 ? (
<p className="py-12 text-center text-sm text-foreground-muted">No reports yet upload customer data in Maturity Assessment.</p>
) : (
reports.map((r) => (
<a key={r.id} href={`/dq/report/${r.id}`} target="_blank" rel="noreferrer"
className="flex items-center justify-between rounded-lg border border-border bg-surface-overlay/30 px-4 py-3 transition-all hover:border-docker/40 hover:bg-docker/10">
<div>
<p className="text-[12px] font-medium">{r.filename}</p>
<p className="text-[10px] text-foreground-faint">{r.ts} · {r.rows?.toLocaleString()} rows · {r.columns} cols</p>
</div>
<div className="text-right">
<p className={cn('text-xl font-bold', SCORE_COLOR(r.overall_score))}>{r.overall_score}</p>
<p className="text-[10px] text-foreground-muted">{r.maturity_level}</p>
</div>
</a>
))
)}
</div>
)}
</div>
</div>
)
}
function ImageGallery({ images, parseId }: { images: DocImage[]; parseId: string }) {
const available = images.filter((i) => i.available)
const [lightbox, setLightbox] = useState<number | null>(null)
if (!available.length) {
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
Images detected ({images.length}) no embedded export
</h3>
<p className="text-[11px] text-foreground-muted">Re-upload the document to extract images (embedded mode).</p>
</section>
)
}
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">
<Image className="h-4 w-4 text-docker" />
Images extracted by Docling ({available.length})
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{available.map((img) => (
<button
key={img.index}
type="button"
onClick={() => setLightbox(img.index)}
className="group overflow-hidden rounded-lg border border-border bg-surface-raised text-left transition-all hover:border-docker/50 hover:shadow-docker"
>
<div className="flex aspect-[4/3] items-center justify-center overflow-hidden bg-black/20">
<img
src={img.url || `/dq/parse/${parseId}/image/${img.index}`}
alt={img.label}
className="max-h-full max-w-full object-contain transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
<div className="p-2">
<p className="text-[10px] font-medium text-foreground">#{img.index + 1} {img.label}</p>
<p className="text-[9px] text-foreground-faint">
{img.width && img.height ? `${Math.round(img.width)}×${Math.round(img.height)}` : ''}
{img.dpi ? ` · ${img.dpi}dpi` : ''}
{img.bytes ? ` · ${(img.bytes / 1024).toFixed(0)}KB` : ''}
</p>
</div>
</button>
))}
</div>
{lightbox !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" onClick={() => setLightbox(null)}>
<div className="relative max-h-[90vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
<img
src={`/dq/parse/${parseId}/image/${lightbox}`}
alt={`Image ${lightbox + 1}`}
className="max-h-[85vh] max-w-full rounded-lg object-contain"
/>
<button type="button" onClick={() => setLightbox(null)} className="absolute -top-3 -right-3 rounded-full bg-surface-raised px-2 py-1 text-xs text-foreground"></button>
</div>
</div>
)}
</section>
)
}
function DocStructurePanel({ structure, title }: { structure: DocStructure; title: string }) {
return (
<section className="rounded-lg border border-border bg-surface-overlay/30 p-4">
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-foreground-faint">{title}</h3>
<div className="mb-3 grid grid-cols-3 gap-2 sm:grid-cols-6">
{[
{ label: 'Pagina\'s', value: structure.pages, icon: Layers },
{ label: 'Images', value: structure.pictures, icon: Image },
{ label: 'Tables', value: structure.tables, icon: Table2 },
{ label: 'Headings', value: structure.headings },
{ label: 'Paragraphs', value: structure.paragraphs },
{ label: 'Text blocks', value: structure.text_blocks },
].map(({ label, value, icon: Icon }) => (
<div key={label} className="rounded-md border border-border bg-surface-raised p-2 text-center">
{Icon && <Icon className="mx-auto mb-1 h-4 w-4 text-docker" />}
<p className="text-lg font-bold text-foreground">{value}</p>
<p className="text-[9px] text-foreground-faint">{label}</p>
</div>
))}
</div>
{structure.picture_details && structure.picture_details.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Images ({structure.picture_details.length})</p>
<div className="flex flex-wrap gap-1">
{structure.picture_details.map((p) => (
<span key={p.index} className="rounded border border-border bg-surface-raised px-2 py-0.5 text-[9px]">
#{p.index + 1} {p.label} {p.has_image ? '🖼' : ''} {p.captions > 0 ? `(${p.captions} captions)` : ''}
</span>
))}
</div>
</div>
)}
{structure.table_details && structure.table_details.length > 0 && (
<div>
<p className="mb-1 text-[10px] font-medium text-foreground-muted">Tables ({structure.table_details.length})</p>
<div className="space-y-1">
{structure.table_details.map((t) => (
<div key={t.index} className="rounded border border-border bg-surface-raised px-2 py-1 text-[10px] text-foreground-muted">
Table {t.index + 1}: {t.rows}×{t.cols} ({t.cells} cells) {t.preview || '…'}
</div>
))}
</div>
</div>
)}
</section>
)
}
function normalizeChecks(items: DqCheck[]): DqCheck[] {
return items.map((c, i) => ({ ...c, id: c.id || `${c.suite}_${i}` }))
}
function DimensionCard({ dimension: d, expanded, onToggle }: { dimension: Dimension; expanded?: boolean; onToggle?: () => void }) {
return (
<button type="button" onClick={onToggle} className="w-full rounded-lg border border-border bg-surface-overlay/40 p-3 text-left transition-colors hover:border-docker/30">
<div className="mb-1 flex items-center justify-between">
<span className="text-[12px] font-semibold">{d.label}</span>
<span className={cn('text-base font-bold', SCORE_COLOR(d.score))}>{d.score}</span>
</div>
<div className="mb-2 h-2 overflow-hidden rounded-full bg-border">
<div className={cn('h-full rounded-full', BAR_COLOR(d.score))} style={{ width: `${d.score}%` }} />
</div>
<p className="mb-2 text-[10px] text-foreground-faint">{d.description}</p>
<ul className="space-y-0.5 text-[10px] text-foreground-muted">
{d.findings.map((f) => (
<li key={f} className="flex gap-1"><span className="text-docker"></span>{f}</li>
))}
</ul>
{expanded && d.recommended_actions && d.recommended_actions.length > 0 && (
<div className="mt-2 border-t border-border pt-2">
<p className="mb-1 text-[9px] font-semibold uppercase text-foreground-faint">Recommended actions</p>
{d.recommended_actions.map((a) => <p key={a} className="text-[9px] text-warning"> {a}</p>)}
</div>
)}
{!expanded && d.recommended_actions && d.recommended_actions.length > 0 && (
<p className="mt-2 border-t border-border pt-2 text-[9px] text-foreground-faint">Click for {d.recommended_actions.length} recommended action(s)</p>
)}
</button>
)
}
function ColumnTable({ profiles, expandedCol, onToggle }: { profiles: ColumnProfile[]; expandedCol: string | null; onToggle: (n: string | null) => void }) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-left text-[11px]">
<thead className="bg-surface-overlay text-[10px] uppercase text-foreground-faint">
<tr>
<th className="px-3 py-2">Column</th><th className="px-3 py-2">Type</th><th className="px-3 py-2">Null%</th>
<th className="px-3 py-2">Unique</th><th className="px-3 py-2">Flags</th>
</tr>
</thead>
<tbody>
{profiles.map((c) => (
<Fragment key={c.name}>
<tr className="cursor-pointer border-t border-border hover:bg-surface-overlay/50" onClick={() => onToggle(expandedCol === c.name ? null : c.name)}>
<td className="px-3 py-2 font-mono text-docker">{c.name}</td>
<td className="px-3 py-2">{c.dtype}</td>
<td className={cn('px-3 py-2', c.null_pct > 10 && 'font-semibold text-warning')}>{c.null_pct}%</td>
<td className="px-3 py-2">{c.unique_count.toLocaleString()}</td>
<td className="px-3 py-2 text-foreground-muted">{c.quality_flags.join(', ') || '—'}</td>
</tr>
{expandedCol === c.name && (
<tr className="border-t border-border bg-surface-overlay/20">
<td colSpan={5} className="px-4 py-2 text-[10px] text-foreground-muted">
{c.sample_values?.length ? <p className="mb-1">Samples: {c.sample_values.join(' · ')}</p> : null}
{c.numeric && <p>Range {c.numeric.min} {c.numeric.max}, μ={c.numeric.mean}, {c.numeric.outliers} outliers</p>}
{c.text && <p>Avg len {c.text.avg_length}, {c.text.empty_strings} empty strings</p>}
{c.top_values?.map((tv) => <span key={tv.value} className="mr-3">{tv.value} ({tv.count})</span>)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)
}
function checkLabel(c: DqCheck): string {
const base = c.expectation || c.name || c.metric || c.check || c.test || c.rule || c.monitor || 'check'
return c.column ? `${base} [${c.column}]` : base
}
function checkStatus(c: DqCheck): string {
return c.status || (c.success === false ? 'fail' : c.success ? 'pass' : c.outcome || 'unknown')
}
function checkDetail(c: DqCheck): string {
return c.result || c.detail || ''
}
function CheckTable({ title, checks, expandedId, onToggle }: { title: string; checks: DqCheck[]; expandedId: string | null; onToggle: (id: string | null) => void }) {
return (
<div className="overflow-x-auto rounded-lg border border-border">
<p className="border-b border-border bg-surface-overlay px-3 py-2 text-[11px] font-semibold capitalize">{title}</p>
<table className="w-full text-[10px]">
<tbody>
{checks.map((c) => {
const id = c.id || checkLabel(c)
const status = checkStatus(c)
const viol = c.violations
return (
<Fragment key={id}>
<tr className="cursor-pointer border-t border-border hover:bg-surface-overlay/50" onClick={() => onToggle(expandedId === id ? null : id)}>
<td className="px-3 py-1.5">
<span className={cn('mr-2 rounded px-1.5 py-0.5 text-[9px] font-bold uppercase',
status === 'pass' ? 'bg-success/20 text-success' : status === 'warn' ? 'bg-warning/20 text-warning' : 'bg-danger/20 text-danger')}>
{status}
</span>
{checkLabel(c)}
{viol?.violation_count ? <span className="ml-2 text-[9px] text-danger">{viol.violation_count} violations</span> : null}
</td>
<td className="px-3 py-1.5 text-foreground-muted">{checkDetail(c)}</td>
</tr>
{expandedId === id && (
<tr className="border-t border-border bg-surface-overlay/20">
<td colSpan={2} className="px-4 py-3 text-[10px]">
<p className="mb-2 font-semibold text-docker">Error location &amp; samples</p>
{viol?.violation_count ? (
<>
<p className="text-foreground-muted">{viol.violation_count} rows affected ({viol.violation_pct}%){viol.location_hint ? ` · ${viol.location_hint}` : ''}</p>
{viol.sample_rows && viol.sample_rows.length > 0 && (
<div className="mt-2 overflow-x-auto rounded border border-border">
<table className="w-full text-[9px]">
<thead><tr className="bg-surface-overlay"><th className="px-2 py-1">Row #</th>{Object.keys(viol.sample_rows[0].values).map((k) => <th key={k} className="px-2 py-1">{k}</th>)}</tr></thead>
<tbody>
{viol.sample_rows.map((s) => (
<tr key={s.row_index} className="border-t border-border/50">
<td className="px-2 py-1 font-mono text-warning">{s.row_index}</td>
{Object.values(s.values).map((v, j) => <td key={j} className="max-w-[120px] truncate px-2 py-1">{v === null ? 'NULL' : String(v)}</td>)}
</tr>
))}
</tbody>
</table>
</div>
)}
</>
) : (
<p className="text-success">No row-level violations check passed.</p>
)}
{c.meta && <pre className="mt-2 rounded bg-black/30 p-2 font-mono text-[9px]">{JSON.stringify(c.meta, null, 2)}</pre>}
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)
}
function CapCard({ title, items, icon: Icon }: { title: string; items: string[]; icon: typeof Layers }) {
return (
<div className="rounded-lg border border-border bg-surface-raised/80 p-2.5">
<div className="mb-1 flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 text-docker" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-foreground-faint">{title}</span>
</div>
<p className="text-[10px] leading-relaxed text-foreground-muted">{items.join(' · ')}</p>
</div>
)
}
function UploadZone({ label, hint, accept, loading, onFile }: { label: string; hint: string; accept: string; loading: boolean; onFile: (f: File) => void }) {
return (
<label className={cn('flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-border/80 bg-surface-overlay/30 px-8 py-10 transition-all hover:border-docker/50 hover:bg-docker/5', loading && 'pointer-events-none opacity-50')}>
<Upload className="mb-3 h-10 w-10 text-docker opacity-60" />
<p className="text-[13px] font-medium text-foreground">{label}</p>
<p className="mt-1 text-[10px] text-foreground-faint">{hint}</p>
<input type="file" accept={accept} className="hidden" disabled={loading} onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
</label>
)
}
function LoadingMsg({ text }: { text: string }) {
return (
<div className="flex items-center justify-center gap-3 rounded-lg border border-docker/30 bg-docker/5 py-10 text-sm text-foreground-muted">
<Loader2 className="h-6 w-6 animate-spin text-docker" />
{text}
</div>
)
}
function StatCard({ label, value, sub, accent, warn, icon: Icon }: { label: string; value: string; sub?: string; accent?: boolean; warn?: boolean; icon?: typeof Image }) {
return (
<div className="rounded-lg border border-border bg-surface-overlay/40 p-3">
<div className="flex items-center gap-1">
{Icon && <Icon className="h-3.5 w-3.5 text-docker" />}
<p className="text-[9px] uppercase tracking-wider text-foreground-faint">{label}</p>
</div>
<p className={cn('text-xl font-bold', accent ? 'text-docker' : warn ? 'text-warning' : 'text-foreground')}>{value}</p>
{sub && <p className="text-[10px] text-foreground-muted">{sub}</p>}
</div>
)
}