From f44d57629f5b19a96af231c021487d639cebc1a2 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Tue, 7 Apr 2026 22:56:59 +0300 Subject: [PATCH] feat: county sync on monitor page + in-app notification system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/eterra/counties — distinct county list from GisUat - POST /api/eterra/sync-county — background sync all UATs in a county (TERENURI + CLADIRI + INTRAVILAN), magic mode for enriched UATs, concurrency guard, creates notification on completion - In-app notification service (KeyValueStore, CRUD, unread count) - GET/PATCH /api/notifications/app — list and mark-read endpoints - NotificationBell component in header with popover + polling - Monitor page: county select dropdown + SyncTestButton with customBody Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/(modules)/monitor/page.tsx | 50 ++- src/app/api/eterra/counties/route.ts | 26 ++ src/app/api/eterra/sync-county/route.ts | 293 ++++++++++++++++++ src/app/api/notifications/app/route.ts | 56 ++++ src/core/notifications/app-notifications.ts | 141 +++++++++ src/core/notifications/index.ts | 9 + src/shared/components/layout/header.tsx | 2 + .../components/layout/notification-bell.tsx | 168 ++++++++++ 8 files changed, 742 insertions(+), 3 deletions(-) create mode 100644 src/app/api/eterra/counties/route.ts create mode 100644 src/app/api/eterra/sync-county/route.ts create mode 100644 src/app/api/notifications/app/route.ts create mode 100644 src/core/notifications/app-notifications.ts create mode 100644 src/shared/components/layout/notification-bell.tsx diff --git a/src/app/(modules)/monitor/page.tsx b/src/app/(modules)/monitor/page.tsx index 4fb78fc..1908fe1 100644 --- a/src/app/(modules)/monitor/page.tsx +++ b/src/app/(modules)/monitor/page.tsx @@ -16,6 +16,8 @@ export default function MonitorPage() { const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(""); const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]); + const [counties, setCounties] = useState([]); + const [selectedCounty, setSelectedCounty] = useState(""); const rebuildPrevRef = useRef(null); const pollRef = useRef | null>(null); @@ -40,6 +42,14 @@ export default function MonitorPage() { setLogs((prev) => [{ time: new Date().toLocaleTimeString("ro-RO"), type, msg }, ...prev.slice(0, 49)]); }, []); + // Fetch counties for sync selector + useEffect(() => { + fetch("/api/eterra/counties") + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d: { counties: string[] }) => setCounties(d.counties ?? [])) + .catch(() => {}); + }, []); + // Cleanup poll on unmount useEffect(() => { return () => { if (pollRef.current) clearInterval(pollRef.current); }; @@ -273,6 +283,38 @@ export default function MonitorPage() { pollRef={pollRef} /> + + {/* County sync */} +
+
+ Sync pe judet + +
+ +
{logs.length > 0 && (
@@ -366,13 +408,15 @@ function ActionButton({ label, description, loading, onClick }: { ); } -function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint }: { +function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint, customBody, disabled }: { label: string; description: string; siruta: string; mode: "base" | "magic"; includeNoGeometry: boolean; actionKey: string; actionLoading: string; setActionLoading: (v: string) => void; addLog: (type: "info" | "ok" | "error" | "wait", msg: string) => void; pollRef: React.MutableRefObject | null>; customEndpoint?: string; + customBody?: Record; + disabled?: boolean; }) { const startTimeRef = useRef(0); const formatElapsed = () => { @@ -389,7 +433,7 @@ function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, a addLog("info", `[${label}] Pornire...`); try { const endpoint = customEndpoint ?? "/api/eterra/sync-background"; - const body = customEndpoint ? {} : { siruta, mode, includeNoGeometry }; + const body = customEndpoint ? (customBody ?? {}) : { siruta, mode, includeNoGeometry }; const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -439,7 +483,7 @@ function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, a }, 3 * 60 * 60_000); } catch { addLog("error", `[${label}] Eroare retea`); setActionLoading(""); } }} - disabled={!!actionLoading} + disabled={!!actionLoading || !!disabled} className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left" > {actionLoading === actionKey ? "Se ruleaza..." : label} diff --git a/src/app/api/eterra/counties/route.ts b/src/app/api/eterra/counties/route.ts new file mode 100644 index 0000000..5c85bea --- /dev/null +++ b/src/app/api/eterra/counties/route.ts @@ -0,0 +1,26 @@ +/** + * GET /api/eterra/counties + * + * Returns distinct county names from GisUat, sorted alphabetically. + */ +import { NextResponse } from "next/server"; +import { prisma } from "@/core/storage/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const rows = await prisma.gisUat.findMany({ + where: { county: { not: null } }, + select: { county: true }, + distinct: ["county"], + orderBy: { county: "asc" }, + }); + const counties = rows.map((r) => r.county).filter(Boolean) as string[]; + return NextResponse.json({ counties }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare la interogare judete"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/eterra/sync-county/route.ts b/src/app/api/eterra/sync-county/route.ts new file mode 100644 index 0000000..abfff02 --- /dev/null +++ b/src/app/api/eterra/sync-county/route.ts @@ -0,0 +1,293 @@ +/** + * POST /api/eterra/sync-county + * + * Starts a background sync for all UATs in a given county. + * Syncs TERENURI_ACTIVE, CLADIRI_ACTIVE, and LIMITE_INTRAV_DYNAMIC. + * UATs with >30% enrichment → magic mode (sync + enrichment). + * + * Body: { county: string } + * Returns immediately with jobId — progress via /api/eterra/progress. + */ + +import { prisma } from "@/core/storage/prisma"; +import { + setProgress, + clearProgress, + type SyncProgress, +} from "@/modules/parcel-sync/services/progress-store"; +import { syncLayer } from "@/modules/parcel-sync/services/sync-service"; +import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service"; +import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; +import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health"; +import { createAppNotification } from "@/core/notifications/app-notifications"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/* Concurrency guard */ +const g = globalThis as { __countySyncRunning?: string }; + +export async function POST(req: Request) { + const username = process.env.ETERRA_USERNAME ?? ""; + const password = process.env.ETERRA_PASSWORD ?? ""; + if (!username || !password) { + return Response.json( + { error: "ETERRA_USERNAME / ETERRA_PASSWORD nu sunt configurate" }, + { status: 500 }, + ); + } + + let body: { county?: string }; + try { + body = (await req.json()) as { county?: string }; + } catch { + return Response.json({ error: "Body invalid" }, { status: 400 }); + } + + const county = body.county?.trim(); + if (!county) { + return Response.json({ error: "Judetul lipseste" }, { status: 400 }); + } + + if (g.__countySyncRunning) { + return Response.json( + { error: `Sync judet deja in curs: ${g.__countySyncRunning}` }, + { status: 409 }, + ); + } + + const jobId = crypto.randomUUID(); + g.__countySyncRunning = county; + + setProgress({ + jobId, + downloaded: 0, + total: 100, + status: "running", + phase: `Pregatire sync ${county}`, + }); + + void runCountySync(jobId, county, username, password); + + return Response.json( + { jobId, message: `Sync judet ${county} pornit` }, + { status: 202 }, + ); +} + +async function runCountySync( + jobId: string, + county: string, + username: string, + password: string, +) { + const push = (p: Partial) => + setProgress({ + jobId, + downloaded: 0, + total: 100, + status: "running", + ...p, + } as SyncProgress); + + try { + // Health check + const health = await checkEterraHealthNow(); + if (!health.available) { + setProgress({ + jobId, + downloaded: 0, + total: 100, + status: "error", + phase: "eTerra indisponibil", + message: health.message ?? "maintenance", + }); + await createAppNotification({ + type: "sync-error", + title: `Sync ${county}: eTerra indisponibil`, + message: health.message ?? "Serviciul eTerra este in mentenanta", + metadata: { county, jobId }, + }); + g.__countySyncRunning = undefined; + setTimeout(() => clearProgress(jobId), 3_600_000); + return; + } + + // Find all UATs in this county with feature stats + const uats = await prisma.$queryRawUnsafe< + Array<{ + siruta: string; + name: string | null; + total: number; + enriched: number; + }> + >( + `SELECT u.siruta, u.name, + COALESCE(f.total, 0)::int as total, + COALESCE(f.enriched, 0)::int as enriched + FROM "GisUat" u + LEFT JOIN ( + SELECT siruta, COUNT(*)::int as total, + COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched + FROM "GisFeature" + WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0 + GROUP BY siruta + ) f ON u.siruta = f.siruta + WHERE u.county = $1 + ORDER BY COALESCE(f.total, 0) DESC`, + county, + ); + + if (uats.length === 0) { + setProgress({ + jobId, + downloaded: 100, + total: 100, + status: "done", + phase: `Niciun UAT gasit in ${county}`, + }); + g.__countySyncRunning = undefined; + setTimeout(() => clearProgress(jobId), 3_600_000); + return; + } + + const results: Array<{ + siruta: string; + name: string; + mode: string; + duration: number; + note: string; + }> = []; + let errors = 0; + + for (let i = 0; i < uats.length; i++) { + const uat = uats[i]!; + const uatName = uat.name ?? uat.siruta; + const ratio = uat.total > 0 ? uat.enriched / uat.total : 0; + const isMagic = ratio > 0.3; + const mode = isMagic ? "magic" : "base"; + const pct = Math.round((i / uats.length) * 100); + + push({ + downloaded: pct, + total: 100, + phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`, + note: + results.length > 0 + ? `Ultimul: ${results[results.length - 1]!.name} — ${results[results.length - 1]!.note}` + : undefined, + }); + + const uatStart = Date.now(); + try { + // Sync TERENURI + CLADIRI + const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { + uatName, + }); + const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { + uatName, + }); + + // Sync ADMINISTRATIV (intravilan) — wrapped in try/catch since it needs UAT geometry + let adminNote = ""; + try { + const aRes = await syncLayer( + username, + password, + uat.siruta, + "LIMITE_INTRAV_DYNAMIC", + { uatName }, + ); + if (aRes.newFeatures > 0) { + adminNote = ` | A:+${aRes.newFeatures}`; + } + } catch { + adminNote = " | A:skip"; + } + + // Enrichment for magic mode + let enrichNote = ""; + if (isMagic) { + const client = await EterraClient.create(username, password, { + timeoutMs: 120_000, + }); + const eRes = await enrichFeatures(client, uat.siruta); + enrichNote = + eRes.status === "done" + ? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}` + : ` | enrich err: ${eRes.error}`; + } + + const dur = Math.round((Date.now() - uatStart) / 1000); + const parts = [ + tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0 + ? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf` + : "T:ok", + cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0 + ? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf` + : "C:ok", + ]; + const note = `${parts.join(", ")}${adminNote}${enrichNote} (${dur}s)`; + results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note }); + console.log(`[sync-county:${county}] ${i + 1}/${uats.length} ${uatName}: ${note}`); + } catch (err) { + errors++; + const dur = Math.round((Date.now() - uatStart) / 1000); + const msg = err instanceof Error ? err.message : "Unknown"; + results.push({ + siruta: uat.siruta, + name: uatName, + mode, + duration: dur, + note: `ERR: ${msg}`, + }); + console.error(`[sync-county:${county}] ${uatName}: ${msg}`); + } + } + + const totalDur = results.reduce((s, r) => s + r.duration, 0); + const summary = `${uats.length} UAT-uri, ${errors} erori, ${totalDur}s total`; + + setProgress({ + jobId, + downloaded: 100, + total: 100, + status: errors > 0 && errors === uats.length ? "error" : "done", + phase: `Sync ${county} finalizat`, + message: summary, + note: results.map((r) => `${r.name}: ${r.note}`).join("\n"), + }); + + await createAppNotification({ + type: errors > 0 ? "sync-error" : "sync-complete", + title: + errors > 0 + ? `Sync ${county}: ${errors} erori din ${uats.length} UAT-uri` + : `Sync ${county}: ${uats.length} UAT-uri sincronizate`, + message: summary, + metadata: { county, jobId, uatCount: uats.length, errors, totalDuration: totalDur }, + }); + + console.log(`[sync-county:${county}] Done: ${summary}`); + setTimeout(() => clearProgress(jobId), 6 * 3_600_000); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown"; + setProgress({ + jobId, + downloaded: 0, + total: 100, + status: "error", + phase: "Eroare", + message: msg, + }); + await createAppNotification({ + type: "sync-error", + title: `Sync ${county}: eroare generala`, + message: msg, + metadata: { county, jobId }, + }); + setTimeout(() => clearProgress(jobId), 3_600_000); + } finally { + g.__countySyncRunning = undefined; + } +} diff --git a/src/app/api/notifications/app/route.ts b/src/app/api/notifications/app/route.ts new file mode 100644 index 0000000..9fa13da --- /dev/null +++ b/src/app/api/notifications/app/route.ts @@ -0,0 +1,56 @@ +/** + * GET /api/notifications/app — list recent + unread count + * PATCH /api/notifications/app — mark read / mark all read + * + * Body for PATCH: + * { action: "mark-read", id: string } + * { action: "mark-all-read" } + */ +import { NextResponse } from "next/server"; +import { + getAppNotifications, + getUnreadCount, + markAsRead, + markAllAsRead, +} from "@/core/notifications/app-notifications"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "30", 10), 100); + + const [notifications, unreadCount] = await Promise.all([ + getAppNotifications(limit), + getUnreadCount(), + ]); + + return NextResponse.json({ notifications, unreadCount }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare notificari"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function PATCH(req: Request) { + try { + const body = (await req.json()) as { action: string; id?: string }; + + if (body.action === "mark-read" && body.id) { + await markAsRead(body.id); + return NextResponse.json({ ok: true }); + } + + if (body.action === "mark-all-read") { + await markAllAsRead(); + return NextResponse.json({ ok: true }); + } + + return NextResponse.json({ error: "Actiune necunoscuta" }, { status: 400 }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare notificari"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/core/notifications/app-notifications.ts b/src/core/notifications/app-notifications.ts new file mode 100644 index 0000000..19b8cc3 --- /dev/null +++ b/src/core/notifications/app-notifications.ts @@ -0,0 +1,141 @@ +/** + * In-app notification service. + * + * Stores lightweight notifications in KeyValueStore (namespace "app-notifications"). + * Used for sync completion alerts, errors, etc. + */ + +import { prisma } from "@/core/storage/prisma"; +import type { Prisma } from "@prisma/client"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type AppNotificationType = "sync-complete" | "sync-error"; + +export interface AppNotification { + id: string; + type: AppNotificationType; + title: string; + message: string; + createdAt: string; + readAt: string | null; + metadata?: Record; +} + +const NAMESPACE = "app-notifications"; +const MAX_AGE_DAYS = 30; + +/* ------------------------------------------------------------------ */ +/* Create */ +/* ------------------------------------------------------------------ */ + +export async function createAppNotification( + input: Omit, +): Promise { + const notification: AppNotification = { + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + readAt: null, + ...input, + }; + + await prisma.keyValueStore.create({ + data: { + namespace: NAMESPACE, + key: notification.id, + value: notification as unknown as Prisma.InputJsonValue, + }, + }); + + return notification; +} + +/* ------------------------------------------------------------------ */ +/* Read */ +/* ------------------------------------------------------------------ */ + +export async function getAppNotifications(limit = 30): Promise { + const rows = await prisma.keyValueStore.findMany({ + where: { namespace: NAMESPACE }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + + const cutoff = Date.now() - MAX_AGE_DAYS * 86_400_000; + const notifications: AppNotification[] = []; + const staleIds: string[] = []; + + for (const row of rows) { + const n = row.value as unknown as AppNotification; + if (new Date(n.createdAt).getTime() < cutoff) { + staleIds.push(row.id); + } else { + notifications.push(n); + } + } + + // Lazy cleanup of old notifications + if (staleIds.length > 0) { + void prisma.keyValueStore.deleteMany({ + where: { id: { in: staleIds } }, + }); + } + + return notifications; +} + +export async function getUnreadCount(): Promise { + const rows = await prisma.$queryRaw>` + SELECT COUNT(*)::int as count + FROM "KeyValueStore" + WHERE namespace = ${NAMESPACE} + AND value->>'readAt' IS NULL + `; + return rows[0]?.count ?? 0; +} + +/* ------------------------------------------------------------------ */ +/* Update */ +/* ------------------------------------------------------------------ */ + +export async function markAsRead(id: string): Promise { + const row = await prisma.keyValueStore.findUnique({ + where: { namespace_key: { namespace: NAMESPACE, key: id } }, + }); + if (!row) return; + + const n = row.value as unknown as AppNotification; + n.readAt = new Date().toISOString(); + + await prisma.keyValueStore.update({ + where: { namespace_key: { namespace: NAMESPACE, key: id } }, + data: { value: n as unknown as Prisma.InputJsonValue }, + }); +} + +export async function markAllAsRead(): Promise { + const rows = await prisma.keyValueStore.findMany({ + where: { namespace: NAMESPACE }, + }); + + const now = new Date().toISOString(); + const updates = rows + .filter((r) => { + const n = r.value as unknown as AppNotification; + return n.readAt === null; + }) + .map((r) => { + const n = r.value as unknown as AppNotification; + n.readAt = now; + return prisma.keyValueStore.update({ + where: { namespace_key: { namespace: NAMESPACE, key: r.key } }, + data: { value: n as unknown as Prisma.InputJsonValue }, + }); + }); + + if (updates.length > 0) { + await prisma.$transaction(updates); + } +} diff --git a/src/core/notifications/index.ts b/src/core/notifications/index.ts index 4509cf0..4ef4079 100644 --- a/src/core/notifications/index.ts +++ b/src/core/notifications/index.ts @@ -15,3 +15,12 @@ export { getAllPreferences, runDigest, } from "./notification-service"; +export { + createAppNotification, + getAppNotifications, + getUnreadCount, + markAsRead, + markAllAsRead, + type AppNotification, + type AppNotificationType, +} from "./app-notifications"; diff --git a/src/shared/components/layout/header.tsx b/src/shared/components/layout/header.tsx index f79dcf4..b9a74d6 100644 --- a/src/shared/components/layout/header.tsx +++ b/src/shared/components/layout/header.tsx @@ -13,6 +13,7 @@ import { import { useAuth } from "@/core/auth"; import { signIn, signOut } from "next-auth/react"; import { ThemeToggle } from "@/shared/components/common/theme-toggle"; +import { NotificationBell } from "./notification-bell"; interface HeaderProps { onToggleSidebar?: () => void; @@ -35,6 +36,7 @@ export function Header({ onToggleSidebar }: HeaderProps) {
+ diff --git a/src/shared/components/layout/notification-bell.tsx b/src/shared/components/layout/notification-bell.tsx new file mode 100644 index 0000000..bca181f --- /dev/null +++ b/src/shared/components/layout/notification-bell.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Bell, Check, CheckCheck, AlertTriangle } from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/components/ui/popover"; +import { ScrollArea } from "@/shared/components/ui/scroll-area"; +import type { AppNotification } from "@/core/notifications/app-notifications"; + +const POLL_INTERVAL = 60_000; // 60s + +function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "acum"; + if (mins < 60) return `acum ${mins} min`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `acum ${hours} ore`; + const days = Math.floor(hours / 24); + if (days === 1) return "ieri"; + return `acum ${days} zile`; +} + +export function NotificationBell() { + const [unreadCount, setUnreadCount] = useState(0); + const [notifications, setNotifications] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + + const fetchUnreadCount = useCallback(async () => { + try { + const res = await fetch("/api/notifications/app?limit=1"); + if (!res.ok) return; + const data = (await res.json()) as { unreadCount: number }; + setUnreadCount(data.unreadCount); + } catch { /* ignore */ } + }, []); + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/notifications/app?limit=30"); + if (!res.ok) return; + const data = (await res.json()) as { + notifications: AppNotification[]; + unreadCount: number; + }; + setNotifications(data.notifications); + setUnreadCount(data.unreadCount); + } catch { /* ignore */ } + setLoading(false); + }, []); + + // Poll unread count + useEffect(() => { + fetchUnreadCount(); + const id = setInterval(fetchUnreadCount, POLL_INTERVAL); + return () => clearInterval(id); + }, [fetchUnreadCount]); + + // Fetch full list when popover opens + useEffect(() => { + if (open) fetchAll(); + }, [open, fetchAll]); + + const handleMarkRead = async (id: string) => { + await fetch("/api/notifications/app", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "mark-read", id }), + }); + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, readAt: new Date().toISOString() } : n)), + ); + setUnreadCount((c) => Math.max(0, c - 1)); + }; + + const handleMarkAllRead = async () => { + await fetch("/api/notifications/app", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "mark-all-read" }), + }); + setNotifications((prev) => + prev.map((n) => ({ ...n, readAt: n.readAt ?? new Date().toISOString() })), + ); + setUnreadCount(0); + }; + + return ( + + + + + + {/* Header */} +
+ Notificari + {unreadCount > 0 && ( + + )} +
+ + {/* List */} + + {loading && notifications.length === 0 ? ( +
+ Se incarca... +
+ ) : notifications.length === 0 ? ( +
+ Nicio notificare +
+ ) : ( + notifications.map((n) => ( + + )) + )} +
+
+
+ ); +}