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,
|
// `customers` replaced `communication_contacts`. `source` column is gone,
|
||||||
// so `imports` is always 0 and `new_contacts` is the day's net add.
|
// 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 }>(
|
const { rows } = await pool.query<{ date: string; new_contacts: number; imports: number; total: number }>(
|
||||||
`SELECT
|
`SELECT
|
||||||
d::date::text AS date,
|
d::date::text AS date,
|
||||||
COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts,
|
COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts,
|
||||||
0::int AS imports,
|
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
|
FROM generate_series($1::date, $2::date, '1 day'::interval) d
|
||||||
LEFT JOIN customers c
|
LEFT JOIN customers c
|
||||||
ON c.created_at::date = d::date
|
ON c.created_at::date = d::date
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<div
|
<div
|
||||||
id="page-content"
|
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)" }}
|
style={{ backgroundColor: "var(--admin-bg)" }}
|
||||||
>
|
>
|
||||||
<SmoothViewTransition>{children}</SmoothViewTransition>
|
<SmoothViewTransition>{children}</SmoothViewTransition>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useReducer, useCallback, useEffect } from "react";
|
import { useReducer, useCallback, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
getReportsSummary,
|
getReportsSummary,
|
||||||
getOrdersByStopReport,
|
getOrdersByStopReport,
|
||||||
@@ -317,7 +317,19 @@ export default function ReportsDashboard({
|
|||||||
}) {
|
}) {
|
||||||
const [state, dispatch] = useReducer(reducer, initialBrandId, makeInitialState);
|
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
|
// Year is read from the clock; compute in the browser only to avoid
|
||||||
// hydration mismatches with the server. The setState is wrapped in an
|
// hydration mismatches with the server. The setState is wrapped in an
|
||||||
|
|||||||
@@ -135,14 +135,17 @@ export default function SettingsClient({
|
|||||||
paymentSettings,
|
paymentSettings,
|
||||||
currentUser,
|
currentUser,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// Initial tab is determined by URL hash if present, otherwise "general".
|
// Server renders the default tab; client picks up the URL hash after mount
|
||||||
// No useEffect needed — we read window.location.hash during the lazy
|
// so the initial server HTML matches the first client render (avoids
|
||||||
// initializer on the client. On the server we default to "general".
|
// hydration mismatch). Reading window.location.hash in a lazy initializer
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
// is unsafe because the server has no hash and would render a different
|
||||||
if (typeof window === "undefined") return "general";
|
// tab than the client.
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("general");
|
||||||
|
useEffect(() => {
|
||||||
const hash = window.location.hash.slice(1);
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function AdminFilterTabs({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
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}
|
${sizes.container} ${className}
|
||||||
`}
|
`}
|
||||||
role="tablist"
|
role="tablist"
|
||||||
|
|||||||
@@ -2,15 +2,23 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 {
|
export function useMediaQuery(query: string): boolean {
|
||||||
const [matches, setMatches] = useState<boolean>(() => {
|
const [matches, setMatches] = useState<boolean>(false);
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
return window.matchMedia(query).matches;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const mq = window.matchMedia(query);
|
const mq = window.matchMedia(query);
|
||||||
|
setMatches(mq.matches);
|
||||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
mq.addEventListener("change", handler);
|
mq.addEventListener("change", handler);
|
||||||
return () => mq.removeEventListener("change", handler);
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
|||||||
Reference in New Issue
Block a user