import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import {
  usePlanWork,
  getCell,
  getCells,
  type ModalMode as HookModalMode,
} from "../hooks/usePlanWork";
import type { PlanCellModalMode } from "../components/PlanCellModal";
import PlanGrid from "../components/PlanGrid";
import PlanCellModal from "../components/PlanCellModal";
import PlanCategoriesModal from "../components/PlanCategoriesModal";
import BulkPlanModal, { type BulkPlanForm } from "../components/BulkPlanModal";
import Forbidden from "../components/Forbidden";
import { Button, Alert, LoadingState, PageEnter } from "../ui";
import { projectListOptions, type Project } from "../lib/queries/projects";
import {
  planCategoriesOptions,
  type PlanCategory,
  type ResolvedCell,
} from "../lib/queries/plan";
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import.

const MONTH_NAMES = [
  "Leden",
  "Únor",
  "Březen",
  "Duben",
  "Květen",
  "Červen",
  "Červenec",
  "Srpen",
  "Září",
  "Říjen",
  "Listopad",
  "Prosinec",
];

const DAY_MS = 86400000;

function isoDate(d: Date): string {
  return d.toISOString().slice(0, 10);
}

function addDays(d: Date, n: number): Date {
  const r = new Date(d);
  r.setUTCDate(d.getUTCDate() + n);
  return r;
}

function shiftAnchor(
  anchor: Date,
  view: "week" | "month",
  direction: 1 | -1,
): Date {
  if (view === "week") {
    return addDays(anchor, direction * 7);
  }
  // Month view: jump a calendar month
  const d = new Date(anchor);
  d.setUTCMonth(d.getUTCMonth() + direction);
  return d;
}

function isoWeekNumber(d: Date): number {
  // ISO-8601 week number
  const target = new Date(
    Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
  );
  const dayNr = (target.getUTCDay() + 6) % 7;
  target.setUTCDate(target.getUTCDate() - dayNr + 3);
  const jan1 = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
  return Math.ceil(((target.getTime() - jan1.getTime()) / DAY_MS + 1) / 7);
}

function formatRangeLabel(
  from: string,
  to: string,
  view: "week" | "month",
): string {
  const fmt = (s: string) => {
    const [, mm, dd] = s.split("-");
    return `${Number(dd)}.${Number(mm)}.`;
  };
  if (view === "month") {
    const [y, m] = from.split("-");
    return `${MONTH_NAMES[Number(m) - 1]} ${y}`;
  }
  const fromDate = new Date(from + "T00:00:00.000Z");
  const week = isoWeekNumber(fromDate);
  return `Týden ${week} (${fmt(from)} – ${fmt(to)} ${fromDate.getUTCFullYear()})`;
}

