feat(notifications): severity (info/warning) + cover the gaps in the flow matrix

Modelled the real crown-and-bridge workflow (impression → accept →
framework try-in → bisque try-in → glaze/cila → delivery, with revisions
bouncing back at any try-in stage). Mapped every event in our system to
the appropriate side and tagged the ones a user actually needs to act on
versus normal progress traffic.

DB
  - notifications.severity enum ('info' | 'warning', default 'info').
    Existing rows default to info.

Helper
  - createNotification gains an optional severity param; persists into
    the new column.

Matrix changes (warnings vs infos)
  Lab side:
    - Düzeltme talebi (clinic → lab)          WARNING
    - İş iptal edildi (counterpart action)    WARNING
  Clinic side:
    - Ödeme reddedildi (lab → clinic)         WARNING
    - Bağlantı reddedildi (counterpart → req) WARNING (newly fired —
      rejectConnectionAction now sends a notification, previously silent)
  Everything else (accept, hand-off, prova ready, prova approved,
  delivery, payment submitted/approved, connection request/approved):
  stays at info.

Behaviour additions in actions
  - cancelJobAction now notifies the *other* party. Previously cancelled
    jobs vanished from one side's inbox without warning. severity=warning.
  - rejectConnectionAction notifies the requester (severity=warning) so
    they know the connection didn't come through.
  - requestRevisionAction tags its existing notification as warning.
  - rejectPaymentAction tags its existing notification as warning.

UI
  - Notifications list row: unread warning rows get an amber dot, an
    amber-tinted background, and a 'Dikkat' destructive badge instead
    of the regular 'Yeni' secondary one.
  - Dashboard recent-notifications widget mirrors the same dot + bold
    treatment so warnings stand out at a glance.

Net result: a user opening the bell sees normal traffic muted (still
listed for the 'herkes her şeyden haberdar' guarantee) and the things
that actually need them coloured amber and labeled Dikkat.
This commit is contained in:
kovakmedya
2026-05-23 17:54:47 +03:00
parent f3442e644a
commit 2762aceb04
7 changed files with 69 additions and 9 deletions
+15 -4
View File
@@ -249,23 +249,34 @@ export default async function DashboardPage() {
</p>
) : (
<ul className="divide-y">
{data.recentNotifications.map((n) => (
{data.recentNotifications.map((n) => {
const isWarning = n.severity === "warning";
return (
<li
key={n.$id}
className={`flex items-start gap-3 py-2.5 ${n.read ? "opacity-70" : ""}`}
>
<span
className={`mt-1.5 size-2 shrink-0 rounded-full ${n.read ? "bg-muted" : "bg-primary"}`}
className={`mt-1.5 size-2 shrink-0 rounded-full ${
n.read
? "bg-muted"
: isWarning
? "bg-amber-500"
: "bg-primary"
}`}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="text-sm leading-tight">{n.message}</p>
<p className={`text-sm leading-tight ${isWarning && !n.read ? "font-medium" : ""}`}>
{n.message}
</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{datetimeFormatter.format(new Date(n.$createdAt))}
</p>
</div>
</li>
))}
);
})}
</ul>
)}
</CardContent>
@@ -66,10 +66,21 @@ function NotificationRow({ row }: { row: Notification }) {
? "/connections"
: null;
const isWarning = row.severity === "warning";
return (
<li className={`flex items-start gap-3 px-3 py-3 ${row.read ? "opacity-70" : ""}`}>
<li
className={`flex items-start gap-3 px-3 py-3 ${row.read ? "opacity-70" : ""} ${
isWarning && !row.read ? "bg-amber-50/60 dark:bg-amber-950/30" : ""
}`}
>
<span
className={`mt-1.5 size-2 shrink-0 rounded-full ${row.read ? "bg-muted" : "bg-primary"}`}
className={`mt-1.5 size-2 shrink-0 rounded-full ${
row.read
? "bg-muted"
: isWarning
? "bg-amber-500"
: "bg-primary"
}`}
aria-hidden
/>
<div className="min-w-0 flex-1">
@@ -79,8 +90,11 @@ function NotificationRow({ row }: { row: Notification }) {
</p>
</div>
{!row.read && (
<Badge variant="secondary" className="text-[10px] uppercase">
Yeni
<Badge
variant={isWarning ? "destructive" : "secondary"}
className="text-[10px] uppercase"
>
{isWarning ? "Dikkat" : "Yeni"}
</Badge>
)}
{link && (
+9
View File
@@ -278,6 +278,15 @@ export async function rejectConnectionAction(
entityId: connectionId,
changes: { status: "rejected" },
});
// Tell the requester their request was turned down — warning, not info.
const requesterTenant =
conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId;
void createNotification({
tenantId: requesterTenant,
connectionId,
severity: "warning",
message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi reddetti.`,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Reddedilemedi.") };
}
+11
View File
@@ -664,6 +664,7 @@ export async function requestRevisionAction(
void createNotification({
tenantId: job.labTenantId,
jobId,
severity: "warning",
message: `Hasta ${job.patientCode} ${stepLabel} provası için düzeltme istendi: ${note.slice(0, 120)}`,
});
} catch (e) {
@@ -773,6 +774,16 @@ export async function cancelJobAction(
entityId: jobId,
changes: { status: "cancelled" },
});
// Notify the other side — cancellation is a warning, not normal traffic.
const otherTenantId =
ctx.tenantId === job.clinicTenantId ? job.labTenantId : job.clinicTenantId;
const actor = ctx.kind === "lab" ? "Laboratuvar" : "Klinik";
void createNotification({
tenantId: otherTenantId,
jobId,
severity: "warning",
message: `${actor} hasta ${job.patientCode} işini iptal etti.`,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "İptal edilemedi.") };
}
+10 -1
View File
@@ -2,7 +2,12 @@ import "server-only";
import { ID, Permission, Query, Role } from "node-appwrite";
import { DATABASE_ID, TABLES, type Notification } from "./schema";
import {
DATABASE_ID,
TABLES,
type Notification,
type NotificationSeverity,
} from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
@@ -12,6 +17,9 @@ type CreateNotificationInput = {
jobId?: string;
connectionId?: string;
message: string;
/** Defaults to 'info'. Use 'warning' for things that need the user's
* attention (revision, cancellation, rejections). */
severity?: NotificationSeverity;
};
/**
@@ -32,6 +40,7 @@ export async function createNotification(input: CreateNotificationInput): Promis
connectionId: input.connectionId,
message: input.message.slice(0, 500),
read: false,
severity: input.severity ?? "info",
},
[
Permission.read(Role.team(input.tenantId)),
+1
View File
@@ -269,6 +269,7 @@ export async function rejectPaymentAction(
});
void createNotification({
tenantId: row.tenantId,
severity: "warning",
message: `Ödeme bildiriminiz reddedildi: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`,
});
} catch (e) {
+5
View File
@@ -194,6 +194,8 @@ export interface Payment extends Row {
recordedBy: string;
}
export type NotificationSeverity = "info" | "warning";
export interface Notification extends Row {
tenantId: string;
userId?: string;
@@ -201,6 +203,9 @@ export interface Notification extends Row {
connectionId?: string;
message: string;
read: boolean;
/** Visual + filtering hint. 'warning' for things requiring attention
* (revision request, cancellation, payment / connection rejection). */
severity?: NotificationSeverity;
}
export type AuditAction = "create" | "update" | "delete";