Improve admin UX: hub i18n, dynamic topbar, settings strings, staff logs DataTable.
Local Build and Deploy / deploy (push) Successful in 1m12s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 20:14:55 +02:00
1 parent 604e63da51
commit 1039044e48
11 files changed
+526 -305

No files matched your search

+2 -34
View File
@@ -6,6 +6,7 @@ import type { ReactNode } from "react";
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
import { AdminNavLink } from "@/components/admin/admin-nav-link";
import { AdminTopbar } from "@/components/admin/admin-topbar";
import { LanguageSwitcher } from "@/components/language-switcher";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireStaff } from "@/lib/admin/guard";
@@ -38,7 +39,7 @@ export default async function AdminLayout({
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<Topbar staff={staff} />
<AdminTopbar staff={staff} />
<section className="admin-page flex-1">
<AdminHubChrome>{children}</AdminHubChrome>
</section>
@@ -128,36 +129,3 @@ async function Sidebar({
);
}
async function Topbar({
staff,
}: {
staff: { id: number; username: string; rank: number };
}) {
const t = await getTranslations("pages.admin.nav");
return (
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
<div className="flex items-center gap-3">
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
{t("housekeeping")}
</h2>
<span className="hidden sm:inline-block w-px h-5 bg-[var(--admin-border)]" />
<span className="hidden sm:inline text-xs text-[var(--admin-text-muted)]">
{t("hotelManagementPanel")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 lg:hidden">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
</div>
<span className="text-xs text-[var(--admin-text-muted)]">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
</header>
);
}
+58 -149
View File
@@ -1,28 +1,28 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { StatusCard } from "@/components/admin/dashboard";
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { StaffActivitiesTable } from "./staff-activities-table";
export const dynamic = "force-dynamic";
const PER_PAGE = 25;
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
const SORT_MAP: Record<string, "createdAt" | "userId" | "action"> = {
when: "createdAt",
staff: "userId",
action: "action",
};
export default async function AdminStaffActivities({
searchParams,
}: {
searchParams: Promise<{
q?: string;
page?: string;
staffId?: string;
action?: string;
authorization?: string;
}>;
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
@@ -30,20 +30,22 @@ export default async function AdminStaffActivities({
}
const t = await getTranslations("pages.admin.logs");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const staffId = sp.staffId ? Number(sp.staffId) : null;
const action = sp.action ?? null;
const authorizationOnly = sp.authorization === "1";
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const authorizationOnly = raw.filter_authorization === "1";
const whereClause = buildStaffActivityWhere({
q,
staffId,
action,
q: parsed.search,
authorizationOnly,
});
const sortField = SORT_MAP[parsed.sort ?? ""] ?? "createdAt";
const total = await prisma.staffActivities
.count({ where: whereClause })
.catch(() => 0);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const activities = await prisma.staffActivities
.findMany({
where: whereClause,
@@ -57,9 +59,9 @@ export default async function AdminStaffActivities({
ipAddress: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
orderBy: { [sortField]: parsed.order },
skip: pagination.offset,
take: pagination.perPage,
})
.catch(() => []);
@@ -77,19 +79,29 @@ export default async function AdminStaffActivities({
nameById = new Map(users.map((x) => [x.id, x.username]));
}
const total = await prisma.staffActivities
.count({ where: whereClause })
.catch(() => 0);
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const onlineCount = activities.filter(
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000,
const recentCount = activities.filter(
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60_000,
).length;
const rows = activities.map((a) => ({
id: Number(a.id),
when: fromDate(a.createdAt),
staff: t("staffWithId", {
id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown",
}),
action: a.action,
description: a.description,
target:
a.targetType && a.targetId
? `${a.targetType === "user" ? t("userPrefix") : a.targetType} #${String(a.targetId)}`
: "—",
ip: a.ipAddress || "—",
}));
return (
<main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<div className="space-y-6">
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
<StatusCard
label={t("onThisPage")}
@@ -98,124 +110,21 @@ export default async function AdminStaffActivities({
/>
<StatusCard
label={t("recent")}
value={onlineCount}
state={onlineCount > 0 ? "ok" : "neutral"}
value={recentCount}
state={recentCount > 0 ? "ok" : "neutral"}
icon="🟢"
/>
</div>
<form className="admin-card flex gap-2 mb-6 flex-wrap">
<input
name="q"
defaultValue={q}
placeholder={t("searchPlaceholder")}
className="flex-1 min-w-[200px]"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="authorization"
value="1"
defaultChecked={authorizationOnly}
/>
Authorization errors
</label>
<input
name="staffId"
defaultValue={staffId?.toString()}
placeholder={t("staffId")}
className="w-[120px]"
/>
<input
name="action"
defaultValue={action ?? ""}
placeholder={t("actionType")}
className="w-[160px]"
/>
<button type="submit" className="btn btn-primary">
{t("filter")}
</button>
{(q || staffId || action || authorizationOnly) && (
<a href="/admin/logs" className="btn btn-outline">
{t("clearFilters")}
</a>
)}
</form>
<section className="mt-6">
<h2 className="admin-section-title">
{t("activities", { count: total })}
</h2>
{activities.length === 0 ? (
<div className="admin-empty">{t("noResults")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
<th>{t("colTarget")}</th>
<th>{t("colIp")}</th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{t("staffWithId", {
id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown",
})}
</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{a.action}
</span>
</td>
<td>{a.description}</td>
<td>
{a.targetType && a.targetId ? (
<span className="text-sm theme-text-muted dark:theme-text-muted">
{a.targetType === "user" ? t("userPrefix") : ""}#
{String(a.targetId)}
</span>
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{a.ipAddress || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("pagination", { page, pages, total })}
{page < pages ? (
<>
{" · "}
<a
href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&authorization=${authorizationOnly ? "1" : ""}&page=${page + 1}`}
>
{t("next")} →
</a>
</>
) : null}
</p>
</main>
<StaffActivitiesTable
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useTranslations } from "next-intl";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import type { DataTableColumn, PaginatedResult } from "@/types";
export interface StaffActivityRow {
id: number;
when: string;
staff: string;
action: string;
description: string;
target: string;
ip: string;
}
export function StaffActivitiesTable({
data,
}: {
data: PaginatedResult<StaffActivityRow>;
}) {
const t = useTranslations("pages.admin.logs");
const columns: DataTableColumn<StaffActivityRow>[] = [
{ key: "when", label: t("colWhen"), sortable: true },
{ key: "staff", label: t("colStaff"), sortable: true },
{
key: "action",
label: t("colAction"),
sortable: true,
render: (value) => (
<Badge variant="secondary" className="font-mono text-[0.7rem]">
{String(value)}
</Badge>
),
},
{ key: "description", label: t("colDescription") },
{ key: "target", label: t("colTarget") },
{ key: "ip", label: t("colIp") },
];
return (
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
presets={[
{
label: t("authorizationErrors"),
params: { filter_authorization: "1" },
},
]}
/>
);
}
+6 -6
View File
@@ -11,6 +11,7 @@ import {
Shield,
UserPlus,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { saveManagedSettings } from "@/actions/admin-settings";
import { Button } from "@/components/ui/button";
@@ -45,6 +46,7 @@ export function CmsSettingsForm({
}) {
const [values, setValues] = useState(initialValues);
const { isPending, run } = useServerAction();
const t = useTranslations("pages.admin.settings");
function handleChange(key: string, value: string) {
setValues((prev) => ({ ...prev, [key]: value }));
@@ -58,8 +60,8 @@ export function CmsSettingsForm({
normalized[key] = values[key] === "1" ? "1" : "0";
}
run(() => saveManagedSettings({ settings: normalized }), {
successMessage: "Settings saved.",
errorMessage: "Could not save settings.",
successMessage: t("saved"),
errorMessage: t("saveFailed"),
});
}
@@ -83,13 +85,11 @@ export function CmsSettingsForm({
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save settings
{isPending ? t("form.saving") : t("saveSettings")}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">
You can view settings but need edit permission to change them.
</p>
<p className="text-sm text-muted-foreground">{t("viewOnly")}</p>
)}
</form>
);
+7 -3
View File
@@ -1,3 +1,4 @@
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
@@ -18,6 +19,7 @@ export default async function AdminSettings() {
PERMS.SETTINGS_EDIT,
session.user.rank,
);
const t = await getTranslations("pages.admin.settings");
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
try {
@@ -42,12 +44,14 @@ export default async function AdminSettings() {
<div className="space-y-8">
<div className="admin-stat-row">
<span className="admin-stat-chip">
{settings.length} keys in database
{t("keysInDatabase", { count: settings.length })}
</span>
<span className="admin-stat-chip">
{MANAGED_SETTING_KEYS.length} managed in form
{t("managedInForm", { count: MANAGED_SETTING_KEYS.length })}
</span>
<span className="admin-stat-chip">
{t("documented", { count: documented })}
</span>
<span className="admin-stat-chip">{documented} documented</span>
</div>
<CmsSettingsForm values={values} canEdit={canEdit} />