// Local-time YYYY-MM-DD for "now". Matches the server's
// `assertNotPastDate` guard in src/services/plan.service.ts and the
// `todayIso()` helper in PlanGrid — all three use the same local-time
// getters so the past-date gate stays consistent across layers.
function todayIsoLocal(): string {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

function isPastDate(dateStr: string, today: string): boolean {
  return dateStr < today;
}

export default function PlanWork() {
  const { hasPermission } = useAuth();
  const alert = useAlert();
  const canEdit = hasPermission("attendance.manage");

  const {
    view,
    setView,
    setAnchor,
    range,
    grid,
    gridLoading,
    modal: hookModal,
    setModal: setHookModal,
    createEntry,
    updateEntry,
    deleteEntry,
    createOverride,
    updateOverride,
    deleteOverride,
    bulkCreate,
    rollbackMutation,
  } = usePlanWork({ canEdit });

  // Projects list — needed by the modal's project selector.
  const { data: projectsData } = useQuery(projectListOptions({ perPage: 200 }));
  const projects: Project[] = (projectsData?.data as Project[]) ?? [];

  const { data: categoriesData } = useQuery(planCategoriesOptions());
  const categories: PlanCategory[] = categoriesData ?? [];

  const [catModalOpen, setCatModalOpen] = useState(false);

  const [bulkOpen, setBulkOpen] = useState(false);
  const [bulkSubmitting, setBulkSubmitting] = useState(false);
  const [bulkForm, setBulkForm] = useState<BulkPlanForm>(() => {
    const today = todayIsoLocal();
    return {
      date_from: today,
      date_to: today,
      is_range: false,
      user_ids: [],
      include_weekends: false,
      project_id: "",
      category: "work",
      note: "",
    };
  });

  const planUsers = useMemo(() => grid?.users ?? [], [grid]);

  const toggleBulkUser = useCallback((userId: number) => {
    setBulkForm((prev) => ({
      ...prev,
      user_ids: prev.user_ids.includes(String(userId))
        ? prev.user_ids.filter((u) => u !== String(userId))
        : [...prev.user_ids, String(userId)],
    }));
  }, []);

  const toggleAllBulkUsers = useCallback(() => {
    const allIds = (grid?.users ?? []).map((u) => String(u.id));
    if (allIds.length === 0) return; // grid not yet loaded — no-op
    setBulkForm((prev) => ({
      ...prev,
      user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
    }));
  }, [grid]);

  const submitBulk = useCallback(async () => {
    if (bulkForm.user_ids.length === 0) return;
    setBulkSubmitting(true);
    try {
      const data: any = await bulkCreate.mutateAsync({
        user_ids: bulkForm.user_ids.map(Number),
        date_from: bulkForm.date_from,
        date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
        include_weekends: bulkForm.include_weekends,
        project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
        category: bulkForm.category,
        note: bulkForm.note,
      });
      // apiCall returns the full envelope { success, data, message };
      // the summary counts are under .data
      const summary = data?.data ?? data;
      const skipped =
        summary?.skipped_days > 0
          ? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
          : "";
      alert.success(
        `Vytvořeno ${summary?.created_days ?? 0} záznamů pro ${summary?.users ?? 0} zaměstnanců.${skipped}`,
      );
      setBulkOpen(false);
      // Reset the volatile parts so reopening starts clean (project /
      // category / weekends stay sticky — users often repeat those).
      const today = todayIsoLocal();
      setBulkForm((prev) => ({
        ...prev,
        date_from: today,
        date_to: today,
        is_range: false,
        user_ids: [],
        note: "",
      }));
    } catch (e) {
      alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
    } finally {
      setBulkSubmitting(false);
    }
  }, [bulkForm, bulkCreate, alert]);

  // `lastMutated` is the (userId, date) of the most recent successful
  // mutation. We pass it to PlanGrid as `pulseKey`; the matching cell
  // gets a one-shot CSS pulse animation. We clear it after 700ms (the
  // animation duration + a small buffer) so subsequent mutations on
  // the same cell can re-trigger it.
  const [lastMutated, setLastMutated] = useState<{
    userId: number;
    date: string;
    nonce: number;
  } | null>(null);
  const pulseTimer = useRef<number | null>(null);
  const firePulse = useCallback((userId: number, date: string) => {
    if (pulseTimer.current !== null) {
      window.clearTimeout(pulseTimer.current);
    }
    setLastMutated({ userId, date, nonce: Date.now() });
    pulseTimer.current = window.setTimeout(() => {
      setLastMutated(null);
      pulseTimer.current = null;
    }, 700);
  }, []);

  useEffect(() => {
    return () => {
      if (pulseTimer.current !== null) {
        window.clearTimeout(pulseTimer.current);
      }
    };
  }, []);

  const goPrev = useCallback(() => {
    setAnchor((a) => shiftAnchor(a, view, -1));
  }, [setAnchor, view]);

  const goNext = useCallback(() => {
    setAnchor((a) => shiftAnchor(a, view, 1));
  }, [setAnchor, view]);

  const goToToday = useCallback(() => {
    setAnchor(new Date());
  }, [setAnchor]);

  const setViewWithAnim = useCallback(
    (next: "week" | "month") => {
      setView(next);
    },
    [setView],
  );

  // --- Modal helpers ---------------------------------------------------------
  // The hook stores a slim ModalMode (no `cell` / `range` payload). The
  // PlanCellModal component expects a richer mode with those fields. We keep
  // a separate `modalCell` state and merge it in at render time.

  // The ResolvedCell currently being edited / viewed (filled on openCell).
  // Null when the modal is closed or in a "create" state with no source cell.
  const [modalCell, setModalCell] = useState<ReturnType<typeof getCell>>(null);

  // Open the create form for a brand-new record. `source` picks the layer:
  // "entry" (default) opens the normal create form; "override" opens the
  // single-day create-override form. The day panel passes "override" when the
  // day is in exception mode so the new record lands in the same layer that's
  // showing (otherwise resolveCell would hide a new entry behind the override).
  const openCreate = useCallback(
    (userId: number, date: string, source: "entry" | "override" = "entry") => {
      setModalCell(null);
      if (source === "override") {
        setHookModal({ kind: "create-override", userId, date });
      } else {
        setHookModal({ kind: "create", userId, date });
      }
    },
    [setHookModal],
  );

  const openCell = useCallback(
    (userId: number, date: string, cells: ResolvedCell[]) => {
      const primary = cells[0] ?? null;
      // Past-date guard: the server rejects create/edit on past dates with
      // a 403 (see assertNotPastDate in plan.service.ts). To keep the user
      // out of the "click → modal opens → error toast" state, route any
      // past-day click to view mode (or to no-op if there's no record).
      if (isPastDate(date, todayIsoLocal())) {
        if (!primary) return;
        setModalCell(primary);
        setHookModal({ kind: "view", userId, date });
        return;
      }
      if (cells.length === 0) {
        if (canEdit) openCreate(userId, date);
        return;
      }
      if (!canEdit) {
        setModalCell(primary);
        setHookModal({ kind: "view", userId, date });
        return;
      }
      setHookModal({ kind: "day", userId, date });
    },
    [canEdit, openCreate, setHookModal],
  );

  // Open the correct editor for one record from the day panel. This is the
  // per-record version of the old openCell branch logic: a multi-day entry
  // routes through the day-in-range chooser; a single-day entry → edit-range;
  // an override → edit-override.
  const editRecord = useCallback(
    (cell: ReturnType<typeof getCell>, userId: number, date: string) => {
      if (!cell) return;
      setModalCell(cell);
      if (cell.source === "entry" && cell.entryId !== null) {
        if (cell.rangeFrom && cell.rangeTo && cell.rangeFrom !== cell.rangeTo) {
          setHookModal({
            kind: "day-in-range",
            entryId: cell.entryId,
            userId,
            date,
          });
        } else {
          setHookModal({
            kind: "edit-range",
            entryId: cell.entryId,
            userId,
            date,
          });
        }
        return;
      }
      if (cell.source === "override" && cell.overrideId !== null) {
        setHookModal({
          kind: "edit-override",
          overrideId: cell.overrideId,
          userId,
          date,
        });
      }
    },
    [setHookModal],
  );

  const closeModal = useCallback(() => {
    setHookModal({ kind: "closed" });
    setModalCell(null);
  }, [setHookModal]);

  // Switch the modal from "day-in-range" to "edit-range" mode, keeping the
  // same cell context so the EditForm opens with the range's values.
  // Uses `modalCell` — the record the user actually clicked in the day panel
  // (captured by editRecord) — NOT getCell (which returns the PRIMARY record).
  // On a day with 2+ records these differ; using the primary would edit the
  // wrong range. modalCell is already set to the clicked record at this point
  // (editRecord → day-in-range → here).
  const switchToEditRange = useCallback(
    (entryId: number, userId: number, date: string) => {
      if (!modalCell) return;
      setHookModal({
        kind: "edit-range",
        entryId,
        userId,
        date,
      });
    },
    [modalCell, setHookModal],
  );

  // --- Mutation wrappers -----------------------------------------------------
  // The modal calls these with plain objects / ids. We resolve them to the
  // hook's mutateAsync calls, surface server errors as alerts, and (on
  // success) trigger the per-cell pulse. On failure we also roll back the
  // optimistic cache patch the hook already applied.

  const saveEntry = useCallback(
    async (body: any) => {
      try {
        await createEntry.mutateAsync(body);
        // Pulse the first day of the new entry — for multi-day entries
        // the first day is what the modal was opened on (body.date_from).
        firePulse(body.user_id, body.date_from);
        alert.success("Záznam vytvořen");
      } catch (e) {
        rollbackMutation(createEntry, body.user_id);
        alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
        throw e;
      }
    },
    [createEntry, alert, firePulse, rollbackMutation],
  );

  const updateEntryFn = useCallback(
    async (id: number, body: any) => {
      // Find the user for this entry so we know which cell to pulse /
      // which cache to roll back. The hook's optimistic patch already
      // discovered this; we read it from the current grid.
      let userId: number | null = null;
      let firstDate: string | null = null;
      if (grid) {
        for (const [uidStr, byDate] of Object.entries(grid.cells)) {
          for (const [date, cells] of Object.entries(byDate)) {
            const hit = cells.find((c) => c.entryId === id);
            if (hit) {
              userId = Number(uidStr);
              firstDate = body.date_from ?? date;
              break;
            }
          }
          if (userId !== null) break;
        }
      }
      try {
        await updateEntry.mutateAsync({ id, body });
        if (userId !== null && firstDate !== null) firePulse(userId, firstDate);
        alert.success("Záznam aktualizován");
      } catch (e) {
        if (userId !== null) rollbackMutation(updateEntry, userId);
        alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
        throw e;
      }
    },
    [updateEntry, grid, alert, firePulse, rollbackMutation],
  );

  const deleteEntryFn = useCallback(
    async (id: number) => {
      // Find the user/date for the pulse + rollback.
      let userId: number | null = null;
      let firstDate: string | null = null;
      if (grid) {
        for (const [uidStr, byDate] of Object.entries(grid.cells)) {
          for (const [date, cells] of Object.entries(byDate)) {
            const hit = cells.find((c) => c.entryId === id);
            if (hit) {
              userId = Number(uidStr);
              firstDate = hit.rangeFrom ?? date;
              break;
            }
          }
          if (userId !== null) break;
        }
      }
      try {
        await deleteEntry.mutateAsync({ id });
        if (userId !== null && firstDate !== null) firePulse(userId, firstDate);
        alert.success("Záznam smazán");
      } catch (e) {
        if (userId !== null) rollbackMutation(deleteEntry, userId);
        alert.error(e instanceof Error ? e.message : "Chyba při mazání");
        throw e;
      }
    },
    [deleteEntry, grid, alert, firePulse, rollbackMutation],
  );

  const saveOverride = useCallback(
    async (body: any) => {
      try {
        await createOverride.mutateAsync(body);
        firePulse(body.user_id, body.shift_date);
        alert.success("Přepsání vytvořeno");
      } catch (e) {
        rollbackMutation(createOverride, body.user_id);
        alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
        throw e;
      }
    },
    [createOverride, alert, firePulse, rollbackMutation],
  );

  const updateOverrideFn = useCallback(
    async (id: number, body: any) => {
      // Find the user/date for the pulse + rollback.
      let userId: number | null = null;
      let date: string | null = null;
      if (grid) {
        for (const [uidStr, byDate] of Object.entries(grid.cells)) {
          for (const [d, cells] of Object.entries(byDate)) {
            const hit = cells.find((c) => c.overrideId === id);
            if (hit) {
              userId = Number(uidStr);
              date = d;
              break;
            }
          }
          if (userId !== null) break;
        }
      }
      try {
        await updateOverride.mutateAsync({ id, body });
        if (userId !== null && date !== null) firePulse(userId, date);
        alert.success("Přepsání aktualizováno");
      } catch (e) {
        if (userId !== null) rollbackMutation(updateOverride, userId);
        alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
        throw e;
      }
    },
    [updateOverride, grid, alert, firePulse, rollbackMutation],
  );

  const deleteOverrideFn = useCallback(
    async (id: number) => {
      let userId: number | null = null;
      let date: string | null = null;
      if (grid) {
        for (const [uidStr, byDate] of Object.entries(grid.cells)) {
          for (const [d, cells] of Object.entries(byDate)) {
            const hit = cells.find((c) => c.overrideId === id);
            if (hit) {
              userId = Number(uidStr);
              date = d;
              break;
            }
          }
          if (userId !== null) break;
        }
      }
      try {
        await deleteOverride.mutateAsync({ id });
        if (userId !== null && date !== null) firePulse(userId, date);
        alert.success("Přepsání smazáno");
      } catch (e) {
        if (userId !== null) rollbackMutation(deleteOverride, userId);
        alert.error(e instanceof Error ? e.message : "Chyba při mazání");
        throw e;
      }
    },
    [deleteOverride, grid, alert, firePulse, rollbackMutation],
  );

  // "Create override from range" — turn a multi-day entry into a one-day
  // override for the clicked date. We mirror the CLICKED record's fields
  // (modalCell, captured by editRecord) — NOT getCell, which returns the
  // PRIMARY record. On a day with 2+ records the clicked entry can differ
  // from the primary; carving from the primary would copy the wrong
  // project/category/note into the override.
  const createOverrideFromRange = useCallback(
    async (entryId: number, userId: number, date: string) => {
      const cell = modalCell;
      if (!cell) {
        alert.error("Nepodařilo se najít zdrojový záznam");
        return;
      }
      try {
        await createOverride.mutateAsync({
          user_id: userId,
          shift_date: date,
          project_id: cell.project_id,
          category: cell.category,
          note: cell.note,
        });
        firePulse(userId, date);
        alert.success("Přepsání dne vytvořeno");
      } catch (e) {
        rollbackMutation(createOverride, userId);
        alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
        throw e;
      }
      // Suppress unused-var warning for entryId — kept in the signature for
      // symmetry with the modal contract.
      void entryId;
    },
    [modalCell, createOverride, alert, firePulse, rollbackMutation],
  );

  // --- Modal key for remount-on-mode-change ----------------------------------
  // PlanCellModal mounts internal state from `mode` once. Re-keying the
  // component whenever the mode changes (or the modal closes) forces a clean
  // remount so the new mode's initial state is computed freshly.
  const modalKey = useMemo(() => {
    if (hookModal.kind === "closed") return "closed";
    const m = hookModal as Exclude<HookModalMode, { kind: "closed" }>;
    return `${m.kind}-${m.userId}-${m.date}`;
  }, [hookModal]);

  // Convert hook's ModalMode to PlanCellModalMode. The hook stores slim
  // variants (no `cell` / `range` payload). The modal component expects rich
  // variants with those fields; we fill them from `modalCell` (captured at
  // open-time) so the form keeps the right initial values even if the grid
  // is refetched while the modal is open.
  const modalMode: PlanCellModalMode = useMemo(() => {
    if (hookModal.kind === "closed") return { kind: "closed" };
    if (hookModal.kind === "create") return hookModal;
    // create-override carries the same userId/date payload as create and needs
    // no merged cell/range — pass it straight through (EditForm handles it).
    if (hookModal.kind === "create-override") return hookModal;
    if (hookModal.kind === "view") {
      if (!modalCell) return { kind: "closed" };
      return {
        kind: "view",
        userId: hookModal.userId,
        date: hookModal.date,
        cell: modalCell,
      };
    }
    if (hookModal.kind === "edit-override") {
      if (!modalCell) return { kind: "closed" };
      return {
        kind: "edit-override",
        overrideId: hookModal.overrideId,
        userId: hookModal.userId,
        date: hookModal.date,
        cell: modalCell,
      };
    }
    if (hookModal.kind === "edit-range" || hookModal.kind === "day-in-range") {
      if (!modalCell) return { kind: "closed" };
      return {
        ...hookModal,
        range: {
          date_from: modalCell.rangeFrom ?? hookModal.date,
          date_to: modalCell.rangeTo ?? hookModal.date,
          project_id: modalCell.project_id,
          category: modalCell.category,
          note: modalCell.note,
        },
      } as PlanCellModalMode;
    }
    if (hookModal.kind === "day") {
      return {
        kind: "day",
        userId: hookModal.userId,
        date: hookModal.date,
        cells: getCells(grid, hookModal.userId, hookModal.date),
      };
    }
    return { kind: "closed" };
  }, [hookModal, modalCell, grid]);

  if (!canEdit && !hasPermission("attendance.record")) {
    // Hard block for users who have neither manage nor record permission.
    return <Forbidden />;
  }

  return (
    <PageEnter sx={{ position: "relative" }}>
      <Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
        Plán prací
      </Typography>

      {!canEdit && (
        <Box role="status" sx={{ mb: 2 }}>
          <Alert severity="info">
            <strong>Režim náhledu</strong> — můžete pouze prohlížet plán. Úpravy
            jsou dostupné oprávněným uživatelům.
          </Alert>
        </Box>
      )}

      <Box
        sx={{
          display: "flex",
          flexWrap: "wrap",
          alignItems: "center",
          gap: 1.5,
          mb: 2,
        }}
      >
        {/* Nav buttons grouped so they stay on one row when the toolbar
              wraps on mobile. */}
        <Box sx={{ display: "inline-flex", gap: 1 }}>
          <Button variant="outlined" color="inherit" onClick={goToToday}>
            Dnes
          </Button>
          <Button
            variant="outlined"
            color="inherit"
            onClick={goPrev}
            aria-label="Předchozí"
          >
            ←
          </Button>
          <Button
            variant="outlined"
            color="inherit"
            onClick={goNext}
            aria-label="Další"
          >
            →
          </Button>
        </Box>
        <Box
          sx={{
            flex: 1,
            minWidth: 220,
            display: "flex",
            justifyContent: "center",
          }}
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.div
              key={`${isoDate(range.from)}-${view}`}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.15 }}
            >
              <Typography
                variant="h6"
                sx={{ fontWeight: 600, whiteSpace: "nowrap" }}
              >
                {formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
              </Typography>
            </motion.div>
          </AnimatePresence>
        </Box>
        <Box
          role="group"
          aria-label="Měřítko zobrazení"
          sx={{ display: "inline-flex", gap: 1 }}
        >
          <Button
            variant={view === "week" ? "contained" : "outlined"}
            color={view === "week" ? "primary" : "inherit"}
            onClick={() => setViewWithAnim("week")}
          >
            Týden
          </Button>
          <Button
            variant={view === "month" ? "contained" : "outlined"}
            color={view === "month" ? "primary" : "inherit"}
            onClick={() => setViewWithAnim("month")}
          >
            Měsíc
          </Button>
        </Box>
        {canEdit && (
          <Button
            variant="outlined"
            color="inherit"
            onClick={() => setCatModalOpen(true)}
          >
            Správa kategorií
          </Button>
        )}
        {canEdit && (
          <Button
            variant="outlined"
            color="inherit"
            onClick={() => setBulkOpen(true)}
          >
            Hromadné přiřazení
          </Button>
        )}
      </Box>

      {gridLoading && <LoadingState />}

      {/*
        Grid entrance: same pattern as the h1 / banner / toolbar above
        (opacity + 12px translateY, 250ms ease-out). The stagger is
        h1 (0s) → banner (0.06s) → toolbar (0.12s) → grid (0.18s), so
        the page assembles top-to-bottom in a single cascade.

        The motion.div mounts ONCE when `grid` first becomes defined
        and stays mounted across all range changes (keepPreviousData
        keeps `grid` defined even while a new fetch is in flight, so
        the condition never drops to null). This prevents the entrance
        animation from re-firing on every week/month switch.

        Range changes are instant — keepPreviousData holds the old data
        visible until the new data arrives, so there's no flash. Mutations
        patch the cache in place and pulse a single cell via pulseKey.
      */}
      {grid ? (
        <PlanGrid
          data={grid}
          projects={projects}
          categories={categories}
          canEdit={canEdit}
          pulseKey={lastMutated}
          onCellClick={openCell}
        />
      ) : null}

      <PlanCellModal
        key={modalKey}
        open={hookModal.kind !== "closed"}
        mode={modalMode}
        projects={projects}
        categories={categories}
        onClose={closeModal}
        onSaveEntry={saveEntry}
        onUpdateEntry={updateEntryFn}
        onDeleteEntry={deleteEntryFn}
        onSaveOverride={saveOverride}
        onUpdateOverride={updateOverrideFn}
        onDeleteOverride={deleteOverrideFn}
        onCreateOverrideFromRange={createOverrideFromRange}
        onSwitchToEditRange={switchToEditRange}
        onAddRecord={openCreate}
        onEditRecord={editRecord}
      />
      <PlanCategoriesModal
        isOpen={catModalOpen}
        onClose={() => setCatModalOpen(false)}
        categories={categories}
      />
      <BulkPlanModal
        isOpen={bulkOpen}
        onClose={() => setBulkOpen(false)}
        form={bulkForm}
        setForm={setBulkForm}
        users={planUsers}
        projects={projects}
        categories={categories}
        onSubmit={submitBulk}
        submitting={bulkSubmitting}
        toggleUser={toggleBulkUser}
        toggleAllUsers={toggleAllBulkUsers}
      />
    </PageEnter>
  );
}
