diff --git a/messages/de/nav.json b/messages/de/nav.json index a3ecd70..c9996f9 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -8,5 +8,8 @@ "reports": "Berichte", "documents": "Dokumente", "settings": "Einstellungen", + "notifications": "Benachrichtigungen", + "audit": "Audit-Protokoll", + "email": "E-Mail-Versand", "admin": "Admin-Konsole" } diff --git a/messages/en/nav.json b/messages/en/nav.json index 8a208a6..ebd2d6e 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -8,5 +8,8 @@ "reports": "Reports", "documents": "Documents", "settings": "Settings", + "notifications": "Notifications", + "audit": "Audit log", + "email": "E-mail delivery", "admin": "Admin console" } diff --git a/src/app/(app)/account/page.tsx b/src/app/(app)/account/page.tsx index 77f1aea..0333a75 100644 --- a/src/app/(app)/account/page.tsx +++ b/src/app/(app)/account/page.tsx @@ -12,6 +12,7 @@ import { TenantRecoveryRegenForm } from "@/components/tenant-recovery-regen-form import { PasskeyManager } from "@/components/passkey-manager"; import { ChangePasswordSelfForm } from "@/components/auth-recovery-forms"; import { describePasswordPolicy, resolvePasswordPolicy } from "@/lib/password-policy"; +import { NotificationPreferencesSection } from "@/components/notifications/preferences-section"; /** * Persönliches Konto des Mandanten-Nutzers (Paket C): optionale MFA selbst @@ -117,6 +118,8 @@ export default async function AccountPage() { Eine Änderung ist derzeit nur über die Plattform-Administration möglich.

+ + ); diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 9b5527e..1d7808c 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -13,6 +13,7 @@ import { TenantBrand } from "@/components/brand/tenant-brand"; import { TenantSwitcher } from "@/components/tenant-switcher"; import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; +import { NotificationBell } from "@/components/notifications/bell"; export default async function AppLayout({ children, @@ -135,6 +136,7 @@ export default async function AppLayout({
{/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */}
+
diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx index dceecbf..498ae23 100644 --- a/src/app/(app)/notifications/page.tsx +++ b/src/app/(app)/notifications/page.tsx @@ -1,5 +1,134 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { BellDot, CheckCheck } from "lucide-react"; +import { getFormatter, getTranslations } from "next-intl/server"; +import { EVENT_TYPES } from "@/lib/events"; +import { PageHead, Pill } from "@/components/mockup-ui"; +import { Button } from "@/components/ui/button"; +import { markAllNotificationsRead, markNotificationRead, openNotification } from "@/server/actions/notifications/inbox"; +import { listNotifications } from "@/server/services/notifications/inbox"; +import { pageCtx } from "@/server/services/notifications/page-ctx"; +import { eventKey } from "@/server/services/notifications/texts"; -export default function Page() { - return ; +const selectCls = "h-11 rounded-md border border-input bg-card px-3 text-sm"; + +export default async function NotificationsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const ctx = await pageCtx(); + if (!ctx.permissions.has("notification:read")) redirect("/dashboard"); + const sp = await searchParams; + const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v); + const data = await listNotifications(ctx, { status: one(sp.status), type: one(sp.type), page: one(sp.page) }); + const [t, format] = await Promise.all([getTranslations("notifications"), getFormatter()]); + + const pageHref = (page: number) => { + const q = new URLSearchParams(); + if (data.filter.status !== "all") q.set("status", data.filter.status); + if (data.filter.type) q.set("type", data.filter.type); + q.set("page", String(page)); + return `/notifications?${q.toString()}`; + }; + + return ( +
+ + + + } + /> + +
+ + + + + {t("list.reset")} + +
+ + {data.rows.length === 0 ? ( +

{t("list.empty")}

+ ) : ( +
    + {data.rows.map((n) => ( +
  • +
    + {n.readAt ? ( + {t("list.read")} + ) : ( + + {t("list.unread")} + + )} +
    +

    {n.title}

    +

    {n.message}

    +

    + {EVENT_TYPES.includes(n.type as (typeof EVENT_TYPES)[number]) + ? t(`types.${eventKey(n.type as (typeof EVENT_TYPES)[number])}`) + : n.type} + {" · "} + {format.dateTime(n.createdAt, { dateStyle: "medium", timeStyle: "short" })} +

    +
    +
    +
    + {!n.readAt && ( +
    + + +
    + )} + {n.link && ( +
    + + +
    + )} +
    +
  • + ))} +
+ )} + + {data.pages > 1 && ( + + )} +
+ ); } diff --git a/src/app/(app)/settings/email/layout.tsx b/src/app/(app)/settings/email/layout.tsx new file mode 100644 index 0000000..dcc4d43 --- /dev/null +++ b/src/app/(app)/settings/email/layout.tsx @@ -0,0 +1,7 @@ +import { requireModule } from "@/server/modules"; + +/** Tenant mail settings belong to the "notifications" module (actions use its moduleGuard). */ +export default async function MailSettingsLayout({ children }: Readonly<{ children: React.ReactNode }>) { + await requireModule("notifications"); + return <>{children}; +} diff --git a/src/app/(app)/settings/email/page.tsx b/src/app/(app)/settings/email/page.tsx new file mode 100644 index 0000000..afad8b3 --- /dev/null +++ b/src/app/(app)/settings/email/page.tsx @@ -0,0 +1,83 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { PageHead } from "@/components/mockup-ui"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { getMailConfig } from "@/server/mail/config"; +import { saveMailSettings } from "@/server/actions/notifications/mail-settings"; +import { getMailSettings } from "@/server/services/notifications/mail-settings"; +import { pageCtx } from "@/server/services/notifications/page-ctx"; + +const areaCls = "min-h-28 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm"; + +export default async function MailSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) { + const ctx = await pageCtx(); + if (!ctx.permissions.has("tenant:manage")) redirect("/dashboard"); + const [sp, s, t] = await Promise.all([searchParams, getMailSettings(ctx), getTranslations("notifications")]); + const platformFrom = getMailConfig().config?.from ?? "—"; + + return ( +
+ + {t("mailSettings.back")} + + + + {sp.saved && ( +

+ ✓ {t("mailSettings.saved")} +

+ )} + {sp.error && ( +

+ ⚠ {t("mailSettings.invalid", { detail: sp.error.split(",").map((f) => t.has(`mailSettings.${f}`) ? t(`mailSettings.${f}`) : f).join(", ") })} +

+ )} + +
+
+

{t("mailSettings.sender")}

+
+
+ + +

{t("mailSettings.fromNameHint")}

+
+
+ +

{t("mailSettings.fromAddressHint", { address: platformFrom })}

+
+
+ + +

{t("mailSettings.replyToHint")}

+
+
+
+ +
+

{t("mailSettings.recipients")}

+
+
+ +