Add files via upload

This commit is contained in:
jasonMPM
2026-03-05 12:13:22 -06:00
committed by GitHub
parent bfa3887e61
commit 20e71ee7f9
40 changed files with 1352 additions and 368 deletions

29
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,29 @@
import { useEffect } from 'react'
import ProjectList from './components/Projects/ProjectList'
import MainCalendar from './components/Calendar/MainCalendar'
import FocusDrawer from './components/FocusView/FocusDrawer'
import { fetchProjects } from './api/projects'
import useProjectStore from './store/useProjectStore'
export default function App() {
const { setProjects, setLoading } = useProjectStore()
useEffect(() => {
setLoading(true)
fetchProjects()
.then(data => { setProjects(data); setLoading(false) })
.catch(() => setLoading(false))
}, [])
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 />
</aside>
<main className="flex-1 overflow-hidden">
<MainCalendar />
</main>
<FocusDrawer />
</div>
)
}

View File

@@ -0,0 +1,6 @@
import axios from 'axios'
const B = '/api'
export const fetchDeliverables = (pid) => axios.get(`${B}/deliverables`, { params: { project_id: pid } }).then(r => r.data)
export const createDeliverable = (data) => axios.post(`${B}/deliverables`, data).then(r => r.data)
export const updateDeliverable = (id, d) => axios.patch(`${B}/deliverables/${id}`, d).then(r => r.data)
export const deleteDeliverable = (id) => axios.delete(`${B}/deliverables/${id}`).then(r => r.data)

View File

@@ -0,0 +1,7 @@
import axios from 'axios'
const B = '/api'
export const fetchProjects = () => axios.get(`${B}/projects`).then(r => r.data)
export const fetchProject = (id) => axios.get(`${B}/projects/${id}`).then(r => r.data)
export const createProject = (data) => axios.post(`${B}/projects`, data).then(r => r.data)
export const updateProject = (id, d) => axios.patch(`${B}/projects/${id}`, d).then(r => r.data)
export const deleteProject = (id) => axios.delete(`${B}/projects/${id}`).then(r => r.data)

View File

