From f3442e644a6ee9d8fbee6d8b931625bcf4eeb104 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Sat, 23 May 2026 16:54:30 +0300 Subject: [PATCH] fix(jobs): server-side redirect after each transition + ?flash toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=`). 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 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. --- .../[jobId]/components/job-actions-panel.tsx | 67 ++++--------------- src/app/(dashboard)/layout.tsx | 5 ++ src/components/flash-toast.tsx | 44 ++++++++++++ src/lib/appwrite/job-actions.ts | 15 +++-- 4 files changed, 72 insertions(+), 59 deletions(-) create mode 100644 src/components/flash-toast.tsx diff --git a/src/app/(dashboard)/jobs/[jobId]/components/job-actions-panel.tsx b/src/app/(dashboard)/jobs/[jobId]/components/job-actions-panel.tsx index a5744a6..61d8bba 100644 --- a/src/app/(dashboard)/jobs/[jobId]/components/job-actions-panel.tsx +++ b/src/app/(dashboard)/jobs/[jobId]/components/job-actions-panel.tsx @@ -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 (
@@ -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 ( @@ -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 ( @@ -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 ( diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index a789e43..ffea413 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -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} + + + ); } diff --git a/src/components/flash-toast.tsx b/src/components/flash-toast.tsx new file mode 100644 index 0000000..c54c05b --- /dev/null +++ b/src/components/flash-toast.tsx @@ -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 = { + 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=, 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(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; +} diff --git a/src/lib/appwrite/job-actions.ts b/src/lib/appwrite/job-actions.ts index e19e9d6..141bc45 100644 --- a/src/lib/appwrite/job-actions.ts +++ b/src/lib/appwrite/job-actions.ts @@ -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`); }