Add files via upload
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import ProjectList from './components/Projects/ProjectList'
|
||||
import MainCalendar from './components/Calendar/MainCalendar'
|
||||
import FocusDrawer from './components/FocusView/FocusDrawer'
|
||||
import ToastContainer from './components/UI/Toast'
|
||||
import { fetchProjects } from './api/projects'
|
||||
import useProjectStore from './store/useProjectStore'
|
||||
import useUIStore from './store/useUIStore'
|
||||
|
||||
export default function App() {
|
||||
const { setProjects, setLoading } = useProjectStore()
|
||||
const { sidebarOpen, toggleSidebar } = useUIStore()
|
||||
const calApiRef = useRef(null)
|
||||
const newProjectFn = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
@@ -15,15 +20,40 @@ export default function App() {
|
||||
.catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return
|
||||
if (e.key === 'n' || e.key === 'N') { e.preventDefault(); newProjectFn.current?.() }
|
||||
if (e.key === 'b' || e.key === 'B') toggleSidebar()
|
||||
if (e.key === 'ArrowLeft' && calApiRef.current) calApiRef.current.prev()
|
||||
if (e.key === 'ArrowRight' && calApiRef.current) calApiRef.current.next()
|
||||
if ((e.key === 't' || e.key === 'T') && calApiRef.current) calApiRef.current.today()
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [toggleSidebar])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-surface overflow-hidden">
|
||||
<aside className="w-72 flex-shrink-0 bg-surface-raised border-r border-surface-border flex flex-col h-full">
|
||||
<ProjectList />
|
||||
{/* Sidebar */}
|
||||
<aside className={`flex-shrink-0 bg-surface-raised border-r border-surface-border flex flex-col h-full transition-[width] duration-300 overflow-hidden ${sidebarOpen ? 'w-72' : 'w-0'}`}>
|
||||
<ProjectList onRegisterNewProject={fn => { newProjectFn.current = fn }} />
|
||||
</aside>
|
||||
|
||||
{/* Sidebar toggle tab */}
|
||||
<button onClick={toggleSidebar} title={`${sidebarOpen ? 'Collapse' : 'Expand'} sidebar [B]`}
|
||||
className={`absolute top-4 z-30 bg-surface-elevated border border-surface-border hover:border-gold/50 text-text-muted hover:text-gold rounded-r-lg px-1 py-3 transition-all duration-300 text-xs ${sidebarOpen ? 'left-72' : 'left-0'}`}>
|
||||
{sidebarOpen ? '◀' : '▶'}
|
||||
</button>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<MainCalendar />
|
||||
<MainCalendar onCalendarReady={api => { calApiRef.current = api }} />
|
||||
</main>
|
||||
|
||||
<FocusDrawer />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
81
frontend/src/components/Calendar/AgendaPanel.jsx
Normal file
81
frontend/src/components/Calendar/AgendaPanel.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react'
|
||||
import { format, parseISO, isAfter, isBefore, addDays, startOfDay, isToday } from 'date-fns'
|
||||
import useProjectStore from '../../store/useProjectStore'
|
||||
import useFocusStore from '../../store/useFocusStore'
|
||||
import Badge from '../UI/Badge'
|
||||
|
||||
export default function AgendaPanel() {
|
||||
const projects = useProjectStore(s => s.projects)
|
||||
const openFocus = useFocusStore(s => s.openFocus)
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const today = startOfDay(new Date())
|
||||
const cutoff = addDays(today, 60)
|
||||
const items = []
|
||||
|
||||
projects.forEach(p => {
|
||||
(p.deliverables || []).forEach(d => {
|
||||
const dt = parseISO(d.due_date)
|
||||
if (!isBefore(dt, today) && !isAfter(dt, cutoff)) {
|
||||
items.push({ ...d, projectName: p.name, projectColor: p.color, projectId: p.id })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
items.sort((a, b) => new Date(a.due_date) - new Date(b.due_date))
|
||||
|
||||
const map = {}
|
||||
items.forEach(item => {
|
||||
if (!map[item.due_date]) map[item.due_date] = []
|
||||
map[item.due_date].push(item)
|
||||
})
|
||||
return map
|
||||
}, [projects])
|
||||
|
||||
const dates = Object.keys(grouped).sort()
|
||||
|
||||
if (dates.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<div className="opacity-20 mb-3">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="6" y="10" width="36" height="32" rx="3" stroke="#C9A84C" strokeWidth="1.5"/>
|
||||
<line x1="6" y1="18" x2="42" y2="18" stroke="#C9A84C" strokeWidth="1.5"/>
|
||||
<circle cx="15" cy="6" r="3" stroke="#C9A84C" strokeWidth="1.5"/>
|
||||
<circle cx="33" cy="6" r="3" stroke="#C9A84C" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-muted text-sm">All clear for 60 days</p>
|
||||
<p className="text-text-muted/50 text-xs mt-1">No upcoming deliverables</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col pb-4">
|
||||
<p className="text-[10px] text-text-muted/50 uppercase tracking-widest px-4 py-2 border-b border-surface-border">
|
||||
Next 60 days · {dates.length} due date{dates.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
{dates.map(date => (
|
||||
<div key={date}>
|
||||
<div className="sticky top-0 bg-surface-raised px-4 py-1.5 border-b border-surface-border/50">
|
||||
<span className={`text-[11px] font-bold uppercase tracking-widest ${isToday(parseISO(date)) ? 'text-gold' : 'text-text-muted/70'}`}>
|
||||
{isToday(parseISO(date)) ? 'Today' : format(parseISO(date), 'EEE, MMM d')}
|
||||
</span>
|
||||
</div>
|
||||
{grouped[date].map(d => (
|
||||
<button key={d.id} onClick={() => openFocus(d.projectId, d.id)}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2 hover:bg-surface-border/20 transition-colors text-left group">
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: d.projectColor }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-text-primary group-hover:text-gold transition-colors truncate">{d.title}</p>
|
||||
<p className="text-[10px] text-text-muted/60 truncate">{d.projectName}</p>
|
||||
</div>
|
||||
<Badge status={d.status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
frontend/src/components/Calendar/EventTooltip.jsx
Normal file
23
frontend/src/components/Calendar/EventTooltip.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Badge from '../UI/Badge'
|
||||
import { formatDate } from '../../utils/dateHelpers'
|
||||
|
||||
export default function EventTooltip({ tooltip }) {
|
||||
if (!tooltip) return null
|
||||
const { x, y, project, deliverable } = tooltip
|
||||
const tx = Math.min(x + 14, window.innerWidth - 230)
|
||||
const ty = Math.max(y - 90, 8)
|
||||
return (
|
||||
<div className="fixed z-[150] pointer-events-none bg-surface-elevated border border-gold/20 rounded-xl shadow-gold px-3.5 py-3 min-w-[190px] max-w-[240px]"
|
||||
style={{ left: tx, top: ty }}>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: project?.color }} />
|
||||
<span className="text-gold text-xs font-semibold truncate">{project?.name}</span>
|
||||
</div>
|
||||
<p className="text-text-primary text-xs font-medium leading-snug mb-1.5">{deliverable?.title}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-muted/70 text-[10px] font-mono">{formatDate(deliverable?.due_date)}</span>
|
||||
<Badge status={deliverable?.status} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
import { useRef, useState, useCallback } from 'react'
|
||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||
import FullCalendar from '@fullcalendar/react'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import useProjectStore from '../../store/useProjectStore'
|
||||
import useFocusStore from '../../store/useFocusStore'
|
||||
import useUIStore from '../../store/useUIStore'
|
||||
import useToastStore from '../../store/useToastStore'
|
||||
import { updateDeliverable, deleteDeliverable } from '../../api/deliverables'
|
||||
import DeliverableModal from '../Deliverables/DeliverableModal'
|
||||
import ContextMenu from '../UI/ContextMenu'
|
||||
import EventTooltip from './EventTooltip'
|
||||
import WorkloadHeatmap from './WorkloadHeatmap'
|
||||
|
||||
export default function MainCalendar() {
|
||||
export default function MainCalendar({ onCalendarReady }) {
|
||||
const calRef = useRef(null)
|
||||
const { projects, updateDeliverable: storeUpdate, removeDeliverable } = useProjectStore()
|
||||
const openFocus = useFocusStore(s => s.openFocus)
|
||||
const { showHeatmap, toggleHeatmap } = useUIStore()
|
||||
const addToast = useToastStore(s => s.addToast)
|
||||
|
||||
const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' })
|
||||
const [contextMenu, setContextMenu] = useState(null)
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
|
||||
// Expose calendar API to App.jsx for keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (calRef.current && onCalendarReady) {
|
||||
onCalendarReady(calRef.current.getApi())
|
||||
}
|
||||
}, [])
|
||||
|
||||
const events = projects.flatMap(p =>
|
||||
(p.deliverables || []).map(d => ({
|
||||
@@ -29,70 +43,80 @@ export default function MainCalendar() {
|
||||
}))
|
||||
)
|
||||
|
||||
const getDeliverable = (projectId, deliverableId) => {
|
||||
const p = projects.find(p => p.id === projectId)
|
||||
return { project: p, deliverable: p?.deliverables.find(d => d.id === deliverableId) }
|
||||
const getCtx = (projectId, deliverableId) => {
|
||||
const project = projects.find(p => p.id === projectId)
|
||||
const deliverable = project?.deliverables.find(d => d.id === deliverableId)
|
||||
return { project, deliverable }
|
||||
}
|
||||
|
||||
// Single click → Focus View
|
||||
const handleEventClick = useCallback(({ event }) => {
|
||||
const { deliverableId, projectId } = event.extendedProps
|
||||
openFocus(projectId, deliverableId)
|
||||
}, [openFocus])
|
||||
|
||||
// Drag-and-drop → patch date
|
||||
const handleEventDrop = useCallback(async ({ event }) => {
|
||||
// Drag-and-drop with 30-second undo toast
|
||||
const handleEventDrop = useCallback(async ({ event, oldEvent }) => {
|
||||
const { deliverableId } = event.extendedProps
|
||||
storeUpdate(await updateDeliverable(deliverableId, { due_date: event.startStr.substring(0, 10) }))
|
||||
}, [storeUpdate])
|
||||
const newDate = event.startStr.substring(0, 10)
|
||||
const oldDate = oldEvent.startStr.substring(0, 10)
|
||||
storeUpdate(await updateDeliverable(deliverableId, { due_date: newDate }))
|
||||
addToast({
|
||||
message: `Moved to ${newDate}`,
|
||||
duration: 30,
|
||||
undoFn: async () => {
|
||||
storeUpdate(await updateDeliverable(deliverableId, { due_date: oldDate }))
|
||||
},
|
||||
})
|
||||
}, [storeUpdate, addToast])
|
||||
|
||||
// Click empty date → add deliverable
|
||||
// Click empty date — open add modal
|
||||
const handleDateClick = useCallback(({ dateStr }) => {
|
||||
setModal({ open: true, deliverable: null, defaultDate: dateStr.substring(0, 10) })
|
||||
}, [])
|
||||
|
||||
// Attach dblclick + contextmenu to each event element after mount
|
||||
// Date range drag-select — pre-fill modal with start date
|
||||
const handleSelect = useCallback(({ startStr }) => {
|
||||
setModal({ open: true, deliverable: null, defaultDate: startStr.substring(0, 10) })
|
||||
}, [])
|
||||
|
||||
// Attach dblclick + contextmenu + tooltip via eventDidMount
|
||||
const handleEventDidMount = useCallback(({ event, el }) => {
|
||||
const { deliverableId, projectId } = event.extendedProps
|
||||
|
||||
// Double-click → open edit modal directly
|
||||
el.addEventListener('mouseenter', (e) => {
|
||||
const { project, deliverable } = getCtx(projectId, deliverableId)
|
||||
setTooltip({ x: e.clientX, y: e.clientY, project, deliverable })
|
||||
})
|
||||
el.addEventListener('mouseleave', () => setTooltip(null))
|
||||
el.addEventListener('mousemove', (e) => {
|
||||
setTooltip(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : null)
|
||||
})
|
||||
|
||||
el.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const { deliverable } = getDeliverable(projectId, deliverableId)
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
setTooltip(null)
|
||||
const { deliverable } = getCtx(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)
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
setTooltip(null)
|
||||
const { project, deliverable } = getCtx(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'),
|
||||
}] : []),
|
||||
{ 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,
|
||||
{ icon: '✕', label: 'Delete Deliverable', danger: true,
|
||||
action: async () => {
|
||||
if (window.confirm(`Delete "${deliverable.title}"?`)) {
|
||||
await deleteDeliverable(deliverableId)
|
||||
removeDeliverable(deliverableId)
|
||||
await deleteDeliverable(deliverableId); removeDeliverable(deliverableId)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -100,8 +124,25 @@ export default function MainCalendar() {
|
||||
}, [projects, openFocus])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-surface p-4" onContextMenu={e => e.preventDefault()}>
|
||||
<div className="h-full flex flex-col bg-surface" onContextMenu={e => e.preventDefault()}>
|
||||
{/* View toggle toolbar */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 pt-3 pb-0 flex-shrink-0">
|
||||
<button onClick={toggleHeatmap}
|
||||
className={`text-xs px-3 py-1.5 rounded-lg border transition-all font-medium
|
||||
${showHeatmap
|
||||
? 'bg-gold text-surface border-gold'
|
||||
: 'bg-surface-elevated border-surface-border text-text-muted hover:border-gold/40 hover:text-gold'
|
||||
}`}>
|
||||
{showHeatmap ? '← Calendar' : '⬡ Heatmap'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{showHeatmap ? (
|
||||
<WorkloadHeatmap />
|
||||
) : (
|
||||
<div className="h-full p-4 pt-2">
|
||||
<FullCalendar
|
||||
ref={calRef}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
@@ -109,9 +150,12 @@ export default function MainCalendar() {
|
||||
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
|
||||
events={events}
|
||||
editable={true}
|
||||
selectable={true}
|
||||
weekNumbers={true}
|
||||
eventDrop={handleEventDrop}
|
||||
eventClick={handleEventClick}
|
||||
dateClick={handleDateClick}
|
||||
select={handleSelect}
|
||||
eventDidMount={handleEventDidMount}
|
||||
height="100%"
|
||||
dayMaxEvents={4}
|
||||
@@ -119,6 +163,8 @@ export default function MainCalendar() {
|
||||
displayEventTime={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DeliverableModal
|
||||
isOpen={modal.open}
|
||||
@@ -128,13 +174,10 @@ export default function MainCalendar() {
|
||||
/>
|
||||
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
items={contextMenu.items}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
<ContextMenu x={contextMenu.x} y={contextMenu.y} items={contextMenu.items} onClose={() => setContextMenu(null)} />
|
||||
)}
|
||||
|
||||
<EventTooltip tooltip={tooltip} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
166
frontend/src/components/Calendar/WorkloadHeatmap.jsx
Normal file
166
frontend/src/components/Calendar/WorkloadHeatmap.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { format, startOfWeek, addDays, addWeeks, isSameDay, parseISO, isToday } from 'date-fns'
|
||||
import useProjectStore from '../../store/useProjectStore'
|
||||
import useFocusStore from '../../store/useFocusStore'
|
||||
import Badge from '../UI/Badge'
|
||||
|
||||
const WEEKS = 20
|
||||
const DAY_INIT = ['M','T','W','T','F','S','S']
|
||||
|
||||
function getCellStyle(count) {
|
||||
if (count === 0) return 'bg-surface border-surface-border'
|
||||
if (count === 1) return 'bg-gold/25 border-gold/40'
|
||||
if (count === 2) return 'bg-gold/55 border-gold/70'
|
||||
return 'bg-gold border-gold shadow-gold'
|
||||
}
|
||||
|
||||
export default function WorkloadHeatmap() {
|
||||
const projects = useProjectStore(s => s.projects)
|
||||
const openFocus = useFocusStore(s => s.openFocus)
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
|
||||
const { weeks, stats } = useMemo(() => {
|
||||
const start = startOfWeek(addWeeks(new Date(), -10), { weekStartsOn: 1 })
|
||||
const map = {}
|
||||
projects.forEach(p => {
|
||||
(p.deliverables || []).forEach(d => {
|
||||
if (!map[d.due_date]) map[d.due_date] = []
|
||||
map[d.due_date].push({ deliverable: d, project: p })
|
||||
})
|
||||
})
|
||||
|
||||
const grid = Array.from({ length: WEEKS }, (_, wi) =>
|
||||
Array.from({ length: 7 }, (_, di) => {
|
||||
const date = addDays(start, wi * 7 + di)
|
||||
const key = format(date, 'yyyy-MM-dd')
|
||||
return { date, key, items: map[key] || [] }
|
||||
})
|
||||
)
|
||||
|
||||
const all = projects.flatMap(p => p.deliverables || [])
|
||||
const stats = {
|
||||
total: all.length,
|
||||
overdue: all.filter(d => d.status === 'overdue').length,
|
||||
in_progress: all.filter(d => d.status === 'in_progress').length,
|
||||
completed: all.filter(d => d.status === 'completed').length,
|
||||
upcoming: all.filter(d => d.status === 'upcoming').length,
|
||||
}
|
||||
return { weeks: grid, stats }
|
||||
}, [projects])
|
||||
|
||||
const monthLabels = useMemo(() => {
|
||||
const labels = []; let last = -1
|
||||
weeks.forEach((week, wi) => {
|
||||
const m = week[0].date.getMonth()
|
||||
if (m !== last) { labels.push({ wi, label: format(week[0].date, 'MMM') }); last = m }
|
||||
})
|
||||
return labels
|
||||
}, [weeks])
|
||||
|
||||
const CELL = 20, GAP = 3
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface overflow-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-gold font-bold text-lg tracking-wide">Workload Heatmap</h2>
|
||||
<p className="text-text-muted text-xs mt-0.5">{WEEKS} weeks of deliverable density</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span>Less</span>
|
||||
{['bg-surface border-surface-border','bg-gold/25 border-gold/40','bg-gold/55 border-gold/70','bg-gold border-gold'].map((c,i) => (
|
||||
<div key={i} className={`w-4 h-4 rounded-sm border ${c}`} />
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-5 gap-3 mb-6">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'text-text-primary' },
|
||||
{ label: 'Upcoming', value: stats.upcoming, color: 'text-blue-400' },
|
||||
{ label: 'In Progress', value: stats.in_progress, color: 'text-amber-400' },
|
||||
{ label: 'Completed', value: stats.completed, color: 'text-green-400' },
|
||||
{ label: 'Overdue', value: stats.overdue, color: 'text-red-400' },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} className="bg-surface-elevated border border-surface-border rounded-xl p-4 text-center">
|
||||
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||
<p className="text-text-muted text-xs mt-1">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap grid */}
|
||||
<div className="flex gap-3 overflow-x-auto pb-4">
|
||||
{/* Day labels */}
|
||||
<div className="flex flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}>
|
||||
{DAY_INIT.map((d, i) => (
|
||||
<div key={i} style={{ height: CELL }} className="flex items-center text-[10px] text-text-muted/50 font-mono w-4">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="flex flex-col flex-shrink-0">
|
||||
{/* Month labels */}
|
||||
<div className="relative h-5 mb-1">
|
||||
{monthLabels.map(({ wi, label }) => (
|
||||
<span key={label+wi} className="absolute text-[10px] text-text-muted/60 font-medium"
|
||||
style={{ left: wi * (CELL + GAP) }}>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Week columns */}
|
||||
<div className="flex" style={{ gap: GAP }}>
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className="flex flex-col flex-shrink-0" style={{ gap: GAP }}>
|
||||
{week.map(({ date, key, items }) => (
|
||||
<div key={key}
|
||||
style={{ width: CELL, height: CELL }}
|
||||
className={`rounded-sm border cursor-pointer transition-all hover:scale-125 hover:z-10 relative
|
||||
${getCellStyle(items.length)}
|
||||
${isToday(date) ? 'ring-1 ring-white/40' : ''}
|
||||
`}
|
||||
onClick={() => items.length > 0 && openFocus(items[0].project.id, items[0].deliverable.id)}
|
||||
onMouseEnter={(e) => setTooltip({ x: e.clientX, y: e.clientY, date, items })}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div className="fixed z-[200] pointer-events-none bg-surface-elevated border border-surface-border rounded-xl shadow-2xl px-3.5 py-3 min-w-[200px] max-w-[280px]"
|
||||
style={{ left: Math.min(tooltip.x + 14, window.innerWidth - 290), top: Math.max(tooltip.y - 100, 8) }}>
|
||||
<p className={`text-xs font-bold mb-1.5 ${isToday(tooltip.date) ? 'text-gold' : 'text-text-primary'}`}>
|
||||
{isToday(tooltip.date) ? 'Today — ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
|
||||
</p>
|
||||
{tooltip.items.length === 0 ? (
|
||||
<p className="text-text-muted/50 text-xs">No deliverables</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{tooltip.items.slice(0, 5).map(({ deliverable, project }) => (
|
||||
<div key={deliverable.id} className="flex items-start gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: project.color }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] text-text-primary truncate">{deliverable.title}</p>
|
||||
<p className="text-[10px] text-text-muted/60">{project.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{tooltip.items.length > 5 && (
|
||||
<p className="text-[10px] text-text-muted/50 pl-3">+{tooltip.items.length - 5} more</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import ProjectCard from './ProjectCard'
|
||||
import ProjectModal from './ProjectModal'
|
||||
import Button from '../UI/Button'
|
||||
import AgendaPanel from '../Calendar/AgendaPanel'
|
||||
import useProjectStore from '../../store/useProjectStore'
|
||||
import useUIStore from '../../store/useUIStore'
|
||||
import { deleteProject } from '../../api/projects'
|
||||
|
||||
export default function ProjectList() {
|
||||
export default function ProjectList({ onRegisterNewProject }) {
|
||||
const { projects, removeProject } = useProjectStore()
|
||||
const { sidebarTab, setSidebarTab } = useUIStore()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
|
||||
useEffect(() => { onRegisterNewProject?.(() => setShowModal(true)) }, [onRegisterNewProject])
|
||||
|
||||
const handleEdit = (p) => { setEditing(p); setShowModal(true) }
|
||||
const handleDelete = async (p) => {
|
||||
if (window.confirm(`Delete "${p.name}" and all its deliverables?`)) {
|
||||
@@ -20,21 +25,64 @@ export default function ProjectList() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-border flex-shrink-0">
|
||||
<h1 className="text-gold font-bold text-lg tracking-widest uppercase">FabDash</h1>
|
||||
<Button size="sm" onClick={() => setShowModal(true)}>+ Project</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{projects.length === 0 && (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-text-muted text-sm">No projects yet.</p>
|
||||
<p className="text-text-muted/50 text-xs mt-1">Click "+ Project" to begin.</p>
|
||||
</div>
|
||||
)}
|
||||
{projects.map(p => (
|
||||
<ProjectCard key={p.id} project={p} onEdit={handleEdit} onDelete={handleDelete} />
|
||||
|
||||
{/* Tab toggle */}
|
||||
<div className="flex border-b border-surface-border flex-shrink-0">
|
||||
{[['projects','Projects'],['agenda','Upcoming']].map(([key, label]) => (
|
||||
<button key={key} onClick={() => setSidebarTab(key)}
|
||||
className={`flex-1 py-2 text-xs font-semibold transition-colors ${sidebarTab === key ? 'text-gold border-b-2 border-gold' : 'text-text-muted hover:text-text-primary'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sidebarTab === 'projects' ? (
|
||||
<div className="p-3 space-y-2">
|
||||
{projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
|
||||
<div className="opacity-20 mb-3">
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none">
|
||||
<rect x="7" y="12" width="42" height="37" rx="3" stroke="#C9A84C" strokeWidth="1.5"/>
|
||||
<line x1="7" y1="21" x2="49" y2="21" stroke="#C9A84C" strokeWidth="1.5"/>
|
||||
<circle cx="17" cy="7" r="3.5" stroke="#C9A84C" strokeWidth="1.5"/>
|
||||
<circle cx="39" cy="7" r="3.5" stroke="#C9A84C" strokeWidth="1.5"/>
|
||||
<line x1="17" y1="31" x2="39" y2="31" stroke="#C9A84C" strokeWidth="1.5" strokeDasharray="4 2.5"/>
|
||||
<line x1="17" y1="39" x2="30" y2="39" stroke="#C9A84C" strokeWidth="1.5" strokeDasharray="4 2.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-muted text-sm font-medium">No projects yet</p>
|
||||
<p className="text-text-muted/50 text-xs mt-1">
|
||||
Press <kbd className="bg-surface-border px-1.5 py-0.5 rounded text-[10px] font-mono">N</kbd> or click + Project
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
projects.map(p => (
|
||||
<ProjectCard key={p.id} project={p} onEdit={handleEdit} onDelete={handleDelete} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<AgendaPanel />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts legend */}
|
||||
<div className="flex-shrink-0 border-t border-surface-border px-4 py-2 flex flex-wrap gap-x-3 gap-y-1">
|
||||
{[['N','New project'],['B','Toggle sidebar'],['←→','Navigate'],['T','Today']].map(([key, desc]) => (
|
||||
<span key={key} className="flex items-center gap-1 text-[10px] text-text-muted/50">
|
||||
<kbd className="bg-surface-border px-1 py-0.5 rounded text-[9px] font-mono">{key}</kbd>
|
||||
{desc}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ProjectModal isOpen={showModal} onClose={handleClose} project={editing} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children, size = 'md' }) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) requestAnimationFrame(() => setVisible(true))
|
||||
else setVisible(false)
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e) => { if (e.key === 'Escape') onClose() }
|
||||
if (isOpen) document.addEventListener('keydown', h)
|
||||
return () => document.removeEventListener('keydown', h)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
const sizes = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' }
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className={`relative w-full ${sizes[size]} mx-4 bg-surface-raised border border-surface-border rounded-xl shadow-2xl`}>
|
||||
<div className={`absolute inset-0 bg-black/70 backdrop-blur-sm transition-opacity duration-200 ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={onClose} />
|
||||
<div className={`relative w-full ${sizes[size]} mx-4 bg-surface-raised border border-surface-border rounded-xl shadow-2xl
|
||||
transition-all duration-200 ${visible ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-95 translate-y-2'}`}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-surface-border">
|
||||
<h2 className="text-lg font-semibold text-gold">{title}</h2>
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors text-xl leading-none">✕</button>
|
||||
|
||||
50
frontend/src/components/UI/Toast.jsx
Normal file
50
frontend/src/components/UI/Toast.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import useToastStore from '../../store/useToastStore'
|
||||
|
||||
function ToastItem({ toast }) {
|
||||
const removeToast = useToastStore(s => s.removeToast)
|
||||
const [secs, setSecs] = useState(toast.duration)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setSecs(s => {
|
||||
if (s <= 1) { clearInterval(t); removeToast(toast.id); return 0 }
|
||||
return s - 1
|
||||
}), 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 bg-surface-elevated border border-surface-border rounded-xl px-4 py-3 shadow-2xl min-w-[300px] animate-slide-up">
|
||||
<span className="text-text-primary text-sm flex-1">{toast.message}</span>
|
||||
{toast.undoFn && (
|
||||
<button onClick={() => { toast.undoFn(); removeToast(toast.id) }}
|
||||
className="text-gold hover:text-gold-light text-xs font-bold border border-gold/40 hover:border-gold rounded px-2.5 py-1 transition-all flex-shrink-0">
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<div className="relative w-5 h-5">
|
||||
<svg className="w-5 h-5 -rotate-90" viewBox="0 0 20 20">
|
||||
<circle cx="10" cy="10" r="8" fill="none" stroke="#2E2E2E" strokeWidth="2"/>
|
||||
<circle cx="10" cy="10" r="8" fill="none" stroke="#C9A84C" strokeWidth="2"
|
||||
strokeDasharray={`${(secs / toast.duration) * 50.3} 50.3`} strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[8px] text-text-muted font-mono">{secs}</span>
|
||||
</div>
|
||||
<button onClick={() => removeToast(toast.id)} className="text-text-muted hover:text-text-primary transition-colors">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ToastContainer() {
|
||||
const toasts = useToastStore(s => s.toasts)
|
||||
if (!toasts.length) return null
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[300] flex flex-col-reverse gap-2 items-center pointer-events-none">
|
||||
{toasts.map(t => (
|
||||
<div key={t.id} className="pointer-events-auto"><ToastItem toast={t} /></div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
frontend/src/store/useToastStore.js
Normal file
15
frontend/src/store/useToastStore.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
let _id = 0
|
||||
|
||||
const useToastStore = create((set) => ({
|
||||
toasts: [],
|
||||
addToast: ({ message, undoFn, duration = 30 }) => {
|
||||
const id = ++_id
|
||||
set(s => ({ toasts: [...s.toasts, { id, message, undoFn, duration }] }))
|
||||
return id
|
||||
},
|
||||
removeToast: (id) => set(s => ({ toasts: s.toasts.filter(t => t.id !== id) })),
|
||||
}))
|
||||
|
||||
export default useToastStore
|
||||
13
frontend/src/store/useUIStore.js
Normal file
13
frontend/src/store/useUIStore.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
const useUIStore = create((set) => ({
|
||||
sidebarOpen: true,
|
||||
sidebarTab: 'projects', // 'projects' | 'agenda'
|
||||
showHeatmap: false,
|
||||
|
||||
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
setSidebarTab: (tab) => set({ sidebarTab: tab }),
|
||||
toggleHeatmap: () => set(s => ({ showHeatmap: !s.showHeatmap })),
|
||||
}))
|
||||
|
||||
export default useUIStore
|
||||
@@ -8,12 +8,17 @@ body {
|
||||
background-color: #111111;
|
||||
color: #F5F5F5;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
margin: 0; padding: 0; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── FullCalendar dark theme overrides ── */
|
||||
/* ── Animations ── */
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-slide-up { animation: slide-up 0.2s ease-out forwards; }
|
||||
|
||||
/* ── FullCalendar dark theme ── */
|
||||
.fc {
|
||||
--fc-border-color: #2E2E2E;
|
||||
--fc-button-bg-color: #1A1A1A;
|
||||
@@ -27,7 +32,6 @@ body {
|
||||
--fc-neutral-bg-color: #1A1A1A;
|
||||
--fc-event-border-color: transparent;
|
||||
}
|
||||
|
||||
.fc-theme-standard td,
|
||||
.fc-theme-standard th,
|
||||
.fc-theme-standard .fc-scrollgrid { border-color: #2E2E2E !important; }
|
||||
@@ -47,27 +51,30 @@ body {
|
||||
}
|
||||
|
||||
.fc-daygrid-event {
|
||||
border-radius: 4px !important;
|
||||
font-size: 0.72rem !important;
|
||||
font-weight: 600 !important;
|
||||
cursor: pointer !important;
|
||||
padding: 1px 5px !important;
|
||||
border-radius: 4px !important; font-size: 0.72rem !important;
|
||||
font-weight: 600 !important; cursor: pointer !important; padding: 1px 5px !important;
|
||||
}
|
||||
|
||||
.fc-event-title { color: #111111 !important; font-weight: 700 !important; }
|
||||
|
||||
.fc-day-today .fc-daygrid-day-number {
|
||||
background-color: #C9A84C !important;
|
||||
color: #111111 !important;
|
||||
border-radius: 50% !important;
|
||||
width: 26px !important;
|
||||
height: 26px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
background-color: #C9A84C !important; color: #111111 !important;
|
||||
border-radius: 50% !important; width: 26px !important; height: 26px !important;
|
||||
display: flex !important; align-items: center !important; justify-content: center !important;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
/* Week number column */
|
||||
.fc-timegrid-axis.fc-scrollgrid-shrink,
|
||||
.fc-daygrid-week-number {
|
||||
color: #C9A84C !important;
|
||||
font-size: 0.65rem !important;
|
||||
opacity: 0.6;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* Selection highlight */
|
||||
.fc-highlight { background: rgba(201, 168, 76, 0.12) !important; }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: #1A1A1A; }
|
||||
::-webkit-scrollbar-thumb { background: #2E2E2E; border-radius: 3px; }
|
||||
|
||||
Reference in New Issue
Block a user