feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const keys = ["actor", "action", "from", "to"] as const;
|
||||
export function AuditFilters() {
|
||||
const t = useTranslations("pages.admin.logs.audit");
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
function apply(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const values = new FormData(event.currentTarget);
|
||||
const next = new URLSearchParams(params.toString());
|
||||
for (const key of keys) {
|
||||
const value = String(values.get(key) ?? "").trim();
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
}
|
||||
next.set("page", "1");
|
||||
startTransition(() => router.replace(`?${next}`, { scroll: false }));
|
||||
}
|
||||
function clear() {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
for (const key of keys) next.delete(key);
|
||||
next.set("page", "1");
|
||||
startTransition(() => router.replace(`?${next}`, { scroll: false }));
|
||||
}
|
||||
return (
|
||||
<form onSubmit={apply} key={params.toString()} aria-busy={pending}>
|
||||
<fieldset disabled={pending} className="space-y-3 rounded-xl border p-4">
|
||||
<legend className="px-1 text-sm font-medium">
|
||||
{t("filtersTitle")}
|
||||
</legend>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{keys.map((key) => (
|
||||
<label
|
||||
htmlFor={`audit-${key}`}
|
||||
key={key}
|
||||
className="space-y-1 text-sm"
|
||||
>
|
||||
<span>{t(`filter${key[0].toUpperCase()}${key.slice(1)}`)}</span>
|
||||
<Input
|
||||
id={`audit-${key}`}
|
||||
name={key}
|
||||
type={key === "from" || key === "to" ? "date" : "text"}
|
||||
maxLength={191}
|
||||
defaultValue={params.get(key) ?? ""}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("filtersHint")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" size="sm">
|
||||
{t("applyFilters")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={clear}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
import { AuditFilters } from "./audit-filters";
|
||||
|
||||
interface AuditRow {
|
||||
id: number;
|
||||
@@ -14,75 +15,73 @@ interface AuditRow {
|
||||
target: string;
|
||||
targetId: number | null;
|
||||
diff: string | null;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function DiffCell({ diff }: { diff: string | null }) {
|
||||
function DiffCell({ row }: { row: AuditRow }) {
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!diff)
|
||||
const { changes, invalid } = readAuditChanges(
|
||||
row.diff,
|
||||
row.before,
|
||||
row.after,
|
||||
);
|
||||
if (invalid)
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("audit.invalidDetails")}
|
||||
</span>
|
||||
);
|
||||
if (!changes.length)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
let parsed: Record<string, { from: unknown; to: unknown }>;
|
||||
try {
|
||||
parsed = JSON.parse(diff);
|
||||
} catch {
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
if (keys.length === 0)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
const summary =
|
||||
keys.length === 1
|
||||
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
|
||||
: t("audit.changesCount", { count: keys.length });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs font-normal"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{summary}
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="mt-1 space-y-0.5 text-xs">
|
||||
{keys.map((k) => (
|
||||
<div key={k} className="flex gap-1">
|
||||
<span className="font-medium">{k}:</span>
|
||||
<span className="text-[var(--admin-error)] line-through">
|
||||
{String(parsed[k].from ?? "∅")}
|
||||
</span>
|
||||
<span>→</span>
|
||||
<span className="text-[var(--admin-success)]">
|
||||
{String(parsed[k].to ?? "∅")}
|
||||
</span>
|
||||
<details className="max-w-xl text-xs">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{t("audit.changesCount", { count: changes.length })}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-3">
|
||||
{changes.map((change) => (
|
||||
<div key={change.key} className="space-y-1">
|
||||
<p className="font-medium break-all">{change.key}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("audit.beforeValue")}
|
||||
</span>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border p-2">
|
||||
{formatAuditValue(change.from)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("audit.afterValue")}
|
||||
</span>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border p-2">
|
||||
{formatAuditValue(change.to)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuditTableProps {
|
||||
data: PaginatedResult<AuditRow>;
|
||||
}
|
||||
|
||||
export function AuditTable({ data }: AuditTableProps) {
|
||||
const params = useSearchParams();
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
|
||||
const columns: DataTableColumn<AuditRow>[] = [
|
||||
{ key: "id", label: t("colId"), sortable: true },
|
||||
{ key: "username", label: t("colUser"), sortable: true },
|
||||
{ key: "id", label: t("colId") },
|
||||
{ key: "username", label: t("colUser") },
|
||||
{
|
||||
key: "action",
|
||||
label: t("colAction"),
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const v = String(value);
|
||||
let variant: "default" | "secondary" | "destructive" = "secondary";
|
||||
@@ -92,7 +91,7 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
return <Badge variant={variant}>{v}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: "target", label: t("colTarget"), sortable: true },
|
||||
{ key: "target", label: t("colTarget") },
|
||||
{
|
||||
key: "targetId",
|
||||
label: t("audit.colTargetId"),
|
||||
@@ -103,12 +102,11 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
{
|
||||
key: "diff",
|
||||
label: t("audit.colDetails"),
|
||||
render: (_value, row) => <DiffCell diff={row.diff} />,
|
||||
render: (_value, row) => <DiffCell row={row} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: t("audit.colDate"),
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span>{String(value) || t("audit.notAvailable")}</span>
|
||||
),
|
||||
@@ -116,11 +114,15 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("audit.searchPlaceholder")}
|
||||
exportUrl="/api/admin/export?type=audit"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<AuditFilters />
|
||||
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("audit.searchPlaceholder")}
|
||||
exportUrl={`/api/admin/logs/audit/export?${params.toString()}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,15 @@ export default async function AuditLogsPage({
|
||||
const sp = new URLSearchParams(params);
|
||||
const { search, perPage, page } = parseListParams(sp);
|
||||
|
||||
const result = await getAuditLogs({ search, page, perPage });
|
||||
const result = await getAuditLogs({
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
actor: sp.get("actor") ?? undefined,
|
||||
action: sp.get("action") ?? undefined,
|
||||
from: sp.get("from") ?? undefined,
|
||||
to: sp.get("to") ?? undefined,
|
||||
});
|
||||
|
||||
const pagination = calcPagination(result.total, result.page, result.perPage);
|
||||
|
||||
@@ -32,6 +40,8 @@ export default async function AuditLogsPage({
|
||||
target: r.target,
|
||||
targetId: r.targetId,
|
||||
diff: r.diff,
|
||||
before: r.before,
|
||||
after: r.after,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user