From 05ff3006f436cc83214b2ff64d8422d24eb88c57 Mon Sep 17 00:00:00 2001 From: jasonMPM Date: Thu, 5 Mar 2026 15:19:38 -0600 Subject: [PATCH] Add files via upload --- .../src/components/Calendar/MainCalendar.jsx | 90 ++++++++++++-- .../components/FocusView/DeliverableCard.jsx | 112 ++++++++++++------ .../src/components/Projects/ProjectCard.jsx | 96 ++++++++++++--- frontend/src/components/UI/ContextMenu.jsx | 55 +++++++++ 4 files changed, 293 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/UI/ContextMenu.jsx diff --git a/frontend/src/components/Calendar/MainCalendar.jsx b/frontend/src/components/Calendar/MainCalendar.jsx index cb0d169..72df263 100644 --- a/frontend/src/components/Calendar/MainCalendar.jsx +++ b/frontend/src/components/Calendar/MainCalendar.jsx @@ -5,14 +5,17 @@ import timeGridPlugin from '@fullcalendar/timegrid' import interactionPlugin from '@fullcalendar/interaction' import useProjectStore from '../../store/useProjectStore' import useFocusStore from '../../store/useFocusStore' -import { updateDeliverable } from '../../api/deliverables' +import { updateDeliverable, deleteDeliverable } from '../../api/deliverables' import DeliverableModal from '../Deliverables/DeliverableModal' +import ContextMenu from '../UI/ContextMenu' export default function MainCalendar() { const calRef = useRef(null) - const { projects, updateDeliverable: storeUpdate } = useProjectStore() + const { projects, updateDeliverable: storeUpdate, removeDeliverable } = useProjectStore() const openFocus = useFocusStore(s => s.openFocus) - const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' }) + + const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' }) + const [contextMenu, setContextMenu] = useState(null) const events = projects.flatMap(p => (p.deliverables || []).map(d => ({ @@ -26,22 +29,78 @@ export default function MainCalendar() { })) ) - const handleEventDrop = useCallback(async ({ event }) => { - const { deliverableId } = event.extendedProps - storeUpdate(await updateDeliverable(deliverableId, { due_date: event.startStr.substring(0,10) })) - }, [storeUpdate]) + const getDeliverable = (projectId, deliverableId) => { + const p = projects.find(p => p.id === projectId) + return { project: p, deliverable: p?.deliverables.find(d => d.id === deliverableId) } + } + // Single click → Focus View const handleEventClick = useCallback(({ event }) => { const { deliverableId, projectId } = event.extendedProps openFocus(projectId, deliverableId) }, [openFocus]) - const handleDateClick = useCallback(({ dateStr }) => { - setModal({ open: true, deliverable: null, defaultDate: dateStr.substring(0,10) }) + // Drag-and-drop → patch date + const handleEventDrop = useCallback(async ({ event }) => { + const { deliverableId } = event.extendedProps + storeUpdate(await updateDeliverable(deliverableId, { due_date: event.startStr.substring(0, 10) })) + }, [storeUpdate]) + + // Click empty date → add deliverable + const handleDateClick = useCallback(({ dateStr }) => { + setModal({ open: true, deliverable: null, defaultDate: dateStr.substring(0, 10) }) }, []) + // Attach dblclick + contextmenu to each event element after mount + const handleEventDidMount = useCallback(({ event, el }) => { + const { deliverableId, projectId } = event.extendedProps + + // Double-click → open edit modal directly + el.addEventListener('dblclick', (e) => { + e.preventDefault() + e.stopPropagation() + const { deliverable } = getDeliverable(projectId, deliverableId) + if (deliverable) setModal({ open: true, deliverable, defaultDate: '' }) + }) + + // Right-click → context menu + el.addEventListener('contextmenu', (e) => { + e.preventDefault() + e.stopPropagation() + const { project, deliverable } = getDeliverable(projectId, deliverableId) + if (!deliverable) return + setContextMenu({ + x: e.clientX, y: e.clientY, + items: [ + { + icon: '✎', label: 'Edit Deliverable', + action: () => setModal({ open: true, deliverable, defaultDate: '' }), + }, + { + icon: '◎', label: 'Open Focus View', + action: () => openFocus(projectId, deliverableId), + }, + ...(project?.drive_url ? [{ + icon: '⬡', label: 'Open Drive Folder', + action: () => window.open(project.drive_url, '_blank'), + }] : []), + { separator: true }, + { + icon: '✕', label: 'Delete Deliverable', danger: true, + action: async () => { + if (window.confirm(`Delete "${deliverable.title}"?`)) { + await deleteDeliverable(deliverableId) + removeDeliverable(deliverableId) + } + }, + }, + ], + }) + }) + }, [projects, openFocus]) + return ( -
+
e.preventDefault()}>
+ setModal({ open: false, deliverable: null, defaultDate: '' })} deliverable={modal.deliverable} defaultDate={modal.defaultDate} /> + + {contextMenu && ( + setContextMenu(null)} + /> + )}
) } diff --git a/frontend/src/components/FocusView/DeliverableCard.jsx b/frontend/src/components/FocusView/DeliverableCard.jsx index 0554a49..10fb059 100644 --- a/frontend/src/components/FocusView/DeliverableCard.jsx +++ b/frontend/src/components/FocusView/DeliverableCard.jsx @@ -1,43 +1,89 @@ +import { useState } from 'react' import Badge from '../UI/Badge' import { formatDate } from '../../utils/dateHelpers' +import ContextMenu from '../UI/ContextMenu' +import { updateDeliverable, deleteDeliverable } from '../../api/deliverables' +import useProjectStore from '../../store/useProjectStore' +import { STATUS_OPTIONS } from '../../utils/statusHelpers' export default function DeliverableCard({ deliverable, isActive, index, projectColor, onSelect, onEdit }) { + const { updateDeliverable: storeUpdate, removeDeliverable } = useProjectStore() + const [ctxMenu, setCtxMenu] = useState(null) + + const handleContextMenu = (e) => { + e.preventDefault() + e.stopPropagation() + setCtxMenu({ + x: e.clientX, y: e.clientY, + items: [ + { icon: '✎', label: 'Edit Deliverable', highlight: true, action: () => onEdit(deliverable) }, + { separator: true }, + ...STATUS_OPTIONS.map(s => ({ + icon: s.value === deliverable.status ? '●' : '○', + label: `Mark ${s.label}`, + action: async () => { + storeUpdate(await updateDeliverable(deliverable.id, { status: s.value })) + }, + })), + { separator: true }, + { + icon: '✕', label: 'Delete Deliverable', danger: true, + action: async () => { + if (window.confirm(`Delete "${deliverable.title}"?`)) { + await deleteDeliverable(deliverable.id) + removeDeliverable(deliverable.id) + } + }, + }, + ], + }) + } + return ( -
onSelect(deliverable.id)} - className={`relative flex flex-col gap-2 rounded-xl border p-4 min-w-[190px] max-w-[230px] flex-shrink-0 cursor-pointer - transition-all duration-200 select-none mt-4 - ${isActive - ? 'border-gold bg-surface-elevated shadow-gold scale-105 ring-2 ring-gold/30' - : 'border-surface-border bg-surface hover:border-gold/40 hover:bg-surface-elevated/60' - }`} - > - {isActive && ( -
- Selected + <> +
onSelect(deliverable.id)} + onDoubleClick={(e) => { e.stopPropagation(); onEdit(deliverable) }} + onContextMenu={handleContextMenu} + title="Click: Select · Double-click: Edit · Right-click: Menu" + className={`relative flex flex-col gap-2 rounded-xl border p-4 min-w-[190px] max-w-[230px] flex-shrink-0 cursor-pointer + transition-all duration-200 select-none mt-4 + ${isActive + ? 'border-gold bg-surface-elevated shadow-gold scale-105 ring-2 ring-gold/30' + : 'border-surface-border bg-surface hover:border-gold/40 hover:bg-surface-elevated/60' + }`} + > + {isActive && ( +
+ Selected +
+ )} +
+
+ + Deliverable {index + 1} +
- )} -
-
- - Deliverable {index + 1} - +

