Compare commits

...

2 Commits

Author SHA1 Message Date
7fac3c3aa8 keyboard radiogroup, modal focus trap, nav session hydration
All checks were successful
Build and Push / build (push) Successful in 1m53s
2026-05-29 22:46:03 +02:00
bff3aad1ca add error/loading/404 boundaries, responsive tables, Metadata API
All checks were successful
Build and Push / build (push) Successful in 1m49s
2026-05-29 22:32:08 +02:00
32 changed files with 468 additions and 57 deletions

View File

@@ -98,6 +98,7 @@ export default async function AdminBillingPage() {
<div className="animate-in animate-in-delay-3"> <div className="animate-in animate-in-delay-3">
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2> <h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
<Card> <Card>
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -126,6 +127,7 @@ export default async function AdminBillingPage() {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
</Card> </Card>
</div> </div>
)} )}

View File

@@ -5,6 +5,11 @@ import { listTenants } from "@/lib/k8s";
import { countPendingSkillActivationRequests } from "@/lib/db"; import { countPendingSkillActivationRequests } from "@/lib/db";
import { AdminPanel } from "@/components/admin/admin-panel"; import { AdminPanel } from "@/components/admin/admin-panel";
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("admin") };
}
export default async function AdminPage() { export default async function AdminPage() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");

View File

@@ -26,6 +26,11 @@ import { RunningTotalWidget } from "@/components/billing/running-total-widget";
* Anyone signed in can view this. The data is org-scoped; even * Anyone signed in can view this. The data is org-scoped; even
* non-owner team members see the same view. * non-owner team members see the same view.
*/ */
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("billing") };
}
export default async function CustomerBillingPage() { export default async function CustomerBillingPage() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");

View File

@@ -22,6 +22,11 @@ import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("dashboard") };
}
export default async function DashboardPage() { export default async function DashboardPage() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
/**
* Error boundary for the [locale] segment. Catches render/data errors
* thrown by any page below the locale layout (which is where K8s, DB,
* LiteLLM and Stripe calls happen). Renders inside NextIntlClientProvider,
* so translations are available. Root-layout failures fall through to
* global-error.tsx instead.
*/
export default function LocaleError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("errors");
useEffect(() => {
// Surface the error for log scraping; the digest correlates with
// the server-side stack in production.
console.error("Portal error boundary:", error);
}, [error]);
return (
<div className="flex min-h-[60vh] items-center justify-center px-5">
<div className="w-full max-w-md text-center">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl bg-error/10">
<svg
className="h-7 w-7 text-error"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 9v4M12 17h.01M10.3 3.86l-8.5 14.7A1.5 1.5 0 003.1 21h17.8a1.5 1.5 0 001.3-2.44l-8.5-14.7a1.5 1.5 0 00-2.6 0z" />
</svg>
</div>
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mb-6">{t("description")}</p>
{error?.digest && (
<p className="text-[11px] font-mono text-text-muted mb-6">
{error.digest}
</p>
)}
<div className="flex items-center justify-center gap-3">
<button
onClick={reset}
className="py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors cursor-pointer"
>
{t("retry")}
</button>
<Link
href="/dashboard"
className="py-2 px-4 rounded-lg border border-border text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors"
>
{t("backToDashboard")}
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,36 @@
import type { Metadata, Viewport } from "next";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server"; import { getMessages, getTranslations } from "next-intl/server";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { NavShell } from "@/components/layout/nav-shell"; import { NavShell } from "@/components/layout/nav-shell";
export function generateStaticParams() { export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale })); return routing.locales.map((locale) => ({ locale }));
} }
// Metadata API (Next 15) instead of a hand-rolled <head>. The title
// template lets each page export a short `title` (e.g. "Dashboard")
// that renders as "Dashboard · PieCed". Pages that export no metadata
// fall back to the default below.
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("common");
const appName = t("appName");
return {
title: {
default: `${appName} Portal`,
template: `%s · ${appName}`,
},
description: "PieCed IT — Multi-tenant AI assistant platform",
};
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};
export default async function LocaleLayout({ export default async function LocaleLayout({
children, children,
params, params,
@@ -22,20 +45,13 @@ export default async function LocaleLayout({
} }
const messages = await getMessages(); const messages = await getMessages();
const session = await auth();
return ( return (
<html lang={locale} className="dark"> <html lang={locale} className="dark">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PieCed Portal</title>
<meta
name="description"
content="PieCed IT — Multi-tenant AI assistant platform"
/>
</head>
<body className="min-h-screen bg-surface-0 text-text-primary antialiased"> <body className="min-h-screen bg-surface-0 text-text-primary antialiased">
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<NavShell>{children}</NavShell> <NavShell session={session}>{children}</NavShell>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,25 @@
/**
* Loading skeleton for the [locale] segment. Shown during navigation
* while a server component fetches (the dashboard, for instance, does
* listTenants() + one K8s GET per provisioning row). Textless on
* purpose so it needs no translations and adds no layout shift.
*/
export default function LocaleLoading() {
return (
<div className="animate-pulse" aria-hidden="true">
<div className="mb-8">
<div className="h-7 w-48 rounded-md bg-surface-2" />
<div className="mt-4 h-4 w-72 rounded bg-surface-1" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-28 rounded-xl border border-border bg-surface-1"
/>
))}
</div>
<span className="sr-only">Loading</span>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { getTranslations } from "next-intl/server";
import { Link } from "@/i18n/navigation";
/**
* 404 for the [locale] segment. Triggered by notFound() calls in pages
* below the locale layout. (A notFound() thrown by the locale layout
* itself — e.g. an unknown locale — resolves to the framework default,
* which is acceptable for that narrow case.)
*/
export default async function LocaleNotFound() {
const t = await getTranslations("errors");
return (
<div className="flex min-h-[60vh] items-center justify-center px-5">
<div className="w-full max-w-md text-center">
<div className="font-display text-5xl font-semibold text-accent mb-4 tabular-nums">
404
</div>
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
{t("notFoundTitle")}
</h1>
<p className="text-sm text-text-secondary mb-6">
{t("notFoundDescription")}
</p>
<Link
href="/dashboard"
className="inline-flex py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
>
{t("backToDashboard")}
</Link>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef, forwardRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter, Link } from "@/i18n/navigation"; import { useRouter, Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -50,6 +50,30 @@ export default function RegisterPage() {
const [state, setState] = useState<FormState>("idle"); const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState(""); const [error, setError] = useState("");
// Radiogroup keyboard support. `role="radio"` requires roving
// tabindex (one tab stop) + arrow-key navigation between options —
// native buttons don't move focus on arrows. The selected card is
// the tab stop; when nothing is selected yet the first card is
// focusable so keyboard users can enter the group.
const TYPES: AccountType[] = ["personal", "company"];
const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
const rovingTabIndex = (type: AccountType, index: number) =>
accountType === type || (accountType === null && index === 0) ? 0 : -1;
const handleCardKeyDown = (e: React.KeyboardEvent, index: number) => {
let next: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
next = (index + 1) % TYPES.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
next = (index - 1 + TYPES.length) % TYPES.length;
}
if (next === null) return;
e.preventDefault();
setAccountType(TYPES[next]);
cardRefs.current[next]?.focus();
};
const isPersonal = accountType === "personal"; const isPersonal = accountType === "personal";
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -146,8 +170,13 @@ export default function RegisterPage() {
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1" className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
> >
<AccountTypeCard <AccountTypeCard
ref={(el) => {
cardRefs.current[0] = el;
}}
selected={accountType === "personal"} selected={accountType === "personal"}
onClick={() => setAccountType("personal")} onClick={() => setAccountType("personal")}
tabIndex={rovingTabIndex("personal", 0)}
onKeyDown={(e) => handleCardKeyDown(e, 0)}
label={t("personalCardTitle")} label={t("personalCardTitle")}
description={t("personalCardDescription")} description={t("personalCardDescription")}
icon={ icon={
@@ -168,8 +197,13 @@ export default function RegisterPage() {
} }
/> />
<AccountTypeCard <AccountTypeCard
ref={(el) => {
cardRefs.current[1] = el;
}}
selected={accountType === "company"} selected={accountType === "company"}
onClick={() => setAccountType("company")} onClick={() => setAccountType("company")}
tabIndex={rovingTabIndex("company", 1)}
onKeyDown={(e) => handleCardKeyDown(e, 1)}
label={t("companyCardTitle")} label={t("companyCardTitle")}
description={t("companyCardDescription")} description={t("companyCardDescription")}
icon={ icon={
@@ -305,41 +339,42 @@ export default function RegisterPage() {
* and text colours intensify when selected to give a clear "this one * and text colours intensify when selected to give a clear "this one
* is on" signal beyond just the border colour. * is on" signal beyond just the border colour.
*/ */
function AccountTypeCard({ const AccountTypeCard = forwardRef<
selected, HTMLButtonElement,
onClick, {
label, selected: boolean;
description, onClick: () => void;
icon, label: string;
}: { description: string;
selected: boolean; icon: React.ReactNode;
onClick: () => void; tabIndex: number;
label: string; onKeyDown: (e: React.KeyboardEvent) => void;
description: string; }
icon: React.ReactNode; >(function AccountTypeCard(
}) { { selected, onClick, label, description, icon, tabIndex, onKeyDown },
ref
) {
return ( return (
<button <button
ref={ref}
type="button" type="button"
role="radio" role="radio"
aria-checked={selected} aria-checked={selected}
tabIndex={tabIndex}
onClick={onClick} onClick={onClick}
onKeyDown={onKeyDown}
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${ className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
selected selected
? "border-accent bg-accent/10" ? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30" : "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
}`} }`}
> >
<div <div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
className={`mb-2 ${
selected ? "text-accent" : "text-text-muted"
}`}
>
{icon} {icon}
</div> </div>
<div <div
className={`text-sm font-semibold mb-0.5 ${ className={`text-sm font-semibold mb-0.5 ${
selected ? "text-text-primary" : "text-text-primary" selected ? "text-text-primary" : "text-text-secondary"
}`} }`}
> >
{label} {label}
@@ -347,4 +382,4 @@ function AccountTypeCard({
<div className="text-xs text-text-muted leading-snug">{description}</div> <div className="text-xs text-text-muted leading-snug">{description}</div>
</button> </button>
); );
} });

View File

@@ -14,6 +14,11 @@ import { Card } from "@/components/ui/card";
* Access: any authenticated user (the cards themselves gate further; * Access: any authenticated user (the cards themselves gate further;
* non-owner users would not see "Billing" as actionable, etc.). * non-owner users would not see "Billing" as actionable, etc.).
*/ */
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("settings") };
}
export default async function SettingsPage() { export default async function SettingsPage() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");

View File

@@ -24,6 +24,11 @@ import { TicketCategoryLabel } from "@/components/support/ticket-category-label"
* having recent activity, but we don't sort by status; that's a * having recent activity, but we don't sort by status; that's a
* filter the admin can add later if the queue grows. * filter the admin can add later if the queue grows.
*/ */
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("support") };
}
export default async function SupportListPage() { export default async function SupportListPage() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");

View File

@@ -17,6 +17,11 @@ import { InviteForm } from "@/components/team/invite-form";
* `<TeamList>` and `<InviteForm>` client components handle live * `<TeamList>` and `<InviteForm>` client components handle live
* updates after invites and refreshes. * updates after invites and refreshes.
*/ */
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("team") };
}
export default async function TeamPage() { export default async function TeamPage() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");

78
src/app/global-error.tsx Normal file
View File

@@ -0,0 +1,78 @@
"use client";
import { useEffect } from "react";
/**
* Last-resort boundary for errors thrown in the root layout itself
* (before the locale layout / intl provider mount). It replaces the
* entire document, so it must render its own <html>/<body> and cannot
* use translations or rely on the app stylesheet being applied — styles
* are inlined with the palette's hex values so it renders correctly in
* isolation. Everything below the locale layout is handled by
* [locale]/error.tsx instead; this should almost never be seen.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Portal global error:", error);
}, [error]);
return (
<html lang="en" className="dark">
<body
style={{
margin: 0,
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#0a0c10",
color: "#e8ecf4",
fontFamily: "system-ui, sans-serif",
padding: "20px",
}}
>
<div style={{ maxWidth: "28rem", textAlign: "center" }}>
<h1 style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 0.5rem" }}>
Something went wrong
</h1>
<p style={{ fontSize: "0.875rem", color: "#8892a4", margin: "0 0 1.5rem" }}>
An unexpected error occurred. Please try again.
</p>
{error?.digest && (
<p
style={{
fontSize: "11px",
fontFamily: "monospace",
color: "#565e6e",
margin: "0 0 1.5rem",
}}
>
{error.digest}
</p>
)}
<button
onClick={reset}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.5rem",
border: "none",
background: "#00d4aa",
color: "#0a0c10",
fontSize: "0.875rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Try again
</button>
</div>
</body>
</html>
);
}

View File

@@ -336,6 +336,7 @@ export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
<Card> <Card>
<CardHeader>{t("editorLinesHeading")}</CardHeader> <CardHeader>{t("editorLinesHeading")}</CardHeader>
<div className="p-4"> <div className="p-4">
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -420,6 +421,7 @@ export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
})} })}
</tbody> </tbody>
</table> </table>
</div>
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">
<button <button
onClick={addLine} onClick={addLine}

View File

@@ -76,6 +76,7 @@ export function DraftList({ drafts, orgNameMap }: Props) {
{t("newInvoiceBtn")} {t("newInvoiceBtn")}
</Link> </Link>
</div> </div>
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -140,6 +141,7 @@ export function DraftList({ drafts, orgNameMap }: Props) {
})} })}
</tbody> </tbody>
</table> </table>
</div>
</Card> </Card>
); );
} }

View File

@@ -265,6 +265,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
</div> </div>
)} )}
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -323,6 +324,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
)} )}
</tbody> </tbody>
</table> </table>
</div>
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm"> <div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">

View File

@@ -463,6 +463,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
{creditNotes.length > 0 && ( {creditNotes.length > 0 && (
<Card> <Card>
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader> <CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -518,12 +519,14 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
</Card> </Card>
)} )}
{/* Lines */} {/* Lines */}
<Card> <Card>
<CardHeader>{t("lineItemsTitle")}</CardHeader> <CardHeader>{t("lineItemsTitle")}</CardHeader>
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -572,6 +575,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
})} })}
</tbody> </tbody>
</table> </table>
</div>
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm"> <div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-text-muted">{t("subtotal")}</span> <span className="text-text-muted">{t("subtotal")}</span>

View File

@@ -126,6 +126,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
{t("noInvoicesFound")} {t("noInvoicesFound")}
</p> </p>
) : ( ) : (
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -178,6 +179,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</Card> </Card>
</div> </div>

View File

@@ -76,6 +76,7 @@ export function OrgPaymentModeList({ orgs }: Props) {
{error} {error}
</div> </div>
)} )}
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -153,6 +154,7 @@ export function OrgPaymentModeList({ orgs }: Props) {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
</Card> </Card>
); );
} }

