- Delete DynamicReport* components and pivot types (PivotConfig, PivotResult, PivotFieldId, etc.) - Remove getDynamicReportData/getDynamicFilterValues from reportService - Strip pivotConfig/pivotResult from useReports hook and ReportsPage - Drop "dynamic" from ReportTab union - Remove reports.pivot.* and reports.dynamic i18n keys in FR and EN - Add skeletons for /reports/highlights, /trends, /compare, /category pages - Register the 4 new sub-routes in App.tsx - Add reports.hub, reports.viewMode, reports.empty, common.underConstruction keys - New shared ContextMenu component with click-outside + Escape handling - Refactor ChartContextMenu to compose generic ContextMenu - New ViewModeToggle with localStorage persistence via storageKey - New Sparkline (Recharts LineChart) for compact trends - Unit tests for readViewMode helper Fixes #69 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
77 lines
2.2 KiB
TypeScript
77 lines
2.2 KiB
TypeScript
import { useEffect, useRef, type ReactNode } from "react";
|
|
|
|
export interface ContextMenuItem {
|
|
icon?: ReactNode;
|
|
label: string;
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface ContextMenuProps {
|
|
x: number;
|
|
y: number;
|
|
header?: ReactNode;
|
|
items: ContextMenuItem[];
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function ContextMenu({ x, y, header, items, onClose }: ContextMenuProps) {
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
onClose();
|
|
}
|
|
}
|
|
function handleEscape(e: KeyboardEvent) {
|
|
if (e.key === "Escape") onClose();
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
document.addEventListener("keydown", handleEscape);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
document.removeEventListener("keydown", handleEscape);
|
|
};
|
|
}, [onClose]);
|
|
|
|
useEffect(() => {
|
|
if (!menuRef.current) return;
|
|
const rect = menuRef.current.getBoundingClientRect();
|
|
if (rect.right > window.innerWidth) {
|
|
menuRef.current.style.left = `${x - rect.width}px`;
|
|
}
|
|
if (rect.bottom > window.innerHeight) {
|
|
menuRef.current.style.top = `${y - rect.height}px`;
|
|
}
|
|
}, [x, y]);
|
|
|
|
return (
|
|
<div
|
|
ref={menuRef}
|
|
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
|
|
style={{ left: x, top: y }}
|
|
>
|
|
{header && (
|
|
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
|
|
{header}
|
|
</div>
|
|
)}
|
|
{items.map((item, i) => (
|
|
<button
|
|
key={i}
|
|
disabled={item.disabled}
|
|
onClick={() => {
|
|
if (item.disabled) return;
|
|
item.onClick();
|
|
onClose();
|
|
}}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{item.icon}
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|