Compare commits

...

2 Commits

Author SHA1 Message Date
kovakmedya 2762aceb04 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.
2026-05-23 17:54:47 +03:00
kovakmedya f3442e644a fix(jobs): server-side redirect after each transition + ?flash toast
User reported that hitting 'İşleme Al' (and every sibling transition
button) succeeded server-side but the UI didn't update — they had to
refresh manually. router.refresh() from the client useEffect was racing
Next 16's RSC payload cache in production and losing.

Replaced the round-trip pattern with the canonical Next approach:
the server action does revalidatePath as before, then calls
redirect(`/jobs/$jobId?flash=<key>`). redirect() throws NEXT_REDIRECT
inside the action, the framework navigates the client, and the
destination page gets a fresh RSC payload — no client-side cache layer
to fight.

Actions wired:
  - acceptJobAction        → ?flash=accepted
  - handToClinicAction     → ?flash=handed
  - approveAtClinicAction  → ?flash=approved
  - requestRevisionAction  → ?flash=revision
  - markDeliveredAction    → ?flash=delivered
  - cancelJobAction        → ?flash=cancelled

Because redirect() never returns, the success branch of every button's
useEffect was now dead code. Trimmed every panel button to only watch
state.error (errors still come back through useActionState the normal
way) and removed the now-unused useRouter / router.refresh wiring.

Toast handling moved to a single client island:
  - components/flash-toast.tsx: reads ?flash, toasts the matching
    Turkish message, then router.replace's the URL without the param
    so a manual reload doesn't re-fire the toast. A useRef guard
    blocks the StrictMode double-mount in dev.
  - Mounted once in (dashboard)/layout.tsx wrapped in <Suspense> per
    Next's requirement for useSearchParams in a layout.

Net result: tap a button, ~400ms later the page is on the new state,
toast confirms it. No manual refresh, no cache mystery.
2026-05-23 16:54:30 +03:00
10 changed files with 141 additions and 68 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>
@@ -1,7 +1,6 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
ArrowRight,
Check,
@@ -87,17 +86,13 @@ export function JobActionsPanel({
}
function AcceptButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
useEffect(() => {
if (state.ok) {
toast.success("İş işleme alındı, alt yapı üretimi başladı.");
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
// Success path redirects from the server action, so state.ok never
// shows up here — we only need to surface errors.
if (state.error) toast.error(state.error);
}, [state]);
return (
<form action={action}>
@@ -111,19 +106,12 @@ function AcceptButton({ jobId }: { jobId: string }) {
}
function HandToClinicButton({ job }: { job: Job }) {
const router = useRouter();
const [state, action, pending] = useActionState(handToClinicAction, initialJobActionState);
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Klinik tarafına gönderildi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
if (state.error) toast.error(state.error);
}, [state]);
const isFinal = job.currentStep === "cila_bitim";
const stageLabel =
@@ -180,19 +168,12 @@ function HandToClinicButton({ job }: { job: Job }) {
}
function ApproveAtClinicButton({ job }: { job: Job }) {
const router = useRouter();
const [state, action, pending] = useActionState(approveAtClinicAction, initialJobActionState);
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Prova onaylandı, lab tarafına gönderildi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
if (state.error) toast.error(state.error);
}, [state]);
const stageLabel = job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
@@ -242,7 +223,6 @@ function ApproveAtClinicButton({ job }: { job: Job }) {
}
function RequestRevisionButton({ job }: { job: Job }) {
const router = useRouter();
const [state, action, pending] = useActionState(
requestRevisionAction,
initialJobActionState,
@@ -250,14 +230,8 @@ function RequestRevisionButton({ job }: { job: Job }) {
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Düzeltme talebi gönderildi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
if (state.error) toast.error(state.error);
}, [state]);
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -304,17 +278,11 @@ function RequestRevisionButton({ job }: { job: Job }) {
}
function DeliverButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
useEffect(() => {
if (state.ok) {
toast.success("İş teslim alındı.");
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
if (state.error) toast.error(state.error);
}, [state]);
return (
<form action={action}>
@@ -328,19 +296,12 @@ function DeliverButton({ jobId }: { jobId: string }) {
}
function CancelButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("İş iptal edildi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
if (state.error) toast.error(state.error);
}, [state]);
return (
<Dialog open={open} onOpenChange={setOpen}>
+5
View File
@@ -1,5 +1,7 @@
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { FlashToast } from "@/components/flash-toast";
import { getActiveContext } from "@/lib/appwrite/active-context";
import { countUnreadNotifications } from "@/lib/appwrite/notification-helpers";
import { getLogoUrl } from "@/lib/appwrite/storage";
@@ -40,6 +42,9 @@ export default async function DashboardLayout({
unreadCount={unreadCount}
>
{children}
<Suspense fallback={null}>
<FlashToast />
</Suspense>
</DashboardShell>
);
}
@@ -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 && (
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
const MESSAGES: Record<string, string> = {
accepted: "İş işleme alındı, alt yapı üretimi başladı.",
handed: "Klinik tarafına gönderildi.",
approved: "Prova onaylandı, lab tarafına geri gönderildi.",
revision: "Düzeltme talebi gönderildi.",
delivered: "İş teslim alındı.",
cancelled: "İş iptal edildi.",
};
/**
* Show a one-shot toast based on ?flash=<key>, then strip the param from
* the URL so a refresh doesn't replay it. Mounted in the dashboard layout
* so it works on every page that server actions might redirect to.
*/
export function FlashToast() {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const fired = useRef<string | null>(null);
useEffect(() => {
const flash = params.get("flash");
if (!flash) return;
// Avoid double-firing under React Strict Mode in dev.
if (fired.current === flash) return;
fired.current = flash;
const message = MESSAGES[flash] ?? null;
if (message) toast.success(message);
const next = new URLSearchParams(params.toString());
next.delete("flash");
const query = next.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [params, pathname, router]);
return null;
}
+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.") };
}
+20 -6
View File
@@ -1,6 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod";
@@ -330,7 +331,9 @@ export async function acceptJobAction(
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
return { ok: true };
// Redirect forces a full RSC payload reload — bypasses any client-side
// cache that router.refresh() might otherwise miss.
redirect(`/jobs/${jobId}?flash=accepted`);
}
/**
@@ -498,7 +501,7 @@ export async function handToClinicAction(
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
revalidatePath("/finance");
return { ok: true };
redirect(`/jobs/${jobId}?flash=handed`);
}
/**
@@ -581,7 +584,7 @@ export async function approveAtClinicAction(
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
return { ok: true };
redirect(`/jobs/${jobId}?flash=approved`);
}
/**
@@ -661,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) {
@@ -670,7 +674,7 @@ export async function requestRevisionAction(
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
return { ok: true };
redirect(`/jobs/${jobId}?flash=revision`);
}
export async function markDeliveredAction(
@@ -727,7 +731,7 @@ export async function markDeliveredAction(
revalidatePath("/jobs/outbound");
revalidatePath("/jobs/inbound");
revalidatePath("/finance");
return { ok: true };
redirect(`/jobs/${jobId}?flash=delivered`);
}
export async function cancelJobAction(
@@ -770,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.") };
}
@@ -777,5 +791,5 @@ export async function cancelJobAction(
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
return { ok: true };
redirect(`/jobs/${jobId}?flash=cancelled`);
}
+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";