feat: county sync on monitor page + in-app notification system

- 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) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-04-07 22:56:59 +03:00
parent 8222be2f0e
commit f44d57629f
8 changed files with 742 additions and 3 deletions
+47 -3
View File
@@ -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<string[]>([]);
const [selectedCounty, setSelectedCounty] = useState("");
const rebuildPrevRef = useRef<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | 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}
/>
</div>
{/* County sync */}
<div className="flex items-end gap-3 mt-2 pt-3 border-t border-border/50">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Sync pe judet</span>
<select
value={selectedCounty}
onChange={(e) => setSelectedCounty(e.target.value)}
className="h-9 w-52 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Alege judet...</option>
{counties.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<SyncTestButton
label={selectedCounty ? `Sync ${selectedCounty}` : "Sync Judet"}
description="TERENURI + CLADIRI + INTRAVILAN pentru tot judetul"
siruta=""
mode="base"
includeNoGeometry={false}
actionKey="sync-county"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
customEndpoint="/api/eterra/sync-county"
customBody={{ county: selectedCounty }}
disabled={!selectedCounty}
/>
</div>
{logs.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
@@ -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<ReturnType<typeof setInterval> | null>;
customEndpoint?: string;
customBody?: Record<string, unknown>;
disabled?: boolean;
}) {
const startTimeRef = useRef<number>(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"
>
<span className="font-medium text-sm">{actionLoading === actionKey ? "Se ruleaza..." : label}</span>
+26
View File
@@ -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 });
}
}
+293
View File
@@ -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<SyncProgress>) =>
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;
}
}
+56
View File
@@ -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 });
}
}
+141
View File
@@ -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<string, unknown>;
}
const NAMESPACE = "app-notifications";
const MAX_AGE_DAYS = 30;
/* ------------------------------------------------------------------ */
/* Create */
/* ------------------------------------------------------------------ */
export async function createAppNotification(
input: Omit<AppNotification, "id" | "createdAt" | "readAt">,
): Promise<AppNotification> {
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<AppNotification[]> {
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<number> {
const rows = await prisma.$queryRaw<Array<{ count: number }>>`
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<void> {
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<void> {
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);
}
}
+9
View File
@@ -15,3 +15,12 @@ export {
getAllPreferences,
runDigest,
} from "./notification-service";
export {
createAppNotification,
getAppNotifications,
getUnreadCount,
markAsRead,
markAllAsRead,
type AppNotification,
type AppNotificationType,
} from "./app-notifications";
+2
View File
@@ -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) {
</div>
<div className="flex items-center gap-3">
<NotificationBell />
<ThemeToggle />
<DropdownMenu>
@@ -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<AppNotification[]>([]);
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-4 min-w-4 rounded-full bg-destructive text-[10px] font-medium text-white flex items-center justify-center px-1">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-sm font-medium">Notificari</span>
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<CheckCheck className="h-3 w-3" />
Marcheaza toate ca citite
</button>
)}
</div>
{/* List */}
<ScrollArea className="max-h-80">
{loading && notifications.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Se incarca...
</div>
) : notifications.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Nicio notificare
</div>
) : (
notifications.map((n) => (
<button
key={n.id}
onClick={() => !n.readAt && handleMarkRead(n.id)}
className={`w-full flex items-start gap-2.5 px-3 py-2.5 text-left border-b border-border/30 last:border-0 hover:bg-muted/50 transition-colors ${
!n.readAt ? "bg-primary/5" : ""
}`}
>
<div className="mt-0.5 shrink-0">
{n.type === "sync-error" ? (
<AlertTriangle className="h-4 w-4 text-destructive" />
) : (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className={`text-sm truncate ${!n.readAt ? "font-medium" : ""}`}>
{n.title}
</p>
{!n.readAt && (
<span className="shrink-0 h-2 w-2 rounded-full bg-primary" />
)}
</div>
<p className="text-xs text-muted-foreground truncate">{n.message}</p>
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
{relativeTime(n.createdAt)}
</p>
</div>
</button>
))
)}
</ScrollArea>
</PopoverContent>
</Popover>
);
}