+ {deliverable.title} +

+

+ {formatDate(deliverable.due_date)} +

+ + {isActive && ( + + )}
-

- {deliverable.title} -

-

- {formatDate(deliverable.due_date)} -

- - {isActive && ( - + + {ctxMenu && ( + setCtxMenu(null)} /> )} -
+ ) } diff --git a/frontend/src/components/Projects/ProjectCard.jsx b/frontend/src/components/Projects/ProjectCard.jsx index baa42d9..65d5d49 100644 --- a/frontend/src/components/Projects/ProjectCard.jsx +++ b/frontend/src/components/Projects/ProjectCard.jsx @@ -1,6 +1,11 @@ +import { useState } from 'react' import Badge from '../UI/Badge' import { formatDate } from '../../utils/dateHelpers' import useFocusStore from '../../store/useFocusStore' +import useProjectStore from '../../store/useProjectStore' +import { deleteDeliverable } from '../../api/deliverables' +import DeliverableModal from '../Deliverables/DeliverableModal' +import ContextMenu from '../UI/ContextMenu' function DriveIcon() { return ( @@ -16,35 +21,74 @@ function DriveIcon() { } export default function ProjectCard({ project, onEdit, onDelete }) { - const openFocus = useFocusStore(s => s.openFocus) + const openFocus = useFocusStore(s => s.openFocus) + const { removeDeliverable } = useProjectStore() + const [delModal, setDelModal] = useState({ open: false, deliverable: null }) + const [ctxMenu, setCtxMenu] = useState(null) + + const openDelEdit = (d) => setDelModal({ open: true, deliverable: d }) + + const handleRowCtx = (e, d) => { + e.preventDefault() + e.stopPropagation() + setCtxMenu({ + x: e.clientX, y: e.clientY, + items: [ + { icon: '✎', label: 'Edit Deliverable', action: () => openDelEdit(d) }, + { icon: '◎', label: 'Open Focus View', action: () => openFocus(project.id, d.id) }, + { separator: true }, + { + icon: '✕', label: 'Delete Deliverable', danger: true, + action: async () => { + if (window.confirm(`Delete "${d.title}"?`)) { + await deleteDeliverable(d.id) + removeDeliverable(d.id) + } + }, + }, + ], + }) + } + + const handleHeaderCtx = (e) => { + e.preventDefault() + setCtxMenu({ + x: e.clientX, y: e.clientY, + items: [ + { icon: '✎', label: 'Edit Project', action: () => onEdit(project) }, + ...(project.drive_url ? [{ icon: '⬡', label: 'Open Drive', action: () => window.open(project.drive_url, '_blank') }] : []), + { separator: true }, + { icon: '✕', label: 'Delete Project', danger: true, action: () => onDelete(project) }, + ], + }) + } return (
- {/* Header row */} -
+ {/* Header — double-click to edit, right-click for menu */} +
onEdit(project)} + onContextMenu={handleHeaderCtx} + title="Double-click to edit project" + >
{project.name}
{project.drive_url && ( - e.stopPropagation()} - className="flex items-center gap-1 text-[10px] text-text-muted hover:text-text-primary bg-surface hover:bg-surface-border/40 border border-surface-border hover:border-gold/30 rounded px-1.5 py-1 transition-all mr-1" - > - - Drive + e.stopPropagation()} + className="flex items-center gap-1 text-[10px] text-text-muted hover:text-text-primary bg-surface hover:bg-surface-border/40 border border-surface-border hover:border-gold/30 rounded px-1.5 py-1 transition-all mr-1"> + Drive )} - - + +
@@ -55,8 +99,14 @@ export default function ProjectCard({ project, onEdit, onDelete }) { {/* Deliverable rows */}
{(project.deliverables || []).map(d => ( -
+ + {/* Local deliverable edit modal */} + setDelModal({ open: false, deliverable: null })} + deliverable={delModal.deliverable} + projectId={project.id} + /> + + {ctxMenu && ( + setCtxMenu(null)} /> + )}
) } diff --git a/frontend/src/components/UI/ContextMenu.jsx b/frontend/src/components/UI/ContextMenu.jsx new file mode 100644 index 0000000..fe97c5a --- /dev/null +++ b/frontend/src/components/UI/ContextMenu.jsx @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react' + +export default function ContextMenu({ x, y, items, onClose }) { + const ref = useRef(null) + + useEffect(() => { + const onMouseDown = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() } + const onKey = (e) => { if (e.key === 'Escape') onClose() } + document.addEventListener('mousedown', onMouseDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onMouseDown) + document.removeEventListener('keydown', onKey) + } + }, [onClose]) + + // Keep menu inside viewport + const W = 192 + const H = items.length * 34 + const adjX = Math.min(x, window.innerWidth - W - 8) + const adjY = Math.min(y, window.innerHeight - H - 8) + + return ( +
+ {items.map((item, i) => + item.separator ? ( +
+ ) : ( + + ) + )} +
+ ) +}