Initial commit: svg frontend
This commit is contained in:
233
src/features/schemes/SchemeAuditTab.tsx
Normal file
233
src/features/schemes/SchemeAuditTab.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user