@@ -0,0 +1,70 @@
import { useRef, useState, useCallback } 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 { updateDeliverable } from '../../api/deliverables'
import DeliverableModal from '../Deliverables/DeliverableModal'
export default function MainCalendar() {
const calRef = useRef(null)
const { projects, updateDeliverable: storeUpdate } = useProjectStore()
const openFocus = useFocusStore(s => s.openFocus)
const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' })
const events = projects.flatMap(p =>
(p.deliverables || []).map(d => ({
id: String(d.id),
title: `${p.name}: ${d.title}`,
start: d.due_date,
allDay: true,
backgroundColor: p.color,
borderColor: p.color,
extendedProps: { deliverableId: d.id, projectId: p.id },
}))
)
const handleEventDrop = useCallback(async ({ event }) => {
const { deliverableId } = event.extendedProps
storeUpdate(await updateDeliverable(deliverableId, { due_date: event.startStr.substring(0,10) }))
}, [storeUpdate])
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) })
}, [])
return (
<div className="h-full flex flex-col bg-surface p-4">
<div className="flex-1 overflow-hidden">
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
events={events}
editable={true}
eventDrop={handleEventDrop}
eventClick={handleEventClick}
dateClick={handleDateClick}
height="100%"
dayMaxEvents={4}
eventDisplay="block"
displayEventTime={false}
/>
</div>
<DeliverableModal
isOpen={modal.open}
onClose={() => setModal({ open: false, deliverable: null, defaultDate: '' })}
deliverable={modal.deliverable}
defaultDate={modal.defaultDate}
/>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useState, useEffect } from 'react'
import Modal from '../UI/Modal'
import Button from '../UI/Button'
import { createDeliverable, updateDeliverable, deleteDeliverable } from '../../api/deliverables'
import useProjectStore from '../../store/useProjectStore'
import { STATUS_OPTIONS } from '../../utils/statusHelpers'
export default function DeliverableModal({ isOpen, onClose, deliverable, projectId, defaultDate }) {
const { addDeliverable, updateDeliverable: storeUpdate, removeDeliverable, projects } = useProjectStore()
const [title, setTitle] = useState('')
const [dueDate, setDueDate] = useState('')
const [status, setStatus] = useState('upcoming')
const [pid, setPid] = useState('')
const [saving, setSaving] = useState(false)
const isEditing = !!deliverable
useEffect(() => {
if (deliverable) {
setTitle(deliverable.title || ''); setDueDate(deliverable.due_date?.substring(0,10) || '')
setStatus(deliverable.status || 'upcoming'); setPid(deliverable.project_id || '')
} else {
setTitle(''); setDueDate(defaultDate || ''); setStatus('upcoming'); setPid(projectId || '')
}
}, [deliverable, isOpen, projectId, defaultDate])
const handleDelete = async () => {
if (!window.confirm('Delete this deliverable?')) return
await deleteDeliverable(deliverable.id); removeDeliverable(deliverable.id); onClose()
}
const handleSubmit = async () => {
if (!title.trim() || !dueDate || !pid) return
setSaving(true)
try {
if (isEditing) {
storeUpdate(await updateDeliverable(deliverable.id, { title, due_date: dueDate, status }))
} else {
addDeliverable(await createDeliverable({ title, due_date: dueDate, status, project_id: Number(pid) }))
}
onClose()
} finally { setSaving(false) }
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Edit Deliverable' : 'Add Deliverable'}>
<div className="space-y-4">
{!isEditing && (
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Project *</label>
<select className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold"
value={pid} onChange={e => setPid(e.target.value)}>
<option value="">Select a project...</option>
{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
)}
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Title *</label>
<input className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold transition-colors"
value={title} onChange={e => setTitle(e.target.value)} placeholder="Deliverable title..." />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Due Date *</label>
<input type="date" className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold transition-colors"
value={dueDate} onChange={e => setDueDate(e.target.value)} />
</div>
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Status</label>
<select className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold"
value={status} onChange={e => setStatus(e.target.value)}>
{STATUS_OPTIONS.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
<div className="flex justify-between pt-2 border-t border-surface-border">
{isEditing ? <Button variant="danger" onClick={handleDelete}>Delete</Button> : <div />}
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={saving || !title.trim() || !dueDate || !pid}>
{saving ? 'Saving...' : isEditing ? 'Save Changes' : 'Add Deliverable'}
</Button>
</div>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,34 @@
import Badge from '../UI/Badge'
import { formatDate } from '../../utils/dateHelpers'
export default function DeliverableCard({ deliverable, isActive, index, projectColor, onEdit }) {
return (
<div
onClick={() => isActive && onEdit && onEdit(deliverable)}
className={`relative flex flex-col gap-2 rounded-xl border p-4 min-w-[190px] max-w-[230px] flex-shrink-0 transition-all duration-300 select-none
${isActive
? 'border-gold bg-surface-elevated shadow-gold scale-105 ring-2 ring-gold/30 cursor-pointer'
: 'border-surface-border bg-surface cursor-default'
}`}
>
{isActive && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-gold text-surface text-[9px] font-black px-2.5 py-0.5 rounded-full tracking-widest uppercase">
Selected
</div>
)}
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: projectColor }} />
<span className={`text-[10px] font-semibold uppercase tracking-widest ${isActive ? 'text-gold' : 'text-text-muted/60'}`}>
Deliverable {index + 1}
</span>
</div>
<p className={`text-sm font-semibold leading-snug ${isActive ? 'text-text-primary' : 'text-text-muted'}`}>
{deliverable.title}
</p>
<p className={`text-xs font-mono ${isActive ? 'text-gold' : 'text-text-muted/50'}`}>
{formatDate(deliverable.due_date)}
</p>
<Badge status={deliverable.status} />
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { useState } from 'react'
import Drawer from '../UI/Drawer'
import FocusTimeline from './FocusTimeline'
import DeliverableModal from '../Deliverables/DeliverableModal'
import useFocusStore from '../../store/useFocusStore'
import useProjectStore from '../../store/useProjectStore'
export default function FocusDrawer() {
const { isOpen, projectId, activeDeliverableId, closeFocus } = useFocusStore()
const getProjectById = useProjectStore(s => s.getProjectById)
const [editDel, setEditDel] = useState(null)
const [showModal, setShowModal] = useState(false)
const project = projectId ? getProjectById(projectId) : null
const handleEdit = (d) => { setEditDel(d); setShowModal(true) }
return (
<>
<Drawer isOpen={isOpen} onClose={closeFocus}>
{project && (
<FocusTimeline
project={project}
activeDeliverableId={activeDeliverableId}
onEditDeliverable={handleEdit}
/>
)}
</Drawer>
<DeliverableModal
isOpen={showModal}
onClose={() => { setShowModal(false); setEditDel(null) }}
deliverable={editDel}
projectId={projectId}
/>
</>
)
}

View File

@@ -0,0 +1,39 @@
import DeliverableCard from './DeliverableCard'
export default function FocusTimeline({ project, activeDeliverableId, onEditDeliverable }) {
const sorted = [...(project.deliverables || [])].sort((a, b) => new Date(a.due_date) - new Date(b.due_date))
return (
<div className="px-6 pb-6 pt-5">
<div className="flex items-center gap-2 mb-5">
<div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
<h3 className="text-gold font-bold text-base tracking-wide">{project.name}</h3>
{project.description && (
<span className="text-text-muted text-xs"> {project.description}</span>
)}
<span className="ml-auto text-text-muted/50 text-xs">{sorted.length} deliverable{sorted.length !== 1 ? 's' : ''}</span>
</div>
<div className="flex items-center overflow-x-auto pb-3 gap-0">
{sorted.map((d, i) => (
<div key={d.id} className="flex items-center flex-shrink-0">
<DeliverableCard
deliverable={d}
isActive={d.id === activeDeliverableId}
index={i}
projectColor={project.color}
onEdit={onEditDeliverable}
/>
{i < sorted.length - 1 && (
<div className="flex items-center flex-shrink-0 px-1">
<div className="h-px w-6 bg-surface-border" />
<span className="text-surface-border text-xs"></span>
</div>
)}
</div>
))}
{sorted.length === 0 && (
<p className="text-text-muted text-sm italic">No deliverables yet.</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import Badge from '../UI/Badge'
import { formatDate } from '../../utils/dateHelpers'
import useFocusStore from '../../store/useFocusStore'
export default function ProjectCard({ project, onEdit, onDelete }) {
const openFocus = useFocusStore(s => s.openFocus)
return (
<div className="bg-surface-elevated border border-surface-border rounded-lg overflow-hidden transition-all hover:border-gold/20">
<div className="h-1 w-full" style={{ backgroundColor: project.color }} />
<div className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
<span className="text-sm font-semibold text-text-primary truncate">{project.name}</span>
</div>
<div className="flex gap-0.5 flex-shrink-0 ml-1">
<button onClick={() => onEdit(project)} className="text-text-muted hover:text-gold p-1 transition-colors text-sm"></button>
<button onClick={() => onDelete(project)} className="text-text-muted hover:text-red-400 p-1 transition-colors text-sm"></button>
</div>
</div>
{project.description && (
<p className="text-xs text-text-muted mb-2 line-clamp-1">{project.description}</p>
)}
<div className="space-y-1">
{(project.deliverables || []).map(d => (
<button key={d.id} onClick={() => openFocus(project.id, d.id)}
className="w-full flex items-center justify-between text-xs bg-surface rounded px-2 py-1.5 border border-transparent hover:border-gold/20 hover:bg-surface-border/20 transition-all text-left group">
<span className="text-text-muted group-hover:text-text-primary truncate flex-1 pr-2">{d.title}</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className="text-text-muted/60 font-mono text-[9px]">{formatDate(d.due_date)}</span>
<Badge status={d.status} />
</div>
</button>
))}
{(!project.deliverables || project.deliverables.length === 0) && (
<p className="text-[11px] text-text-muted/40 italic text-center py-1">No deliverables</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react'
import ProjectCard from './ProjectCard'
import ProjectModal from './ProjectModal'
import Button from '../UI/Button'
import useProjectStore from '../../store/useProjectStore'
import { deleteProject } from '../../api/projects'
export default function ProjectList() {
const { projects, removeProject } = useProjectStore()
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState(null)
const handleEdit = (p) => { setEditing(p); setShowModal(true) }
const handleDelete = async (p) => {
if (window.confirm(`Delete "${p.name}" and all its deliverables?`)) {
await deleteProject(p.id); removeProject(p.id)
}
}
const handleClose = () => { setShowModal(false); setEditing(null) }
return (
<div className="flex flex-col h-full">
<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} />
))}
</div>
<ProjectModal isOpen={showModal} onClose={handleClose} project={editing} />
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { useState, useEffect } from 'react'
import Modal from '../UI/Modal'
import Button from '../UI/Button'
import { createProject, updateProject } from '../../api/projects'
import useProjectStore from '../../store/useProjectStore'
import { STATUS_OPTIONS } from '../../utils/statusHelpers'
const PALETTE = ['#4A90D9','#2ECC9A','#9B59B6','#E74C3C','#E67E22','#27AE60','#E91E8C','#00BCD4','#5C6BC0','#F39C12','#C9A84C','#E8608A']
const emptyRow = () => ({ title: '', due_date: '', status: 'upcoming' })
export default function ProjectModal({ isOpen, onClose, project }) {
const { addProject, updateProject: storeUpdate } = useProjectStore()
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [color, setColor] = useState('#4A90D9')
const [rows, setRows] = useState([emptyRow()])
const [saving, setSaving] = useState(false)
const isEditing = !!project
useEffect(() => {
if (project) {
setName(project.name || '')
setDesc(project.description || '')
setColor(project.color || '#4A90D9')
setRows(project.deliverables?.length
? project.deliverables.map(d => ({ id: d.id, title: d.title, due_date: d.due_date?.substring(0,10)||'', status: d.status }))
: [emptyRow()])
} else {
setName(''); setDesc(''); setColor('#4A90D9'); setRows([emptyRow()])
}
}, [project, isOpen])
const updRow = (i, f, v) => setRows(r => r.map((row, idx) => idx === i ? { ...row, [f]: v } : row))
const handleSubmit = async () => {
if (!name.trim()) return
setSaving(true)
try {
if (isEditing) {
const updated = await updateProject(project.id, { name, description: desc, color })
storeUpdate({ ...updated, deliverables: project.deliverables })
} else {
const valid = rows.filter(r => r.title.trim() && r.due_date)
const created = await createProject({ name, description: desc, color, deliverables: valid })
addProject(created)
}
onClose()
} finally { setSaving(false) }
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Edit Project' : 'New Project'} size="lg">
<div className="space-y-4">
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Project Name *</label>
<input className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold transition-colors"
value={name} onChange={e => setName(e.target.value)} placeholder="e.g. CODA" />
</div>
<div>
<label className="block text-xs text-text-muted mb-1 font-medium">Description</label>
<textarea className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm text-text-primary focus:outline-none focus:border-gold transition-colors resize-none"
rows={2} value={desc} onChange={e => setDesc(e.target.value)} placeholder="Optional..." />
</div>
<div>
<label className="block text-xs text-text-muted mb-2 font-medium">Color</label>
<div className="flex flex-wrap gap-2 items-center">
{PALETTE.map(c => (
<button key={c} type="button" onClick={() => setColor(c)}
className={`w-7 h-7 rounded-full transition-all ${color===c ? 'ring-2 ring-offset-2 ring-offset-surface-raised ring-white scale-110' : 'hover:scale-105'}`}
style={{ backgroundColor: c }} />
))}
<input type="color" value={color} onChange={e => setColor(e.target.value)}
className="w-7 h-7 rounded cursor-pointer border-0 bg-transparent" title="Custom color" />
</div>
</div>
{!isEditing && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs text-text-muted font-medium">Deliverables</label>
<button type="button" onClick={() => setRows(r => [...r, emptyRow()])}
className="text-xs text-gold hover:text-gold-light transition-colors">+ Add Row</button>
</div>
<div className="space-y-2">
{rows.map((r, i) => (
<div key={i} className="flex gap-2 items-center">
<input className="flex-1 bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:border-gold"
placeholder={`Deliverable ${i+1}`} value={r.title} onChange={e => updRow(i,'title',e.target.value)} />
<input type="date" className="bg-surface border border-surface-border rounded-lg px-2 py-1.5 text-xs text-text-primary focus:outline-none focus:border-gold"
value={r.due_date} onChange={e => updRow(i,'due_date',e.target.value)} />
<select className="bg-surface border border-surface-border rounded-lg px-2 py-1.5 text-xs text-text-primary focus:outline-none focus:border-gold"
value={r.status} onChange={e => updRow(i,'status',e.target.value)}>
{STATUS_OPTIONS.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
{rows.length > 1 && (
<button type="button" onClick={() => setRows(d => d.filter((_,idx)=>idx!==i))}
className="text-red-400 hover:text-red-300 px-1"></button>
)}
</div>
))}
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-surface-border">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Project'}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,9 @@
import { STATUS_COLORS, getStatusLabel } from '../../utils/statusHelpers'
export default function Badge({ status }) {
const c = STATUS_COLORS[status] || STATUS_COLORS.upcoming
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${c.bg} ${c.text} ${c.border}`}>
{getStatusLabel(status)}
</span>
)
}

View File

@@ -0,0 +1,16 @@
export default function Button({ children, variant='primary', size='md', onClick, type='button', disabled=false, className='' }) {
const base = 'font-medium rounded transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-gold/50 disabled:opacity-50 disabled:cursor-not-allowed'
const variants = {
primary: 'bg-gold text-surface hover:bg-gold-light',
secondary: 'bg-surface-elevated border border-surface-border text-text-primary hover:border-gold hover:text-gold',
danger: 'bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30',
ghost: 'text-text-muted hover:text-gold hover:bg-surface-elevated',
}
const sizes = { sm: 'px-3 py-1.5 text-xs', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
return (
<button type={type} onClick={onClick} disabled={disabled}
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}>
{children}
</button>
)
}

View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react'
export default function Drawer({ isOpen, onClose, children }) {
useEffect(() => {
const h = (e) => { if (e.key === 'Escape') onClose() }
if (isOpen) document.addEventListener('keydown', h)
return () => document.removeEventListener('keydown', h)
}, [isOpen, onClose])
return (
<>
{isOpen && <div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm" onClick={onClose} />}
<div
className={`fixed bottom-0 left-0 right-0 z-50 bg-surface-raised border-t border-surface-border rounded-t-2xl shadow-2xl transition-transform duration-300 ease-in-out ${isOpen ? 'translate-y-0' : 'translate-y-full'}`}
style={{ maxHeight: '65vh' }}
>
<div className="relative flex items-center justify-between px-6 py-3 border-b border-surface-border">
<div className="absolute left-1/2 -translate-x-1/2 top-2 w-10 h-1 bg-surface-border rounded-full" />
<div className="flex-1" />
<button onClick={onClose} className="text-text-muted hover:text-gold transition-colors text-lg"></button>
</div>
<div className="overflow-y-auto" style={{ maxHeight: 'calc(65vh - 52px)' }}>{children}</div>
</div>
</>
)
}

View File

@@ -0,0 +1,22 @@
import { useEffect } from 'react'
export default function Modal({ isOpen, onClose, title, children, size='md' }) {
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="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>
</div>
<div className="px-6 py-5 overflow-y-auto max-h-[80vh]">{children}</div>
</div>
</div>
)
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './styles/globals.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,12 @@
import { create } from 'zustand'
const useFocusStore = create((set) => ({
isOpen: false,
projectId: null,
activeDeliverableId: null,
openFocus: (projectId, deliverableId) => set({ isOpen: true, projectId, activeDeliverableId: deliverableId }),
closeFocus: () => set({ isOpen: false, projectId: null, activeDeliverableId: null }),
}))
export default useFocusStore

View File

@@ -0,0 +1,41 @@
import { create } from 'zustand'
const useProjectStore = create((set, get) => ({
projects: [],
loading: false,
error: null,
setProjects: (projects) => set({ projects }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
addProject: (p) => set((s) => ({ projects: [p, ...s.projects] })),
updateProject: (updated) => set((s) => ({
projects: s.projects.map(p => p.id === updated.id ? { ...updated, deliverables: p.deliverables } : p),
})),
removeProject: (id) => set((s) => ({ projects: s.projects.filter(p => p.id !== id) })),
addDeliverable: (d) => set((s) => ({
projects: s.projects.map(p => p.id === d.project_id
? { ...p, deliverables: [...(p.deliverables||[]), d].sort((a,b) => new Date(a.due_date)-new Date(b.due_date)) }
: p
),
})),
updateDeliverable: (updated) => set((s) => ({
projects: s.projects.map(p => p.id === updated.project_id
? { ...p, deliverables: p.deliverables.map(d => d.id === updated.id ? updated : d) }
: p
),
})),
removeDeliverable: (id) => set((s) => ({
projects: s.projects.map(p => ({ ...p, deliverables: (p.deliverables||[]).filter(d => d.id !== id) })),
})),
getProjectById: (id) => get().projects.find(p => p.id === id),
}))
export default useProjectStore

View File

@@ -0,0 +1,74 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body {
background-color: #111111;
color: #F5F5F5;
font-family: 'Inter', system-ui, sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
}
/* ── FullCalendar dark theme overrides ── */
.fc {
--fc-border-color: #2E2E2E;
--fc-button-bg-color: #1A1A1A;
--fc-button-border-color: #2E2E2E;
--fc-button-hover-bg-color: #242424;
--fc-button-hover-border-color: #C9A84C;
--fc-button-active-bg-color: #C9A84C;
--fc-button-active-border-color: #C9A84C;
--fc-today-bg-color: rgba(201, 168, 76, 0.07);
--fc-page-bg-color: #111111;
--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; }
.fc-col-header-cell-cushion,
.fc-daygrid-day-number { color: #F5F5F5 !important; text-decoration: none !important; }
.fc-toolbar-title { color: #C9A84C !important; font-weight: 600 !important; }
.fc-button { font-size: 0.8rem !important; padding: 0.3rem 0.75rem !important; font-weight: 500 !important; color: #F5F5F5 !important; }
.fc-button-primary:not(:disabled):active,
.fc-button-primary:not(:disabled).fc-button-active {
background-color: #C9A84C !important;
border-color: #C9A84C !important;
color: #111111 !important;
}
.fc-daygrid-event {
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;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: #1A1A1A; }
::-webkit-scrollbar-thumb { background: #2E2E2E; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #C9A84C; }

View File

@@ -0,0 +1,6 @@
import { format, isBefore, isToday, parseISO } from 'date-fns'
export const formatDate = (s) => s ? format(parseISO(s), 'MMM d, yyyy') : ''
export const formatDateForInput = (s) => s ? s.substring(0, 10) : ''
export const isOverdue = (s) => s && isBefore(parseISO(s), new Date()) && !isToday(parseISO(s))
export const isDueToday = (s) => s && isToday(parseISO(s))

View File

@@ -0,0 +1,15 @@
export const STATUS_OPTIONS = [
{ value: 'upcoming', label: 'Upcoming' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'completed', label: 'Completed' },
{ value: 'overdue', label: 'Overdue' },
]
export const STATUS_COLORS = {
upcoming: { bg: 'bg-blue-500/20', text: 'text-blue-400', border: 'border-blue-500/30' },
in_progress: { bg: 'bg-amber-500/20', text: 'text-amber-400', border: 'border-amber-500/30' },
completed: { bg: 'bg-green-500/20', text: 'text-green-400', border: 'border-green-500/30' },
overdue: { bg: 'bg-red-500/20', text: 'text-red-400', border: 'border-red-500/30' },
}
export const getStatusLabel = (s) => STATUS_OPTIONS.find(o => o.value === s)?.label || s