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:
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user