Files
svg-frontend/src/features/schemes/SchemeAuditTab.tsx
2026-03-19 13:42:23 +03:00

234 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from "react";
import { getApiErrorMessage, useSchemeAuditQuery } from "../../api/queries";
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
import { StatCard } from "../../shared/ui/StatCard";
type Props = {
schemeId: string;
};
type AuditGroup = "all" | "lifecycle" | "pricing" | "versioning" | "upload";
function valueText(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "—";
}
return String(value);
}
function detectGroup(eventType: string | null | undefined): AuditGroup {
const value = (eventType || "").toLowerCase();
if (value.includes("pricing.")) return "pricing";
if (value.includes("version")) return "versioning";
if (value.includes("upload") || value.includes("import")) return "upload";
if (
value.includes("published") ||
value.includes("unpublished") ||
value.includes("rolled_back") ||
value.includes("scheme.created")
) {
return "lifecycle";
}
return "all";
}
function groupLabel(group: AuditGroup): string {
if (group === "all") return "Все";
if (group === "lifecycle") return "Жизненный цикл";
if (group === "pricing") return "Тарифы";
if (group === "versioning") return "Версионирование";
return "Загрузка / импорт";
}
function prettyDetails(value: unknown): string {
if (value === null || value === undefined) {
return "null";
}
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
return JSON.stringify(parsed, null, 2);
} catch {
return value;
}
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function SchemeAuditTab({ schemeId }: Props) {
const auditQuery = useSchemeAuditQuery(schemeId);
const [selectedGroup, setSelectedGroup] = useState<AuditGroup>("all");
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
const items = useMemo(() => {
const raw = auditQuery.data ?? [];
return [...raw].sort((a, b) => {
const left = new Date(a.created_at || 0).getTime();
const right = new Date(b.created_at || 0).getTime();
return right - left;
});
}, [auditQuery.data]);
const counts = useMemo(() => {
return {
all: items.length,
lifecycle: items.filter((item) => detectGroup(item.event_type) === "lifecycle").length,
pricing: items.filter((item) => detectGroup(item.event_type) === "pricing").length,
versioning: items.filter((item) => detectGroup(item.event_type) === "versioning").length,
upload: items.filter((item) => detectGroup(item.event_type) === "upload").length
};
}, [items]);
const filtered = useMemo(() => {
if (selectedGroup === "all") {
return items;
}
return items.filter((item) => detectGroup(item.event_type) === selectedGroup);
}, [items, selectedGroup]);
const detailsMap = useMemo(() => {
const result: Record<string, string> = {};
filtered.forEach((item, index) => {
const key = item.audit_event_id || `${item.event_type || "event"}-${index}`;
result[key] = prettyDetails(item.details_json);
});
return result;
}, [filtered]);
if (auditQuery.isLoading) {
return <div className="panel">Загрузка аудита...</div>;
}
if (auditQuery.isError) {
return <ApiErrorView title="Ошибка загрузки аудита" message={getApiErrorMessage(auditQuery.error)} />;
}
return (
<div className="detail-grid">
<div className="panel">
<h3>Аудит схемы</h3>
<p className="muted">
История событий по схеме. Список отсортирован от новых к старым.
</p>
<div className="stats-grid">
<StatCard label="Всего событий" value={counts.all} />
<StatCard label="Жизненный цикл" value={counts.lifecycle} />
<StatCard label="Тарифы" value={counts.pricing} />
<StatCard label="Версионирование" value={counts.versioning} />
</div>
</div>
<div className="panel">
<div className="toolbar">
<button
type="button"
className={`btn ${selectedGroup === "all" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setSelectedGroup("all")}
>
Все ({counts.all})
</button>
<button
type="button"
className={`btn ${selectedGroup === "lifecycle" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setSelectedGroup("lifecycle")}
>
Жизненный цикл ({counts.lifecycle})
</button>
<button
type="button"
className={`btn ${selectedGroup === "pricing" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setSelectedGroup("pricing")}
>
Тарифы ({counts.pricing})
</button>
<button
type="button"
className={`btn ${selectedGroup === "versioning" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setSelectedGroup("versioning")}
>
Версионирование ({counts.versioning})
</button>
<button
type="button"
className={`btn ${selectedGroup === "upload" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setSelectedGroup("upload")}
>
Загрузка / импорт ({counts.upload})
</button>
</div>
</div>
<div className="panel">
<h3>События</h3>
{filtered.length === 0 ? (
<p className="muted">Событий для выбранной группы нет.</p>
) : (
<div className="audit-list">
{filtered.map((item, index) => {
const rowKey = item.audit_event_id || `${item.event_type || "event"}-${index}`;
const isOpen = expandedRows[rowKey] === true;
const details = detailsMap[rowKey] || "{}";
return (
<div key={rowKey} className="audit-card">
<div className="audit-card-head">
<div>
<div className="audit-title">{valueText(item.event_type)}</div>
<div className="muted">
Группа: {groupLabel(detectGroup(item.event_type))} · Создано: {valueText(item.created_at)}
</div>
</div>
<div className="toolbar">
<button
type="button"
className="btn btn-secondary"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !prev[rowKey]
}))
}
>
{isOpen ? "Скрыть details_json" : "Показать details_json"}
</button>
</div>
</div>
<div className="audit-meta-grid">
<div><strong>Тип объекта:</strong> {valueText(item.object_type)}</div>
<div><strong>Ссылка на объект:</strong> {valueText(item.object_ref)}</div>
<div><strong>ID события:</strong> <span className="mono-cell">{valueText(item.audit_event_id)}</span></div>
</div>
{isOpen ? (
<div className="code-box" style={{ marginTop: 12 }}>
{details}
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
</div>
);
}