Add files via upload
This commit is contained in:
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FabDash</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "fabdash",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"axios": "^1.7.9",
|
||||
"date-fns": "^3.6.0",
|
||||
"react": "^18.3.1",
|
||||
"react-chrono": "^2.7.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
29
frontend/src/App.jsx
Normal file
29
frontend/src/App.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
frontend/src/api/deliverables.js
Normal file
6
frontend/src/api/deliverables.js
Normal 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)
|
||||
7
frontend/src/api/projects.js
Normal file
7
frontend/src/api/projects.js
Normal 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)
|
||||
70
frontend/src/components/Calendar/MainCalendar.jsx
Normal file
70
frontend/src/components/Calendar/MainCalendar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
frontend/src/components/Deliverables/DeliverableModal.jsx
Normal file
88
frontend/src/components/Deliverables/DeliverableModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/FocusView/DeliverableCard.jsx
Normal file
34
frontend/src/components/FocusView/DeliverableCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
frontend/src/components/FocusView/FocusDrawer.jsx
Normal file
37
frontend/src/components/FocusView/FocusDrawer.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
39
frontend/src/components/FocusView/FocusTimeline.jsx
Normal file
39
frontend/src/components/FocusView/FocusTimeline.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
frontend/src/components/Projects/ProjectCard.jsx
Normal file
42
frontend/src/components/Projects/ProjectCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
frontend/src/components/Projects/ProjectList.jsx
Normal file
41
frontend/src/components/Projects/ProjectList.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
frontend/src/components/Projects/ProjectModal.jsx
Normal file
112
frontend/src/components/Projects/ProjectModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
frontend/src/components/UI/Badge.jsx
Normal file
9
frontend/src/components/UI/Badge.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/UI/Button.jsx
Normal file
16
frontend/src/components/UI/Button.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
frontend/src/components/UI/Drawer.jsx
Normal file
24
frontend/src/components/UI/Drawer.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
frontend/src/components/UI/Modal.jsx
Normal file
22
frontend/src/components/UI/Modal.jsx
Normal 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
10
frontend/src/main.jsx
Normal 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>
|
||||
)
|
||||
12
frontend/src/store/useFocusStore.js
Normal file
12
frontend/src/store/useFocusStore.js
Normal 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
|
||||
41
frontend/src/store/useProjectStore.js
Normal file
41
frontend/src/store/useProjectStore.js
Normal 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
|
||||
74
frontend/src/styles/globals.css
Normal file
74
frontend/src/styles/globals.css
Normal 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; }
|
||||
6
frontend/src/utils/dateHelpers.js
Normal file
6
frontend/src/utils/dateHelpers.js
Normal 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))
|
||||
15
frontend/src/utils/statusHelpers.js
Normal file
15
frontend/src/utils/statusHelpers.js
Normal 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
|
||||
27
frontend/tailwind.config.js
Normal file
27
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
gold: '#C9A84C',
|
||||
'gold-light': '#E8C96A',
|
||||
'gold-muted': '#8A6E2F',
|
||||
surface: '#111111',
|
||||
'surface-raised': '#1A1A1A',
|
||||
'surface-elevated': '#242424',
|
||||
'surface-border': '#2E2E2E',
|
||||
'text-primary': '#F5F5F5',
|
||||
'text-muted': '#888888',
|
||||
},
|
||||
boxShadow: {
|
||||
gold: '0 0 12px rgba(201, 168, 76, 0.4)',
|
||||
'gold-lg':'0 0 24px rgba(201, 168, 76, 0.55)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
14
frontend/vite.config.js
Normal file
14
frontend/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user