UX: implement visual product roadmap
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type { DataQualityIssue, ScanResult } from "../api/types";
|
||||
import type { DataQualityIssue, Page, ScanResult } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
|
||||
const RULE_TYPES = [
|
||||
"possible_duplicate_customer",
|
||||
@@ -19,15 +20,27 @@ const RULE_TYPES = [
|
||||
export function DataQuality() {
|
||||
const { t } = useTranslation("quality");
|
||||
const { user } = useAuth();
|
||||
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [issues, setIssues] = useState<Page<DataQualityIssue> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("open");
|
||||
const [ruleType, setRuleType] = useState("");
|
||||
const status = searchParams.get("status") ?? "open";
|
||||
const ruleType = searchParams.get("rule_type") ?? "";
|
||||
const severity = searchParams.get("severity") ?? "";
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [confirmingScan, setConfirmingScan] = useState(false);
|
||||
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
|
||||
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
|
||||
|
||||
function updateFilters(updates: Record<string, string | boolean | number | null>) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value === null || value === "" || value === false) next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
});
|
||||
setSearchParams(next);
|
||||
}
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (user?.role !== "operations_manager") return;
|
||||
@@ -36,11 +49,14 @@ export function DataQuality() {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
if (ruleType) params.set("rule_type", ruleType);
|
||||
if (severity) params.set("severity", severity);
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", "25");
|
||||
api
|
||||
.get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`)
|
||||
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
|
||||
.then(setIssues)
|
||||
.catch(() => setError(t("list.unavailable")));
|
||||
}, [status, ruleType, user]);
|
||||
}, [status, ruleType, severity, page, user, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -73,8 +89,8 @@ export function DataQuality() {
|
||||
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0;
|
||||
const visibleIssues = issues
|
||||
? demoScenariosOnly
|
||||
? issues.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
|
||||
: issues
|
||||
? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
|
||||
: issues.items
|
||||
: [];
|
||||
|
||||
return (
|
||||
@@ -118,7 +134,7 @@ export function DataQuality() {
|
||||
<form className="filters" aria-label={t("list.title")}>
|
||||
<label>
|
||||
{t("list.statusLabel")}
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
|
||||
<option value="">{t("list.statusAll")}</option>
|
||||
<option value="open">{t("list.statusOpen")}</option>
|
||||
<option value="deferred">{t("list.statusDeferred")}</option>
|
||||
@@ -128,7 +144,7 @@ export function DataQuality() {
|
||||
</label>
|
||||
<label>
|
||||
{t("list.ruleTypeLabel")}
|
||||
<select value={ruleType} onChange={(e) => setRuleType(e.target.value)}>
|
||||
<select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}>
|
||||
<option value="">{t("list.ruleTypeAll")}</option>
|
||||
{RULE_TYPES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
@@ -137,11 +153,20 @@ export function DataQuality() {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("list.severityLabel")}
|
||||
<select value={severity} onChange={(e) => updateFilters({ severity: e.target.value, page: 1 })}>
|
||||
<option value="">{t("list.severityAll")}</option>
|
||||
<option value="high">{t("severities.high")}</option>
|
||||
<option value="medium">{t("severities.medium")}</option>
|
||||
<option value="low">{t("severities.low")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={demoScenariosOnly}
|
||||
onChange={(e) => setDemoScenariosOnly(e.target.checked)}
|
||||
onChange={(e) => { setDemoScenariosOnly(e.target.checked); updateFilters({ demo: e.target.checked, page: 1 }); }}
|
||||
/>
|
||||
{t("list.demoScenariosOnly")}
|
||||
</label>
|
||||
@@ -149,13 +174,13 @@ export function DataQuality() {
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
{!error && !issues && <LoadingState label={t("list.loading")} />}
|
||||
{issues && issues.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
|
||||
{issues && issues.length > 0 && visibleIssues.length === 0 && (
|
||||
{issues && issues.items.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
|
||||
{issues && issues.items.length > 0 && visibleIssues.length === 0 && (
|
||||
<EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} />
|
||||
)}
|
||||
|
||||
{visibleIssues.length > 0 && (
|
||||
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
|
||||
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
|
||||
<caption className="visually-hidden">{t("list.title")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -184,7 +209,7 @@ export function DataQuality() {
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</table>{issues && !demoScenariosOnly && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user