View File

@@ -255,6 +255,7 @@ export function PricingEditor({
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p> <p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
{initialSkillPricing.length > 0 ? ( {initialSkillPricing.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm mb-6"> <table className="w-full text-sm mb-6">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -319,6 +320,7 @@ export function PricingEditor({
})} })}
</tbody> </tbody>
</table> </table>
</div>
) : ( ) : (
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p> <p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
)} )}

View File

@@ -194,6 +194,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
{t("noRunsYet")} {t("noRunsYet")}
</p> </p>
) : ( ) : (
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -241,6 +242,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</Card> </Card>
</section> </section>

View File

@@ -99,6 +99,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
{error} {error}
</div> </div>
)} )}
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -199,6 +200,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
</Card> </Card>
); );
} }

View File

@@ -36,6 +36,7 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
return ( return (
<Card> <Card>
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -96,6 +97,7 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
</Card> </Card>
); );
} }

View File

@@ -107,6 +107,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
</Card> </Card>
<Card> <Card>
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -160,6 +161,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div>
</Card> </Card>
</div> </div>
); );

View File

@@ -46,6 +46,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
return ( return (
<Card> <Card>
<div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left"> <thead className="text-xs text-text-muted text-left">
<tr> <tr>
@@ -104,6 +105,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
</Card> </Card>
); );
} }

