feat(platform): add audit logs and activity feed
This commit is contained in:
parent
92cb8d1286
commit
c816e9869d
34 changed files with 1592 additions and 43 deletions
7
frontend/athena/app/api/activity-feed/route.ts
Normal file
7
frontend/athena/app/api/activity-feed/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/activity-feed${request.nextUrl.search}`);
|
||||
}
|
||||
7
frontend/athena/app/api/audit-logs/route.ts
Normal file
7
frontend/athena/app/api/audit-logs/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/audit-logs${request.nextUrl.search}`);
|
||||
}
|
||||
294
frontend/athena/app/audit-logs/page.tsx
Normal file
294
frontend/athena/app/audit-logs/page.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Eye, Filter, RotateCcw } from "lucide-react";
|
||||
|
||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||
import SearchInput from "@/components/common/SearchInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ApiSuccess, AuditLog, AuditLogListResponse } from "@/types/audit";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (typeof error === "object" && error !== null && "response" in error) {
|
||||
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
|
||||
return response?.data?.message ?? response?.data?.detail ?? "Audit Logs konnten nicht geladen werden";
|
||||
}
|
||||
return "Audit Logs konnten nicht geladen werden";
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatJson(value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [items, setItems] = useState<AuditLog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [entityType, setEntityType] = useState("");
|
||||
const [action, setAction] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
|
||||
const loadAuditLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
});
|
||||
|
||||
if (entityType.trim()) {
|
||||
params.set("entity_type", entityType.trim());
|
||||
}
|
||||
|
||||
if (action.trim()) {
|
||||
params.set("action", action.trim());
|
||||
}
|
||||
|
||||
const response = await api.get<ApiSuccess<AuditLogListResponse>>(`/audit-logs?${params.toString()}`);
|
||||
setItems(response.data.data.items);
|
||||
setTotal(response.data.data.total);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [action, entityType, page]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadAuditLogs();
|
||||
});
|
||||
}, [loadAuditLogs]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const normalized = search.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((item) => (
|
||||
item.actor_username.toLowerCase().includes(normalized)
|
||||
|| item.action.toLowerCase().includes(normalized)
|
||||
|| item.entity_type.toLowerCase().includes(normalized)
|
||||
|| item.entity_label.toLowerCase().includes(normalized)
|
||||
));
|
||||
}, [items, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const columns: DataTableColumn<AuditLog>[] = [
|
||||
{
|
||||
key: "created_at",
|
||||
label: "Zeitpunkt",
|
||||
render: (log) => <span className="whitespace-nowrap">{formatDate(log.created_at)}</span>,
|
||||
},
|
||||
{
|
||||
key: "actor_username",
|
||||
label: "Benutzer",
|
||||
render: (log) => log.actor_username || "System",
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
label: "Aktion",
|
||||
render: (log) => <span className="font-medium text-slate-950">{log.action}</span>,
|
||||
},
|
||||
{
|
||||
key: "entity",
|
||||
label: "Objekt",
|
||||
render: (log) => (
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{log.entity_label || "-"}</p>
|
||||
<p className="text-xs text-slate-500">{log.entity_type}{log.entity_id ? ` #${log.entity_id}` : ""}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "ip_address",
|
||||
label: "IP",
|
||||
render: (log) => log.ip_address || "-",
|
||||
},
|
||||
{
|
||||
key: "details",
|
||||
label: "",
|
||||
className: "px-4 py-3 text-right",
|
||||
render: (log) => (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setSelectedLog(log)}>
|
||||
<Eye size={16} />
|
||||
Details
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-950">Audit Logs</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Nachvollziehbare Systemereignisse und Änderungen</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => void loadAuditLogs()}>
|
||||
<RotateCcw size={16} />
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border bg-white p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_220px_220px_auto] lg:items-end">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Audit Logs durchsuchen..." />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entity-type">Objekttyp</Label>
|
||||
<Input
|
||||
id="entity-type"
|
||||
value={entityType}
|
||||
onChange={(event) => {
|
||||
setEntityType(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="customers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action">Aktion</Label>
|
||||
<Input
|
||||
id="action"
|
||||
value={action}
|
||||
onChange={(event) => {
|
||||
setAction(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="users.update"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setEntityType("");
|
||||
setAction("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Filter size={16} />
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={filteredItems}
|
||||
rowKey={(row) => row.id}
|
||||
sortKey="created_at"
|
||||
sortDirection="desc"
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="Keine Audit Logs gefunden"
|
||||
emptyDescription="Für die aktuellen Filter sind keine Einträge vorhanden."
|
||||
onSort={() => undefined}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||
<span>{total} Einträge</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<span>Seite {page} von {totalPages}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={selectedLog !== null} onOpenChange={(open) => !open && setSelectedLog(null)}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Audit Log Details</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedLog && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||
<Detail label="Aktion" value={selectedLog.action} />
|
||||
<Detail label="Benutzer" value={selectedLog.actor_username || "System"} />
|
||||
<Detail label="Zeitpunkt" value={formatDate(selectedLog.created_at)} />
|
||||
<Detail label="Objekt" value={`${selectedLog.entity_type}${selectedLog.entity_id ? ` #${selectedLog.entity_id}` : ""}`} />
|
||||
<Detail label="Label" value={selectedLog.entity_label || "-"} />
|
||||
<Detail label="IP" value={selectedLog.ip_address || "-"} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<JsonPanel title="Vorher" value={selectedLog.before_data} />
|
||||
<JsonPanel title="Nachher" value={selectedLog.after_data} />
|
||||
<JsonPanel title="Metadaten" value={selectedLog.metadata_data} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-slate-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">{label}</p>
|
||||
<p className="mt-1 break-words text-slate-950">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonPanel({ title, value }: { title: string; value: unknown }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold text-slate-950">{title}</h2>
|
||||
<pre className="max-h-96 overflow-auto rounded-lg border bg-slate-950 p-3 text-xs text-slate-100">
|
||||
{formatJson(value)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { ArrowLeft, Edit, Plus, Trash2 } from "lucide-react";
|
|||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import DetailSection from "@/components/common/DetailSection";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import CustomerContactFormDialog from "@/components/customers/CustomerContactFormDialog";
|
||||
import CustomerFormDialog from "@/components/customers/CustomerFormDialog";
|
||||
|
|
@ -32,6 +33,7 @@ function formatDateTime(value: string) {
|
|||
}
|
||||
|
||||
export default function CustomerDetailPage() {
|
||||
const { showToast } = useToast();
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||
|
|
@ -79,8 +81,11 @@ export default function CustomerDetailPage() {
|
|||
const response = await api.put<Customer>(`/customers/${customer.id}`, payload);
|
||||
setCustomer(response.data);
|
||||
setFormOpen(false);
|
||||
showToast({ type: "success", title: "Kunde aktualisiert", description: response.data.company_name });
|
||||
} catch (err) {
|
||||
setFormError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setFormError(message);
|
||||
showToast({ type: "error", title: "Kunde konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
@ -101,14 +106,18 @@ export default function CustomerDetailPage() {
|
|||
try {
|
||||
if (selectedContact) {
|
||||
await api.put<CustomerContact>(`/customers/${customer.id}/contacts/${selectedContact.id}`, payload);
|
||||
showToast({ type: "success", title: "Ansprechpartner aktualisiert" });
|
||||
} else {
|
||||
await api.post<CustomerContact>(`/customers/${customer.id}/contacts`, payload);
|
||||
showToast({ type: "success", title: "Ansprechpartner erstellt" });
|
||||
}
|
||||
const response = await api.get<Customer>(`/customers/${customer.id}`);
|
||||
setCustomer(response.data);
|
||||
setContactOpen(false);
|
||||
} catch (err) {
|
||||
setContactError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setContactError(message);
|
||||
showToast({ type: "error", title: "Ansprechpartner konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
@ -124,9 +133,12 @@ export default function CustomerDetailPage() {
|
|||
await api.delete(`/customers/${customer.id}/contacts/${deleteContact.id}`);
|
||||
const response = await api.get<Customer>(`/customers/${customer.id}`);
|
||||
setCustomer(response.data);
|
||||
showToast({ type: "success", title: "Ansprechpartner gelöscht", description: `${deleteContact.first_name} ${deleteContact.last_name}` });
|
||||
setDeleteContact(null);
|
||||
} catch (err) {
|
||||
setDeleteError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setDeleteError(message);
|
||||
showToast({ type: "error", title: "Ansprechpartner konnte nicht gelöscht werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
@ -139,10 +151,14 @@ export default function CustomerDetailPage() {
|
|||
setPending(true);
|
||||
setDeleteError("");
|
||||
try {
|
||||
const deletedCustomerName = customer.company_name;
|
||||
await api.delete(`/customers/${customer.id}`);
|
||||
showToast({ type: "success", title: "Kunde gelöscht", description: deletedCustomerName });
|
||||
router.push("/customers");
|
||||
} catch (err) {
|
||||
setDeleteError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setDeleteError(message);
|
||||
showToast({ type: "error", title: "Kunde konnte nicht gelöscht werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Edit, Eye, Plus, Trash2 } from "lucide-react";
|
|||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||
import SearchInput from "@/components/common/SearchInput";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import CustomerFormDialog from "@/components/customers/CustomerFormDialog";
|
||||
import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge";
|
||||
|
|
@ -48,6 +49,7 @@ function getSortableValue(customer: Customer, key: string) {
|
|||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
const { showToast } = useToast();
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -183,13 +185,17 @@ export default function CustomersPage() {
|
|||
if (selectedCustomer) {
|
||||
const response = await api.put<Customer>(`/customers/${selectedCustomer.id}`, payload);
|
||||
setCustomers((current) => current.map((customer) => customer.id === selectedCustomer.id ? response.data : customer));
|
||||
showToast({ type: "success", title: "Kunde aktualisiert", description: response.data.company_name });
|
||||
} else {
|
||||
const response = await api.post<Customer>("/customers", payload);
|
||||
setCustomers((current) => [response.data, ...current]);
|
||||
showToast({ type: "success", title: "Kunde erstellt", description: response.data.company_name });
|
||||
}
|
||||
setFormOpen(false);
|
||||
} catch (err) {
|
||||
setFormError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setFormError(message);
|
||||
showToast({ type: "error", title: "Kunde konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
@ -204,9 +210,12 @@ export default function CustomersPage() {
|
|||
try {
|
||||
await api.delete(`/customers/${deleteCustomer.id}`);
|
||||
setCustomers((current) => current.filter((customer) => customer.id !== deleteCustomer.id));
|
||||
showToast({ type: "success", title: "Kunde gelöscht", description: deleteCustomer.company_name });
|
||||
setDeleteCustomer(null);
|
||||
} catch (err) {
|
||||
setDeleteError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setDeleteError(message);
|
||||
showToast({ type: "error", title: "Kunde konnte nicht gelöscht werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Link from "next/link";
|
|||
|
||||
import SummaryCard from "@/components/common/SummaryCard";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ActivityFeedItem, ApiSuccess } from "@/types/audit";
|
||||
import type { DashboardSummary, EmptyWidget } from "@/types/dashboard";
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
|
|
@ -17,13 +18,19 @@ function getErrorMessage(error: unknown) {
|
|||
|
||||
export default function DashboardPage() {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [activities, setActivities] = useState<ActivityFeedItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [activityError, setActivityError] = useState("");
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<DashboardSummary>("/dashboard/summary");
|
||||
setSummary(response.data);
|
||||
const [summaryResponse, activityResponse] = await Promise.all([
|
||||
api.get<DashboardSummary>("/dashboard/summary"),
|
||||
api.get<ApiSuccess<ActivityFeedItem[]>>("/activity-feed?limit=10"),
|
||||
]);
|
||||
setSummary(summaryResponse.data);
|
||||
setActivities(activityResponse.data.data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
|
|
@ -31,6 +38,16 @@ export default function DashboardPage() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const loadActivities = useCallback(async () => {
|
||||
setActivityError("");
|
||||
try {
|
||||
const response = await api.get<ApiSuccess<ActivityFeedItem[]>>("/activity-feed?limit=10");
|
||||
setActivities(response.data.data);
|
||||
} catch (err) {
|
||||
setActivityError(getErrorMessage(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void loadSummary();
|
||||
|
|
@ -92,7 +109,12 @@ export default function DashboardPage() {
|
|||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<EmptyPanel widget={summary.activities} />
|
||||
<ActivityPanel
|
||||
widget={summary.activities}
|
||||
activities={activities}
|
||||
error={activityError}
|
||||
onReload={() => void loadActivities()}
|
||||
/>
|
||||
<EmptyPanel widget={summary.tasks} />
|
||||
<EmptyPanel widget={summary.tickets} />
|
||||
<EmptyPanel widget={summary.projects} />
|
||||
|
|
@ -101,6 +123,54 @@ export default function DashboardPage() {
|
|||
);
|
||||
}
|
||||
|
||||
function ActivityPanel({
|
||||
widget,
|
||||
activities,
|
||||
error,
|
||||
onReload,
|
||||
}: {
|
||||
widget: EmptyWidget;
|
||||
activities: ActivityFeedItem[];
|
||||
error: string;
|
||||
onReload: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-5 md:col-span-2 xl:col-span-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-slate-950">{widget.title}</h2>
|
||||
<button type="button" className="text-sm font-medium text-slate-600 hover:text-slate-950" onClick={onReload}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
) : activities.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">{widget.message}</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="py-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-medium text-slate-950">{activity.title}</p>
|
||||
<time className="text-xs text-slate-500" dateTime={activity.created_at}>
|
||||
{new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(activity.created_at))}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{activity.description} · {activity.actor || "System"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPanel({ widget }: { widget: EmptyWidget }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-5">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import "./globals.css";
|
|||
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Header from "@/components/Header";
|
||||
import { ToastProvider } from "@/components/common/ToastProvider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Olympus ERP",
|
||||
|
|
@ -18,25 +19,27 @@ export default function RootLayout({
|
|||
<html lang="de">
|
||||
<body className="bg-slate-100">
|
||||
|
||||
<div className="flex h-screen">
|
||||
<ToastProvider>
|
||||
<div className="flex h-screen">
|
||||
|
||||
<Sidebar />
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex flex-col flex-1">
|
||||
|
||||
<Header />
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 overflow-auto p-8">
|
||||
<main className="flex-1 overflow-auto p-8">
|
||||
|
||||
{children}
|
||||
{children}
|
||||
|
||||
</main>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ToastProvider>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Edit, Eye, Shield, Trash2 } from "lucide-react";
|
|||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||
import SearchInput from "@/components/common/SearchInput";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -47,6 +48,7 @@ function getSortableValue(role: Role, key: string) {
|
|||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
const { showToast } = useToast();
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
|
|
@ -217,14 +219,18 @@ export default function RolesPage() {
|
|||
setRoles((current) => current.map((role) => (
|
||||
role.id === selectedRole.id ? response.data : role
|
||||
)));
|
||||
showToast({ type: "success", title: "Rolle aktualisiert", description: response.data.display_name });
|
||||
} else {
|
||||
const response = await api.post<Role>("/roles", payload);
|
||||
setRoles((current) => [response.data, ...current]);
|
||||
showToast({ type: "success", title: "Rolle erstellt", description: response.data.display_name });
|
||||
}
|
||||
|
||||
setFormOpen(false);
|
||||
} catch (err) {
|
||||
setFormError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setFormError(message);
|
||||
showToast({ type: "error", title: "Rolle konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
@ -241,9 +247,12 @@ export default function RolesPage() {
|
|||
try {
|
||||
await api.delete(`/roles/${deleteRole.id}`);
|
||||
setRoles((current) => current.filter((role) => role.id !== deleteRole.id));
|
||||
showToast({ type: "success", title: "Rolle gelöscht", description: deleteRole.display_name });
|
||||
setDeleteRole(null);
|
||||
} catch (err) {
|
||||
setDeleteError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setDeleteError(message);
|
||||
showToast({ type: "error", title: "Rolle konnte nicht gelöscht werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useParams } from "next/navigation";
|
|||
import { ArrowLeft, KeyRound, Pencil } from "lucide-react";
|
||||
|
||||
import StatusBadge from "@/components/common/StatusBadge";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -41,6 +42,7 @@ function formatDateTime(value: string) {
|
|||
}
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { showToast } = useToast();
|
||||
const params = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
|
|
@ -94,8 +96,11 @@ export default function UserDetailPage() {
|
|||
const response = await api.put<User>(`/users/${user.id}`, payload);
|
||||
setUser(response.data);
|
||||
setFormOpen(false);
|
||||
showToast({ type: "success", title: "Benutzer aktualisiert", description: response.data.username });
|
||||
} catch (err) {
|
||||
setFormError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setFormError(message);
|
||||
showToast({ type: "error", title: "Benutzer konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
@ -119,8 +124,11 @@ export default function UserDetailPage() {
|
|||
setUser(response.data);
|
||||
setPassword("");
|
||||
setPasswordOpen(false);
|
||||
showToast({ type: "success", title: "Passwort geändert", description: response.data.username });
|
||||
} catch (err) {
|
||||
setPasswordError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setPasswordError(message);
|
||||
showToast({ type: "error", title: "Passwort konnte nicht geändert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Plus } from "lucide-react";
|
|||
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||
import SearchInput from "@/components/common/SearchInput";
|
||||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import UserFormDialog from "@/components/users/UserFormDialog";
|
||||
import UserTable from "@/components/users/UserTable";
|
||||
|
|
@ -32,6 +33,7 @@ function getSortableValue(user: User, key: string) {
|
|||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { showToast } = useToast();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
|
|
@ -159,14 +161,18 @@ export default function UsersPage() {
|
|||
setUsers((current) => current.map((user) => (
|
||||
user.id === selectedUser.id ? response.data : user
|
||||
)));
|
||||
showToast({ type: "success", title: "Benutzer aktualisiert", description: response.data.username });
|
||||
} else {
|
||||
const response = await api.post<User>("/users", payload);
|
||||
setUsers((current) => [response.data, ...current]);
|
||||
showToast({ type: "success", title: "Benutzer erstellt", description: response.data.username });
|
||||
}
|
||||
|
||||
setFormOpen(false);
|
||||
} catch (err) {
|
||||
setFormError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setFormError(message);
|
||||
showToast({ type: "error", title: "Benutzer konnte nicht gespeichert werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
@ -183,9 +189,12 @@ export default function UsersPage() {
|
|||
try {
|
||||
await api.delete(`/users/${deleteUser.id}`);
|
||||
setUsers((current) => current.filter((user) => user.id !== deleteUser.id));
|
||||
showToast({ type: "success", title: "Benutzer gelöscht", description: deleteUser.username });
|
||||
setDeleteUser(null);
|
||||
} catch (err) {
|
||||
setDeleteError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
setDeleteError(message);
|
||||
showToast({ type: "error", title: "Benutzer konnte nicht gelöscht werden", description: message });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ClipboardList,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
|
|
@ -24,6 +25,12 @@ const menu = [
|
|||
href: "/dashboard",
|
||||
permission: "dashboard.read",
|
||||
},
|
||||
{
|
||||
icon: ClipboardList,
|
||||
name: "Audit Logs",
|
||||
href: "/audit-logs",
|
||||
permission: "audit_logs.read",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
name: "Benutzer",
|
||||
|
|
|
|||
107
frontend/athena/components/common/ToastProvider.tsx
Normal file
107
frontend/athena/components/common/ToastProvider.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { CheckCircle2, Info, X, XCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type ToastType = "success" | "error" | "info";
|
||||
|
||||
type Toast = {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ToastInput = Omit<Toast, "id">;
|
||||
|
||||
type ToastContextValue = {
|
||||
showToast: (toast: ToastInput) => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const iconByType = {
|
||||
success: CheckCircle2,
|
||||
error: XCircle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const colorByType = {
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-950",
|
||||
error: "border-red-200 bg-red-50 text-red-950",
|
||||
info: "border-slate-200 bg-white text-slate-950",
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((toast: ToastInput) => {
|
||||
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
setToasts((current) => [...current, { ...toast, id }]);
|
||||
window.setTimeout(() => dismissToast(id), 5000);
|
||||
}, [dismissToast]);
|
||||
|
||||
const value = useMemo(() => ({ showToast }), [showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="fixed right-6 top-6 z-50 flex w-[min(24rem,calc(100vw-3rem))] flex-col gap-3">
|
||||
{toasts.map((toast) => {
|
||||
const Icon = iconByType[toast.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`rounded-lg border p-4 shadow-lg ${colorByType[toast.type]}`}
|
||||
role="status"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Icon className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold">{toast.title}</p>
|
||||
{toast.description && (
|
||||
<p className="mt-1 text-sm opacity-80">{toast.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
aria-label="Meldung schließen"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useToast muss innerhalb des ToastProvider verwendet werden");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
38
frontend/athena/types/audit.ts
Normal file
38
frontend/athena/types/audit.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export type ApiSuccess<T> = {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AuditLog = {
|
||||
id: number;
|
||||
actor_user_id: number | null;
|
||||
actor_username: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: number | null;
|
||||
entity_label: string;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
before_data: unknown;
|
||||
after_data: unknown;
|
||||
metadata_data: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AuditLogListResponse = {
|
||||
items: AuditLog[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
export type ActivityFeedItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
actor: string;
|
||||
entity_type: string;
|
||||
entity_id: number | null;
|
||||
created_at: string;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue