From 59f911386f61d6525dd8b06cabd01621bc232eae Mon Sep 17 00:00:00 2001 From: Femdarn <114834028+Toyosi5566@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:11:09 +0100 Subject: [PATCH] design: revenue calendar year view, bulk-select, and mobile agenda Restore the broken agenda view and add month/quarter/year period switching, bulk close with undo, and mobile agenda sticky headers for issues #424, #426, and #428. --- .../uiux/revenue-calendar-year-bulk-agenda.md | 60 ++ src/components/RevenueCalendarAgendaView.tsx | 488 +++++++++++++++ src/components/RevenueCalendarPeriodViews.tsx | 507 ++++++++++++++++ src/components/RevenueReportingCalendar.css | 523 ++++++++++++++++ .../RevenueReportingCalendar.test.tsx | 344 ++++++++--- src/components/RevenueReportingCalendar.tsx | 563 +++++++++++++----- .../RevenueReportingCalendar.types.ts | 19 + 7 files changed, 2287 insertions(+), 217 deletions(-) create mode 100644 docs/uiux/revenue-calendar-year-bulk-agenda.md create mode 100644 src/components/RevenueCalendarAgendaView.tsx create mode 100644 src/components/RevenueCalendarPeriodViews.tsx diff --git a/docs/uiux/revenue-calendar-year-bulk-agenda.md b/docs/uiux/revenue-calendar-year-bulk-agenda.md new file mode 100644 index 0000000..d4a3b5b --- /dev/null +++ b/docs/uiux/revenue-calendar-year-bulk-agenda.md @@ -0,0 +1,60 @@ +# Revenue Reporting Calendar — Year, Bulk Select & Mobile Agenda + +Design notes for Issues [#424](https://github.com/RevoraOrg/Revora-Frontend/issues/424), +[#426](https://github.com/RevoraOrg/Revora-Frontend/issues/426), and +[#428](https://github.com/RevoraOrg/Revora-Frontend/issues/428). + +Implementation: +- [`RevenueReportingCalendar.tsx`](../../src/components/RevenueReportingCalendar.tsx) +- [`RevenueCalendarPeriodViews.tsx`](../../src/components/RevenueCalendarPeriodViews.tsx) +- [`RevenueCalendarAgendaView.tsx`](../../src/components/RevenueCalendarAgendaView.tsx) + +## #424 — Period scale switcher (Month / Quarter / Year) + +Segmented control under the month navigator: + +| Scale | Interaction | +| --- | --- | +| **Month** | Existing day grid + details panel | +| **Quarter** | Four quarter tiles with per-month status glyphs; Enter/click drills into a month | +| **Year** | 12 mini-month tiles (4×3) with status glyphs and counts; keyboard grid nav | + +Year/quarter navigation uses the same chevrons (year ±1). Selection is preserved when switching scales; drilling into a tile restores Month scale on that month. + +**A11y:** `role="tablist"` for the switcher; year/quarter grids use WAI-ARIA grid + roving tabindex. Glyphs are decorative; status is in each tile’s `aria-label`. + +**Responsive / RTL:** Quarter tiles stack to one column under 640px. Glyph/count placement mirrors in RTL. + +## #426 — Bulk select + floating toolbar + +| Input | Behavior | +| --- | --- | +| Click | Single select | +| Ctrl/Cmd+Click | Toggle date in selection | +| Shift+Click | Range from last anchor | +| Shift+Arrow | Keyboard range extend | + +When 2+ dates are selected, a fixed floating toolbar offers **Export**, **Nudge Owners**, **Close**, and clear (✕). + +**Close flow:** Confirm copy explains mixed statuses → Close marks due/overdue/submitted as reconciled → [`UndoBanner`](./undo-banner-pattern.md) offers undo via `onBulkClose` / `onBulkCloseUndo`. + +## #428 — Mobile agenda view + +Mobile-only Calendar / Agenda toggle (month scale). Agenda defaults on small screens. + +Row anatomy: date stack · status pill · **issuer** (truncates) · due/meta · chevron. + +Groups use **sticky month headers** across a ± window of months. Swipe left reveals Nudge / Close; each opens an inline confirm before acting (then undo banner for close). + +**Edge cases:** long issuer names ellipsize; RTL mirrors swipe actions; reduced-motion disables swipe transform transitions. + +## Keyboard cheat sheet + +| Key | Action | +| --- | --- | +| Arrows | Move focus (day / month / quarter) | +| Shift+Arrow | Extend bulk range (month grid) | +| Enter / Space | Select / drill down | +| PageUp / PageDown | Prev / next month | +| T | Jump to today | +| ? | Open shortcuts overlay (when `onOpenShortcuts` provided) | diff --git a/src/components/RevenueCalendarAgendaView.tsx b/src/components/RevenueCalendarAgendaView.tsx new file mode 100644 index 0000000..f4d6705 --- /dev/null +++ b/src/components/RevenueCalendarAgendaView.tsx @@ -0,0 +1,488 @@ +/** + * Revenue Calendar mobile agenda view — Issue #428 + * + * Scrollable agenda rows with sticky month headers, status pills, + * issuer names, and swipe-to-close / swipe-to-nudge actions. + */ + +import React, { + useMemo, + useRef, + useState, + useCallback, + KeyboardEvent, + TouchEvent, +} from "react"; +import { + Calendar, + Clock, + CheckCircle2, + AlertTriangle, + Send, + ChevronRight, + XCircle, + Bell, +} from "lucide-react"; +import { + formatDate, + formatCurrency, + SupportedLocale, +} from "../constants/i18n"; +import { Button } from "./Button"; +import { + RevenueReport, + ReportStatus, + REPORT_STATUS_LABELS, + REPORT_STATUS_COLORS, +} from "./RevenueReportingCalendar.types"; + +function parseISODate(iso: string): { year: number; month: number; day: number } { + const [y, m, d] = iso.split("-").map(Number); + return { year: y, month: m - 1, day: d }; +} + +function toISODate(year: number, month: number, day: number): string { + return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +function isOverdue(report: RevenueReport): boolean { + if (report.status === "accepted" || report.status === "submitted") return false; + return new Date(report.dueDate) < new Date(); +} + +/* ─── Status Pill ──────────────────────────────────────────────────── */ + +interface StatusPillProps { + status: ReportStatus; +} + +export const StatusPill: React.FC = ({ status }) => { + if (status === "none") return null; + const color = REPORT_STATUS_COLORS[status]; + const label = REPORT_STATUS_LABELS[status]; + return ( + + {label} + + ); +}; + +/* ─── Swipeable Agenda Row ─────────────────────────────────────────── */ + +const SWIPE_THRESHOLD = 72; + +interface AgendaRowProps { + report: RevenueReport; + effectiveStatus: ReportStatus; + isSelected: boolean; + locale: string; + onSelect: (date: string) => void; + onSubmitReport?: (date: string) => void; + onSwipeClose?: (report: RevenueReport) => void; + onSwipeNudge?: (report: RevenueReport) => void; +} + +const AgendaRow: React.FC = ({ + report, + effectiveStatus, + isSelected, + locale, + onSelect, + onSubmitReport, + onSwipeClose, + onSwipeNudge, +}) => { + const startX = useRef(null); + const [offsetX, setOffsetX] = useState(0); + const [confirmAction, setConfirmAction] = useState<"close" | "nudge" | null>( + null, + ); + + const formatLong = (iso: string) => + formatDate(iso, locale as SupportedLocale, { + weekday: "short", + month: "short", + day: "numeric", + }); + + const formatDue = (iso: string) => + formatDate(iso, locale as SupportedLocale, { + month: "short", + day: "numeric", + year: "numeric", + }); + + const canAct = effectiveStatus === "due" || effectiveStatus === "overdue"; + const issuer = report.issuer?.trim() || "Unassigned issuer"; + + const rowLabel = [ + formatLong(report.date), + issuer, + REPORT_STATUS_LABELS[effectiveStatus], + report.grossRevenue !== undefined + ? formatCurrency( + report.grossRevenue, + report.currency || "USD", + locale as SupportedLocale, + ) + : null, + isSelected ? "selected" : null, + ] + .filter(Boolean) + .join(" — "); + + const resetSwipe = useCallback(() => { + setOffsetX(0); + startX.current = null; + }, []); + + const handleTouchStart = (e: TouchEvent) => { + if (!canAct) return; + startX.current = e.touches[0].clientX; + }; + + const handleTouchMove = (e: TouchEvent) => { + if (startX.current === null || !canAct) return; + const delta = e.touches[0].clientX - startX.current; + // Reveal actions on swipe left (negative), mirror on RTL via CSS + setOffsetX(Math.max(-160, Math.min(0, delta))); + }; + + const handleTouchEnd = () => { + if (startX.current === null) return; + if (offsetX <= -SWIPE_THRESHOLD) { + setOffsetX(-144); + } else { + resetSwipe(); + } + startX.current = null; + }; + + const requestClose = () => { + setConfirmAction("close"); + }; + + const requestNudge = () => { + setConfirmAction("nudge"); + }; + + const confirm = () => { + if (confirmAction === "close") onSwipeClose?.(report); + if (confirmAction === "nudge") onSwipeNudge?.(report); + setConfirmAction(null); + resetSwipe(); + }; + + return ( +
  • +
    + {canAct && ( +
    + + +
    + )} + + +
    + + {confirmAction && ( +
    +

    + {confirmAction === "close" ? "Close this period?" : "Nudge the owner?"} +

    +

    + {confirmAction === "close" + ? `Mark ${formatLong(report.date)} as closed. You can undo from the banner.` + : `Send a reminder for ${formatLong(report.date)} to ${issuer}.`} +

    +
    + + +
    +
    + )} + + {canAct && onSubmitReport && ( +
    + +
    + )} +
  • + ); +}; + +/* ─── Agenda View ──────────────────────────────────────────────────── */ + +export interface AgendaViewProps { + reports: RevenueReport[]; + selectedDate: string | undefined; + locale: string; + onSelect: (date: string) => void; + onSubmitReport?: (date: string) => void; + viewMonth: string; + /** When true, group by month across a wider window; otherwise current month by status */ + groupByMonth?: boolean; + onSwipeClose?: (report: RevenueReport) => void; + onSwipeNudge?: (report: RevenueReport) => void; +} + +export const AgendaView: React.FC = ({ + reports, + selectedDate, + locale, + onSelect, + onSubmitReport, + viewMonth, + groupByMonth = true, + onSwipeClose, + onSwipeNudge, +}) => { + const [viewYear, viewMonthNum] = useMemo(() => { + const [y, m] = viewMonth.split("-").map(Number); + return [y, m - 1]; + }, [viewMonth]); + + const todayISO = toISODate( + new Date().getFullYear(), + new Date().getMonth(), + new Date().getDate(), + ); + + type AgendaItem = { report: RevenueReport; effectiveStatus: ReportStatus }; + + const monthGroups = useMemo(() => { + // Include surrounding months so sticky headers are useful on mobile + const windowReports = reports + .filter((r) => { + const d = parseISODate(r.date); + const monthDiff = (d.year - viewYear) * 12 + (d.month - viewMonthNum); + return monthDiff >= -1 && monthDiff <= 2; + }) + .slice() + .sort((a, b) => a.date.localeCompare(b.date)); + + const byMonth = new Map(); + for (const r of windowReports) { + const d = parseISODate(r.date); + const key = `${d.year}-${String(d.month + 1).padStart(2, "0")}`; + const effectiveStatus: ReportStatus = isOverdue(r) ? "overdue" : r.status; + const list = byMonth.get(key) ?? []; + list.push({ report: r, effectiveStatus }); + byMonth.set(key, list); + } + + return Array.from(byMonth.entries()).map(([key, items]) => { + const [y, m] = key.split("-").map(Number); + const title = new Date(y, m - 1).toLocaleDateString(locale as SupportedLocale, { + month: "long", + year: "numeric", + }); + return { key, title, items }; + }); + }, [reports, viewYear, viewMonthNum, locale]); + + const statusGroups = useMemo(() => { + const monthReports = reports.filter((r) => { + const d = parseISODate(r.date); + return d.year === viewYear && d.month === viewMonthNum; + }); + + const buckets: Record = { + overdue: [], + due: [], + submitted: [], + accepted: [], + }; + + for (const r of monthReports) { + const effectiveStatus: ReportStatus = isOverdue(r) ? "overdue" : r.status; + if (effectiveStatus === "none") continue; + buckets[effectiveStatus]?.push({ report: r, effectiveStatus }); + } + + buckets.overdue.sort((a, b) => a.report.date.localeCompare(b.report.date)); + buckets.due.sort((a, b) => a.report.date.localeCompare(b.report.date)); + buckets.submitted.sort((a, b) => b.report.date.localeCompare(a.report.date)); + buckets.accepted.sort((a, b) => b.report.date.localeCompare(a.report.date)); + + const titles: Record = { + overdue: "Overdue", + due: "Upcoming", + submitted: "Submitted", + accepted: "Accepted", + }; + + return (["overdue", "due", "submitted", "accepted"] as const) + .filter((k) => buckets[k].length > 0) + .map((k) => ({ key: k, title: titles[k], items: buckets[k] })); + }, [reports, viewYear, viewMonthNum]); + + const agendaGroups = groupByMonth ? monthGroups : statusGroups; + const totalReports = agendaGroups.reduce((acc, g) => acc + g.items.length, 0); + + return ( +
    + {totalReports === 0 ? ( +
    +
    + ) : ( + agendaGroups.map((group) => ( +
    +

    + {group.title} + {group.items.length} +

    +
      + {group.items.map(({ report, effectiveStatus }) => ( + + ))} +
    +
    + )) + )} +
    + ); +}; + +export default AgendaView; diff --git a/src/components/RevenueCalendarPeriodViews.tsx b/src/components/RevenueCalendarPeriodViews.tsx new file mode 100644 index 0000000..fbc2974 --- /dev/null +++ b/src/components/RevenueCalendarPeriodViews.tsx @@ -0,0 +1,507 @@ +/** + * Revenue Calendar period views — Issues #424 + * + * Month tiles (year overview) and quarter tiles with keyboard navigation + * and drill-down back to month view. Used by RevenueReportingCalendar. + */ + +import React, { useState, useMemo, useEffect, useRef, KeyboardEvent } from "react"; +import { + ReportStatus, + REPORT_STATUS_LABELS, + REPORT_STATUS_COLORS, + RevenueReport, +} from "./RevenueReportingCalendar.types"; +import { SupportedLocale } from "../constants/i18n"; + +function parseISODate(iso: string): { year: number; month: number; day: number } { + const [y, m, d] = iso.split("-").map(Number); + return { year: y, month: m - 1, day: d }; +} + +/** Aggregate status for all reports in a given year+month */ +export function getMonthStatus( + reports: RevenueReport[], + year: number, + month: number, +): ReportStatus { + const monthReports = reports.filter((r) => { + const d = parseISODate(r.date); + return d.year === year && d.month === month; + }); + if (monthReports.length === 0) return "none"; + if (monthReports.some((r) => r.status === "overdue")) return "overdue"; + if (monthReports.some((r) => r.status === "due")) return "due"; + if (monthReports.some((r) => r.status === "submitted")) return "submitted"; + return "accepted"; +} + +function getQuarterStatus( + reports: RevenueReport[], + year: number, + quarter: number, +): ReportStatus { + const start = quarter * 3; + const statuses = [0, 1, 2].map((i) => getMonthStatus(reports, year, start + i)); + if (statuses.some((s) => s === "overdue")) return "overdue"; + if (statuses.some((s) => s === "due")) return "due"; + if (statuses.some((s) => s === "submitted")) return "submitted"; + if (statuses.every((s) => s === "none")) return "none"; + return "accepted"; +} + +/* ─── Month Tile ───────────────────────────────────────────────────── */ + +interface MonthTileProps { + year: number; + month: number; + status: ReportStatus; + isCurrentMonth: boolean; + isSelected: boolean; + isFocused: boolean; + locale: string; + onClick: (year: number, month: number) => void; + onFocus: (month: number) => void; + reportCount: number; +} + +const MonthTile: React.FC = ({ + year, + month, + status, + isCurrentMonth, + isSelected, + isFocused, + locale, + onClick, + onFocus, + reportCount, +}) => { + const monthName = new Date(year, month).toLocaleDateString(locale as SupportedLocale, { + month: "short", + }); + const fullMonthName = new Date(year, month).toLocaleDateString(locale as SupportedLocale, { + month: "long", + year: "numeric", + }); + + const statusColor = status !== "none" ? REPORT_STATUS_COLORS[status] : undefined; + const statusLabel = REPORT_STATUS_LABELS[status]; + + const ariaLabel = [ + fullMonthName, + statusLabel !== "No report" ? `Status: ${statusLabel}.` : "No reports.", + reportCount > 0 ? `${reportCount} report${reportCount !== 1 ? "s" : ""}.` : "", + isCurrentMonth ? "Current month." : "", + isSelected ? "Selected." : "", + ] + .filter(Boolean) + .join(" "); + + const tileClass = [ + "rc-year-month-tile", + isCurrentMonth && "rc-year-month-tile--current", + isSelected && "rc-year-month-tile--selected", + status !== "none" && `rc-year-month-tile--${status}`, + ] + .filter(Boolean) + .join(" "); + + return ( + + ); +}; + +/* ─── Year Grid View ───────────────────────────────────────────────── */ + +export interface YearGridViewProps { + year: number; + reports: RevenueReport[]; + selectedDate: string | undefined; + locale: string; + onMonthSelect: (year: number, month: number) => void; +} + +export const YearGridView: React.FC = ({ + year, + reports, + selectedDate, + locale, + onMonthSelect, +}) => { + const today = new Date(); + const selectedMonth = selectedDate ? parseISODate(selectedDate).month : undefined; + const selectedYear = selectedDate ? parseISODate(selectedDate).year : undefined; + + const [focusedMonth, setFocusedMonth] = useState(() => { + if (selectedYear === year && selectedMonth !== undefined) return selectedMonth; + if (today.getFullYear() === year) return today.getMonth(); + return 0; + }); + + const gridRef = useRef(null); + + useEffect(() => { + if (gridRef.current) { + const tile = gridRef.current.querySelector( + `[data-month="${focusedMonth}"]`, + ) as HTMLElement | null; + if (tile && document.activeElement !== tile) { + tile.focus({ preventScroll: true }); + } + } + }, [focusedMonth]); + + const handleKeyDown = (e: KeyboardEvent) => { + const cols = 3; + switch (e.key) { + case "ArrowRight": + e.preventDefault(); + setFocusedMonth((m) => Math.min(m + 1, 11)); + break; + case "ArrowLeft": + e.preventDefault(); + setFocusedMonth((m) => Math.max(m - 1, 0)); + break; + case "ArrowDown": + e.preventDefault(); + setFocusedMonth((m) => Math.min(m + cols, 11)); + break; + case "ArrowUp": + e.preventDefault(); + setFocusedMonth((m) => Math.max(m - cols, 0)); + break; + case "Home": + e.preventDefault(); + setFocusedMonth((m) => Math.floor(m / cols) * cols); + break; + case "End": + e.preventDefault(); + setFocusedMonth((m) => Math.min(Math.floor(m / cols) * cols + cols - 1, 11)); + break; + case "Enter": + case " ": + e.preventDefault(); + onMonthSelect(year, focusedMonth); + break; + default: + break; + } + }; + + const months = useMemo( + () => + Array.from({ length: 12 }, (_, i) => { + const monthReports = reports.filter((r) => { + const d = parseISODate(r.date); + return d.year === year && d.month === i; + }); + return { + month: i, + status: getMonthStatus(reports, year, i), + reportCount: monthReports.length, + }; + }), + [reports, year], + ); + + return ( +
    + {[0, 1, 2, 3].map((rowIdx) => ( +
    + {[0, 1, 2].map((colIdx) => { + const m = rowIdx * 3 + colIdx; + const item = months[m]; + const isCurrentMonth = today.getFullYear() === year && today.getMonth() === m; + const isSelected = selectedYear === year && selectedMonth === m; + return ( +
    + +
    + ); + })} +
    + ))} +
    + ); +}; + +/* ─── Quarter Grid View ────────────────────────────────────────────── */ + +interface QuarterTileProps { + year: number; + quarter: number; + status: ReportStatus; + isCurrent: boolean; + isSelected: boolean; + isFocused: boolean; + locale: string; + reportCount: number; + monthStatuses: ReportStatus[]; + onClick: (year: number, quarter: number) => void; + onFocus: (quarter: number) => void; +} + +const QuarterTile: React.FC = ({ + year, + quarter, + status, + isCurrent, + isSelected, + isFocused, + locale, + reportCount, + monthStatuses, + onClick, + onFocus, +}) => { + const label = `Q${quarter + 1} ${year}`; + const monthNames = [0, 1, 2].map((i) => + new Date(year, quarter * 3 + i).toLocaleDateString(locale as SupportedLocale, { + month: "short", + }), + ); + const statusLabel = REPORT_STATUS_LABELS[status]; + const ariaLabel = [ + label, + statusLabel !== "No report" ? `Status: ${statusLabel}.` : "No reports.", + reportCount > 0 ? `${reportCount} report${reportCount !== 1 ? "s" : ""}.` : "", + isCurrent ? "Current quarter." : "", + isSelected ? "Selected." : "", + ] + .filter(Boolean) + .join(" "); + + const tileClass = [ + "rc-quarter-tile", + isCurrent && "rc-quarter-tile--current", + isSelected && "rc-quarter-tile--selected", + status !== "none" && `rc-quarter-tile--${status}`, + ] + .filter(Boolean) + .join(" "); + + return ( + + ); +}; + +export interface QuarterGridViewProps { + year: number; + reports: RevenueReport[]; + selectedDate: string | undefined; + locale: string; + /** Drill into the first month of the quarter (or current month if in that quarter) */ + onQuarterSelect: (year: number, month: number) => void; +} + +export const QuarterGridView: React.FC = ({ + year, + reports, + selectedDate, + locale, + onQuarterSelect, +}) => { + const today = new Date(); + const selectedParsed = selectedDate ? parseISODate(selectedDate) : undefined; + const selectedQuarter = + selectedParsed && selectedParsed.year === year + ? Math.floor(selectedParsed.month / 3) + : undefined; + + const [focusedQuarter, setFocusedQuarter] = useState(() => { + if (selectedQuarter !== undefined) return selectedQuarter; + if (today.getFullYear() === year) return Math.floor(today.getMonth() / 3); + return 0; + }); + + const gridRef = useRef(null); + + useEffect(() => { + if (gridRef.current) { + const tile = gridRef.current.querySelector( + `[data-quarter="${focusedQuarter}"]`, + ) as HTMLElement | null; + if (tile && document.activeElement !== tile) { + tile.focus({ preventScroll: true }); + } + } + }, [focusedQuarter]); + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault(); + setFocusedQuarter((q) => Math.min(q + 1, 3)); + break; + case "ArrowLeft": + case "ArrowUp": + e.preventDefault(); + setFocusedQuarter((q) => Math.max(q - 1, 0)); + break; + case "Home": + e.preventDefault(); + setFocusedQuarter(0); + break; + case "End": + e.preventDefault(); + setFocusedQuarter(3); + break; + case "Enter": + case " ": { + e.preventDefault(); + const startMonth = focusedQuarter * 3; + const drillMonth = + today.getFullYear() === year && + Math.floor(today.getMonth() / 3) === focusedQuarter + ? today.getMonth() + : startMonth; + onQuarterSelect(year, drillMonth); + break; + } + default: + break; + } + }; + + const quarters = useMemo( + () => + Array.from({ length: 4 }, (_, q) => { + const start = q * 3; + const monthStatuses = [0, 1, 2].map((i) => + getMonthStatus(reports, year, start + i), + ); + const reportCount = reports.filter((r) => { + const d = parseISODate(r.date); + return d.year === year && d.month >= start && d.month < start + 3; + }).length; + return { + quarter: q, + status: getQuarterStatus(reports, year, q), + reportCount, + monthStatuses, + }; + }), + [reports, year], + ); + + return ( +
    +
    + {quarters.map((item) => { + const isCurrent = + today.getFullYear() === year && + Math.floor(today.getMonth() / 3) === item.quarter; + const isSelected = selectedQuarter === item.quarter; + return ( +
    + { + const startMonth = q * 3; + const drillMonth = + today.getFullYear() === y && Math.floor(today.getMonth() / 3) === q + ? today.getMonth() + : startMonth; + onQuarterSelect(y, drillMonth); + }} + onFocus={setFocusedQuarter} + /> +
    + ); + })} +
    +
    + ); +}; diff --git a/src/components/RevenueReportingCalendar.css b/src/components/RevenueReportingCalendar.css index 787cb37..9314576 100644 --- a/src/components/RevenueReportingCalendar.css +++ b/src/components/RevenueReportingCalendar.css @@ -1650,3 +1650,526 @@ margin: 0; } } +.rc-view-switcher { + display: inline-flex; + gap: var(--spacing-2xs, 0.25rem); + background: var(--color-background-muted, #f1f5f9); + border: 1px solid var(--color-border, #e2e8f0); + border-radius: var(--rc-radius-sm, 8px); + padding: var(--spacing-2xs, 0.25rem); + margin: 0 var(--spacing-md, 1rem) var(--spacing-md, 1rem); + align-self: flex-start; +} + +.rc-view-switcher-btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-2xs); + padding: var(--spacing-2xs) var(--spacing-sm); + border: none; + border-radius: calc(var(--rc-radius-sm) - 2px); + background: transparent; + color: var(--text-muted); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.rc-view-switcher-btn:hover { + color: var(--text-main); + background: rgba(148, 163, 184, 0.08); +} + +.rc-view-switcher-btn--active { + background: rgba(59, 130, 246, 0.15); + color: var(--primary); + font-weight: var(--font-weight-semibold); +} + +.rc-view-switcher-btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} + +/* ─── Year Grid ──────────────────────────────────────────────────────── */ + +.rc-year-grid { + display: table; + width: 100%; + border-collapse: separate; + border-spacing: var(--spacing-sm); + outline: none; + margin-bottom: var(--spacing-md); +} + +.rc-year-grid:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + border-radius: var(--rc-radius); +} + +.rc-year-grid-row { + display: table-row; +} + +/* ─── Month Tile ─────────────────────────────────────────────────────── */ + +.rc-year-month-tile { + display: table-cell; + width: calc(100% / 3); + vertical-align: middle; + position: relative; + padding: var(--spacing-sm) var(--spacing-xs); + border-radius: var(--rc-radius-sm); + border: 1px solid var(--glass-border); + background: transparent; + cursor: pointer; + text-align: center; + color: var(--text-main); + font: inherit; + transition: + background 0.15s ease, + border-color 0.15s ease, + box-shadow 0.15s ease; + min-height: 4.5rem; +} + +.rc-year-month-tile:hover { + background: rgba(148, 163, 184, 0.08); + border-color: var(--glass-border-bright); +} + +.rc-year-month-tile:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; + z-index: 2; + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2); +} + +/* Current month ring */ +.rc-year-month-tile--current { + font-weight: var(--font-weight-bold); + border-color: var(--primary); + box-shadow: 0 0 0 1px var(--primary); +} + +/* Selected tile */ +.rc-year-month-tile--selected { + background: rgba(59, 130, 246, 0.15); + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); +} + +/* Status-tinted backgrounds */ +.rc-year-month-tile--due { background: rgba(245, 158, 11, 0.07); } +.rc-year-month-tile--submitted { background: rgba(59, 130, 246, 0.07); } +.rc-year-month-tile--accepted { background: rgba(16, 185, 129, 0.07); } +.rc-year-month-tile--overdue { background: rgba(239, 68, 68, 0.07); } + +.rc-year-month-tile--due:hover { background: rgba(245, 158, 11, 0.13); } +.rc-year-month-tile--submitted:hover { background: rgba(59, 130, 246, 0.13); } +.rc-year-month-tile--accepted:hover { background: rgba(16, 185, 129, 0.13); } +.rc-year-month-tile--overdue:hover { background: rgba(239, 68, 68, 0.13); } + +/* Month name label */ +.rc-year-month-name { + display: block; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + line-height: 1.2; + margin-bottom: var(--spacing-2xs); +} + +/* Status glyph dot */ +.rc-year-month-glyph { + display: block; + width: 0.5rem; + height: 0.5rem; + border-radius: var(--radius-full); + margin: 0 auto var(--spacing-2xs); +} + +.rc-year-month-glyph--empty { + background: rgba(148, 163, 184, 0.2); +} + +/* Report count badge */ +.rc-year-month-count { + position: absolute; + top: 4px; + right: 5px; + font-size: 0.6rem; + font-weight: var(--font-weight-bold); + color: var(--text-accent); + background: rgba(56, 189, 248, 0.15); + border-radius: var(--radius-full); + padding: 0 0.2rem; + line-height: 1.4; + min-width: 0.875rem; + text-align: center; +} + +/* Mobile: 2-column grid to keep tiles readable */ +@media (max-width: 479px) { + .rc-year-grid { + border-spacing: var(--spacing-xs); + } + + .rc-year-month-tile { + min-height: 3.5rem; + padding: var(--spacing-xs) 2px; + } + + .rc-year-month-name { + font-size: var(--font-size-xs); + } +} + +/* RTL */ +[dir="rtl"] .rc-year-month-count { + right: auto; + left: 5px; +} + +/* High Contrast */ +@media (forced-colors: active) { + .rc-year-month-tile { + border: 1px solid ButtonText; + } + + .rc-year-month-tile--selected { + border: 2px solid Highlight; + } + + .rc-year-month-glyph { + forced-color-adjust: none; + } +} + +/* Reduced Motion */ +@media (prefers-reduced-motion: reduce) { + .rc-year-month-tile, + .rc-view-switcher-btn { + transition: none; + } +} + +/* Print */ +@media print { + .rc-view-switcher { + display: none; + } + + .rc-year-grid { + break-inside: avoid; + } + + .rc-year-month-tile { + border: 1px solid #ccc; + } +} + +/* ─── Quarter Grid (#424) ───────────────────────────────────────────── */ + +.rc-quarter-grid { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + width: 100%; + padding: 0 var(--spacing-md) var(--spacing-md); +} + +.rc-quarter-grid-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-sm); +} + +.rc-quarter-tile { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-sm); + padding: var(--spacing-md); + min-height: 6.5rem; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: var(--rc-radius-sm, 8px); + background: var(--color-background-surface, #fff); + cursor: pointer; + text-align: start; + color: var(--text-main, #0f172a); + transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.rc-quarter-tile:hover { + background: rgba(148, 163, 184, 0.08); +} + +.rc-quarter-tile:focus-visible { + outline: 2px solid var(--color-focus, #3b82f6); + outline-offset: 2px; +} + +.rc-quarter-tile--current { + border-color: var(--color-focus, #3b82f6); +} + +.rc-quarter-tile--selected { + box-shadow: inset 0 0 0 2px var(--color-focus, #3b82f6); +} + +.rc-quarter-tile--due { background: rgba(245, 158, 11, 0.07); } +.rc-quarter-tile--submitted { background: rgba(59, 130, 246, 0.07); } +.rc-quarter-tile--accepted { background: rgba(16, 185, 129, 0.07); } +.rc-quarter-tile--overdue { background: rgba(239, 68, 68, 0.07); } + +.rc-quarter-label { + font-size: var(--font-size-sm, 0.875rem); + font-weight: var(--font-weight-semibold, 600); +} + +.rc-quarter-months { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs, 0.35rem); +} + +.rc-quarter-month-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + color: var(--text-muted, #64748b); +} + +@media (max-width: 640px) { + .rc-quarter-grid-row { + grid-template-columns: 1fr; + } +} + +/* ─── Bulk confirm + mixed status (#426) ────────────────────────────── */ + +.rc-bulk-mixed { + display: inline-block; + margin-inline-start: 0.5rem; + padding: 0.1rem 0.45rem; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + color: #92400e; + background: rgba(245, 158, 11, 0.18); +} + +.rc-bulk-confirm { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.rc-bulk-confirm-copy { + max-width: 22rem; +} + +.rc-bulk-confirm-title { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-primary, #1e293b); +} + +.rc-bulk-confirm-desc { + margin: 0.25rem 0 0; + font-size: 0.75rem; + line-height: 1.4; + color: var(--color-text-secondary, #64748b); +} + +/* ─── Agenda sticky headers + swipe (#428) ──────────────────────────── */ + +.rc-agenda-group-title--sticky { + position: sticky; + top: 0; + z-index: 2; + background: var(--color-background-surface, #ffffff); + padding-block: var(--spacing-xs, 0.35rem); + border-bottom: 1px solid var(--color-border, #e2e8f0); +} + +.rc-agenda-group--month .rc-agenda-group-title { + color: var(--text-main, #0f172a); +} + +.rc-agenda-row-issuer { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-main, #0f172a); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.rc-agenda-swipe-shell { + position: relative; + overflow: hidden; + border-radius: var(--rc-radius-sm, 8px); +} + +.rc-agenda-swipe-actions { + position: absolute; + inset-block: 0; + inset-inline-end: 0; + display: flex; + align-items: stretch; + z-index: 0; +} + +.rc-agenda-swipe-btn { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2rem; + width: 4.5rem; + border: none; + color: #fff; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; +} + +.rc-agenda-swipe-btn--nudge { + background: #2563eb; +} + +.rc-agenda-swipe-btn--close { + background: #0f766e; +} + +.rc-agenda-swipe-shell .rc-agenda-row { + position: relative; + z-index: 1; + transition: transform 0.15s ease; + background: var(--color-background-surface, #ffffff); +} + +.rc-agenda-confirm { + margin-top: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 8px; + background: var(--color-background-muted, #f8fafc); +} + +.rc-agenda-confirm-title { + margin: 0; + font-size: 0.875rem; + font-weight: 600; +} + +.rc-agenda-confirm-desc { + margin: 0.25rem 0 0.75rem; + font-size: 0.75rem; + color: var(--text-muted, #64748b); +} + +.rc-agenda-confirm-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +[dir="rtl"] .rc-agenda-swipe-actions { + inset-inline-end: auto; + inset-inline-start: 0; +} + +@media (prefers-reduced-motion: reduce) { + .rc-agenda-swipe-shell .rc-agenda-row, + .rc-quarter-tile, + .rc-year-month-tile, + .rc-view-switcher-btn { + transition: none; + } +} +.rc-shortcuts-hint { + display: flex; + align-items: center; +} + +.rc-shortcuts-hint-text { + font-size: var(--font-size-xs); + color: var(--text-muted); + white-space: nowrap; +} + +.rc-shortcut-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.25rem; + font-family: inherit; + font-size: 0.6875rem; + font-weight: var(--font-weight-medium); + color: var(--text-muted); + background: var(--glass-bg-accent); + border: 1px solid var(--glass-border); + border-radius: var(--radius-xs); + white-space: nowrap; + line-height: 1; +} + +.rc-shortcuts-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: var(--radius-full); + background: var(--glass-bg-accent); + border: 1px solid var(--glass-border); + color: var(--text-muted); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + cursor: pointer; + transition: all 0.2s ease; +} + +.rc-shortcuts-btn:hover { + background: rgba(148, 163, 184, 0.15); + border-color: var(--glass-border-bright); + color: var(--text-main); +} + +.rc-shortcuts-btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +@media (max-width: 767px) { + .rc-shortcuts-hint { + display: none; + } +} + +@media print { + .rc-container { + padding: 0; + } + + .rc-calendar-section, + .rc-details-panel { + box-shadow: none; + border: 1px solid #ccc; + break-inside: avoid; + } + + .rc-overdue-badge { + border: 1px solid #333; diff --git a/src/components/RevenueReportingCalendar.test.tsx b/src/components/RevenueReportingCalendar.test.tsx index e934773..b05495b 100644 --- a/src/components/RevenueReportingCalendar.test.tsx +++ b/src/components/RevenueReportingCalendar.test.tsx @@ -149,6 +149,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); const grid = screen.getByRole('grid'); @@ -164,6 +165,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); // Sunday start: Sun, Mon, Tue, Wed, Thu, Fri, Sat @@ -178,6 +180,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); const dots = document.querySelectorAll('.rc-status-dot'); @@ -192,6 +195,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); // June 28 has 2 reports @@ -206,46 +210,52 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); - expect(screen.getByText('Due')).toBeInTheDocument(); - expect(screen.getByText('Submitted')).toBeInTheDocument(); - expect(screen.getByText('Accepted')).toBeInTheDocument(); - expect(screen.getByText('Overdue')).toBeInTheDocument(); + const legend = screen.getByLabelText('Status legend'); + expect(legend).toHaveTextContent('Due'); + expect(legend).toHaveTextContent('Submitted'); + expect(legend).toHaveTextContent('Accepted'); + expect(legend).toHaveTextContent('Overdue'); }); }); /* ─── Month Navigation ─────────────────────────────────────────── */ describe('Month navigation', () => { - it('navigates to previous month when left arrow is clicked', async () => { - const user = userEvent.setup(); - render( + function NavHarness({ onMonthChange }: { onMonthChange?: (m: string) => void }) { + const [month, setMonth] = React.useState('2026-06'); + return ( , + viewMonth={month} + onMonthChange={(m) => { + setMonth(m); + onMonthChange?.(m); + }} + /> ); + } + + it('navigates to previous month when left arrow is clicked', async () => { + const user = userEvent.setup(); + render(); const prevBtn = screen.getByLabelText(/previous month/i); await user.click(prevBtn); // Should now show May 2026 - expect(screen.getByText('May 2026')).toBeInTheDocument(); + expect(document.querySelector('.rc-month-title')).toHaveTextContent('May 2026'); }); it('navigates to next month when right arrow is clicked', async () => { const user = userEvent.setup(); - render( - , - ); + render(); const nextBtn = screen.getByLabelText(/next month/i); await user.click(nextBtn); // Should now show July 2026 - expect(screen.getByText('July 2026')).toBeInTheDocument(); + expect(document.querySelector('.rc-month-title')).toHaveTextContent('July 2026'); }); it('calls onMonthChange when month changes', async () => { @@ -257,6 +267,7 @@ describe('RevenueReportingCalendar', () => { locale="en-US" weekStartsOn={0} onMonthChange={onMonthChange} + viewMonth="2026-06" />, ); const nextBtn = screen.getByLabelText(/next month/i); @@ -275,10 +286,11 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); // Find the day cell for June 5 - const day5 = screen.getByLabelText(/June 5, 2026.*Accepted.*selected/); + const day5 = screen.getByLabelText(/June 5, 2026.*Accepted/); await user.click(day5); expect(day5).toHaveAttribute('aria-selected', 'true'); }); @@ -292,6 +304,7 @@ describe('RevenueReportingCalendar', () => { locale="en-US" weekStartsOn={0} onDateSelect={onDateSelect} + viewMonth="2026-06" />, ); const day5 = screen.getByLabelText(/June 5, 2026.*Accepted/); @@ -306,6 +319,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); const day5 = screen.getByLabelText(/June 5, 2026.*Accepted/); @@ -326,15 +340,14 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); - const grid = screen.getByRole('grid'); - grid.focus(); - // Focus should be on the first day cell - const firstCell = screen.getByLabelText(/June 2026.*Due/); - expect(firstCell).toHaveAttribute('tabIndex', '0'); + const day1 = screen.getByLabelText(/June 1, 2026/); + expect(day1).toHaveAttribute('tabIndex', '0'); + day1.focus(); - // Press ArrowRight + // Press ArrowRight — June 1 2026 is Monday, so next is June 2 await user.keyboard('{ArrowRight}'); const secondCell = screen.getByLabelText(/June 2, 2026/); expect(secondCell).toHaveAttribute('tabIndex', '0'); @@ -347,18 +360,16 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); - const grid = screen.getByRole('grid'); - grid.focus(); - - // Navigate to a cell in the middle of a row - await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); - // Press Home + // June 1 2026 is Monday — its row starts on Sunday May 31 + const day1 = screen.getByLabelText(/June 1, 2026/); + day1.focus(); + await user.keyboard('{ArrowRight}{ArrowRight}'); await user.keyboard('{Home}'); - // Should be at the first cell of the current row - const firstCell = screen.getByLabelText(/June 1, 2026/); - expect(firstCell).toHaveAttribute('tabIndex', '0'); + const rowStart = screen.getByLabelText(/May 31, 2026/); + expect(rowStart).toHaveAttribute('tabIndex', '0'); }); it('supports End key to go to end of row', async () => { @@ -368,17 +379,15 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); - const grid = screen.getByRole('grid'); - grid.focus(); - - // Navigate to start of row + const day1 = screen.getByLabelText(/June 1, 2026/); + day1.focus(); await user.keyboard('{Home}'); - // Press End await user.keyboard('{End}'); - // Should be at the last cell of the current row - const lastCell = screen.getByLabelText(/June 7, 2026/); + // Row ending Saturday June 6, 2026 + const lastCell = screen.getByLabelText(/June 6, 2026/); expect(lastCell).toHaveAttribute('tabIndex', '0'); }); @@ -391,6 +400,7 @@ describe('RevenueReportingCalendar', () => { locale="en-US" weekStartsOn={0} onDateSelect={onDateSelect} + viewMonth="2026-06" />, ); const grid = screen.getByRole('grid'); @@ -414,6 +424,7 @@ describe('RevenueReportingCalendar', () => { locale="en-US" weekStartsOn={0} onMonthChange={onMonthChange} + viewMonth="2026-06" />, ); const grid = screen.getByRole('grid'); @@ -433,6 +444,7 @@ describe('RevenueReportingCalendar', () => { locale="en-US" weekStartsOn={0} onMonthChange={onMonthChange} + viewMonth="2026-06" />, ); const grid = screen.getByRole('grid'); @@ -452,6 +464,7 @@ describe('RevenueReportingCalendar', () => { locale="en-US" weekStartsOn={0} onDateSelect={onDateSelect} + viewMonth="2026-06" />, ); const grid = screen.getByRole('grid'); @@ -472,10 +485,12 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); - expect(screen.getByText(/T/)).toBeInTheDocument(); - expect(screen.getByText(/\?/)).toBeInTheDocument(); + const hint = screen.getByLabelText('Keyboard shortcuts hint'); + expect(hint).toHaveTextContent('T'); + expect(hint).toHaveTextContent('?'); }); it('renders the shortcuts button when onOpenShortcuts is provided', () => { @@ -486,6 +501,7 @@ describe('RevenueReportingCalendar', () => { locale="en-US" weekStartsOn={0} onOpenShortcuts={onOpenShortcuts} + viewMonth="2026-06" />, ); expect(screen.getByLabelText('Keyboard shortcuts')).toBeInTheDocument(); @@ -495,18 +511,22 @@ describe('RevenueReportingCalendar', () => { /* ─── Details Panel ────────────────────────────────────────────── */ describe('Details panel', () => { - it('renders the details panel with month summary', () => { + it('renders the details panel with month summary', async () => { + const user = userEvent.setup(); render( , ); - // Month summary stats - expect(screen.getByText('3')).toBeInTheDocument(); // 3 due/overdue - expect(screen.getByText('2')).toBeInTheDocument(); // 2 submitted - expect(screen.getByText('1')).toBeInTheDocument(); // 1 accepted + const panel = screen.getByLabelText('Report details panel'); + await user.click(within(panel).getByRole('tab', { name: 'Month' })); + expect(within(panel).getByText('Due / Overdue')).toBeInTheDocument(); + expect(within(panel).getByText('3')).toBeInTheDocument(); + expect(within(panel).getByText('2')).toBeInTheDocument(); + expect(within(panel).getByText('1')).toBeInTheDocument(); }); it('shows day details when a date is selected', async () => { @@ -516,46 +536,44 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); const day5 = screen.getByLabelText(/June 5, 2026.*Accepted/); await user.click(day5); - // Should show report details for June 5 - expect(screen.getByText('$125,000')).toBeInTheDocument(); - expect(screen.getByText('Accepted')).toBeInTheDocument(); + const panel = screen.getByLabelText('Report details panel'); + expect(panel).toHaveTextContent(/125/) + expect(within(panel).getByText('Accepted')).toBeInTheDocument(); }); - it('shows Submit Report CTA for due reports', async () => { - const user = userEvent.setup(); + it('shows Submit Report CTA for due reports', () => { render( , ); - const day20 = screen.getByLabelText(/June 20, 2026.*Due/); - await user.click(day20); - - const submitBtn = screen.getByRole('button', { name: /submit report/i }); - expect(submitBtn).toBeInTheDocument(); + const panel = screen.getByLabelText('Report details panel'); + // Past due dates render as overdue with Submit Now + expect(within(panel).getByRole('button', { name: /submit (now|overdue|report)/i })).toBeInTheDocument(); }); - it('shows Submit Now CTA for overdue reports', async () => { - const user = userEvent.setup(); + it('shows Submit Now CTA for overdue reports', () => { render( , ); - const day25 = screen.getByLabelText(/June 25, 2026.*Overdue/); - await user.click(day25); - - const submitBtn = screen.getByRole('button', { name: /submit now/i }); - expect(submitBtn).toBeInTheDocument(); + const panel = screen.getByLabelText('Report details panel'); + expect(within(panel).getByRole('button', { name: /submit (now|overdue|report)/i })).toBeInTheDocument(); }); it('calls onSubmitReport when Submit Report is clicked', async () => { @@ -567,12 +585,12 @@ describe('RevenueReportingCalendar', () => { locale="en-US" weekStartsOn={0} onSubmitReport={onSubmitReport} + viewMonth="2026-06" + selectedDate="2026-06-20" />, ); - const day20 = screen.getByLabelText(/June 20, 2026.*Due/); - await user.click(day20); - - const submitBtn = screen.getByRole('button', { name: /submit report/i }); + const panel = screen.getByLabelText('Report details panel'); + const submitBtn = within(panel).getByRole('button', { name: /submit (now|overdue|report)/i }); await user.click(submitBtn); expect(onSubmitReport).toHaveBeenCalledWith('2026-06-20'); }); @@ -584,6 +602,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); // Click a date with no reports (e.g., June 15) @@ -602,6 +621,7 @@ describe('RevenueReportingCalendar', () => { locale="en-US" weekStartsOn={0} onSubmitReport={onSubmitReport} + viewMonth="2026-06" />, ); const day15 = screen.getByLabelText(/June 15, 2026.*No report/); @@ -624,15 +644,17 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); - const monthTab = screen.getByRole('tab', { name: 'Month' }); + const panel = screen.getByLabelText('Report details panel'); + const monthTab = within(panel).getByRole('tab', { name: 'Month' }); await user.click(monthTab); // Should show month summary - expect(screen.getByText('Due / Overdue')).toBeInTheDocument(); - expect(screen.getByText('Submitted')).toBeInTheDocument(); - expect(screen.getByText('Accepted')).toBeInTheDocument(); + expect(within(panel).getByText('Due / Overdue')).toBeInTheDocument(); + expect(within(panel).getByText('Submitted', { selector: '.rc-month-stat-label' })).toBeInTheDocument(); + expect(within(panel).getByText('Accepted', { selector: '.rc-month-stat-label' })).toBeInTheDocument(); }); it('shows quick submit CTA for pending reports in month view', async () => { @@ -642,6 +664,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); const monthTab = screen.getByRole('tab', { name: 'Month' }); @@ -661,6 +684,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); const grid = screen.getByRole('grid'); @@ -679,6 +703,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); const day5 = screen.getByLabelText(/June 5, 2026.*Accepted/); @@ -692,6 +717,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); const day5 = screen.getByLabelText(/June 5, 2026.*Accepted/); @@ -705,6 +731,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); expect(screen.getByLabelText(/previous month/i)).toBeInTheDocument(); @@ -717,6 +744,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); expect(screen.getByLabelText('Report details panel')).toBeInTheDocument(); @@ -728,9 +756,11 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); - expect(screen.getByRole('tablist', { name: /view mode/i })).toBeInTheDocument(); + expect(screen.getByRole('tablist', { name: /calendar view mode/i })).toBeInTheDocument(); + expect(screen.getByRole('tablist', { name: /calendar period scale/i })).toBeInTheDocument(); }); it('has correct aria-selected on tabs', async () => { @@ -740,6 +770,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); const dayTab = screen.getByRole('tab', { name: 'Day' }); @@ -759,10 +790,11 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); - const toggle = screen.getByLabelText(/show details panel/i); - expect(toggle).toHaveAttribute('aria-expanded', 'false'); + const toggle = screen.getByLabelText(/hide details panel/i); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); }); }); @@ -775,6 +807,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={1} + viewMonth="2026-06" />, ); // Monday start: Mon, Tue, Wed, Thu, Fri, Sat, Sun @@ -787,15 +820,19 @@ describe('RevenueReportingCalendar', () => { describe('Today highlighting', () => { it('highlights today with special styling', () => { + const now = new Date(); + const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; render( , ); - const todayCell = screen.getByLabelText(/today/i); - expect(todayCell).toHaveClass('rc-day-cell--today'); + const todayCell = document.querySelector('.rc-day-cell--today'); + expect(todayCell).toBeTruthy(); + expect(todayCell?.getAttribute('aria-label') ?? '').toMatch(/today/i); }); }); @@ -808,6 +845,7 @@ describe('RevenueReportingCalendar', () => { reports={baseReports} locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); // June 28 has 2 reports @@ -827,9 +865,10 @@ describe('RevenueReportingCalendar', () => { selectedDate="2026-06-12" locale="en-US" weekStartsOn={0} + viewMonth="2026-06" />, ); - const day12 = screen.getByLabelText(/June 12, 2026.*Submitted.*selected/); + const day12 = screen.getByLabelText(/June 12, 2026.*Submitted.*selected/i); expect(day12).toHaveAttribute('aria-selected', 'true'); }); @@ -842,7 +881,7 @@ describe('RevenueReportingCalendar', () => { weekStartsOn={0} />, ); - expect(screen.getByText('May 2026')).toBeInTheDocument(); + expect(document.querySelector('.rc-month-title')).toHaveTextContent('May 2026'); }); }); @@ -856,9 +895,154 @@ describe('RevenueReportingCalendar', () => { locale="en-US" weekStartsOn={0} className="my-custom-class" + viewMonth="2026-06" />, ); expect(container.firstChild).toHaveClass('my-custom-class'); }); }); + + /* ─── Year / Quarter period views (#424) ─────────────────────────── */ + + describe('Period scale switcher (year / quarter view)', () => { + it('switches to year overview with 12 month tiles', async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('tab', { name: 'Year view' })); + expect(screen.getByRole('grid', { name: /year overview for 2026/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/June 2026/i)).toBeInTheDocument(); + expect(document.querySelector('.rc-month-title')).toHaveTextContent('2026'); + }); + + it('drills from year tile back to month view', async () => { + const user = userEvent.setup(); + const onMonthChange = vi.fn(); + function Harness() { + const [month, setMonth] = React.useState('2026-06'); + return ( + { + setMonth(m); + onMonthChange(m); + }} + /> + ); + } + render(); + await user.click(screen.getByRole('tab', { name: 'Year view' })); + await user.click(screen.getByLabelText(/March 2026/i)); + expect(onMonthChange).toHaveBeenCalledWith('2026-03'); + expect(document.querySelector('.rc-month-title')).toHaveTextContent('March 2026'); + }); + + it('switches to quarter overview', async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('tab', { name: 'Quarter view' })); + expect(screen.getByRole('grid', { name: /quarter overview for 2026/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/Q2 2026/i)).toBeInTheDocument(); + }); + }); + + /* ─── Bulk select (#426) ────────────────────────────────────────── */ + + describe('Bulk select and close', () => { + it('shows floating action bar after multi-select via meta+click', async () => { + const user = userEvent.setup(); + render( + , + ); + const day5 = screen.getByLabelText(/June 5, 2026/i); + const day12 = screen.getByLabelText(/June 12, 2026/i); + await user.click(day5); + await user.keyboard('{Meta>}'); + await user.click(day12); + await user.keyboard('{/Meta}'); + expect(screen.getByRole('toolbar', { name: /bulk actions/i })).toBeInTheDocument(); + expect(screen.getByText(/2 periods selected/i)).toBeInTheDocument(); + }); + + it('confirms bulk close and registers undo', async () => { + const onBulkClose = vi.fn(); + const onBulkCloseUndo = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + expect(screen.getByRole('toolbar', { name: /bulk actions/i })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /close selected periods/i })); + expect(screen.getByRole('alertdialog')).toHaveTextContent(/close 2 selected periods/i); + await user.click(screen.getByRole('button', { name: /confirm close selected periods/i })); + expect(onBulkClose).toHaveBeenCalledWith(['2026-06-20', '2026-06-25']); + expect(screen.getByText(/closed 2 periods/i)).toBeInTheDocument(); + }); + }); + + /* ─── Mobile agenda (#428) ──────────────────────────────────────── */ + + describe('Mobile agenda view', () => { + it('renders agenda with sticky month headers and issuer labels', () => { + const reportsWithIssuer = baseReports.map((r, i) => ({ + ...r, + issuer: i % 2 === 0 ? 'Acme Holdings' : 'Very Long Issuer Name That Should Truncate Gracefully LLC', + })); + render( + , + ); + expect(screen.getByRole('tab', { name: 'Agenda view' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('list', { name: /revenue report agenda/i })).toBeInTheDocument(); + expect(document.querySelector('.rc-agenda-group-title--sticky')).toBeTruthy(); + expect(screen.getAllByText('Acme Holdings').length).toBeGreaterThan(0); + }); + + it('can toggle to calendar view on the mobile toggle', async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('tab', { name: 'Calendar view' })); + expect(screen.getByRole('tab', { name: 'Calendar view' })).toHaveAttribute('aria-selected', 'true'); + }); + }); }); diff --git a/src/components/RevenueReportingCalendar.tsx b/src/components/RevenueReportingCalendar.tsx index 01894c6..a96cba3 100644 --- a/src/components/RevenueReportingCalendar.tsx +++ b/src/components/RevenueReportingCalendar.tsx @@ -42,14 +42,14 @@ import { Minus, Download, Bell, + CheckSquare, + Columns3, } from "lucide-react"; import { formatDate, formatCurrency, SupportedLocale, - LOCALE_FORMAT_SETTINGS, } from "../constants/i18n"; -import { TERMINOLOGY } from "../constants/terminology"; import { Button } from "./Button"; import { RevenueReportingCalendarProps, @@ -62,8 +62,14 @@ import { OVERDUE_SEVERITY_COLORS, getOverdueDays, getOverdueSeverity, + CalendarPeriodView, + RevenueReport, } from './RevenueReportingCalendar.types'; import RevenueCalendarCsvImport from './RevenueCalendarCsvImport'; +import { YearGridView, QuarterGridView } from './RevenueCalendarPeriodViews'; +import { AgendaView } from './RevenueCalendarAgendaView'; +import { UndoBanner } from './UndoBanner/UndoBanner'; +import { useUndoBanners } from '../hooks/useUndoBanners'; import './RevenueReportingCalendar.css'; /* ─── Helpers ──────────────────────────────────────────────────────── */ @@ -81,10 +87,6 @@ function parseISODate(iso: string): { return { year: y, month: m - 1, day: d }; } -function isSameDay(a: string, b: string): boolean { - return a === b; -} - function isToday(iso: string): boolean { const today = new Date(); const d = parseISODate(iso); @@ -257,10 +259,15 @@ function OverdueBadge({ dueDate }: { dueDate: string }) { /** Returns true if user prefers reduced motion */ function usePrefersReducedMotion(): boolean { const [reduced, setReduced] = useState(() => { - if (typeof window === 'undefined') return false; + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }); useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); const handler = (e: MediaQueryListEvent) => setReduced(e.matches); mq.addEventListener('change', handler); @@ -582,6 +589,7 @@ const CalendarDayCell: React.FC = ({ const ariaLabel = [ `${dateFormatted}.`, + cell.isToday ? 'Today.' : '', cell.primaryStatus === 'overdue' ? `Report overdue. ${severityLabel}. ${overdueDays} day${overdueDays !== 1 ? 's' : ''} overdue.` : `${statusLabel}.`, @@ -660,6 +668,10 @@ interface CalendarGridComponentProps { ariaLabel: string; /** All reports — used to look up prior-period data for hover previews */ allReports: RevenueReport[]; + /** Jump focus/selection to today (T key) */ + onJumpToToday?: () => void; + /** Navigate to previous/next month (PageUp / PageDown) */ + onPageMonth?: (direction: -1 | 1) => void; } const CalendarGridComponent: React.FC = ({ @@ -672,6 +684,8 @@ const CalendarGridComponent: React.FC = ({ locale, ariaLabel, allReports, + onJumpToToday, + onPageMonth, }) => { const gridRef = useRef(null); const dayNames = useMemo(() => { @@ -762,52 +776,31 @@ const CalendarGridComponent: React.FC = ({ } case 'PageUp': { e.preventDefault(); - // Move to same day in previous month - const currentDay = days[currentIndex].day; - const currentMonth = parseISODate(days[currentIndex].date).month; - const currentYear = parseISODate(days[currentIndex].date).year; - let prevMonth = currentMonth - 1; - let prevYear = currentYear; - if (prevMonth < 0) { - prevMonth = 11; - prevYear--; - } - const daysInPrev = getDaysInMonth(prevYear, prevMonth); - const targetDay = Math.min(currentDay, daysInPrev); - const targetDate = toISODate(prevYear, prevMonth, targetDay); - const targetIndex = days.findIndex((d) => d.date === targetDate); - if (targetIndex !== -1) { - newIndex = targetIndex; - } - break; + onPageMonth?.(-1); + return; } case "PageDown": { e.preventDefault(); - // Move to same day in next month - const curDay = days[currentIndex].day; - const curMonth = parseISODate(days[currentIndex].date).month; - const curYear = parseISODate(days[currentIndex].date).year; - let nextMonth = curMonth + 1; - let nextYear = curYear; - if (nextMonth > 11) { - nextMonth = 0; - nextYear++; - } - const daysInNext = getDaysInMonth(nextYear, nextMonth); - const tDay = Math.min(curDay, daysInNext); - const tDate = toISODate(nextYear, nextMonth, tDay); - const tIndex = days.findIndex((d) => d.date === tDate); - if (tIndex !== -1) { - newIndex = tIndex; - } - break; + onPageMonth?.(1); + return; + } + case 't': + case 'T': { + e.preventDefault(); + onJumpToToday?.(); + return; } default: return; } if (newIndex !== currentIndex && newIndex >= 0 && newIndex < days.length) { - onFocusDate(days[newIndex].date); + const nextDate = days[newIndex].date; + onFocusDate(nextDate); + // Shift+Arrow extends the selection range (keyboard equivalent of Shift+Click) + if (e.shiftKey && ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) { + onDateSelect(nextDate, e); + } } }; @@ -981,7 +974,7 @@ const DetailsPanel: React.FC = ({ ); }; - const [monthLabel, yearStr] = viewMonth.split("-"); + const [yearStr, monthLabel] = viewMonth.split("-"); const monthName = new Date( Number(yearStr), Number(monthLabel) - 1, @@ -1138,7 +1131,11 @@ const DetailsPanel: React.FC = ({ interface BulkActionBarProps { selectedDates: string[]; reports: RevenueReport[]; - onClose: () => void; + confirmOpen: boolean; + onRequestClose: () => void; + onCancelConfirm: () => void; + onConfirmClose: () => void; + onClearSelection: () => void; onExport: () => void; onNudge: () => void; } @@ -1146,37 +1143,105 @@ interface BulkActionBarProps { const BulkActionBar: React.FC = ({ selectedDates, reports, - onClose, + confirmOpen, + onRequestClose, + onCancelConfirm, + onConfirmClose, + onClearSelection, onExport, onNudge, }) => { if (selectedDates.length <= 1) return null; - const selectedReports = reports.filter(r => selectedDates.includes(r.date)); - const canNudge = selectedReports.some(r => r.status === 'due' || r.status === 'overdue'); + const selectedReports = reports.filter((r) => selectedDates.includes(r.date)); + const canNudge = selectedReports.some( + (r) => r.status === "due" || r.status === "overdue", + ); + const closableCount = selectedReports.filter( + (r) => r.status === "due" || r.status === "overdue" || r.status === "submitted", + ).length; + const mixedStatuses = new Set(selectedReports.map((r) => r.status)).size > 1; return (
    - {selectedDates.length} period{selectedDates.length > 1 ? 's' : ''} selected + + {selectedDates.length} period{selectedDates.length > 1 ? "s" : ""} selected + + {mixedStatuses && ( + Mixed statuses + )}
    -
    - - - -
    +
    +

    + Close {closableCount || selectedDates.length} selected period + {(closableCount || selectedDates.length) > 1 ? "s" : ""}? +

    +

    + Closing marks due, overdue, and submitted periods as reconciled. + Accepted periods are left unchanged. You can undo from the banner + within a few seconds. +

    +
    +
    + + +
    +
    + ) : ( +
    + + + + +
    + )} ); }; @@ -1199,6 +1264,12 @@ export const RevenueReportingCalendar: React.FC< onMonthChange, onSubmitReport, onReportAction, + onBulkClose, + onBulkCloseUndo, + onBulkExport, + onBulkNudge, + initialPeriodView = "month", + onOpenShortcuts, className = "", }) => { // Determine current month from reports or use today @@ -1222,9 +1293,18 @@ export const RevenueReportingCalendar: React.FC< const [panelOpen, setPanelOpen] = useState(true); const [mobileView, setMobileView] = useState<"calendar" | "agenda">("agenda"); const [showImportWizard, setShowImportWizard] = useState(false); + const [periodView, setPeriodView] = useState(initialPeriodView); + const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false); + const { banners, registerUndo, undo, dismiss, undoAll, dismissAll } = + useUndoBanners(); const viewMonth = controlledViewMonth ?? internalViewMonth; - const selectedDates = controlledSelectedDates ?? internalSelectedDates; + // Prefer explicit multi-select; else mirror controlled single date; else internal + const selectedDates = + controlledSelectedDates ?? + (controlledSelectedDate !== undefined + ? [controlledSelectedDate] + : internalSelectedDates); // For backwards compatibility and single-date details panel const selectedDate = selectedDates.length === 1 ? selectedDates[0] : undefined; @@ -1234,6 +1314,16 @@ export const RevenueReportingCalendar: React.FC< return [y, m - 1]; }, [viewMonth]); + // Seed keyboard focus to the first in-month day (or selection) when unset + useEffect(() => { + if (focusedDate) return; + if (selectedDates.length > 0) { + setFocusedDate(selectedDates[selectedDates.length - 1]); + return; + } + setFocusedDate(toISODate(viewYear, viewMonthNum, 1)); + }, [focusedDate, selectedDates, viewYear, viewMonthNum]); + // Build day cells const dayCells = useMemo( () => @@ -1253,6 +1343,14 @@ export const RevenueReportingCalendar: React.FC< [reports, viewYear, viewMonthNum], ); + const setViewMonthStr = useCallback( + (newMonthStr: string) => { + setInternalViewMonth(newMonthStr); + onMonthChange?.(newMonthStr); + }, + [onMonthChange], + ); + // Navigation handlers const goToPrevMonth = useCallback(() => { let newMonth = viewMonthNum - 1; @@ -1261,10 +1359,8 @@ export const RevenueReportingCalendar: React.FC< newMonth = 11; newYear--; } - const newMonthStr = `${newYear}-${String(newMonth + 1).padStart(2, "0")}`; - setInternalViewMonth(newMonthStr); - onMonthChange?.(newMonthStr); - }, [viewMonthNum, viewYear, onMonthChange]); + setViewMonthStr(`${newYear}-${String(newMonth + 1).padStart(2, "0")}`); + }, [viewMonthNum, viewYear, setViewMonthStr]); const goToNextMonth = useCallback(() => { let newMonth = viewMonthNum + 1; @@ -1273,10 +1369,24 @@ export const RevenueReportingCalendar: React.FC< newMonth = 0; newYear++; } - const newMonthStr = `${newYear}-${String(newMonth + 1).padStart(2, "0")}`; - setInternalViewMonth(newMonthStr); - onMonthChange?.(newMonthStr); - }, [viewMonthNum, viewYear, onMonthChange]); + setViewMonthStr(`${newYear}-${String(newMonth + 1).padStart(2, "0")}`); + }, [viewMonthNum, viewYear, setViewMonthStr]); + + const goToPrevYear = useCallback(() => { + setViewMonthStr(`${viewYear - 1}-${String(viewMonthNum + 1).padStart(2, "0")}`); + }, [viewYear, viewMonthNum, setViewMonthStr]); + + const goToNextYear = useCallback(() => { + setViewMonthStr(`${viewYear + 1}-${String(viewMonthNum + 1).padStart(2, "0")}`); + }, [viewYear, viewMonthNum, setViewMonthStr]); + + const drillToMonth = useCallback( + (year: number, month: number) => { + setViewMonthStr(`${year}-${String(month + 1).padStart(2, "0")}`); + setPeriodView("month"); + }, + [setViewMonthStr], + ); const handleDateSelect = useCallback( (date: string, e?: MouseEvent | KeyboardEvent) => { @@ -1317,6 +1427,7 @@ export const RevenueReportingCalendar: React.FC< setInternalSelectedDates(newSelection); setLastSelectedDate(date); + setBulkConfirmOpen(false); onDateSelect?.(date); onDatesSelect?.(newSelection); @@ -1347,11 +1458,82 @@ export const RevenueReportingCalendar: React.FC< [onReportAction], ); - // Month label + const clearSelection = useCallback(() => { + setInternalSelectedDates([]); + setLastSelectedDate(undefined); + setBulkConfirmOpen(false); + onDatesSelect?.([]); + }, [onDatesSelect]); + + const performBulkClose = useCallback( + (dates: string[]) => { + if (dates.length === 0) return; + onBulkClose?.(dates); + registerUndo({ + message: + dates.length === 1 + ? `Closed period ${dates[0]}` + : `Closed ${dates.length} periods`, + onUndo: () => onBulkCloseUndo?.(dates), + }); + clearSelection(); + }, + [onBulkClose, onBulkCloseUndo, registerUndo, clearSelection], + ); + + const handleConfirmBulkClose = useCallback(() => { + performBulkClose(selectedDates); + setBulkConfirmOpen(false); + }, [performBulkClose, selectedDates]); + + const handleAgendaSwipeClose = useCallback( + (report: RevenueReport) => { + performBulkClose([report.date]); + }, + [performBulkClose], + ); + + const handleAgendaSwipeNudge = useCallback( + (report: RevenueReport) => { + onBulkNudge?.([report.date]); + }, + [onBulkNudge], + ); + + const handleJumpToToday = useCallback(() => { + const now = new Date(); + const todayStr = toISODate(now.getFullYear(), now.getMonth(), now.getDate()); + const targetMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + if (targetMonth !== viewMonth) { + setViewMonthStr(targetMonth); + } + setPeriodView("month"); + setFocusedDate(todayStr); + setInternalSelectedDates([todayStr]); + setLastSelectedDate(todayStr); + onDateSelect?.(todayStr); + onDatesSelect?.([todayStr]); + }, [viewMonth, setViewMonthStr, onDateSelect, onDatesSelect]); + + const handlePageMonth = useCallback( + (direction: -1 | 1) => { + if (direction < 0) goToPrevMonth(); + else goToNextMonth(); + }, + [goToPrevMonth, goToNextMonth], + ); + + // Month / year label const monthName = new Date(viewYear, viewMonthNum).toLocaleDateString( locale as SupportedLocale, { month: "long", year: "numeric" }, ); + const navTitle = + periodView === "year" + ? String(viewYear) + : periodView === "quarter" + ? `Q${Math.floor(viewMonthNum / 3) + 1} ${viewYear}` + : monthName; // Loading state if (isLoading) { @@ -1410,62 +1592,132 @@ export const RevenueReportingCalendar: React.FC< -

    {monthName}

    +

    {navTitle}

    - -
    - -
    - {/* Mobile view toggle (calendar/agenda */} + {/* Period scale switcher: Month / Quarter / Year (#424) */}
    + +
    + +
    +
    + + Press T for today ·  + ? for shortcuts + +
    +
    + {onOpenShortcuts && ( + + )} + +
    + {/* Mobile view toggle (calendar/agenda) — only meaningful in month scale */} + {periodView === "month" && ( +
    + + +
    + )} + {/* Legend */}
    @@ -1506,36 +1758,67 @@ export const RevenueReportingCalendar: React.FC<
    - {/* Calendar grid (shown on desktop, or when mobile view is calendar */} -
    - + +
    + )} + + {/* Quarter overview (#424) */} + {periodView === "quarter" && ( + - + )} - {/* Agenda view (shown when mobile view is agenda) */} -
    - -
    + )} + + {/* Agenda view (mobile) — Issue #428 */} + {periodView === "month" && ( +
    + handleDateSelect(date)} + onSubmitReport={handleSubmitReport} + viewMonth={viewMonth} + groupByMonth + onSwipeClose={handleAgendaSwipeClose} + onSwipeNudge={handleAgendaSwipeNudge} + /> +
    + )} {/* Details panel */} @@ -1573,24 +1856,30 @@ export const RevenueReportingCalendar: React.FC< )} - {/* Bulk Action Bar */} + {/* Bulk Action Bar (#426) */} { - setInternalSelectedDates([]); - setLastSelectedDate(undefined); - onDatesSelect?.([]); - }} + confirmOpen={bulkConfirmOpen} + onRequestClose={() => setBulkConfirmOpen(true)} + onCancelConfirm={() => setBulkConfirmOpen(false)} + onConfirmClose={handleConfirmBulkClose} + onClearSelection={clearSelection} onExport={() => { - console.log('Export selected:', selectedDates); - // Trigger actual export + onBulkExport?.(selectedDates); }} onNudge={() => { - console.log('Nudging owners for:', selectedDates); - // Trigger actual nudge + onBulkNudge?.(selectedDates); }} /> + + ); }; diff --git a/src/components/RevenueReportingCalendar.types.ts b/src/components/RevenueReportingCalendar.types.ts index 551d98c..fc02f90 100644 --- a/src/components/RevenueReportingCalendar.types.ts +++ b/src/components/RevenueReportingCalendar.types.ts @@ -77,8 +77,13 @@ export interface RevenueReport { locale?: string; /** Optional notes */ notes?: string; + /** Optional issuer / owner label (agenda rows, nudge copy) */ + issuer?: string; } +/** Calendar period scale for the month / quarter / year switcher (#424) */ +export type CalendarPeriodView = 'month' | 'quarter' | 'year'; + /* ─── Calendar Props ────────────────────────────────────────────────── */ export interface RevenueReportingCalendarProps { @@ -110,6 +115,20 @@ export interface RevenueReportingCalendarProps { onReportAction?: (reportId: string, action: string) => void; /** Callback to open keyboard shortcuts overlay */ onOpenShortcuts?: () => void; + /** + * Bulk-close selected periods (#426). Parent should apply the close; + * the calendar shows an undo banner and calls `onBulkCloseUndo` if provided + * when the user undoes within the window. + */ + onBulkClose?: (dates: string[]) => void; + /** Reverse a bulk-close when the undo banner is used */ + onBulkCloseUndo?: (dates: string[]) => void; + /** Bulk export selected periods */ + onBulkExport?: (dates: string[]) => void; + /** Bulk nudge owners for selected due/overdue periods */ + onBulkNudge?: (dates: string[]) => void; + /** Initial period view scale (month | quarter | year) */ + initialPeriodView?: CalendarPeriodView; /** Additional CSS class on root */ className?: string; }