From 6565590eb33baeabda6e92b98f85def22aae4515 Mon Sep 17 00:00:00 2001 From: Forge Date: Wed, 25 Mar 2026 15:47:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20SSR=20crash=20=E2=80=94=20QueryClient=20?= =?UTF-8?q?in=20useState,=20mounted=20guard=20for=20auth=20hydration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(app)/layout.tsx | 18 ++-- app/(app)/remittances/page.tsx | 161 +++++++++++++++++++-------------- app/(app)/reports/page.tsx | 2 +- components/providers.tsx | 13 ++- 4 files changed, 115 insertions(+), 79 deletions(-) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 85407ba..3209cb8 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import Sidebar from '@/components/layout/sidebar'; @@ -9,16 +9,22 @@ import Topbar from '@/components/layout/topbar'; export default function AppLayout({ children }: { children: React.ReactNode }) { const router = useRouter(); const accessToken = useAuthStore((s) => s.accessToken); + // Wait for zustand to hydrate from localStorage before checking auth + const [mounted, setMounted] = useState(false); useEffect(() => { - if (!accessToken) { + setMounted(true); + }, []); + + useEffect(() => { + if (mounted && !accessToken) { router.replace('/login'); } - }, [accessToken, router]); + }, [mounted, accessToken, router]); - if (!accessToken) { - return null; - } + // Show nothing until hydrated (prevents flash redirect) + if (!mounted) return null; + if (!accessToken) return null; return (
diff --git a/app/(app)/remittances/page.tsx b/app/(app)/remittances/page.tsx index b71ea35..e992521 100644 --- a/app/(app)/remittances/page.tsx +++ b/app/(app)/remittances/page.tsx @@ -1,103 +1,124 @@ 'use client'; -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '@/lib/api'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from '@/components/ui/table'; -import { api } from '@/lib/api'; +import { ChevronRight, CheckCircle } from 'lucide-react'; import { format } from 'date-fns'; interface Remittance { id: string; - totalAmount: number; + totalAmount: string; + notes: string | null; status: string; - notes?: string; createdAt: string; collectedBy?: { firstName: string; lastName: string }; confirmedBy?: { firstName: string; lastName: string }; + payments?: Array<{ id: string; amount: string }>; } -const STATUS_COLORS: Record = { - PENDING: 'bg-yellow-100 text-yellow-700', - CONFIRMED: 'bg-emerald-100 text-emerald-700', - REJECTED: 'bg-red-100 text-red-700', -}; +const peso = (v: string | number) => + '₱' + Number(v ?? 0).toLocaleString('en-PH', { minimumFractionDigits: 2 }); export default function RemittancesPage() { - const [page, setPage] = useState(1); - const limit = 20; + const qc = useQueryClient(); - const { data, isLoading } = useQuery({ - queryKey: ['remittances', page], + const { data, isLoading } = useQuery<{ data: Remittance[]; total: number }>({ + queryKey: ['remittances'], queryFn: async () => { - const res = await api.get(`/api/v1/remittances?page=${page}&limit=${limit}`); + const res = await api.get('/api/v1/remittances?limit=30'); return res.data; }, + staleTime: 30_000, }); - const items: Remittance[] = Array.isArray(data) ? data : (data?.data ?? data?.items ?? []); - const total = (data as any)?.meta?.total ?? (data as any)?.total ?? items.length; + const confirm = useMutation({ + mutationFn: async (id: string) => { + await api.patch(`/api/v1/remittances/${id}/confirm`); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['remittances'] }), + }); + + const remittances = data?.data ?? []; return (

Remittances

-

Collector cash remittance records

+

Cash collections submitted by collectors

- + - - - - Date - Collector - Status - Total Amount - Confirmed By - Notes - - - - {isLoading ? ( - Array.from({ length: 6 }).map((_, i) => ( - {Array.from({ length: 6 }).map((_, j) => ( - - ))} - )) - ) : items.length === 0 ? ( - No remittances yet - ) : ( - items.map((r) => ( - - - {format(new Date(r.createdAt), 'MMM d, yyyy')} - - - {r.collectedBy ? `${r.collectedBy.firstName} ${r.collectedBy.lastName}` : '—'} - - - - {r.status} - - - - ₱{Number(r.totalAmount).toLocaleString()} - - - {r.confirmedBy ? `${r.confirmedBy.firstName} ${r.confirmedBy.lastName}` : '—'} - - - {r.notes ?? '—'} - - - )) - )} - -
+
+ + + + + + + + + + + + + + {isLoading + ? Array.from({ length: 6 }).map((_, i) => ( + + {[...Array(7)].map((_, j) => ( + + ))} + + )) + : remittances.map((r) => ( + + + + + + + + + + ))} + +
DateCollectorAmountPaymentsStatusConfirmed By
+ {r.createdAt ? format(new Date(r.createdAt), 'MMM d, yyyy') : '—'} + + {r.collectedBy ? `${r.collectedBy.firstName} ${r.collectedBy.lastName}` : '—'} + + {peso(r.totalAmount)} + + {r.payments?.length ?? '—'} + + + {r.status} + + + {r.confirmedBy ? `${r.confirmedBy.firstName} ${r.confirmedBy.lastName}` : '—'} + + {r.status !== 'CONFIRMED' && ( + + )} +
+
+ {!isLoading && remittances.length === 0 && ( +
No remittances yet
+ )}
diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index f416ecd..bc9f68d 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -117,7 +117,7 @@ export default function ReportsPage() { `₱${(v/1000).toFixed(0)}k`} /> - peso(v)} /> + peso(v)} /> diff --git a/components/providers.tsx b/components/providers.tsx index a896ad7..7452291 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -1,10 +1,19 @@ 'use client'; -import { QueryClientProvider } from '@tanstack/react-query'; -import { queryClient } from '@/lib/query-client'; +import { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from '@/components/ui/sonner'; export default function Providers({ children }: { children: React.ReactNode }) { + // Must be created inside component — NOT at module level + // Module-level singleton causes SSR crashes in Next.js App Router + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { queries: { staleTime: 30_000, retry: 1 } }, + }) + ); + return ( {children}