fix(admin): SQL GROUP BY in reports, hydration in settings, mobile tabs scroll
- reports.ts: wrap window-function arg in SUM() so it operates on the per-group aggregate instead of the raw c.id column (PostgreSQL rejects window funcs over non-grouped, non-aggregated columns). - SettingsClient.tsx: read URL hash in useEffect instead of a lazy useState initializer to avoid server/client first-render mismatch on tab content (was triggering full client re-render in /admin/settings). - layout.tsx: add pt-16 lg:pt-0 to page-content for mobile header clearance. - ReportsDashboard.tsx: memoize DateRange on primitive inputs to break useEffect → setState → re-render loop caused by buildRange returning a new object every call. - AdminFilterTabs.tsx: add overflow-x-auto so wide tab rows scroll on mobile instead of overflowing. - use-media-query.ts: always start as false for hydration safety; callers handle the mobile/unknown branch first.
This commit is contained in:
@@ -234,12 +234,15 @@ await getSession(); const adminUser = await getAdminUser();
|
||||
|
||||
// `customers` replaced `communication_contacts`. `source` column is gone,
|
||||
// so `imports` is always 0 and `new_contacts` is the day's net add.
|
||||
// Wrap COUNT in SUM() so the window function operates on an aggregate,
|
||||
// not the raw `c.id` column (which is not in GROUP BY).
|
||||
const { rows } = await pool.query<{ date: string; new_contacts: number; imports: number; total: number }>(
|
||||
`SELECT
|
||||
d::date::text AS date,
|
||||
COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts,
|
||||
0::int AS imports,
|
||||
COUNT(c.id) OVER (ORDER BY d::date)::int AS total
|
||||
SUM(COUNT(c.id) FILTER (WHERE c.created_at::date = d::date))
|
||||
OVER (ORDER BY d::date)::int AS total
|
||||
FROM generate_series($1::date, $2::date, '1 day'::interval) d
|
||||
LEFT JOIN customers c
|
||||
ON c.created_at::date = d::date
|
||||
|
||||
@@ -105,7 +105,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<CommandPalette />
|
||||
<div
|
||||
id="page-content"
|
||||
className="min-h-screen lg:pl-60 admin-section outline-none"
|
||||
className="min-h-screen lg:pl-60 pt-16 lg:pt-0 admin-section outline-none"
|
||||
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||
>
|
||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useCallback, useEffect } from "react";
|
||||
import { useReducer, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
getReportsSummary,
|
||||
getOrdersByStopReport,
|
||||
@@ -317,7 +317,19 @@ export default function ReportsDashboard({
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(reducer, initialBrandId, makeInitialState);
|
||||
|
||||
const range: DateRange = buildRange(state.preset, state.customStart, state.customEnd);
|
||||
// Memoize range — `buildRange` returns a new object each call (it uses
|
||||
// `new Date()` for the end-date). Without this memo, `range` would have a
|
||||
// fresh identity every render, which makes `fetchAll` a fresh identity
|
||||
// every render (it's a useCallback depending on `range`), which makes the
|
||||
// `useEffect(() => fetchAll(), [fetchAll])` below refire every render and
|
||||
// dispatch SET_LOADING/SET_REPORTS, which re-renders → infinite loop.
|
||||
// Memoizing on the primitive inputs pins range to user-driven changes
|
||||
// only; the day-rollover case is intentionally not handled here (would
|
||||
// require a separate timer to refresh `today`).
|
||||
const range: DateRange = useMemo(
|
||||
() => buildRange(state.preset, state.customStart, state.customEnd),
|
||||
[state.preset, state.customStart, state.customEnd]
|
||||
);
|
||||
|
||||
// Year is read from the clock; compute in the browser only to avoid
|
||||
// hydration mismatches with the server. The setState is wrapped in an
|
||||
|
||||
@@ -135,14 +135,17 @@ export default function SettingsClient({
|
||||
paymentSettings,
|
||||
currentUser,
|
||||
}: Props) {
|
||||
// Initial tab is determined by URL hash if present, otherwise "general".
|
||||
// No useEffect needed — we read window.location.hash during the lazy
|
||||
// initializer on the client. On the server we default to "general".
|
||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
||||
if (typeof window === "undefined") return "general";
|
||||
// Server renders the default tab; client picks up the URL hash after mount
|
||||
// so the initial server HTML matches the first client render (avoids
|
||||
// hydration mismatch). Reading window.location.hash in a lazy initializer
|
||||
// is unsafe because the server has no hash and would render a different
|
||||
// tab than the client.
|
||||
const [activeTab, setActiveTab] = useState<Tab>("general");
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
return TABS.find((t) => t.hash === hash)?.id ?? "general";
|
||||
});
|
||||
const tab = TABS.find((t) => t.hash === hash)?.id;
|
||||
if (tab) setActiveTab(tab);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
|
||||
@@ -50,9 +50,9 @@ export default function AdminFilterTabs({
|
||||
const sizes = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={`
|
||||
flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]
|
||||
flex overflow-x-auto rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]
|
||||
${sizes.container} ${className}
|
||||
`}
|
||||
role="tablist"
|
||||
|
||||
@@ -2,15 +2,23 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Returns whether the given media query currently matches.
|
||||
*
|
||||
* Always starts as `false` so that server-rendered HTML and the initial
|
||||
* client render produce the same markup (no hydration mismatch). The
|
||||
* real value is fetched after mount via `matchMedia`. Callers that need
|
||||
* a different initial branch (e.g. AdminShell choosing mobile vs desktop
|
||||
* layout) should render the "mobile / not-yet-known" branch first and
|
||||
* rely on a re-render after mount.
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mq = window.matchMedia(query);
|
||||
setMatches(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
|
||||
Reference in New Issue
Block a user