Initial commit: svg frontend

This commit is contained in:
greebo
2026-03-19 13:42:23 +03:00
commit 89e52e3193
31 changed files with 3425 additions and 0 deletions

View 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>
);
}