Improve admin UX: hub i18n, dynamic topbar, settings strings, staff logs DataTable.
Local Build and Deploy / deploy (push) Successful in 1m12s
Local Build and Deploy / deploy (push) Successful in 1m12s
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
604e63da51
commit
1039044e48
11 files changed
+524
-303
No files matched your search
@@ -6,6 +6,7 @@ import type { ReactNode } from "react";
|
|||||||
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
|
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
|
||||||
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
|
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
|
||||||
import { AdminNavLink } from "@/components/admin/admin-nav-link";
|
import { AdminNavLink } from "@/components/admin/admin-nav-link";
|
||||||
|
import { AdminTopbar } from "@/components/admin/admin-topbar";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
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"
|
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
|
||||||
style={{ backgroundColor: "var(--admin-canvas)" }}
|
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||||
>
|
>
|
||||||
<Topbar staff={staff} />
|
<AdminTopbar staff={staff} />
|
||||||
<section className="admin-page flex-1">
|
<section className="admin-page flex-1">
|
||||||
<AdminHubChrome>{children}</AdminHubChrome>
|
<AdminHubChrome>{children}</AdminHubChrome>
|
||||||
</section>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+56
-147
File diff suppressed because it is too large.
Load diff
@@ -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" },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { saveManagedSettings } from "@/actions/admin-settings";
|
import { saveManagedSettings } from "@/actions/admin-settings";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -45,6 +46,7 @@ export function CmsSettingsForm({
|
|||||||
}) {
|
}) {
|
||||||
const [values, setValues] = useState(initialValues);
|
const [values, setValues] = useState(initialValues);
|
||||||
const { isPending, run } = useServerAction();
|
const { isPending, run } = useServerAction();
|
||||||
|
const t = useTranslations("pages.admin.settings");
|
||||||
|
|
||||||
function handleChange(key: string, value: string) {
|
function handleChange(key: string, value: string) {
|
||||||
setValues((prev) => ({ ...prev, [key]: value }));
|
setValues((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -58,8 +60,8 @@ export function CmsSettingsForm({
|
|||||||
normalized[key] = values[key] === "1" ? "1" : "0";
|
normalized[key] = values[key] === "1" ? "1" : "0";
|
||||||
}
|
}
|
||||||
run(() => saveManagedSettings({ settings: normalized }), {
|
run(() => saveManagedSettings({ settings: normalized }), {
|
||||||
successMessage: "Settings saved.",
|
successMessage: t("saved"),
|
||||||
errorMessage: "Could not save settings.",
|
errorMessage: t("saveFailed"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +85,11 @@ export function CmsSettingsForm({
|
|||||||
) : (
|
) : (
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Save settings
|
{isPending ? t("form.saving") : t("saveSettings")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t("viewOnly")}</p>
|
||||||
You can view settings but need edit permission to change them.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -18,6 +19,7 @@ export default async function AdminSettings() {
|
|||||||
PERMS.SETTINGS_EDIT,
|
PERMS.SETTINGS_EDIT,
|
||||||
session.user.rank,
|
session.user.rank,
|
||||||
);
|
);
|
||||||
|
const t = await getTranslations("pages.admin.settings");
|
||||||
|
|
||||||
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
|
let settings: Awaited<ReturnType<typeof prisma.websiteSetting.findMany>> = [];
|
||||||
try {
|
try {
|
||||||
@@ -42,12 +44,14 @@ export default async function AdminSettings() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="admin-stat-row">
|
<div className="admin-stat-row">
|
||||||
<span className="admin-stat-chip">
|
<span className="admin-stat-chip">
|
||||||
{settings.length} keys in database
|
{t("keysInDatabase", { count: settings.length })}
|
||||||
</span>
|
</span>
|
||||||
<span className="admin-stat-chip">
|
<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>
|
||||||
<span className="admin-stat-chip">{documented} documented</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CmsSettingsForm values={values} canEdit={canEdit} />
|
<CmsSettingsForm values={values} canEdit={canEdit} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { AdminSectionTabs } from "@/components/admin/admin-section-tabs";
|
import { AdminSectionTabs } from "@/components/admin/admin-section-tabs";
|
||||||
@@ -13,15 +14,23 @@ import { findAdminHub } from "@/lib/admin-nav";
|
|||||||
export function AdminHubChrome({ children }: { children: ReactNode }) {
|
export function AdminHubChrome({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
const hub = findAdminHub(pathname);
|
const hub = findAdminHub(pathname);
|
||||||
|
const t = useTranslations("pages.admin.hubs");
|
||||||
|
|
||||||
if (!hub) return <>{children}</>;
|
if (!hub) return <>{children}</>;
|
||||||
|
|
||||||
|
const tabs = hub.tabs.map((tab) => ({
|
||||||
|
href: tab.href,
|
||||||
|
label: t(`tabs.${tab.labelKey}`),
|
||||||
|
match: tab.match,
|
||||||
|
group: tab.group,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPageShell
|
<AdminPageShell
|
||||||
icon={hub.icon}
|
icon={hub.icon}
|
||||||
title={hub.title}
|
title={t(hub.titleKey)}
|
||||||
subtitle={hub.subtitle}
|
subtitle={t(hub.subtitleKey)}
|
||||||
tabs={<AdminSectionTabs tabs={hub.tabs} />}
|
tabs={<AdminSectionTabs tabs={tabs} />}
|
||||||
className="admin-hub-panel"
|
className="admin-hub-panel"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface AdminTab {
|
export interface AdminTab {
|
||||||
@@ -13,11 +14,6 @@ export interface AdminTab {
|
|||||||
group?: string;
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
|
||||||
primary: "",
|
|
||||||
tools: "Tools",
|
|
||||||
};
|
|
||||||
|
|
||||||
function matchLength(pathname: string, prefix: string): number {
|
function matchLength(pathname: string, prefix: string): number {
|
||||||
if (pathname === prefix) return prefix.length + 1; // prefer exact
|
if (pathname === prefix) return prefix.length + 1; // prefer exact
|
||||||
if (pathname.startsWith(`${prefix}/`)) return prefix.length;
|
if (pathname.startsWith(`${prefix}/`)) return prefix.length;
|
||||||
@@ -71,6 +67,7 @@ function TabRow({
|
|||||||
|
|
||||||
export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
|
export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
|
||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
|
const t = useTranslations("pages.admin.hubs");
|
||||||
|
|
||||||
let bestIndex = -1;
|
let bestIndex = -1;
|
||||||
let bestScore = -1;
|
let bestScore = -1;
|
||||||
@@ -87,9 +84,7 @@ export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
|
|||||||
|
|
||||||
const hasGroups = tabs.some((t) => t.group);
|
const hasGroups = tabs.some((t) => t.group);
|
||||||
if (!hasGroups) {
|
if (!hasGroups) {
|
||||||
return (
|
return <TabRow tabs={tabs} bestIndex={bestIndex} globalOffset={0} />;
|
||||||
<TabRow tabs={tabs} bestIndex={bestIndex} globalOffset={0} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
@@ -102,16 +97,18 @@ export function AdminSectionTabs({ tabs }: { tabs: AdminTab[] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{order.map((groupId) => {
|
{order.map((groupId) => {
|
||||||
const groupTabs = tabs.filter((t) => (t.group ?? "primary") === groupId);
|
const groupTabs = tabs.filter((tab) => (tab.group ?? "primary") === groupId);
|
||||||
const rowOffset = offset;
|
const rowOffset = offset;
|
||||||
offset += groupTabs.length;
|
offset += groupTabs.length;
|
||||||
|
const label =
|
||||||
|
groupId === "tools" ? t("tabs.toolsGroup") : undefined;
|
||||||
return (
|
return (
|
||||||
<TabRow
|
<TabRow
|
||||||
key={groupId}
|
key={groupId}
|
||||||
tabs={groupTabs}
|
tabs={groupTabs}
|
||||||
bestIndex={bestIndex}
|
bestIndex={bestIndex}
|
||||||
globalOffset={rowOffset}
|
globalOffset={rowOffset}
|
||||||
label={GROUP_LABELS[groupId] || undefined}
|
label={label}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
|
import { findAdminHub } from "@/lib/admin-nav";
|
||||||
|
|
||||||
|
export function AdminTopbar({
|
||||||
|
staff,
|
||||||
|
}: {
|
||||||
|
staff: { id: number; username: string; rank: number };
|
||||||
|
}) {
|
||||||
|
const tNav = useTranslations("pages.admin.nav");
|
||||||
|
const tHubs = useTranslations("pages.admin.hubs");
|
||||||
|
const pathname = usePathname() ?? "";
|
||||||
|
const hub = findAdminHub(pathname);
|
||||||
|
const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping");
|
||||||
|
|
||||||
|
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)]">
|
||||||
|
{title}
|
||||||
|
</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)]">
|
||||||
|
{tNav("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">
|
||||||
|
{tNav("rankLabel", { rank: staff.rank })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
+101
-99
File diff suppressed because it is too large.
Load diff
+126
-1
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user