diff --git a/src/admin/components/PlanGrid.tsx b/src/admin/components/PlanGrid.tsx index 0b3e4e9..9130082 100644 --- a/src/admin/components/PlanGrid.tsx +++ b/src/admin/components/PlanGrid.tsx @@ -1,6 +1,7 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { CSSProperties } from "react"; -import { styled } from "@mui/material/styles"; +import { styled, useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; import Box from "@mui/material/Box"; import { GridData, @@ -11,7 +12,8 @@ import { } from "../lib/queries/plan"; import type { Project } from "../lib/queries/projects"; import PlanRangeChips from "./PlanRangeChips"; -import { LoadingState } from "../ui"; +import { LoadingState, Select, Field } from "../ui"; +import { useAuth } from "../context/AuthContext"; import { fonts } from "../theme"; /** @@ -30,7 +32,10 @@ const PlanGridRoot = styled(Box)(({ theme }) => ({ border: `1px solid ${theme.vars!.palette.divider}`, borderRadius: 14, boxShadow: theme.shadows[2], - maxHeight: "calc(100dvh - 240px)", + // Never collapse below ~5 rows: on a phone in LANDSCAPE 100dvh is only + // ~360-400px, so the bare calc left <160px and the cells disappeared + // under the sticky header. The grid scrolls internally either way. + maxHeight: "max(calc(100dvh - 240px), 360px)", isolation: "isolate", "& .plan-grid": { @@ -464,6 +469,15 @@ export default function PlanGrid({ pulseKey, onCellClick, }: Props) { + const theme = useTheme(); + // Phone layout: the multi-person grid doesn't fit a portrait viewport, so + // a person picker shows ONE column at a time (Datum + selected person). + // Desktop/tablet keeps all columns. + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + const { user: authUser } = useAuth(); + // null = "no explicit choice yet" → defaults to the logged-in user when + // they are in the plan, else the first person. + const [mobileUserId, setMobileUserId] = useState(null); const today = useMemo(() => todayIso(), []); const catMap = useMemo(() => categoryMap(categories), [categories]); // Index projects by id once per projects change instead of a linear @@ -483,7 +497,15 @@ export default function PlanGrid({ ); if (!data) return ; - const users = data.users; + const allUsers = data.users; + // Mobile: narrow to the picked person (derived, not stored — a stale pick + // after the user list changes falls back gracefully). + const mobileUser = + allUsers.find((u) => u.id === mobileUserId) ?? + allUsers.find((u) => u.id === authUser?.id) ?? + allUsers[0]; + const users = + isMobile && allUsers.length > 1 && mobileUser ? [mobileUser] : allUsers; // pulseKey?.nonce is included in the data-pulse attribute so a second // mutation on the same cell re-triggers the animation (CSS animations // don't restart unless the keyframe applies to a fresh element/class @@ -493,156 +515,172 @@ export default function PlanGrid({ : undefined; return ( - - - {/* The colgroup is what actually controls column width in a + <> + {isMobile && allUsers.length > 1 && ( + + +
+ {/* The colgroup is what actually controls column width in a table — `min-width` on `- - {users.map((u) => ( - - ))} - - - - - {users.map((u) => { - const { first, last } = splitName(u.full_name); - return ( - + + {users.map((u) => ( + + ))} + + + + + {users.map((u) => { + const { first, last } = splitName(u.full_name); + return ( + + + ); + })} + + + + {days.map((date) => { + const dow = czechWeekday(date); + const dayNum = date.slice(8, 10); + const isToday = date === today; + const trCls = [ + isWeekend(date) ? "plan-grid-weekend" : "", + isToday ? "is-today" : "", + ] + .filter(Boolean) + .join(" "); + return ( + + + {users.map((u) => { + const cellArr = data.cells[u.id]?.[date] ?? []; + const cell = cellArr[0] ?? null; + const past = isPastDate(date, today); + // Past-day cells are read-only in the UI: an empty past + // cell is non-interactive (no create modal, no "+" hint), + // a past cell with data opens in view mode only. The + // server still enforces the past-date rule with a 403 + // (defense in depth), but the click path here never + // reaches a create/edit submission for a past date. + const isLocked = !canEdit || past; + // `isPulsing` is true for the single (user, date) cell + // that the most recent successful mutation touched. + // CSS restarts the keyframe animation whenever the + // `nonce` changes (we embed it in data-pulse on the + // wrapper, see above), so back-to-back mutations on the + // same cell re-trigger the pulse. + const isPulsing = + !!pulseKey && + pulseKey.userId === u.id && + pulseKey.date === date; + let cls: string; + if (cell) { + cls = isLocked + ? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}` + : "plan-cell"; + } else { + cls = isLocked + ? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}` + : "plan-cell plan-cell--empty"; + } + if (isPulsing) cls += " plan-cell--pulse"; + return ( + + ); + })} + ); })} - - - - {days.map((date) => { - const dow = czechWeekday(date); - const dayNum = date.slice(8, 10); - const isToday = date === today; - const trCls = [ - isWeekend(date) ? "plan-grid-weekend" : "", - isToday ? "is-today" : "", - ] - .filter(Boolean) - .join(" "); - return ( - - - {users.map((u) => { - const cellArr = data.cells[u.id]?.[date] ?? []; - const cell = cellArr[0] ?? null; - const past = isPastDate(date, today); - // Past-day cells are read-only in the UI: an empty past - // cell is non-interactive (no create modal, no "+" hint), - // a past cell with data opens in view mode only. The - // server still enforces the past-date rule with a 403 - // (defense in depth), but the click path here never - // reaches a create/edit submission for a past date. - const isLocked = !canEdit || past; - // `isPulsing` is true for the single (user, date) cell - // that the most recent successful mutation touched. - // CSS restarts the keyframe animation whenever the - // `nonce` changes (we embed it in data-pulse on the - // wrapper, see above), so back-to-back mutations on the - // same cell re-trigger the pulse. - const isPulsing = - !!pulseKey && - pulseKey.userId === u.id && - pulseKey.date === date; - let cls: string; - if (cell) { - cls = isLocked - ? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}` - : "plan-cell"; - } else { - cls = isLocked - ? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}` - : "plan-cell plan-cell--empty"; - } - if (isPulsing) cls += " plan-cell--pulse"; - return ( - - ); - })} - - ); - })} - -
`/`` is just a floor that `table-layout: auto` happily blows past. The first column gets a fixed width via the col element so the date stamp doesn't get stretched to share space with person columns. */} -
Datum - - - - - {first} - {last ? ` ${last}` : ""} - - {shortRole(u.role_name) && ( - {shortRole(u.role_name)} - )} +
Datum + + + + + {first} + {last ? ` ${last}` : ""} + + {shortRole(u.role_name) && ( + {shortRole(u.role_name)} + )} + - -
+ + {dayNum} + {dow} + + + +
- - {dayNum} - {dow} - - - -
-
+ + + + ); } diff --git a/src/admin/components/odin/OdinChat.tsx b/src/admin/components/odin/OdinChat.tsx index b3e72ab..466ad59 100644 --- a/src/admin/components/odin/OdinChat.tsx +++ b/src/admin/components/odin/OdinChat.tsx @@ -422,7 +422,12 @@ export default function OdinChat() { return ( )} + {/* Mobile: stacked full-width rows (same pattern as headerActionsSx on + the detail pages); sm+ keeps the single wrapping toolbar row. The + paired controls (Dnes/arrows, Týden/Měsíc) stay together as one + full-width row each. */} {/* Nav buttons grouped so they stay on one row when the toolbar wraps on mobile. */} - -