234 lines
7.8 KiB
TypeScript
234 lines
7.8 KiB
TypeScript
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>
|
||
);
|
||
}
|