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.
This commit is contained in:
kovakmedya
2026-05-23 16:54:30 +03:00
parent 68f82d79c2
commit f3442e644a
4 changed files with 72 additions and 59 deletions
@@ -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>
);
}
+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 -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`);
}
/**
@@ -670,7 +673,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 +730,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(
@@ -777,5 +780,5 @@ export async function cancelJobAction(
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
return { ok: true };
redirect(`/jobs/${jobId}?flash=cancelled`);
}