PlanGrid's bespoke plan.css surface is replaced by a styled(Box) wrapper that scopes all the grid + chip rules and pulls every color from the theme palette via theme.vars (auto light/dark — no more [data-theme] CSS-var blocks). Sticky header + date column, marker-tape cells, category color via the per-cell --cat-color var, weekend/today treatment, hover affordances, the '+' empty hint, and the one-shot mutation pulse are all preserved; the paper-grain/ruled-paper texture was dropped for a clean MUI surface. plan.css trimmed from ~1150 lines to just the category-manager modal rows (the grid is MUI now; the toolbar/banner/day-choice modal had already moved to MUI components). Verified on /plan-work in both light and dark. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
609 lines
20 KiB
TypeScript
609 lines
20 KiB
TypeScript
import { useMemo } from "react";
|
|
import type { CSSProperties } from "react";
|
|
import { styled } from "@mui/material/styles";
|
|
import Box from "@mui/material/Box";
|
|
import {
|
|
GridData,
|
|
ResolvedCell,
|
|
planCategoryLabel,
|
|
categoryMap,
|
|
PlanCategory,
|
|
} from "../lib/queries/plan";
|
|
import type { Project } from "../lib/queries/projects";
|
|
import PlanRangeChips from "./PlanRangeChips";
|
|
import { LoadingState } from "../ui";
|
|
import { fonts } from "../theme";
|
|
|
|
/**
|
|
* The planner surface, fully MUI/theme-driven (replaces the bespoke plan.css
|
|
* grid styling). All colors come from the theme palette via `theme.vars`, so it
|
|
* adapts to light/dark automatically; category colors come from the DB and are
|
|
* injected per cell as the `--cat-color` CSS variable. Class names from the JSX
|
|
* below are styled here as scoped nested selectors.
|
|
*/
|
|
const PlanGridRoot = styled(Box)(({ theme }) => ({
|
|
position: "relative",
|
|
overflow: "auto",
|
|
WebkitOverflowScrolling: "touch",
|
|
overscrollBehavior: "contain",
|
|
background: theme.vars!.palette.background.default,
|
|
border: `1px solid ${theme.vars!.palette.divider}`,
|
|
borderRadius: 14,
|
|
boxShadow: theme.shadows[2],
|
|
maxHeight: "calc(100dvh - 240px)",
|
|
isolation: "isolate",
|
|
|
|
"& .plan-grid": {
|
|
position: "relative",
|
|
zIndex: 1,
|
|
borderCollapse: "separate",
|
|
borderSpacing: 0,
|
|
width: "100%",
|
|
fontSize: "12.5px",
|
|
color: theme.vars!.palette.text.primary,
|
|
fontFamily: fonts.body,
|
|
},
|
|
"& col.plan-grid-date-col": { width: 88 },
|
|
"& tbody tr td + td": {
|
|
borderLeft: `1px solid ${theme.vars!.palette.divider}`,
|
|
},
|
|
|
|
// Sticky header
|
|
"& thead th": {
|
|
position: "sticky",
|
|
top: 0,
|
|
zIndex: 3,
|
|
padding: "14px 12px 12px",
|
|
background: theme.vars!.palette.background.paper,
|
|
color: theme.vars!.palette.text.primary,
|
|
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
|
textAlign: "left",
|
|
fontWeight: 600,
|
|
whiteSpace: "nowrap",
|
|
},
|
|
"& thead th.plan-grid-date-col": {
|
|
left: 0,
|
|
zIndex: 4,
|
|
minWidth: 88,
|
|
padding: "12px 10px 10px 14px",
|
|
fontSize: "10.5px",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.18em",
|
|
textTransform: "uppercase",
|
|
color: theme.vars!.palette.text.secondary,
|
|
},
|
|
"& .plan-person-head": {
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 9,
|
|
minWidth: 0,
|
|
},
|
|
"& .plan-person-dot": {
|
|
flexShrink: 0,
|
|
width: 9,
|
|
height: 9,
|
|
borderRadius: "50%",
|
|
background: theme.vars!.palette.action.disabled,
|
|
},
|
|
"& .plan-person-name": {
|
|
display: "inline-flex",
|
|
flexDirection: "column",
|
|
minWidth: 0,
|
|
lineHeight: 1.1,
|
|
},
|
|
"& .plan-person-name strong": {
|
|
fontWeight: 600,
|
|
fontSize: 13,
|
|
color: theme.vars!.palette.text.primary,
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
maxWidth: 180,
|
|
},
|
|
"& .plan-person-name small": {
|
|
fontSize: 10,
|
|
fontWeight: 500,
|
|
color: theme.vars!.palette.text.disabled,
|
|
letterSpacing: "0.04em",
|
|
textTransform: "uppercase",
|
|
fontFamily: fonts.mono,
|
|
marginTop: 1,
|
|
},
|
|
|
|
// Sticky date column
|
|
"& tbody td.plan-grid-date-col": {
|
|
position: "sticky",
|
|
left: 0,
|
|
zIndex: 1,
|
|
background: theme.vars!.palette.background.paper,
|
|
fontWeight: 500,
|
|
color: theme.vars!.palette.text.secondary,
|
|
whiteSpace: "nowrap",
|
|
minWidth: 88,
|
|
padding: "9px 10px 9px 14px",
|
|
borderRight: `1px solid ${theme.vars!.palette.divider}`,
|
|
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
|
},
|
|
"& .plan-date-stamp": {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "flex-start",
|
|
lineHeight: 1.1,
|
|
gap: 1,
|
|
},
|
|
"& .plan-date-daynum": {
|
|
fontFamily: fonts.mono,
|
|
fontSize: 17,
|
|
fontWeight: 600,
|
|
color: theme.vars!.palette.text.primary,
|
|
fontVariantNumeric: "tabular-nums",
|
|
lineHeight: 1.05,
|
|
},
|
|
"& .plan-date-dow": {
|
|
fontSize: 10,
|
|
fontWeight: 600,
|
|
letterSpacing: "0.14em",
|
|
textTransform: "uppercase",
|
|
color: theme.vars!.palette.text.disabled,
|
|
lineHeight: 1.3,
|
|
},
|
|
|
|
// Today
|
|
"& tbody tr.is-today td.plan-grid-date-col": {
|
|
background: theme.vars!.palette.background.paper,
|
|
"&::before": {
|
|
content: '""',
|
|
position: "absolute",
|
|
left: 0,
|
|
top: 6,
|
|
bottom: 6,
|
|
width: 3,
|
|
borderRadius: "0 2px 2px 0",
|
|
background: theme.vars!.palette.primary.main,
|
|
boxShadow: `0 0 14px rgba(${theme.vars!.palette.primary.mainChannel} / 0.3)`,
|
|
},
|
|
"& .plan-date-daynum": { color: theme.vars!.palette.primary.main },
|
|
},
|
|
"& tbody tr.is-today td:not(.plan-grid-date-col)": {
|
|
background: `rgba(${theme.vars!.palette.primary.mainChannel} / 0.06)`,
|
|
},
|
|
|
|
// Body cells
|
|
"& tbody td": {
|
|
padding: 0,
|
|
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
|
verticalAlign: "stretch",
|
|
minWidth: 168,
|
|
background: theme.vars!.palette.background.paper,
|
|
},
|
|
|
|
// Weekend
|
|
"& tr.plan-grid-weekend td": {
|
|
background: `rgba(${theme.vars!.palette.text.primaryChannel} / 0.03)`,
|
|
},
|
|
"& tr.plan-grid-weekend td.plan-grid-date-col": {
|
|
background: theme.vars!.palette.background.paper,
|
|
color: theme.vars!.palette.text.disabled,
|
|
"& .plan-date-dow": { color: theme.vars!.palette.warning.main },
|
|
"& .plan-date-daynum": { color: theme.vars!.palette.text.secondary },
|
|
},
|
|
|
|
// The cell button + marker tape
|
|
"& .plan-cell": {
|
|
position: "relative",
|
|
display: "block",
|
|
width: "100%",
|
|
height: "100%",
|
|
minHeight: 60,
|
|
textAlign: "left",
|
|
background: "transparent",
|
|
border: 0,
|
|
borderRadius: 0,
|
|
padding: "9px 11px 10px 16px",
|
|
cursor: "pointer",
|
|
font: "inherit",
|
|
color: "inherit",
|
|
transition: "background 150ms ease, transform 150ms ease",
|
|
"&::before": {
|
|
content: '""',
|
|
position: "absolute",
|
|
left: 4,
|
|
top: 8,
|
|
bottom: 8,
|
|
width: 3,
|
|
borderRadius: 2,
|
|
background: `var(--cat-color, ${theme.vars!.palette.divider})`,
|
|
transition:
|
|
"background 150ms ease, box-shadow 150ms ease, transform 150ms ease",
|
|
},
|
|
"&:hover": { background: theme.vars!.palette.action.hover },
|
|
"&:hover::before": {
|
|
transform: "scaleX(1.4)",
|
|
boxShadow: "0 0 12px currentColor",
|
|
},
|
|
"&:active": { transform: "translateY(0.5px)" },
|
|
"&:focus-visible": {
|
|
outline: "none",
|
|
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, inset 0 0 0 4px ${theme.vars!.palette.background.paper}`,
|
|
},
|
|
},
|
|
"& .plan-cell--readonly": {
|
|
cursor: "default",
|
|
"&:hover": { background: "transparent" },
|
|
"&:hover::before": { transform: "none", boxShadow: "none" },
|
|
},
|
|
// A read-only cell that HAS data is still clickable (view mode).
|
|
"& .plan-cell--readonly:not(.plan-cell--empty):not(.plan-cell--past)": {
|
|
cursor: "pointer",
|
|
"&:hover": { background: theme.vars!.palette.action.hover },
|
|
"&:hover::before": {
|
|
transform: "scaleX(1.4)",
|
|
boxShadow: "0 0 12px currentColor",
|
|
},
|
|
},
|
|
"& .plan-cell--past": {
|
|
cursor: "default",
|
|
opacity: 0.55,
|
|
"&:hover": { background: "transparent" },
|
|
"&:hover::before": { transform: "none", boxShadow: "none" },
|
|
},
|
|
// Empty cells: dashed tape + a "+" hover hint.
|
|
"& .plan-cell--empty::before": {
|
|
background: "transparent",
|
|
border: `1px dashed ${theme.vars!.palette.divider}`,
|
|
width: 2,
|
|
left: 5,
|
|
},
|
|
"& .plan-cell--empty:hover::before": {
|
|
background: theme.vars!.palette.primary.main,
|
|
borderColor: theme.vars!.palette.primary.main,
|
|
transform: "scaleX(1.6)",
|
|
},
|
|
"& .plan-cell--empty::after": {
|
|
content: '"+"',
|
|
position: "absolute",
|
|
inset: 0,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
color: theme.vars!.palette.text.disabled,
|
|
opacity: 0,
|
|
fontSize: 18,
|
|
fontWeight: 300,
|
|
pointerEvents: "none",
|
|
transition: "opacity 150ms ease",
|
|
},
|
|
"& .plan-cell--empty:hover::after": {
|
|
opacity: 0.7,
|
|
color: theme.vars!.palette.primary.main,
|
|
},
|
|
"& .plan-cell--empty.plan-cell--readonly::after": { content: "none" },
|
|
|
|
// One-shot mutation pulse
|
|
"@keyframes plan-cell-pulse": {
|
|
"0%": {
|
|
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 0 rgba(${theme.vars!.palette.primary.mainChannel} / 0.4)`,
|
|
},
|
|
"60%": {
|
|
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 6px transparent`,
|
|
},
|
|
"100%": { boxShadow: "inset 0 0 0 2px transparent, 0 0 0 0 transparent" },
|
|
},
|
|
"& .plan-cell--pulse": { animation: "plan-cell-pulse 600ms ease-out" },
|
|
|
|
// Chips inside a cell
|
|
"& .plan-chip-block": {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
textAlign: "left",
|
|
width: "100%",
|
|
minWidth: 0,
|
|
},
|
|
"& .plan-chip-line": {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 7,
|
|
flexWrap: "wrap",
|
|
minWidth: 0,
|
|
},
|
|
"& .plan-chip": {
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
borderRadius: 4,
|
|
padding: "2px 7px",
|
|
fontSize: "10.5px",
|
|
fontWeight: 600,
|
|
letterSpacing: "0.04em",
|
|
textTransform: "uppercase",
|
|
color: `var(--cat-color, ${theme.vars!.palette.text.primary})`,
|
|
background: `color-mix(in srgb, var(--cat-color, transparent) 12%, ${theme.vars!.palette.background.paper})`,
|
|
border: `1px solid color-mix(in srgb, var(--cat-color, ${theme.vars!.palette.divider}) 35%, transparent)`,
|
|
whiteSpace: "nowrap",
|
|
},
|
|
"& .plan-chip-project": {
|
|
fontFamily: fonts.mono,
|
|
fontSize: "12.5px",
|
|
fontWeight: 600,
|
|
color: theme.vars!.palette.text.primary,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
maxWidth: "100%",
|
|
},
|
|
"& .plan-cell-note": {
|
|
display: "-webkit-box",
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: "vertical",
|
|
overflow: "hidden",
|
|
marginTop: 1,
|
|
fontSize: "11.5px",
|
|
color: theme.vars!.palette.text.secondary,
|
|
lineHeight: 1.35,
|
|
fontStyle: "italic",
|
|
},
|
|
|
|
"@media (max-width: 480px)": {
|
|
"& col.plan-grid-date-col": { width: 72 },
|
|
"& tbody td": { minWidth: 132 },
|
|
"& .plan-cell": { minHeight: 56 },
|
|
},
|
|
// Touch: show a faint persistent "+" on empty editable cells.
|
|
"@media (hover: none)": {
|
|
"& .plan-cell--empty:not(.plan-cell--readonly)::after": {
|
|
opacity: 0.26,
|
|
color: theme.vars!.palette.text.disabled,
|
|
},
|
|
},
|
|
"@media (prefers-reduced-motion: reduce)": {
|
|
"& .plan-cell, & .plan-cell::before, & .plan-cell::after": {
|
|
transition: "none",
|
|
},
|
|
"& .plan-cell--pulse": { animation: "none" },
|
|
},
|
|
}));
|
|
|
|
interface Props {
|
|
data: GridData | undefined;
|
|
canEdit: boolean;
|
|
projects: Project[];
|
|
categories: PlanCategory[];
|
|
// The (userId, date) of the most recent successful mutation. The
|
|
// matching cell gets a one-shot highlight pulse. `nonce` is part of
|
|
// the value so back-to-back mutations on the same cell re-trigger
|
|
// the animation (React needs a new key to re-mount the class).
|
|
pulseKey?: { userId: number; date: string; nonce: number } | null;
|
|
onCellClick: (
|
|
userId: number,
|
|
date: string,
|
|
cell: ResolvedCell | null,
|
|
) => void;
|
|
}
|
|
|
|
// Full Czech weekday names, indexed by Date.getUTCDay() (0 = Sunday).
|
|
// Used in the date column "stamp" so the day name reads in full rather
|
|
// than as a 2-letter abbreviation — easier to scan when the planner
|
|
// spans several weeks.
|
|
const CZECH_WEEKDAYS = [
|
|
"Neděle",
|
|
"Pondělí",
|
|
"Úterý",
|
|
"Středa",
|
|
"Čtvrtek",
|
|
"Pátek",
|
|
"Sobota",
|
|
];
|
|
|
|
function eachDay(from: string, to: string): string[] {
|
|
const out: string[] = [];
|
|
const a = new Date(from + "T00:00:00.000Z");
|
|
const b = new Date(to + "T00:00:00.000Z");
|
|
for (let d = new Date(a); d <= b; d.setUTCDate(d.getUTCDate() + 1)) {
|
|
out.push(d.toISOString().slice(0, 10));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function czechWeekday(dateStr: string): string {
|
|
return CZECH_WEEKDAYS[new Date(dateStr + "T00:00:00.000Z").getUTCDay()];
|
|
}
|
|
|
|
function isWeekend(dateStr: string): boolean {
|
|
const day = new Date(dateStr + "T00:00:00.000Z").getUTCDay();
|
|
return day === 0 || day === 6;
|
|
}
|
|
|
|
// Today's local date as YYYY-MM-DD. Used to mark the current row and to
|
|
// gate past-day editing. Uses local time (matches the project's date
|
|
// conventions in CLAUDE.md and the server's `assertNotPastDate` guard in
|
|
// src/services/plan.service.ts).
|
|
function todayIso(): string {
|
|
const d = new Date();
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
// True when the given YYYY-MM-DD cell date is strictly before today (local).
|
|
// Used to gate the create / edit UI on past days — the server still enforces
|
|
// the rule with a 403, but the client should never put the user in the
|
|
// "I clicked, modal opened, error toast" state in the first place.
|
|
function isPastDate(dateStr: string, today: string): boolean {
|
|
return dateStr < today;
|
|
}
|
|
|
|
// Split a full name into first / last for the column header.
|
|
function splitName(full: string): { first: string; last: string } {
|
|
const parts = full.trim().split(/\s+/);
|
|
if (parts.length === 1) return { first: parts[0], last: "" };
|
|
return { first: parts[0], last: parts.slice(1).join(" ") };
|
|
}
|
|
|
|
// Short role label for the column header sub-line.
|
|
function shortRole(role: string | null): string {
|
|
if (!role) return "";
|
|
// Keep it brief — column header is tight
|
|
if (/^admin$/i.test(role)) return "Admin";
|
|
if (/^viewer$/i.test(role)) return "Viewer";
|
|
return role.length > 10 ? role.slice(0, 9) + "…" : role;
|
|
}
|
|
|
|
export default function PlanGrid({
|
|
data,
|
|
canEdit,
|
|
projects,
|
|
categories,
|
|
pulseKey,
|
|
onCellClick,
|
|
}: Props) {
|
|
const today = useMemo(() => todayIso(), []);
|
|
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
|
|
|
if (!data) return <LoadingState />;
|
|
const days = eachDay(data.date_from, data.date_to);
|
|
const users = data.users;
|
|
// 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
|
|
// binding).
|
|
const pulseAttr = pulseKey
|
|
? `${pulseKey.userId}|${pulseKey.date}|${pulseKey.nonce}`
|
|
: undefined;
|
|
|
|
return (
|
|
<PlanGridRoot data-pulse={pulseAttr}>
|
|
<table className="plan-grid">
|
|
{/* The colgroup is what actually controls column width in a
|
|
table — `min-width` on `<th>`/`<td>` 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. */}
|
|
<colgroup>
|
|
<col className="plan-grid-date-col" />
|
|
{users.map((u) => (
|
|
<col key={u.id} />
|
|
))}
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th className="plan-grid-date-col">Datum</th>
|
|
{users.map((u) => {
|
|
const { first, last } = splitName(u.full_name);
|
|
return (
|
|
<th key={u.id}>
|
|
<span className="plan-person-head">
|
|
<span className="plan-person-dot" aria-hidden />
|
|
<span className="plan-person-name">
|
|
<strong title={u.full_name}>
|
|
{first}
|
|
{last ? ` ${last}` : ""}
|
|
</strong>
|
|
{shortRole(u.role_name) && (
|
|
<small>{shortRole(u.role_name)}</small>
|
|
)}
|
|
</span>
|
|
</span>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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 (
|
|
<tr key={date} className={trCls}>
|
|
<td className="plan-grid-date-col">
|
|
<span className="plan-date-stamp">
|
|
<span className="plan-date-daynum">{dayNum}</span>
|
|
<span className="plan-date-dow">{dow}</span>
|
|
</span>
|
|
</td>
|
|
{users.map((u) => {
|
|
const cell = data.cells[u.id]?.[date] ?? 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 (
|
|
<td key={u.id}>
|
|
<button
|
|
type="button"
|
|
className={cls}
|
|
style={
|
|
cell
|
|
? ({
|
|
"--cat-color": catMap[cell.category]?.color,
|
|
} as CSSProperties)
|
|
: undefined
|
|
}
|
|
onClick={() => onCellClick(u.id, date, cell)}
|
|
aria-label={
|
|
cell
|
|
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
|
: isLocked
|
|
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
|
: `${u.full_name}, ${date}, prázdné — přidat záznam`
|
|
}
|
|
>
|
|
<PlanRangeChips
|
|
cell={cell}
|
|
project={
|
|
cell?.project_id
|
|
? (projects.find(
|
|
(p) => p.id === cell.project_id,
|
|
) ?? null)
|
|
: null
|
|
}
|
|
readonly={!canEdit}
|
|
categoryLabel={
|
|
cell ? planCategoryLabel(cell.category, catMap) : ""
|
|
}
|
|
/>
|
|
</button>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</PlanGridRoot>
|
|
);
|
|
}
|