View File

@@ -160,6 +160,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
<summary className="cursor-pointer text-text-muted hover:text-text-secondary"> <summary className="cursor-pointer text-text-muted hover:text-text-secondary">
{t("breakdownToggle", { count: draft.lines.length })} {t("breakdownToggle", { count: draft.lines.length })}
</summary> </summary>
<div className="overflow-x-auto">
<table className="w-full mt-2 text-xs"> <table className="w-full mt-2 text-xs">
<tbody> <tbody>
{draft.lines.map((ln, i) => ( {draft.lines.map((ln, i) => (
@@ -188,6 +189,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</details> </details>
)} )}
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p> <p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>

View File

@@ -6,6 +6,7 @@ import { signOut, useSession } from "next-auth/react";
import { usePathname } from "@/i18n/navigation"; import { usePathname } from "@/i18n/navigation";
import { Link } from "@/i18n/navigation"; import { Link } from "@/i18n/navigation";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import type { Session } from "next-auth";
import { LanguageSwitcher } from "@/components/ui/language-switcher"; import { LanguageSwitcher } from "@/components/ui/language-switcher";
function NavBar() { function NavBar() {
@@ -211,9 +212,19 @@ function NavLink({
); );
} }
export function NavShell({ children }: { children: React.ReactNode }) { export function NavShell({
children,
session,
}: {
children: React.ReactNode;
// Server-resolved session passed down from the locale layout. Seeding
// SessionProvider with it means useSession() is populated on the first
// client render, so the nav links render immediately instead of
// popping in after the client-side session fetch (CLS / flash).
session: Session | null;
}) {
return ( return (
<SessionProvider> <SessionProvider session={session}>
<NavBar /> <NavBar />
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main> <main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
</SessionProvider> </SessionProvider>

View File

@@ -16,6 +16,9 @@ interface Props {
ariaLabel?: string; ariaLabel?: string;
} }
const FOCUSABLE =
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
/** /**
* Portal-based modal. * Portal-based modal.
* *
@@ -25,45 +28,86 @@ interface Props {
* ancestor's containing block, not the viewport, when ANY ancestor * ancestor's containing block, not the viewport, when ANY ancestor
* has a `transform`, `perspective`, or `filter` applied. Our * has a `transform`, `perspective`, or `filter` applied. Our
* `animate-in` utility sets `transform: translateY(0)` on a lot of * `animate-in` utility sets `transform: translateY(0)` on a lot of
* dashboard/tenant-detail containers (because of the fade-up * dashboard/tenant-detail containers, which broke modals rendered as
* animation, which uses `animation-fill-mode: both` to keep the * in-place children — they centred to the panel they lived in, not to
* transform on after the animation finishes). That broke modals * the page. Rendering at `document.body` via `createPortal` escapes
* rendered as in-place children — they centred to the panel they * every containing-block ancestor and gives us true viewport coords.
* lived in, not to the page.
* *
* Rendering at `document.body` via `createPortal` escapes every * UX / a11y details
* containing-block ancestor and gives us true viewport coordinates. * -----------------
* * - Backdrop click triggers `onClose` (only when the click target IS
* UX details * the backdrop, not the panel inside).
* ---------- * - Escape triggers `onClose`.
* - Backdrop click triggers `onClose`. (Bubbling check: only fires * - `body` overflow is locked while open so background content doesn't
* when the click target IS the backdrop, not the panel inside.) * scroll behind the modal.
* - Escape key triggers `onClose`. Standard modal expectation. * - Focus is moved into the panel on open, trapped within it while open
* - `body` overflow is locked while open so background content * (Tab / Shift+Tab cycle), and restored to the previously focused
* doesn't scroll behind the modal. * element on close — so keyboard and screen-reader users can't tab
* - Renders nothing on first paint server-side, then mounts on * out to the inert page behind the dialog.
* client. `useEffect` gating ensures `document.body` is available;
* without it Next.js SSR would throw on `document` reference.
*/ */
export function Modal({ open, onClose, children, ariaLabel }: Props) { export function Modal({ open, onClose, children, ariaLabel }: Props) {
const closeRef = useRef(onClose); const closeRef = useRef(onClose);
closeRef.current = onClose; closeRef.current = onClose;
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
// Lock background scroll. Restore on unmount/close. // Remember what had focus so we can restore it on close.
const previouslyFocused = document.activeElement as HTMLElement | null;
// Lock background scroll.
const previousOverflow = document.body.style.overflow; const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
// Move focus into the dialog — first focusable element, else the
// panel itself (it carries tabIndex={-1}).
const panel = panelRef.current;
const focusables = panel
? Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
: [];
(focusables[0] ?? panel)?.focus();
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeRef.current(); if (e.key === "Escape") {
closeRef.current();
return;
}
if (e.key !== "Tab" || !panel) return;
// Re-query each time — modal content can change between tabs.
const items = Array.from(
panel.querySelectorAll<HTMLElement>(FOCUSABLE)
).filter((el) => el.offsetParent !== null || el === document.activeElement);
if (items.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey) {
if (active === first || active === panel) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => { return () => {
document.body.style.overflow = previousOverflow; document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
// Restore focus to the trigger (if it's still in the document).
if (previouslyFocused && document.contains(previouslyFocused)) {
previouslyFocused.focus();
}
}; };
}, [open]); }, [open]);
@@ -72,15 +116,19 @@ export function Modal({ open, onClose, children, ariaLabel }: Props) {
return createPortal( return createPortal(
<div <div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
> >
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto"> <div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto focus:outline-none"
>
{children} {children}
</div> </div>
</div>, </div>,

View File

@@ -964,5 +964,13 @@
"saving": "Speichern…", "saving": "Speichern…",
"saved": "Gespeichert.", "saved": "Gespeichert.",
"missingRequired": "Vor- und Nachname sind erforderlich." "missingRequired": "Vor- und Nachname sind erforderlich."
},
"errors": {
"title": "Etwas ist schiefgelaufen",
"description": "Beim Laden dieser Seite ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"retry": "Erneut versuchen",
"backToDashboard": "Zurück zum Dashboard",
"notFoundTitle": "Seite nicht gefunden",
"notFoundDescription": "Die angeforderte Seite existiert nicht oder wurde verschoben."
} }
} }

View File

@@ -964,5 +964,13 @@
"saving": "Saving…", "saving": "Saving…",
"saved": "Saved.", "saved": "Saved.",
"missingRequired": "First and last name are required." "missingRequired": "First and last name are required."
},
"errors": {
"title": "Something went wrong",
"description": "An error occurred while loading this page. Please try again.",
"retry": "Try again",
"backToDashboard": "Back to dashboard",
"notFoundTitle": "Page not found",
"notFoundDescription": "The page you're looking for doesn't exist or has moved."
} }
} }

