feat: add email notification system (Brevo SMTP + N8N daily digest)

- Add core notification service: types, email-service (nodemailer/Brevo SMTP), notification-service (digest builder, preference CRUD, HTML renderer)
- Add API routes: POST /api/notifications/digest (N8N cron, Bearer auth), GET/PUT /api/notifications/preferences (session auth)
- Add NotificationPreferences UI component (Bell button + dialog with per-type toggles) in Registratura toolbar
- Add 7 Brevo SMTP env vars to docker-compose.yml
- Update CLAUDE.md, ROADMAP.md, DATA-MODEL.md, SYSTEM-ARCHITECTURE.md, CONFIGURATION.md, DOCKER-DEPLOYMENT.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-11 01:12:36 +02:00
parent 6941074106
commit 974d06fff8
17 changed files with 998 additions and 14 deletions
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { runDigest } from "@/core/notifications";
/**
* POST /api/notifications/digest
*
* Server-to-server endpoint called by N8N cron.
* Auth via Authorization: Bearer <NOTIFICATION_CRON_SECRET>
*/
export async function POST(request: Request) {
const secret = process.env.NOTIFICATION_CRON_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "NOTIFICATION_CRON_SECRET not configured" },
{ status: 500 },
);
}
const authHeader = request.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");
if (token !== secret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const result = await runDigest();
return NextResponse.json(result, {
status: result.success ? 200 : 500,
});
}
@@ -0,0 +1,109 @@
import { NextResponse } from "next/server";
import { getAuthSession } from "@/core/auth";
import type { CompanyId } from "@/core/auth/types";
import {
getPreference,
savePreference,
defaultPreference,
} from "@/core/notifications";
import type { NotificationType, NotificationPreference } from "@/core/notifications";
const VALID_TYPES: NotificationType[] = [
"deadline-urgent",
"deadline-overdue",
"document-expiry",
];
type SessionUser = {
id?: string;
name?: string | null;
email?: string | null;
company?: string;
};
/**
* GET /api/notifications/preferences
*
* Returns the current user's notification preferences.
* Creates defaults (all enabled) if none exist.
*/
export async function GET() {
const session = await getAuthSession();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const u = session.user as SessionUser;
const id = u.id ?? "unknown";
const email = u.email ?? "";
const name = u.name ?? "";
const company = (u.company ?? "beletage") as CompanyId;
let pref = await getPreference(id);
if (!pref) {
pref = defaultPreference(id, email, name, company);
await savePreference(pref);
}
return NextResponse.json(pref);
}
/**
* PUT /api/notifications/preferences
*
* Update the current user's notification preferences.
* Body: { enabledTypes?: NotificationType[], globalOptOut?: boolean }
*/
export async function PUT(request: Request) {
const session = await getAuthSession();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const u = session.user as SessionUser;
const id = u.id ?? "unknown";
const email = u.email ?? "";
const name = u.name ?? "";
const company = (u.company ?? "beletage") as CompanyId;
const body = (await request.json()) as Partial<
Pick<NotificationPreference, "enabledTypes" | "globalOptOut">
>;
// Validate types
if (body.enabledTypes) {
const invalid = body.enabledTypes.filter(
(t) => !VALID_TYPES.includes(t),
);
if (invalid.length > 0) {
return NextResponse.json(
{ error: `Tipuri invalide: ${invalid.join(", ")}` },
{ status: 400 },
);
}
}
// Load existing or create default
let pref = await getPreference(id);
if (!pref) {
pref = defaultPreference(id, email, name, company);
}
// Update fields
if (body.enabledTypes !== undefined) {
pref.enabledTypes = body.enabledTypes;
}
if (body.globalOptOut !== undefined) {
pref.globalOptOut = body.globalOptOut;
}
// Always refresh identity from session
pref.email = email;
pref.name = name;
pref.company = company;
await savePreference(pref);
return NextResponse.json(pref);
}
+55
View File
@@ -0,0 +1,55 @@
import nodemailer from "nodemailer";
import type { Transporter } from "nodemailer";
import type { EmailPayload } from "./types";
// ── Singleton transport (lazy init, same pattern as prisma) ──
const globalForEmail = globalThis as unknown as {
emailTransport: Transporter | undefined;
};
function getTransport(): Transporter {
if (globalForEmail.emailTransport) return globalForEmail.emailTransport;
const host = process.env.BREVO_SMTP_HOST ?? "smtp-relay.brevo.com";
const port = parseInt(process.env.BREVO_SMTP_PORT ?? "587", 10);
const user = process.env.BREVO_SMTP_USER ?? "";
const pass = process.env.BREVO_SMTP_PASS ?? "";
if (!user || !pass) {
throw new Error(
"BREVO_SMTP_USER and BREVO_SMTP_PASS must be set for email notifications",
);
}
const transport = nodemailer.createTransport({
host,
port,
secure: false, // STARTTLS on port 587
auth: { user, pass },
});
if (process.env.NODE_ENV !== "production") {
globalForEmail.emailTransport = transport;
}
return transport;
}
/**
* Send a single email via Brevo SMTP relay.
*/
export async function sendEmail(payload: EmailPayload): Promise<void> {
const fromEmail =
process.env.NOTIFICATION_FROM_EMAIL ?? "noreply@beletage.ro";
const fromName = process.env.NOTIFICATION_FROM_NAME ?? "ArchiTools";
const transport = getTransport();
await transport.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to: payload.to,
subject: payload.subject,
html: payload.html,
});
}
+17
View File
@@ -0,0 +1,17 @@
export type {
NotificationType,
NotificationTypeInfo,
NotificationPreference,
DigestSection,
DigestItem,
DigestResult,
EmailPayload,
} from "./types";
export { NOTIFICATION_TYPES, defaultPreference } from "./types";
export { sendEmail } from "./email-service";
export {
getPreference,
savePreference,
getAllPreferences,
runDigest,
} from "./notification-service";
@@ -0,0 +1,387 @@
import { prisma } from "@/core/storage/prisma";
import type { Prisma } from "@prisma/client";
import type { CompanyId } from "@/core/auth/types";
import type { RegistryEntry } from "@/modules/registratura/types";
import {
getDeadlineDisplayStatus,
} from "@/modules/registratura/services/deadline-service";
import { getDeadlineType } from "@/modules/registratura/services/deadline-catalog";
import { sendEmail } from "./email-service";
import type {
NotificationPreference,
NotificationType,
DigestSection,
DigestItem,
DigestResult,
} from "./types";
import { defaultPreference } from "./types";
const NAMESPACE = "notifications";
// ── Preference CRUD (KeyValueStore) ──
export async function getPreference(
userId: string,
): Promise<NotificationPreference | null> {
const row = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: `pref:${userId}` } },
});
if (!row) return null;
return row.value as unknown as NotificationPreference;
}
export async function savePreference(
pref: NotificationPreference,
): Promise<void> {
await prisma.keyValueStore.upsert({
where: {
namespace_key: { namespace: NAMESPACE, key: `pref:${pref.userId}` },
},
update: { value: pref as unknown as Prisma.InputJsonValue },
create: {
namespace: NAMESPACE,
key: `pref:${pref.userId}`,
value: pref as unknown as Prisma.InputJsonValue,
},
});
}
export async function getAllPreferences(): Promise<NotificationPreference[]> {
const rows = await prisma.keyValueStore.findMany({
where: { namespace: NAMESPACE },
});
return rows
.filter((r) => r.key.startsWith("pref:"))
.map((r) => r.value as unknown as NotificationPreference);
}
// ── Load registry entries (direct Prisma — avoids N+1) ──
async function loadAllRegistryEntries(): Promise<RegistryEntry[]> {
const rows = await prisma.keyValueStore.findMany({
where: { namespace: "registratura" },
select: { key: true, value: true },
});
return rows
.filter((r) => r.key.startsWith("entry:"))
.map((r) => r.value as unknown as RegistryEntry);
}
// ── Build digest for a company ──
function buildCompanyDigest(
entries: RegistryEntry[],
company: CompanyId,
): DigestSection[] {
const companyEntries = entries.filter((e) => e.company === company);
const sections: DigestSection[] = [];
const now = new Date();
now.setHours(0, 0, 0, 0);
// ── Deadline sections ──
const urgentItems: DigestItem[] = [];
const overdueItems: DigestItem[] = [];
for (const entry of companyEntries) {
if (entry.status !== "deschis") continue;
for (const dl of entry.trackedDeadlines ?? []) {
if (dl.resolution !== "pending") continue;
const status = getDeadlineDisplayStatus(dl);
const def = getDeadlineType(dl.typeId);
const label = def?.label ?? dl.typeId;
if (status.variant === "yellow" && status.daysRemaining !== null) {
urgentItems.push({
entryNumber: entry.number,
subject: entry.subject,
label,
dueDate: dl.dueDate,
daysRemaining: status.daysRemaining,
color: "yellow",
});
}
if (status.variant === "red" && status.daysRemaining !== null) {
overdueItems.push({
entryNumber: entry.number,
subject: entry.subject,
label,
dueDate: dl.dueDate,
daysRemaining: status.daysRemaining,
color: "red",
});
}
// Tacit approval (overdue but applicable) — also report
if (status.variant === "blue" && status.daysRemaining !== null && status.daysRemaining < 0) {
overdueItems.push({
entryNumber: entry.number,
subject: entry.subject,
label: `${label} (aprobat tacit)`,
dueDate: dl.dueDate,
daysRemaining: status.daysRemaining,
color: "blue",
});
}
}
}
if (urgentItems.length > 0) {
sections.push({
type: "deadline-urgent",
title: "Termene urgente (5 zile sau mai putin)",
items: urgentItems.sort((a, b) => a.daysRemaining - b.daysRemaining),
});
}
if (overdueItems.length > 0) {
sections.push({
type: "deadline-overdue",
title: "Termene depasite",
items: overdueItems.sort((a, b) => a.daysRemaining - b.daysRemaining),
});
}
// ── Document expiry section ──
const expiryItems: DigestItem[] = [];
for (const entry of companyEntries) {
if (entry.status !== "deschis" || !entry.expiryDate) continue;
const expiry = new Date(entry.expiryDate);
expiry.setHours(0, 0, 0, 0);
const daysLeft = Math.ceil(
(expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
const alertDays = entry.expiryAlertDays ?? 30;
if (daysLeft <= alertDays) {
expiryItems.push({
entryNumber: entry.number,
subject: entry.subject,
label: daysLeft < 0 ? "Expirat" : "Expira curand",
dueDate: entry.expiryDate,
daysRemaining: daysLeft,
color: daysLeft < 0 ? "red" : "yellow",
});
}
}
if (expiryItems.length > 0) {
sections.push({
type: "document-expiry",
title: "Documente care expira",
items: expiryItems.sort((a, b) => a.daysRemaining - b.daysRemaining),
});
}
return sections;
}
// ── Render HTML digest ──
function formatDateRo(dateStr: string): string {
const d = new Date(dateStr);
const day = String(d.getDate()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const year = d.getFullYear();
return `${day}.${month}.${year}`;
}
function renderDigestHtml(
sections: DigestSection[],
companyName: string,
date: string,
): string {
const colorMap: Record<string, string> = {
red: "#ef4444",
yellow: "#f59e0b",
blue: "#3b82f6",
};
const bgMap: Record<string, string> = {
red: "#fef2f2",
yellow: "#fffbeb",
blue: "#eff6ff",
};
let sectionsHtml = "";
for (const section of sections) {
let rowsHtml = "";
for (const item of section.items) {
const daysText =
item.daysRemaining < 0
? `${Math.abs(item.daysRemaining)} zile depasit`
: item.daysRemaining === 0
? "Azi"
: `${item.daysRemaining} zile ramase`;
rowsHtml += `
<tr style="border-bottom: 1px solid #e5e7eb;">
<td style="padding: 8px 12px; font-size: 13px; color: #374151;">${item.entryNumber}</td>
<td style="padding: 8px 12px; font-size: 13px; color: #374151; max-width: 250px; overflow: hidden; text-overflow: ellipsis;">${item.subject}</td>
<td style="padding: 8px 12px; font-size: 13px; color: #374151;">${item.label}</td>
<td style="padding: 8px 12px; font-size: 13px; color: #374151;">${formatDateRo(item.dueDate)}</td>
<td style="padding: 8px 12px; font-size: 13px; font-weight: 600; color: ${colorMap[item.color] ?? "#374151"}; background: ${bgMap[item.color] ?? "transparent"}; border-radius: 4px;">${daysText}</td>
</tr>`;
}
sectionsHtml += `
<div style="margin-bottom: 24px;">
<h3 style="margin: 0 0 8px 0; font-size: 15px; font-weight: 600; color: #111827;">${section.title} (${section.items.length})</h3>
<table style="width: 100%; border-collapse: collapse; border: 1px solid #e5e7eb; border-radius: 6px; overflow: hidden;">
<thead>
<tr style="background: #f9fafb;">
<th style="padding: 8px 12px; font-size: 12px; font-weight: 600; color: #6b7280; text-align: left;">Nr.</th>
<th style="padding: 8px 12px; font-size: 12px; font-weight: 600; color: #6b7280; text-align: left;">Subiect</th>
<th style="padding: 8px 12px; font-size: 12px; font-weight: 600; color: #6b7280; text-align: left;">Termen</th>
<th style="padding: 8px 12px; font-size: 12px; font-weight: 600; color: #6b7280; text-align: left;">Scadent</th>
<th style="padding: 8px 12px; font-size: 12px; font-weight: 600; color: #6b7280; text-align: left;">Status</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
</div>`;
}
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /></head>
<body style="margin: 0; padding: 0; background: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<div style="max-width: 700px; margin: 0 auto; padding: 24px;">
<div style="background: #ffffff; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden;">
<!-- Header -->
<div style="background: #111827; padding: 20px 24px;">
<h1 style="margin: 0; font-size: 18px; font-weight: 600; color: #ffffff;">ArchiTools — Digest zilnic</h1>
<p style="margin: 4px 0 0 0; font-size: 13px; color: #9ca3af;">${companyName} &middot; ${formatDateRo(date)}</p>
</div>
<!-- Body -->
<div style="padding: 24px;">
${sectionsHtml}
<div style="margin-top: 24px; padding-top: 16px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 12px; color: #9ca3af;">
Acest email este generat automat de ArchiTools. Poti dezactiva notificarile din
<a href="https://tools.beletage.ro/registratura" style="color: #3b82f6; text-decoration: none;">Registratura</a>
&rarr; butonul Notificari.
</p>
</div>
</div>
</div>
</div>
</body>
</html>`;
}
// ── Company labels ──
const COMPANY_LABELS: Record<CompanyId, string> = {
beletage: "Beletage",
"urban-switch": "Urban Switch",
"studii-de-teren": "Studii de Teren",
group: "Grup",
};
// ── Main orchestrator ──
export async function runDigest(): Promise<DigestResult> {
const result: DigestResult = {
success: true,
totalEmails: 0,
errors: [],
companySummary: {},
};
try {
// 1. Load all entries + all preferences
const [entries, preferences] = await Promise.all([
loadAllRegistryEntries(),
getAllPreferences(),
]);
if (preferences.length === 0) {
return { ...result, errors: ["Nu exista preferinte de notificare configurate"] };
}
// 2. Group subscribers by company
const subscribersByCompany = new Map<CompanyId, NotificationPreference[]>();
for (const pref of preferences) {
if (pref.globalOptOut) continue;
if (pref.enabledTypes.length === 0) continue;
const existing = subscribersByCompany.get(pref.company) ?? [];
existing.push(pref);
subscribersByCompany.set(pref.company, existing);
}
// 3. Build digest per company, send per subscriber
const today = new Date().toISOString().slice(0, 10);
for (const [company, subscribers] of subscribersByCompany.entries()) {
const allSections = buildCompanyDigest(entries, company);
if (allSections.length === 0) {
result.companySummary[company] = { emails: 0, sections: 0 };
continue;
}
let emailsSent = 0;
for (const subscriber of subscribers) {
// Filter sections per subscriber's enabled types
const userSections = allSections.filter((s) =>
subscriber.enabledTypes.includes(s.type),
);
if (userSections.length === 0) continue;
const totalItems = userSections.reduce(
(acc, s) => acc + s.items.length,
0,
);
const html = renderDigestHtml(
userSections,
COMPANY_LABELS[company] ?? company,
today,
);
const subject = `[ArchiTools] ${totalItems} alerte — ${COMPANY_LABELS[company] ?? company} (${formatDateRo(today)})`;
try {
await sendEmail({
to: subscriber.email,
subject,
html,
});
emailsSent++;
result.totalEmails++;
} catch (err) {
const msg = `Eroare trimitere email catre ${subscriber.email}: ${err instanceof Error ? err.message : String(err)}`;
result.errors.push(msg);
}
}
result.companySummary[company] = {
emails: emailsSent,
sections: allSections.length,
};
}
} catch (err) {
result.success = false;
result.errors.push(
`Eroare generala digest: ${err instanceof Error ? err.message : String(err)}`,
);
}
return result;
}
+99
View File
@@ -0,0 +1,99 @@
import type { CompanyId } from "@/core/auth/types";
// ── Notification types ──
export type NotificationType =
| "deadline-urgent"
| "deadline-overdue"
| "document-expiry";
export interface NotificationTypeInfo {
type: NotificationType;
label: string;
description: string;
}
/** All notification types with Romanian labels */
export const NOTIFICATION_TYPES: NotificationTypeInfo[] = [
{
type: "deadline-urgent",
label: "Termene urgente",
description: "Termene legale cu 5 sau mai putine zile ramase",
},
{
type: "deadline-overdue",
label: "Termene depasite",
description: "Termene legale care au depasit data scadenta",
},
{
type: "document-expiry",
label: "Documente care expira",
description: "CU/AC si alte documente care expira in fereastra de alerta",
},
];
// ── User preferences ──
export interface NotificationPreference {
userId: string;
email: string;
name: string;
company: CompanyId;
enabledTypes: NotificationType[];
globalOptOut: boolean;
}
/** Default preference — all types enabled, not opted out */
export function defaultPreference(
userId: string,
email: string,
name: string,
company: CompanyId,
): NotificationPreference {
return {
userId,
email,
name,
company,
enabledTypes: ["deadline-urgent", "deadline-overdue", "document-expiry"],
globalOptOut: false,
};
}
// ── Digest result types ──
export interface DigestItem {
/** Entry number (e.g., "BTG-0042/2026") */
entryNumber: string;
/** Entry subject */
subject: string;
/** Deadline or expiry label */
label: string;
/** Due date (YYYY-MM-DD) */
dueDate: string;
/** Days remaining (negative = overdue) */
daysRemaining: number;
/** Color for HTML rendering */
color: "red" | "yellow" | "blue";
}
export interface DigestSection {
type: NotificationType;
title: string;
items: DigestItem[];
}
export interface DigestResult {
success: boolean;
totalEmails: number;
errors: string[];
companySummary: Record<string, { emails: number; sections: number }>;
}
// ── Email payload ──
export interface EmailPayload {
to: string;
subject: string;
html: string;
}
@@ -0,0 +1,160 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Bell, Loader2 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/shared/components/ui/dialog';
import { Button } from '@/shared/components/ui/button';
import { Switch } from '@/shared/components/ui/switch';
import { Label } from '@/shared/components/ui/label';
import { useAuth } from '@/core/auth';
import type { NotificationPreference, NotificationType } from '@/core/notifications/types';
import { NOTIFICATION_TYPES } from '@/core/notifications/types';
export function NotificationPreferences() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [pref, setPref] = useState<NotificationPreference | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
// Load preferences when dialog opens
const loadPreferences = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/notifications/preferences');
if (res.ok) {
const data = (await res.json()) as NotificationPreference;
setPref(data);
}
} catch {
// Silently fail — will show defaults
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
void loadPreferences();
}
}, [open, loadPreferences]);
// Auto-save on every change
const savePref = useCallback(async (updated: NotificationPreference) => {
setSaving(true);
try {
await fetch('/api/notifications/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabledTypes: updated.enabledTypes,
globalOptOut: updated.globalOptOut,
}),
});
} catch {
// Silently fail
} finally {
setSaving(false);
}
}, []);
const handleGlobalToggle = (checked: boolean) => {
if (!pref) return;
const updated = { ...pref, globalOptOut: !checked };
setPref(updated);
void savePref(updated);
};
const handleTypeToggle = (type: NotificationType, checked: boolean) => {
if (!pref) return;
const enabledTypes = checked
? [...pref.enabledTypes, type]
: pref.enabledTypes.filter((t) => t !== type);
const updated = { ...pref, enabledTypes };
setPref(updated);
void savePref(updated);
};
return (
<>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setOpen(true)}
>
<Bell className="mr-1.5 h-4 w-4" />
Notificari
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Preferinte notificari</DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : pref ? (
<div className="space-y-6 py-2">
{/* Email display */}
<div className="text-sm text-muted-foreground">
Email: <span className="font-medium text-foreground">{user?.email ?? pref.email}</span>
</div>
{/* Global toggle */}
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">Primeste notificari zilnice</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Digest email in fiecare dimineata (L-V)
</p>
</div>
<Switch
checked={!pref.globalOptOut}
onCheckedChange={handleGlobalToggle}
/>
</div>
{/* Per-type toggles */}
{!pref.globalOptOut && (
<div className="space-y-3 pl-1 border-l-2 border-muted ml-2">
{NOTIFICATION_TYPES.map((nt) => (
<div key={nt.type} className="flex items-center justify-between pl-4">
<div>
<Label className="text-sm">{nt.label}</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{nt.description}
</p>
</div>
<Switch
checked={pref.enabledTypes.includes(nt.type)}
onCheckedChange={(checked) => handleTypeToggle(nt.type, checked)}
/>
</div>
))}
</div>
)}
{/* Save indicator */}
{saving && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Se salveaza...
</div>
)}
</div>
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
Nu s-au putut incarca preferintele.
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
@@ -2,6 +2,7 @@
import { useState, useMemo, useCallback } from "react";
import { BookOpen, Plus } from "lucide-react";
import { NotificationPreferences } from "./notification-preferences";
import {
Popover,
PopoverContent,
@@ -421,6 +422,7 @@ export function RegistraturaModule() {
</ScrollArea>
</PopoverContent>
</Popover>
<NotificationPreferences />
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>