View File

@@ -964,5 +964,13 @@
"saving": "Enregistrement…", "saving": "Enregistrement…",
"saved": "Enregistré.", "saved": "Enregistré.",
"missingRequired": "Le prénom et le nom sont obligatoires." "missingRequired": "Le prénom et le nom sont obligatoires."
},
"errors": {
"title": "Une erreur est survenue",
"description": "Une erreur s'est produite lors du chargement de cette page. Veuillez réessayer.",
"retry": "Réessayer",
"backToDashboard": "Retour au tableau de bord",
"notFoundTitle": "Page introuvable",
"notFoundDescription": "La page que vous recherchez n'existe pas ou a été déplacée."
} }
} }

View File

@@ -964,5 +964,13 @@
"saving": "Salvataggio…", "saving": "Salvataggio…",
"saved": "Salvato.", "saved": "Salvato.",
"missingRequired": "Nome e cognome sono obbligatori." "missingRequired": "Nome e cognome sono obbligatori."
},
"errors": {
"title": "Si è verificato un errore",
"description": "Si è verificato un errore durante il caricamento di questa pagina. Riprova.",
"retry": "Riprova",
"backToDashboard": "Torna alla dashboard",
"notFoundTitle": "Pagina non trovata",
"notFoundDescription": "La pagina che stai cercando non esiste o è stata spostata."
} }
} }