Implement wiring hardening, runtime API config, smoke tests, and build scripts
This commit is contained in:
@@ -4,8 +4,18 @@ const fs = require('fs')
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
const devUrl = 'http://localhost:5173'
|
||||
const API_URL_KEY = 'runtime_api_url'
|
||||
const DEFAULT_API_URL = process.env.VITE_API_URL || 'http://localhost:4000/api'
|
||||
let mainWindow
|
||||
|
||||
function normalizeApiUrl(value) {
|
||||
if (!value || typeof value !== 'string') return null
|
||||
const trimmed = value.trim().replace(/\/+$/, '')
|
||||
if (!trimmed) return null
|
||||
if (!/^https?:\/\//i.test(trimmed)) return null
|
||||
return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`
|
||||
}
|
||||
|
||||
function getStoragePath() {
|
||||
return path.join(app.getPath('userData'), 'storage.json')
|
||||
}
|
||||
@@ -31,9 +41,11 @@ function writeStorage(obj) {
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('storage-get', () => {
|
||||
ipcMain.handle('storage-get', (event, key) => {
|
||||
const s = readStorage()
|
||||
return s
|
||||
if (!key) return s
|
||||
if (!(key in s)) return null
|
||||
return { value: s[key] }
|
||||
})
|
||||
|
||||
ipcMain.handle('storage-set', (event, key, value) => {
|
||||
@@ -50,7 +62,20 @@ ipcMain.handle('storage-remove', (event, key) => {
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('get-api-url', () => process.env.VITE_API_URL || 'http://localhost:4000/api')
|
||||
ipcMain.handle('get-api-url', () => {
|
||||
const s = readStorage()
|
||||
return normalizeApiUrl(s[API_URL_KEY]) || normalizeApiUrl(DEFAULT_API_URL) || 'http://localhost:4000/api'
|
||||
})
|
||||
|
||||
ipcMain.handle('set-api-url', (event, value) => {
|
||||
const next = normalizeApiUrl(value)
|
||||
if (!next) throw new Error('API URL must be a valid http(s) URL')
|
||||
|
||||
const s = readStorage()
|
||||
s[API_URL_KEY] = next
|
||||
writeStorage(s)
|
||||
return next
|
||||
})
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('ENV', {
|
||||
API_URL: process.env.VITE_API_URL || 'http://localhost:4000/api'
|
||||
API_URL: process.env.VITE_API_URL || 'http://localhost:4000/api',
|
||||
API_KEY: process.env.WRITE_API_KEY || process.env.VITE_WRITE_API_KEY || ''
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('app', {
|
||||
@@ -10,5 +11,13 @@ contextBridge.exposeInMainWorld('app', {
|
||||
set: (key, value) => ipcRenderer.invoke('storage-set', key, value),
|
||||
remove: (key) => ipcRenderer.invoke('storage-remove', key)
|
||||
},
|
||||
getAPIUrl: () => ipcRenderer.invoke('get-api-url')
|
||||
getAPIUrl: () => ipcRenderer.invoke('get-api-url'),
|
||||
setAPIUrl: (url) => ipcRenderer.invoke('set-api-url', url)
|
||||
})
|
||||
|
||||
// Backward-compatible alias used by the React app persistence wrapper.
|
||||
contextBridge.exposeInMainWorld('storage', {
|
||||
get: (key) => ipcRenderer.invoke('storage-get', key),
|
||||
set: (key, value) => ipcRenderer.invoke('storage-set', key, value),
|
||||
remove: (key) => ipcRenderer.invoke('storage-remove', key)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { api } from './api.js'
|
||||
import GanttView from './components/GanttView.jsx'
|
||||
import CalendarView from './components/CalendarView.jsx'
|
||||
import BurndownChart from './components/BurndownChart.jsx'
|
||||
|
||||
// The full App adapted from your original inline HTML (keeps UI and behavior).
|
||||
|
||||
@@ -116,9 +119,17 @@ function App(){
|
||||
const [delId, setDelId] = useState(null)
|
||||
const [view, setView] = useState('projects')
|
||||
const [allMembers, setAllMembers] = useState([])
|
||||
const [serverOpen, setServerOpen] = useState(false)
|
||||
const [apiUrlInput, setApiUrlInput] = useState('')
|
||||
const [apiUrlMsg, setApiUrlMsg] = useState('')
|
||||
|
||||
useEffect(()=>{
|
||||
(async()=>{
|
||||
try {
|
||||
const currentApiUrl = await api.getApiUrl()
|
||||
setApiUrlInput(currentApiUrl)
|
||||
} catch {}
|
||||
|
||||
try { const m = await api.getMembers(); setAllMembers(m) } catch {}
|
||||
try {
|
||||
const bp = await api.getProjects()
|
||||
@@ -158,6 +169,42 @@ function App(){
|
||||
setDelId(null)
|
||||
api.deleteProject(id).catch(() => {})
|
||||
}
|
||||
|
||||
const createTaskForProject = async (projectId, taskDraft) => {
|
||||
try {
|
||||
const created = await api.createTask(projectId, taskDraft)
|
||||
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, created] } : p))
|
||||
if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, created] } : s)
|
||||
} catch {
|
||||
const local = { ...taskDraft, id: uid(), subtasks: taskDraft.subtasks || [], attachments: taskDraft.attachments || [] }
|
||||
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, local] } : p))
|
||||
if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, local] } : s)
|
||||
}
|
||||
}
|
||||
|
||||
const updateTaskForProject = async (projectId, taskId, patch) => {
|
||||
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : p))
|
||||
if (sel?.id === projectId) {
|
||||
setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : s)
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await api.updateTask(projectId, taskId, patch)
|
||||
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.map(t => t.id === taskId ? updated : t) } : p))
|
||||
if (sel?.id === projectId) {
|
||||
setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? updated : t) } : s)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const deleteTaskForProject = async (projectId, taskId) => {
|
||||
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.filter(t => t.id !== taskId) } : p))
|
||||
if (sel?.id === projectId) {
|
||||
setSel(s => s ? { ...s, tasks: s.tasks.filter(t => t.id !== taskId) } : s)
|
||||
}
|
||||
try { await api.deleteTask(projectId, taskId) } catch {}
|
||||
}
|
||||
|
||||
const change = u => {
|
||||
setProjects(ps => ps.map(p => p.id === u.id ? u : p))
|
||||
setSel(u)
|
||||
@@ -181,15 +228,22 @@ function App(){
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
<button onClick={()=>setView('projects')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='projects' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='projects' ? '#818cf8' : '#64748b' }}>Projects</button>
|
||||
<button onClick={()=>setView('mytasks')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='mytasks' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='mytasks' ? '#818cf8' : '#64748b' }}>My Tasks</button>
|
||||
<button onClick={()=>setView('members')} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: view==='members' ? 'rgba(99,102,241,0.12)' : 'transparent', color: view==='members' ? '#818cf8' : '#64748b' }}>Members <span style={{ background: '#181828', padding: '1px 6px', borderRadius: 999, fontSize: 10, marginLeft: 3 }}>{allMembers.length}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
{view === 'projects' && <button onClick={()=>setEditing({name:'',description:'',status:'planning',color:COLORS[0],startDate:'',dueDate:'',members:[],tasks:[],milestones:[]})} style={{ ...btn(), background: '#6366f1', color: '#fff', padding: '8px 15px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Plus size={14}/> New Project
|
||||
</button>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button onClick={() => { setApiUrlMsg(''); setServerOpen(true) }} style={{ ...btn(), background: 'transparent', color: '#94a3b8', border: '1px solid #252540', padding: '7px 12px', fontSize: 12, fontWeight: 500 }}>
|
||||
Server
|
||||
</button>
|
||||
{view === 'projects' && <button onClick={()=>setEditing({name:'',description:'',status:'planning',color:COLORS[0],startDate:'',dueDate:'',members:[],tasks:[],milestones:[]})} style={{ ...btn(), background: '#6366f1', color: '#fff', padding: '8px 15px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Plus size={14}/> New Project
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '22px 24px', maxWidth: 1400, margin: '0 auto' }}>
|
||||
{view === 'mytasks' && <MyTasksView projects={projects} allMembers={allMembers} onSelectProject={id => { const p = projects.find(x => x.id === id); if(p) { setSel(p); setTab('tasks'); setView('projects') }}} />}
|
||||
{view === 'members' && <MembersPage members={allMembers}
|
||||
onAdd={async m => {
|
||||
const temp = { ...m, id: uid() }
|
||||
@@ -246,7 +300,7 @@ function App(){
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{sel && <Panel project={sel} tab={tab} setTab={setTab} onClose={() => setSel(null)} onEdit={() => setEditing(sel)} onChange={change} allMembers={allMembers} />}
|
||||
{sel && <Panel project={sel} tab={tab} setTab={setTab} onClose={() => setSel(null)} onEdit={() => setEditing(sel)} onChange={change} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} allMembers={allMembers} />}
|
||||
{editing !== null && <FormModal project={editing} onSave={save} onClose={() => setEditing(null)} />}
|
||||
{delId && (
|
||||
<Overlay>
|
||||
@@ -260,6 +314,38 @@ function App(){
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
|
||||
{serverOpen && (
|
||||
<Overlay>
|
||||
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 14, padding: 24, width: '100%', maxWidth: 520 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#f1f5f9', marginBottom: 8 }}>API Server</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 12 }}>Set the backend base URL used by this app. Example: http://localhost:4000/api</div>
|
||||
<input
|
||||
value={apiUrlInput}
|
||||
onChange={e => setApiUrlInput(e.target.value)}
|
||||
placeholder="http://localhost:4000/api"
|
||||
style={{ ...inp(), marginBottom: 10 }}
|
||||
/>
|
||||
{apiUrlMsg && <div style={{ fontSize: 11, color: apiUrlMsg.startsWith('Saved') ? '#86efac' : '#fca5a5', marginBottom: 10 }}>{apiUrlMsg}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setServerOpen(false)} style={{ ...btn(), padding: '8px 14px', background: 'transparent', border: '1px solid #181828', color: '#94a3b8', fontWeight: 500 }}>Close</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.setApiUrl(apiUrlInput)
|
||||
setApiUrlMsg('Saved. Reload the app if you changed environments.')
|
||||
} catch {
|
||||
setApiUrlMsg('Invalid API URL. Use a full http(s) URL.')
|
||||
}
|
||||
}}
|
||||
style={{ ...btn(), padding: '8px 14px', background: '#6366f1', color: '#fff' }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -308,11 +394,33 @@ function Card({ project: p, onOpen, onEdit, onDel }){
|
||||
}
|
||||
|
||||
// ── Detail Panel ──
|
||||
function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers = [] }){
|
||||
function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreate, onTaskUpdate, onTaskDelete, allMembers = [] }){
|
||||
const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning
|
||||
const upTask = (id, u) => onChange({ ...p, tasks: p.tasks.map(t => t.id === id ? { ...t, ...u } : t) })
|
||||
const addTask = t => onChange({ ...p, tasks: [...p.tasks, { ...t, id: uid(), subtasks: [] }] })
|
||||
const delTask = id => onChange({ ...p, tasks: p.tasks.filter(t => t.id !== id) })
|
||||
const upTask = (id, u) => {
|
||||
const task = p.tasks.find(t => t.id === id)
|
||||
if (!task) return
|
||||
|
||||
const merged = { ...task, ...u }
|
||||
onTaskUpdate?.(p.id, id, u)
|
||||
|
||||
// Recurring: when marked done, auto-clone with next due date
|
||||
if (u.status === 'done' && merged.recurrence && merged.recurrence !== 'none' && merged.dueDate) {
|
||||
const base = new Date(merged.dueDate)
|
||||
if (merged.recurrence === 'daily') base.setDate(base.getDate() + 1)
|
||||
if (merged.recurrence === 'weekly') base.setDate(base.getDate() + 7)
|
||||
if (merged.recurrence === 'monthly') base.setMonth(base.getMonth() + 1)
|
||||
const next = {
|
||||
...merged,
|
||||
status: 'todo',
|
||||
dueDate: base.toISOString().slice(0,10),
|
||||
subtasks: (merged.subtasks || []).map(s => ({ ...s, done: false })),
|
||||
}
|
||||
delete next.id
|
||||
onTaskCreate?.(p.id, next)
|
||||
}
|
||||
}
|
||||
const addTask = t => onTaskCreate?.(p.id, { ...t, subtasks: t.subtasks || [] })
|
||||
const delTask = id => onTaskDelete?.(p.id, id)
|
||||
const togMs = id => onChange({ ...p, milestones: p.milestones.map(m => m.id === id ? { ...m, completed: !m.completed } : m) })
|
||||
const addMs = m => onChange({ ...p, milestones: [...p.milestones, { ...m, id: uid(), completed: false }] })
|
||||
const delMs = id => onChange({ ...p, milestones: p.milestones.filter(m => m.id !== id) })
|
||||
@@ -337,16 +445,22 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#475569', marginBottom: 5 }}><span>Progress</span><span style={{ color: p.color, fontWeight: 700 }}>{pr}%</span></div>
|
||||
<div style={{ height: 5, background: '#181828', borderRadius: 999 }}><div style={{ height: '100%', width: `${pr}%`, background: p.color, borderRadius: 999 }} /></div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 2, marginTop: 14 }}>
|
||||
{['tasks','milestones','team'].map(t => (
|
||||
<button key={t} onClick={() => setTab(t)} style={{ ...btn(), padding: '6px 14px', fontSize: 12, fontWeight: 500, background: tab === t ? 'rgba(99,102,241,0.12)' : 'transparent', color: tab === t ? '#818cf8' : '#64748b' }}>{t.charAt(0).toUpperCase() + t.slice(1)} <span style={{ marginLeft: 3, background: '#181828', padding: '1px 6px', borderRadius: 999, fontSize: 10 }}>{t === 'tasks' ? p.tasks.length : t === 'milestones' ? p.milestones.length : p.members.length}</span></button>
|
||||
<div style={{ display: 'flex', gap: 2, marginTop: 14, flexWrap: 'wrap' }}>
|
||||
{['tasks','milestones','team','gantt','calendar','burndown'].map(t => (
|
||||
<button key={t} onClick={() => setTab(t)} style={{ ...btn(), padding: '5px 12px', fontSize: 11, fontWeight: 500, background: tab === t ? 'rgba(99,102,241,0.12)' : 'transparent', color: tab === t ? '#818cf8' : '#64748b' }}>
|
||||
{t === 'tasks' ? '📋 Tasks' : t === 'milestones' ? '🏁 Milestones' : t === 'team' ? '👥 Team' : t === 'gantt' ? '📊 Gantt' : t === 'calendar' ? '📅 Calendar' : '📉 Burndown'}
|
||||
{(t === 'tasks' || t === 'milestones' || t === 'team') && <span style={{ marginLeft: 3, background: '#181828', padding: '1px 5px', borderRadius: 999, fontSize: 9 }}>{t === 'tasks' ? p.tasks.length : t === 'milestones' ? p.milestones.length : p.members.length}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '18px 22px', flex: 1 }}>
|
||||
{tab === 'tasks' && <TasksTab project={p} onUpdate={upTask} onAdd={addTask} onDel={delTask} />}
|
||||
{tab === 'milestones' && <MsTab project={p} onToggle={togMs} onAdd={addMs} onDel={delMs} />}
|
||||
{tab === 'team' && <TeamTab project={p} onChange={onChange} allMembers={allMembers} />}
|
||||
{tab === 'team' && <TeamTab project={p} onChange={onChange} allMembers={allMembers} />}
|
||||
{tab === 'gantt' && <GanttView project={p} />}
|
||||
{tab === 'calendar' && <CalendarView project={p} onEditTask={t => upTask(t.id, {})} />}
|
||||
{tab === 'burndown' && <BurndownChart project={p} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -357,10 +471,11 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers
|
||||
// For brevity these components are preserved from the original inline app; include full implementations below.
|
||||
|
||||
function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
||||
const EMPTY = { title: '', status: 'todo', priority: 'medium', dueDate: '', startDate: '', assignedTo: '', estimatedHours: '', recurrence: 'none' }
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [nt, setNt] = useState({ title: '', status: 'todo', priority: 'medium', dueDate: '', assignedTo: '' })
|
||||
const [nt, setNt] = useState(EMPTY)
|
||||
const [exp, setExp] = useState(null)
|
||||
const submit = () => { if(!nt.title.trim()) return; onAdd(nt); setNt({ title:'', status:'todo', priority:'medium', dueDate:'', assignedTo:'' }); setAdding(false) }
|
||||
const submit = () => { if(!nt.title.trim()) return; onAdd({ ...nt, estimatedHours: nt.estimatedHours ? Number(nt.estimatedHours) : undefined }); setNt(EMPTY); setAdding(false) }
|
||||
const groups = { todo: p.tasks.filter(t => t.status === 'todo'), 'in-progress': p.tasks.filter(t => t.status === 'in-progress'), done: p.tasks.filter(t => t.status === 'done') }
|
||||
return (
|
||||
<div>
|
||||
@@ -375,7 +490,14 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
||||
<select value={nt.priority} onChange={e => setNt({ ...nt, priority: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }}>
|
||||
<option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option>
|
||||
</select>
|
||||
<input type="date" value={nt.dueDate} onChange={e => setNt({ ...nt, dueDate: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Start Date</div><input type="date" value={nt.startDate} onChange={e => setNt({ ...nt, startDate: e.target.value })} style={{ ...inp(), padding: '6px 8px' }} /></div>
|
||||
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Due Date</div><input type="date" value={nt.dueDate} onChange={e => setNt({ ...nt, dueDate: e.target.value })} style={{ ...inp(), padding: '6px 8px' }} /></div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Est. Hours</div><input type="number" min="0" step="0.5" value={nt.estimatedHours} onChange={e => setNt({ ...nt, estimatedHours: e.target.value })} placeholder="0" style={{ ...inp(), padding: '6px 8px' }} /></div>
|
||||
<div style={{ flex: 1 }}><div style={{ fontSize: 9, color: '#475569', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Recurrence</div><select value={nt.recurrence} onChange={e => setNt({ ...nt, recurrence: e.target.value })} style={{ ...inp(), padding: '6px 8px' }}><option value="none">None</option><option value="daily">Daily</option><option value="weekly">Weekly</option><option value="monthly">Monthly</option></select></div>
|
||||
</div>
|
||||
{p.members.length > 0 && (
|
||||
<select value={nt.assignedTo} onChange={e => setNt({ ...nt, assignedTo: e.target.value })} style={{ ...inp(), marginBottom: 9, padding: '6px 8px' }}>
|
||||
@@ -383,7 +505,7 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
||||
{p.members.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 7, marginTop: p.members.length > 0 ? 0 : 9 }}>
|
||||
<div style={{ display: 'flex', gap: 7, marginTop: 2 }}>
|
||||
<button onClick={submit} style={{ ...btn(), flex:1, padding: '7px', background: '#6366f1', color: '#fff' }}>Add Task</button>
|
||||
<button onClick={() => setAdding(false)} style={{ ...btn(), flex:1, padding: '7px', background: 'transparent', border: '1px solid #181828', color: '#64748b', fontWeight: 500 }}>Cancel</button>
|
||||
</div>
|
||||
@@ -393,7 +515,7 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
||||
groups[st].length > 0 && (
|
||||
<div key={st} style={{ marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 7 }}>{st === 'in-progress' ? 'In Progress' : st === 'todo' ? 'To Do' : 'Done'} · {groups[st].length}</div>
|
||||
{groups[st].map(t => <TaskRow key={t.id} task={t} color={p.color} members={p.members} onUpdate={onUpdate} onDel={onDel} expanded={exp === t.id} onToggle={() => setExp(exp === t.id ? null : t.id)} />)}
|
||||
{groups[st].map(t => <TaskRow key={t.id} task={t} color={p.color} members={p.members} projectId={p.id} onUpdate={onUpdate} onDel={onDel} expanded={exp === t.id} onToggle={() => setExp(exp === t.id ? null : t.id)} />)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
@@ -402,12 +524,27 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
|
||||
)
|
||||
}
|
||||
|
||||
function TaskRow({ task: t, color, members = [], onUpdate, onDel, expanded, onToggle }){
|
||||
function TaskRow({ task: t, color, members = [], projectId, onUpdate, onDel, expanded, onToggle }){
|
||||
const pr = PRI[t.priority] || PRI.medium, ov = overdue(t.dueDate)
|
||||
const cycle = e => { e.stopPropagation(); const c = { todo: 'in-progress', 'in-progress': 'done', done: 'todo' }; onUpdate(t.id, { status: c[t.status] }) }
|
||||
const togSub = sid => onUpdate(t.id, { subtasks: t.subtasks.map(s => s.id === sid ? { ...s, done: !s.done } : s) })
|
||||
const addSub = title => onUpdate(t.id, { subtasks: [...t.subtasks, { id: uid(), title, done: false }] })
|
||||
const delSub = sid => onUpdate(t.id, { subtasks: t.subtasks.filter(s => s.id !== sid) })
|
||||
const togSub = sid => onUpdate(t.id, { subtasks: (t.subtasks||[]).map(s => s.id === sid ? { ...s, done: !s.done } : s) })
|
||||
const addSub = title => onUpdate(t.id, { subtasks: [...(t.subtasks||[]), { id: uid(), title, done: false }] })
|
||||
const delSub = sid => onUpdate(t.id, { subtasks: (t.subtasks||[]).filter(s => s.id !== sid) })
|
||||
|
||||
const handleUpload = async e => {
|
||||
const file = e.target.files?.[0]; if (!file || !projectId) return
|
||||
try {
|
||||
const att = await api.uploadAttachment(projectId, t.id, file)
|
||||
onUpdate(t.id, { attachments: [...(t.attachments||[]), att] })
|
||||
} catch { /* show nothing on failure */ }
|
||||
e.target.value = ''
|
||||
}
|
||||
const delAttachment = async id => {
|
||||
if (!projectId) return
|
||||
await api.deleteAttachment(projectId, t.id, id).catch(()=>{})
|
||||
onUpdate(t.id, { attachments: (t.attachments||[]).filter(a => a.id !== id) })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 8, marginBottom: 5, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '9px 11px', display: 'flex', alignItems: 'center', gap: 9, cursor: 'pointer' }} onClick={onToggle}>
|
||||
@@ -419,12 +556,35 @@ function TaskRow({ task: t, color, members = [], onUpdate, onDel, expanded, onTo
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: pr.color }}>{pr.label}</span>
|
||||
{t.dueDate && <span style={{ fontSize: 10, color: ov ? '#ef4444' : '#475569' }}>{fmt(t.dueDate)}</span>}
|
||||
{t.subtasks.length > 0 && <span style={{ fontSize: 10, color: '#475569' }}>{t.subtasks.filter(s => s.done).length}/{t.subtasks.length}</span>}
|
||||
{t.estimatedHours > 0 && <span style={{ fontSize: 9, color: '#3d4166' }} title="Estimated hours">⏱{t.estimatedHours}h</span>}
|
||||
{t.recurrence && t.recurrence !== 'none' && <span style={{ fontSize: 9, color: '#6366f1' }} title={`Repeats ${t.recurrence}`}>🔁</span>}
|
||||
{(t.attachments?.length > 0) && <span style={{ fontSize: 9, color: '#64748b' }} title="Attachments">📎{t.attachments.length}</span>}
|
||||
{t.subtasks?.length > 0 && <span style={{ fontSize: 10, color: '#475569' }}>{t.subtasks.filter(s => s.done).length}/{t.subtasks.length}</span>}
|
||||
{t.assignedTo && (() => { const m = members.find(x => x.id === t.assignedTo); return m ? <div title={m.name} style={{ width: 16, height: 16, borderRadius: '50%', background: color, color: '#fff', fontSize: 8, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>{m.initials}</div> : null })()}
|
||||
<button onClick={e => { e.stopPropagation(); onDel(t.id) }} style={{ background: 'none', border: 'none', color: '#475569', cursor: 'pointer', padding: 0 }}><X size={11} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && <SubList task={t} onToggle={togSub} onAdd={addSub} onDel={delSub} />}
|
||||
{expanded && (
|
||||
<>
|
||||
<SubList task={t} onToggle={togSub} onAdd={addSub} onDel={delSub} />
|
||||
{/* Attachments section */}
|
||||
<div style={{ borderTop: '1px solid #181828', padding: '8px 11px 10px', marginLeft: 27 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 5 }}>Attachments</div>
|
||||
{(t.attachments||[]).map(a => (
|
||||
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 3 }}>
|
||||
<span style={{ fontSize: 10 }}>📎</span>
|
||||
<a href={api.toAbsoluteUrl(a.url)} target="_blank" rel="noreferrer" style={{ fontSize: 11, color: '#818cf8', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.originalName}</a>
|
||||
<span style={{ fontSize: 9, color: '#3d4166' }}>{(a.size/1024).toFixed(0)}KB</span>
|
||||
<button onClick={() => delAttachment(a.id)} style={{ background: 'none', border: 'none', color: '#475569', cursor: 'pointer', padding: 0 }}><X size={9} /></button>
|
||||
</div>
|
||||
))}
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 10, color: '#6366f1', cursor: 'pointer', marginTop: 3 }}>
|
||||
<Plus size={10} />Upload file
|
||||
<input type="file" style={{ display: 'none' }} onChange={handleUpload} />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -433,7 +593,7 @@ function SubList({ task, onToggle, onAdd, onDel }){
|
||||
const [val, setVal] = useState('')
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid #181828', padding: '8px 11px 10px 38px' }}>
|
||||
{task.subtasks.map(s => (
|
||||
{(task.subtasks||[]).map(s => (
|
||||
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '3px 0' }}>
|
||||
<button onClick={() => onToggle(s.id)} style={{ width: 13, height: 13, borderRadius: 3, border: '1px solid #252540', background: s.done ? '#6366f1' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0, flexShrink: 0 }}>{s.done && <Check size={8} stroke="#fff" />}</button>
|
||||
<span style={{ flex: 1, fontSize: 11, color: s.done ? '#475569' : '#94a3b8', textDecoration: s.done ? 'line-through' : 'none' }}>{s.title}</span>
|
||||
@@ -492,6 +652,19 @@ function TeamTab({ project: p, onChange, allMembers = [] }){
|
||||
const [mode, setMode] = useState('roster')
|
||||
const [nm, setNm] = useState({ name: '', role: '' })
|
||||
const [rSearch, setRSearch] = useState('')
|
||||
const [inviting, setInviting] = useState(false)
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [inviteResult, setInviteResult] = useState(null)
|
||||
const [inviteErr, setInviteErr] = useState('')
|
||||
|
||||
const doInvite = async () => {
|
||||
setInviteErr(''); setInviteResult(null)
|
||||
try {
|
||||
const res = await api.createInvite(p.id, inviteEmail)
|
||||
setInviteResult(res)
|
||||
setInviteEmail('')
|
||||
} catch (e) { setInviteErr('Failed to create invite. Is the backend running?') }
|
||||
}
|
||||
|
||||
const assigned = new Set(p.members.map(m => m.id))
|
||||
const available = allMembers.filter(m => !assigned.has(m.id) &&
|
||||
@@ -508,7 +681,28 @@ function TeamTab({ project: p, onChange, allMembers = [] }){
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => { setAdding(true); setMode('roster') }} style={{ width: '100%', padding: '8px', border: '1px dashed #1e2030', borderRadius: 8, background: 'transparent', color: '#475569', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 18 }}><Plus size={12}/> Assign Member</button>
|
||||
<div style={{ display: 'flex', gap: 7, marginBottom: 18 }}>
|
||||
<button onClick={() => { setAdding(true); setMode('roster') }} style={{ flex: 1, padding: '8px', border: '1px dashed #1e2030', borderRadius: 8, background: 'transparent', color: '#475569', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}><Plus size={12}/> Assign Member</button>
|
||||
<button onClick={() => setInviting(!inviting)} style={{ flex: 1, padding: '8px', border: '1px dashed #3d2060', borderRadius: 8, background: inviting ? 'rgba(99,102,241,0.08)' : 'transparent', color: '#818cf8', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>✉ Invite by email</button>
|
||||
</div>
|
||||
|
||||
{/* Invite panel */}
|
||||
{inviting && (
|
||||
<div style={{ background: '#0d0d1a', border: '1px solid #252545', borderRadius: 10, padding: 13, marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#818cf8', marginBottom: 8 }}>Send invite link</div>
|
||||
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
|
||||
<input type="email" value={inviteEmail} onChange={e => setInviteEmail(e.target.value)} placeholder="colleague@example.com" style={{ ...inp(), flex: 1, padding: '6px 8px' }} onKeyDown={e => e.key === 'Enter' && doInvite()} />
|
||||
<button onClick={doInvite} style={{ ...btn(), padding: '6px 14px', background: '#6366f1', color: '#fff', fontSize: 12 }}>Send</button>
|
||||
</div>
|
||||
{inviteErr && <div style={{ fontSize: 11, color: '#fca5a5', marginBottom: 6 }}>{inviteErr}</div>}
|
||||
{inviteResult && (
|
||||
<div style={{ fontSize: 11, color: '#86efac', background: '#0a1f0a', borderRadius: 6, padding: '8px 10px' }}>
|
||||
✓ Invite created!{inviteResult.token && <span style={{ display: 'block', marginTop: 4, color: '#64748b', wordBreak: 'break-all' }}>Token: {inviteResult.token.slice(0,16)}…<button onClick={() => navigator.clipboard?.writeText(inviteResult.token).catch(()=>{})} style={{ ...btn(), marginLeft: 6, fontSize: 10, color: '#818cf8', padding: '1px 5px', border: '1px solid #252545' }}>copy</button></span>}
|
||||
<span style={{ color: '#475569', display: 'block', marginTop: 3 }}>Expires: {new Date(inviteResult.expiresAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{adding && (
|
||||
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 10, padding: 13, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', gap: 5, marginBottom: 10 }}>
|
||||
@@ -658,4 +852,102 @@ function MembersPage({ members, onAdd, onUpdate, onDelete }){
|
||||
)
|
||||
}
|
||||
|
||||
// ── My Tasks (cross-project view) ──
|
||||
const PRI_COLOR_MT = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' }
|
||||
|
||||
function MyTasksView({ projects, allMembers, onSelectProject }) {
|
||||
const [memberId, setMemberId] = useState(allMembers[0]?.id || '')
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
const member = allMembers.find(m => m.id === memberId)
|
||||
|
||||
// Collect tasks across all projects for this member
|
||||
const taskItems = []
|
||||
projects.forEach(proj => {
|
||||
proj.tasks.forEach(t => {
|
||||
if (memberId && t.assignedTo !== memberId) return
|
||||
taskItems.push({ task: t, project: proj })
|
||||
})
|
||||
})
|
||||
|
||||
const filtered = filter === 'all' ? taskItems : taskItems.filter(i => i.task.status === filter)
|
||||
const todayNow = new Date()
|
||||
|
||||
const groups = {
|
||||
overdue: filtered.filter(i => i.task.dueDate && new Date(i.task.dueDate) < todayNow && i.task.status !== 'done'),
|
||||
inProgress: filtered.filter(i => i.task.status === 'in-progress'),
|
||||
todo: filtered.filter(i => i.task.status === 'todo' && !(i.task.dueDate && new Date(i.task.dueDate) < todayNow)),
|
||||
done: filtered.filter(i => i.task.status === 'done'),
|
||||
}
|
||||
|
||||
const renderItem = ({ task: t, project: proj }) => (
|
||||
<div key={t.id} onClick={() => onSelectProject(proj.id)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 13px', background: '#0d0d1a', border: '1px solid #1e2030', borderRadius: 8, marginBottom: 5, cursor: 'pointer' }}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = '#2d3148'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = '#1e2030'}
|
||||
>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', flexShrink: 0, background: t.status === 'done' ? '#10b981' : t.status === 'in-progress' ? '#f59e0b' : '#3d4166' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, color: t.status === 'done' ? '#475569' : '#e2e8f0', textDecoration: t.status === 'done' ? 'line-through' : 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.title}</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 2, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 10, color: '#64748b', borderLeft: `2px solid ${proj.color || '#6366f1'}`, paddingLeft: 5 }}>{proj.name}</span>
|
||||
<span style={{ fontSize: 10, color: PRI_COLOR_MT[t.priority] }}>{t.priority}</span>
|
||||
{t.dueDate && <span style={{ fontSize: 10, color: new Date(t.dueDate) < todayNow && t.status !== 'done' ? '#ef4444' : '#475569' }}>{fmt(t.dueDate)}</span>}
|
||||
{t.recurrence && t.recurrence !== 'none' && <span style={{ fontSize: 9, color: '#6366f1' }}>🔁 {t.recurrence}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{t.estimatedHours > 0 && <span style={{ fontSize: 10, color: '#3d4166', flexShrink: 0 }}>{t.estimatedHours}h</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
const Section = ({ label, color, items }) => items.length === 0 ? null : (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 7 }}>{label} · {items.length}</div>
|
||||
{items.map(renderItem)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: '#f1f5f9', marginBottom: 18 }}>
|
||||
✅ My Tasks
|
||||
<span style={{ fontSize: 12, color: '#475569', fontWeight: 400, marginLeft: 8 }}>{taskItems.length} total across {projects.length} project{projects.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
{/* Member picker */}
|
||||
{allMembers.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#64748b' }}>Showing tasks for:</span>
|
||||
<select value={memberId} onChange={e => setMemberId(e.target.value)}
|
||||
style={{ ...inp(), width: 'auto', padding: '5px 10px', fontSize: 12 }}>
|
||||
<option value="">All members</option>
|
||||
{allMembers.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status filter */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 18 }}>
|
||||
{['all','todo','in-progress','done'].map(s => (
|
||||
<button key={s} onClick={() => setFilter(s)}
|
||||
style={{ ...btn(), background: filter===s ? '#6366f1' : '#1e2030', color: filter===s ? '#fff' : '#94a3b8', padding: '5px 12px', fontSize: 11, border: 'none' }}>
|
||||
{s === 'all' ? 'All' : s === 'in-progress' ? 'In Progress' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>
|
||||
{memberId ? 'No tasks assigned to this member.' : 'No tasks found.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section label="⚠ Overdue" color="#ef4444" items={groups.overdue} />
|
||||
<Section label="In Progress" color="#f59e0b" items={groups.inProgress} />
|
||||
<Section label="To Do" color="#64748b" items={groups.todo} />
|
||||
{(filter === 'all' || filter === 'done') && <Section label="Done" color="#10b981" items={groups.done} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -1,17 +1,156 @@
|
||||
const BASE = (typeof window !== 'undefined' && window.ENV && window.ENV.API_URL) || import.meta.env.VITE_API_URL || 'http://localhost:4000/api'
|
||||
const h = { 'Content-Type': 'application/json' }
|
||||
const json = r => { if (!r.ok) throw new Error(r.status); return r.json() }
|
||||
const API_URL_STORAGE_KEY = 'project_hub_api_url'
|
||||
const DEFAULT_API_URL = 'http://localhost:4000/api'
|
||||
|
||||
let cachedBaseUrl = null
|
||||
|
||||
const normalizeApiUrl = (value) => {
|
||||
if (!value || typeof value !== 'string') return null
|
||||
const trimmed = value.trim().replace(/\/+$/, '')
|
||||
if (!trimmed) return null
|
||||
if (!/^https?:\/\//i.test(trimmed)) return null
|
||||
return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`
|
||||
}
|
||||
|
||||
const getEnvApiUrl = () => {
|
||||
if (typeof window !== 'undefined' && window.ENV?.API_URL) return window.ENV.API_URL
|
||||
return import.meta.env.VITE_API_URL || DEFAULT_API_URL
|
||||
}
|
||||
|
||||
const getStoredApiUrl = () => {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
return localStorage.getItem(API_URL_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getWriteApiKey = () => {
|
||||
if (typeof window !== 'undefined' && window.ENV?.API_KEY) return window.ENV.API_KEY
|
||||
return import.meta.env.VITE_WRITE_API_KEY || ''
|
||||
}
|
||||
|
||||
const parseResponse = async (response) => {
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new Error(body || String(response.status))
|
||||
}
|
||||
|
||||
if (response.status === 204) return null
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (!contentType.includes('application/json')) return null
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const getSyncBaseUrl = () => {
|
||||
if (cachedBaseUrl) return cachedBaseUrl
|
||||
const candidate = normalizeApiUrl(getStoredApiUrl()) || normalizeApiUrl(getEnvApiUrl()) || DEFAULT_API_URL
|
||||
cachedBaseUrl = candidate
|
||||
return cachedBaseUrl
|
||||
}
|
||||
|
||||
const getBaseUrl = async () => {
|
||||
if (cachedBaseUrl) return cachedBaseUrl
|
||||
|
||||
let runtimeUrl = null
|
||||
if (typeof window !== 'undefined' && window.app?.getAPIUrl) {
|
||||
runtimeUrl = await window.app.getAPIUrl().catch(() => null)
|
||||
}
|
||||
|
||||
cachedBaseUrl =
|
||||
normalizeApiUrl(runtimeUrl) ||
|
||||
normalizeApiUrl(getStoredApiUrl()) ||
|
||||
normalizeApiUrl(getEnvApiUrl()) ||
|
||||
DEFAULT_API_URL
|
||||
|
||||
return cachedBaseUrl
|
||||
}
|
||||
|
||||
const request = async (path, { method = 'GET', body, write = false, formData = false } = {}) => {
|
||||
const base = await getBaseUrl()
|
||||
const headers = {}
|
||||
|
||||
if (!formData) headers['Content-Type'] = 'application/json'
|
||||
if (write) {
|
||||
const key = getWriteApiKey()
|
||||
if (key) headers['x-api-key'] = key
|
||||
}
|
||||
|
||||
const options = { method, headers }
|
||||
if (body !== undefined) options.body = formData ? body : JSON.stringify(body)
|
||||
|
||||
const response = await fetch(`${base}${path}`, options)
|
||||
return parseResponse(response)
|
||||
}
|
||||
|
||||
const toAbsoluteUrl = (maybeRelativeUrl) => {
|
||||
if (!maybeRelativeUrl) return ''
|
||||
if (/^https?:\/\//i.test(maybeRelativeUrl)) return maybeRelativeUrl
|
||||
const root = getSyncBaseUrl().replace(/\/api$/i, '')
|
||||
const suffix = maybeRelativeUrl.startsWith('/') ? maybeRelativeUrl : `/${maybeRelativeUrl}`
|
||||
return `${root}${suffix}`
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getApiUrl: () => getBaseUrl(),
|
||||
setApiUrl: async (url) => {
|
||||
const normalized = normalizeApiUrl(url)
|
||||
if (!normalized) throw new Error('API URL must be http(s) and non-empty')
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try { localStorage.setItem(API_URL_STORAGE_KEY, normalized) } catch {}
|
||||
if (window.app?.setAPIUrl) await window.app.setAPIUrl(normalized)
|
||||
}
|
||||
|
||||
cachedBaseUrl = normalized
|
||||
return normalized
|
||||
},
|
||||
toAbsoluteUrl,
|
||||
|
||||
// Members
|
||||
getMembers: () => fetch(`${BASE}/members`).then(json),
|
||||
addMember: d => fetch(`${BASE}/members`, { method: 'POST', headers: h, body: JSON.stringify(d) }).then(json),
|
||||
updateMember: (id, d) => fetch(`${BASE}/members/${id}`, { method: 'PUT', headers: h, body: JSON.stringify(d) }).then(json),
|
||||
deleteMember: id => fetch(`${BASE}/members/${id}`, { method: 'DELETE' }),
|
||||
getMembers: () => request('/members'),
|
||||
addMember: d => request('/members', { method: 'POST', body: d, write: true }),
|
||||
updateMember: (id, d) => request(`/members/${id}`, { method: 'PUT', body: d, write: true }),
|
||||
deleteMember: id => request(`/members/${id}`, { method: 'DELETE', write: true }),
|
||||
|
||||
// Projects
|
||||
getProjects: () => fetch(`${BASE}/projects`).then(json),
|
||||
createProject: d => fetch(`${BASE}/projects`, { method: 'POST', headers: h, body: JSON.stringify(d) }).then(json),
|
||||
updateProject: (id,d) => fetch(`${BASE}/projects/${id}`, { method: 'PUT', headers: h, body: JSON.stringify(d) }),
|
||||
deleteProject: id => fetch(`${BASE}/projects/${id}`, { method: 'DELETE' }),
|
||||
getProjects: () => request('/projects'),
|
||||
createProject: d => request('/projects', { method: 'POST', body: d, write: true }),
|
||||
updateProject: (id, d) => request(`/projects/${id}`, { method: 'PUT', body: d, write: true }),
|
||||
deleteProject: id => request(`/projects/${id}`, { method: 'DELETE', write: true }),
|
||||
|
||||
// Project members
|
||||
assignMemberToProject: (projectId, memberId) =>
|
||||
request(`/projects/${projectId}/members/${memberId}`, { method: 'POST', write: true }),
|
||||
|
||||
// Tasks
|
||||
getTasks: projectId => request(`/projects/${projectId}/tasks`),
|
||||
createTask: (projectId, data) => request(`/projects/${projectId}/tasks`, { method: 'POST', body: data, write: true }),
|
||||
updateTask: (projectId, taskId, data) =>
|
||||
request(`/projects/${projectId}/tasks/${taskId}`, { method: 'PUT', body: data, write: true }),
|
||||
deleteTask: (projectId, taskId) => request(`/projects/${projectId}/tasks/${taskId}`, { method: 'DELETE', write: true }),
|
||||
|
||||
// Attachments
|
||||
uploadAttachment: (projectId, taskId, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return request(`/projects/${projectId}/tasks/${taskId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
formData: true,
|
||||
write: true,
|
||||
})
|
||||
},
|
||||
deleteAttachment: (projectId, taskId, attachmentId) =>
|
||||
request(`/projects/${projectId}/tasks/${taskId}/attachments/${attachmentId}`, { method: 'DELETE', write: true }),
|
||||
|
||||
// Invites
|
||||
createInvite: (projectId, email) =>
|
||||
request(`/projects/${projectId}/invites`, { method: 'POST', body: { email }, write: true }),
|
||||
getInvite: token => request(`/invites/${token}`),
|
||||
acceptInvite: (token, data) => request(`/invites/${token}/accept`, { method: 'POST', body: data }),
|
||||
|
||||
// Notify (test webhook/email)
|
||||
notify: (projectId, subject, text) =>
|
||||
request(`/projects/${projectId}/notify`, { method: 'POST', body: { subject, text }, write: true }),
|
||||
}
|
||||
|
||||
173
frontend/src/components/BurndownChart.jsx
Normal file
173
frontend/src/components/BurndownChart.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const SVG_W = 480
|
||||
const SVG_H = 220
|
||||
const PAD = { top: 16, right: 20, bottom: 36, left: 40 }
|
||||
const CW = SVG_W - PAD.left - PAD.right
|
||||
const CH = SVG_H - PAD.top - PAD.bottom
|
||||
|
||||
const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r }
|
||||
const dayStr = d => d.toISOString().slice(0, 10)
|
||||
|
||||
export default function BurndownChart({ project }) {
|
||||
const { tasks, startDate: pStart, dueDate: pEnd } = project
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||
|
||||
const start = pStart ? new Date(pStart) : (() => {
|
||||
const dates = tasks.filter(t => t.dueDate).map(t => new Date(t.dueDate))
|
||||
return dates.length ? new Date(Math.min(...dates)) : today
|
||||
})()
|
||||
start.setHours(0, 0, 0, 0)
|
||||
|
||||
const end = pEnd ? new Date(pEnd) : (() => {
|
||||
const dates = tasks.filter(t => t.dueDate).map(t => new Date(t.dueDate))
|
||||
return dates.length ? addDays(new Date(Math.max(...dates)), 1) : addDays(today, 14)
|
||||
})()
|
||||
end.setHours(0, 0, 0, 0)
|
||||
|
||||
const total = tasks.length
|
||||
const done = tasks.filter(t => t.status === 'done').length
|
||||
const open = total - done
|
||||
|
||||
const spanDays = Math.max(Math.round((end - start) / 86400000), 1)
|
||||
const daysLeft = Math.max(Math.round((end - today) / 86400000), 0)
|
||||
const elapsed = Math.round((today - start) / 86400000)
|
||||
|
||||
// Ideal burndown: linear from (0, total) to (spanDays, 0)
|
||||
const idealLine = total > 0
|
||||
? [[0, total], [spanDays, 0]].map(([d, v]) => [
|
||||
PAD.left + (d / spanDays) * CW,
|
||||
PAD.top + (1 - v / total) * CH,
|
||||
])
|
||||
: []
|
||||
|
||||
// Actual: we only know current state, so we draw from (0, total) → (elapsed, open)
|
||||
// This gives a useful snapshot even without historical data
|
||||
const actualPoints = total > 0 ? [
|
||||
[PAD.left, PAD.top],
|
||||
[PAD.left + Math.min(elapsed, spanDays) / spanDays * CW, PAD.top + (1 - open / total) * CH],
|
||||
] : []
|
||||
|
||||
const todayX = PAD.left + Math.min(elapsed, spanDays) / spanDays * CW
|
||||
|
||||
// Y axis ticks (0%, 25%, 50%, 75%, 100%)
|
||||
const yTicks = [0, 0.25, 0.5, 0.75, 1].map(frac => ({
|
||||
y: PAD.top + (1 - frac) * CH,
|
||||
label: Math.round(frac * total),
|
||||
}))
|
||||
|
||||
// X axis ticks — start / mid / end
|
||||
const xTicks = [0, 0.5, 1].map(frac => {
|
||||
const d = addDays(start, Math.round(frac * spanDays))
|
||||
return { x: PAD.left + frac * CW, label: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }
|
||||
})
|
||||
|
||||
const toPoints = pts => pts.map(([x, y]) => `${x},${y}`).join(' ')
|
||||
|
||||
const behind = total > 0 && elapsed > 0 && open > (total * (1 - elapsed / spanDays))
|
||||
|
||||
const stats = [
|
||||
{ label: 'Total', value: total, color: '#64748b' },
|
||||
{ label: 'Done', value: done, color: '#10b981' },
|
||||
{ label: 'Open', value: open, color: '#f59e0b' },
|
||||
{ label: 'Days left',value: daysLeft, color: daysLeft <= 3 ? '#ef4444' : '#818cf8' },
|
||||
]
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>
|
||||
Add tasks to generate the burndown chart.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 18 }}>
|
||||
{stats.map(s => (
|
||||
<div key={s.label} style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 8, padding: '10px 12px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: s.color, lineHeight: 1 }}>{s.value}</div>
|
||||
<div style={{ fontSize: 10, color: '#475569', marginTop: 3 }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{behind && (
|
||||
<div style={{ fontSize: 11, color: '#fca5a5', background: '#2d1515', border: '1px solid #3d1515', borderRadius: 6, padding: '6px 12px', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
⚠ Behind schedule — ideal remaining at this point: {Math.round(total * (1 - elapsed / spanDays))} tasks
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SVG chart */}
|
||||
<svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} width="100%" style={{ display: 'block', overflow: 'visible' }}>
|
||||
{/* Background */}
|
||||
<rect x={PAD.left} y={PAD.top} width={CW} height={CH} fill="#0a0a18" rx={4} />
|
||||
|
||||
{/* Y grid lines */}
|
||||
{yTicks.map((t, i) => (
|
||||
<g key={i}>
|
||||
<line x1={PAD.left} y1={t.y} x2={PAD.left + CW} y2={t.y} stroke="#181828" strokeWidth={0.5} />
|
||||
<text x={PAD.left - 4} y={t.y + 3.5} textAnchor="end" fill="#475569" fontSize={9}>{t.label}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* X axis ticks */}
|
||||
{xTicks.map((t, i) => (
|
||||
<text key={i} x={t.x} y={PAD.top + CH + 14} textAnchor="middle" fill="#475569" fontSize={9}>{t.label}</text>
|
||||
))}
|
||||
|
||||
{/* Ideal line (dashed) */}
|
||||
{idealLine.length === 2 && (
|
||||
<line x1={idealLine[0][0]} y1={idealLine[0][1]} x2={idealLine[1][0]} y2={idealLine[1][1]}
|
||||
stroke="#475569" strokeWidth={1.5} strokeDasharray="5,3" />
|
||||
)}
|
||||
|
||||
{/* Area under actual line */}
|
||||
{actualPoints.length === 2 && (
|
||||
<polygon
|
||||
points={`${actualPoints[0][0]},${actualPoints[0][1]} ${actualPoints[1][0]},${actualPoints[1][1]} ${actualPoints[1][0]},${PAD.top + CH} ${actualPoints[0][0]},${PAD.top + CH}`}
|
||||
fill="#6366f1" opacity={0.08} />
|
||||
)}
|
||||
|
||||
{/* Actual line */}
|
||||
{actualPoints.length === 2 && (
|
||||
<polyline points={toPoints(actualPoints)} fill="none" stroke="#6366f1" strokeWidth={2.5} strokeLinejoin="round" strokeLinecap="round" />
|
||||
)}
|
||||
|
||||
{/* Today vertical line */}
|
||||
{elapsed >= 0 && elapsed <= spanDays && (
|
||||
<line x1={todayX} y1={PAD.top} x2={todayX} y2={PAD.top + CH}
|
||||
stroke="#6366f1" strokeWidth={1} strokeDasharray="3,2" opacity={0.6} />
|
||||
)}
|
||||
|
||||
{/* Start / end dots */}
|
||||
{actualPoints.length > 0 && <circle cx={actualPoints[0][0]} cy={actualPoints[0][1]} r={4} fill="#6366f1" />}
|
||||
{actualPoints.length > 1 && <circle cx={actualPoints[1][0]} cy={actualPoints[1][1]} r={4} fill="#6366f1" />}
|
||||
|
||||
{/* Axes */}
|
||||
<line x1={PAD.left} y1={PAD.top} x2={PAD.left} y2={PAD.top + CH} stroke="#252540" />
|
||||
<line x1={PAD.left} y1={PAD.top + CH} x2={PAD.left + CW} y2={PAD.top + CH} stroke="#252540" />
|
||||
|
||||
{/* Y axis label */}
|
||||
<text x={10} y={PAD.top + CH / 2} fill="#475569" fontSize={9} textAnchor="middle"
|
||||
transform={`rotate(-90, 10, ${PAD.top + CH / 2})`}>Tasks remaining</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: '#475569' }}>
|
||||
<svg width={24} height={8}><line x1={0} y1={4} x2={24} y2={4} stroke="#475569" strokeWidth={1.5} strokeDasharray="4,2" /></svg>
|
||||
Ideal
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: '#475569' }}>
|
||||
<svg width={24} height={8}><line x1={0} y1={4} x2={24} y2={4} stroke="#6366f1" strokeWidth={2.5} /></svg>
|
||||
Actual
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: '#475569' }}>
|
||||
Snapshot as of today — historical data recorded over time
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
frontend/src/components/CalendarView.jsx
Normal file
147
frontend/src/components/CalendarView.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||
|
||||
const PRI_COLOR = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' }
|
||||
const STATUS_DOT = { todo: '#3d4166', 'in-progress': '#f59e0b', done: '#10b981' }
|
||||
|
||||
const isoDate = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
|
||||
export default function CalendarView({ project, onEditTask }) {
|
||||
const today = new Date()
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth())
|
||||
|
||||
const prevMonth = () => { if (month === 0) { setYear(y => y-1); setMonth(11) } else setMonth(m => m-1) }
|
||||
const nextMonth = () => { if (month === 11) { setYear(y => y+1); setMonth(0) } else setMonth(m => m+1) }
|
||||
|
||||
// Build a map of dateString → items
|
||||
const itemMap = {}
|
||||
const push = (dateStr, item) => { if (!itemMap[dateStr]) itemMap[dateStr] = []; itemMap[dateStr].push(item) }
|
||||
|
||||
project.tasks.forEach(t => {
|
||||
if (!t.dueDate) return
|
||||
const d = new Date(t.dueDate); d.setHours(0,0,0,0)
|
||||
push(isoDate(d), { type: 'task', data: t })
|
||||
})
|
||||
project.milestones.forEach(m => {
|
||||
if (!m.date) return
|
||||
const d = new Date(m.date); d.setHours(0,0,0,0)
|
||||
push(isoDate(d), { type: 'milestone', data: m })
|
||||
})
|
||||
|
||||
// Build calendar grid
|
||||
const firstDay = new Date(year, month, 1).getDay()
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const cells = []
|
||||
for (let i = 0; i < firstDay; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
// Pad to full weeks
|
||||
while (cells.length % 7 !== 0) cells.push(null)
|
||||
|
||||
const todayStr = isoDate(today)
|
||||
|
||||
const MAX_VISIBLE = 3
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<button onClick={prevMonth} style={{ background: 'none', border: '1px solid #1e2030', color: '#94a3b8', cursor: 'pointer', borderRadius: 6, padding: '4px 10px', fontSize: 16 }}>‹</button>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#e2e8f0' }}>{MONTHS[month]} {year}</div>
|
||||
<button onClick={nextMonth} style={{ background: 'none', border: '1px solid #1e2030', color: '#94a3b8', cursor: 'pointer', borderRadius: 6, padding: '4px 10px', fontSize: 16 }}>›</button>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 2 }}>
|
||||
{DAYS.map(d => (
|
||||
<div key={d} style={{ textAlign: 'center', fontSize: 10, fontWeight: 700, color: '#475569', padding: '4px 0', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
|
||||
{cells.map((day, idx) => {
|
||||
if (!day) return <div key={idx} style={{ minHeight: 80, background: '#0a0a18', borderRadius: 4 }} />
|
||||
|
||||
const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`
|
||||
const items = itemMap[dateStr] || []
|
||||
const isToday = dateStr === todayStr
|
||||
const visible = items.slice(0, MAX_VISIBLE)
|
||||
const overflow = items.length - MAX_VISIBLE
|
||||
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
minHeight: 80, background: '#0d0d1a',
|
||||
border: `1px solid ${isToday ? '#6366f1' : '#181828'}`,
|
||||
borderRadius: 6, padding: '5px 4px', display: 'flex', flexDirection: 'column', gap: 2,
|
||||
}}>
|
||||
{/* Day number */}
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: isToday ? 800 : 500,
|
||||
color: isToday ? '#818cf8' : '#64748b',
|
||||
marginBottom: 2, textAlign: 'right', paddingRight: 2,
|
||||
}}>
|
||||
{day}
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{visible.map((item, i) => (
|
||||
item.type === 'task' ? (
|
||||
<div key={i}
|
||||
onClick={() => onEditTask && onEditTask(item.data)}
|
||||
style={{
|
||||
fontSize: 10, lineHeight: '14px',
|
||||
background: '#0a0a18',
|
||||
borderLeft: `2px solid ${PRI_COLOR[item.data.priority] || '#475569'}`,
|
||||
borderRadius: '0 3px 3px 0',
|
||||
padding: '2px 4px',
|
||||
color: item.data.status === 'done' ? '#475569' : '#94a3b8',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
textDecoration: item.data.status === 'done' ? 'line-through' : 'none',
|
||||
}}
|
||||
title={item.data.title}
|
||||
>
|
||||
<span style={{ display: 'inline-block', width: 5, height: 5, borderRadius: '50%', background: STATUS_DOT[item.data.status], marginRight: 3, verticalAlign: 'middle' }} />
|
||||
{item.data.title}
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} style={{
|
||||
fontSize: 10, lineHeight: '14px',
|
||||
background: 'rgba(139,92,246,0.15)',
|
||||
borderLeft: '2px solid #8b5cf6',
|
||||
borderRadius: '0 3px 3px 0',
|
||||
padding: '2px 4px',
|
||||
color: item.data.completed ? '#475569' : '#c4b5fd',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
textDecoration: item.data.completed ? 'line-through' : 'none',
|
||||
}} title={item.data.title}>
|
||||
◆ {item.data.title}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
|
||||
{overflow > 0 && (
|
||||
<div style={{ fontSize: 9, color: '#475569', paddingLeft: 4 }}>+{overflow} more</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: 'flex', gap: 14, marginTop: 14, flexWrap: 'wrap' }}>
|
||||
{[['High priority','#ef4444'],['Medium','#f59e0b'],['Low','#10b981']].map(([l, c]) => (
|
||||
<span key={l} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10, color: '#475569' }}>
|
||||
<span style={{ display: 'inline-block', width: 8, height: 8, borderLeft: `3px solid ${c}`, borderRadius: '0 2px 2px 0', background: '#0a0a18' }} />{l}
|
||||
</span>
|
||||
))}
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10, color: '#475569' }}>
|
||||
<span style={{ color: '#8b5cf6' }}>◆</span> Milestone
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
231
frontend/src/components/GanttView.jsx
Normal file
231
frontend/src/components/GanttView.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
|
||||
const ROW_H = 34
|
||||
const HEAD_H = 52
|
||||
const LEFT_W = 200
|
||||
const DAY_W = 28
|
||||
const PAD_DAYS = 3
|
||||
|
||||
const STATUS_COLOR = {
|
||||
todo: '#3d4166',
|
||||
'in-progress': '#f59e0b',
|
||||
done: '#10b981',
|
||||
}
|
||||
const PRI_COLOR = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' }
|
||||
|
||||
const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r }
|
||||
const diffDays = (a, b) => Math.round((b - a) / 86400000)
|
||||
const fmt = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
|
||||
export default function GanttView({ project }) {
|
||||
const svgRef = useRef(null)
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
|
||||
const { tasks, milestones } = project
|
||||
|
||||
// Compute timeline bounds -----------------------------------------
|
||||
const dates = []
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||
|
||||
if (project.startDate) dates.push(new Date(project.startDate))
|
||||
if (project.dueDate) dates.push(new Date(project.dueDate))
|
||||
tasks.forEach(t => {
|
||||
if (t.startDate) dates.push(new Date(t.startDate))
|
||||
if (t.dueDate) dates.push(new Date(t.dueDate))
|
||||
})
|
||||
milestones.forEach(m => { if (m.date) dates.push(new Date(m.date)) })
|
||||
dates.push(today)
|
||||
|
||||
if (dates.length === 0) {
|
||||
return <div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>Add tasks with due dates to see the Gantt chart.</div>
|
||||
}
|
||||
|
||||
const rawStart = new Date(Math.min(...dates))
|
||||
const rawEnd = new Date(Math.max(...dates))
|
||||
rawStart.setHours(0, 0, 0, 0)
|
||||
rawEnd.setHours(0, 0, 0, 0)
|
||||
|
||||
const start = addDays(rawStart, -PAD_DAYS)
|
||||
const end = addDays(rawEnd, PAD_DAYS + 1)
|
||||
const totalDays = diffDays(start, end)
|
||||
|
||||
const SVG_W = LEFT_W + totalDays * DAY_W
|
||||
const taskRows = tasks.filter(t => t.dueDate)
|
||||
const msRows = milestones.filter(m => m.date)
|
||||
const SVG_H = HEAD_H + (taskRows.length + msRows.length) * ROW_H + 10
|
||||
|
||||
const dayX = date => LEFT_W + diffDays(start, date) * DAY_W
|
||||
const todayX = dayX(today)
|
||||
|
||||
// Build month tick marks
|
||||
const months = []
|
||||
const cur = new Date(start)
|
||||
cur.setDate(1)
|
||||
while (cur <= end) {
|
||||
months.push({ label: cur.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }), x: dayX(cur) })
|
||||
cur.setMonth(cur.getMonth() + 1)
|
||||
}
|
||||
|
||||
// Week ticks (only if span ≤ 120 days)
|
||||
const weekTicks = []
|
||||
if (totalDays <= 120) {
|
||||
const w = new Date(start)
|
||||
while (w <= end) {
|
||||
weekTicks.push(dayX(w))
|
||||
w.setDate(w.getDate() + 7)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto', overflowY: 'auto', position: 'relative', maxHeight: '70vh' }}>
|
||||
<svg ref={svgRef} width={SVG_W} height={SVG_H} style={{ display: 'block', fontSize: 11, fontFamily: 'inherit' }}>
|
||||
<defs>
|
||||
<clipPath id="clip-chart"><rect x={LEFT_W} y={0} width={SVG_W - LEFT_W} height={SVG_H} /></clipPath>
|
||||
</defs>
|
||||
|
||||
{/* Background */}
|
||||
<rect width={SVG_W} height={SVG_H} fill="#090910" />
|
||||
|
||||
{/* Row backgrounds */}
|
||||
{[...taskRows, ...msRows].map((_, i) => (
|
||||
<rect key={i} x={0} y={HEAD_H + i * ROW_H} width={SVG_W} height={ROW_H}
|
||||
fill={i % 2 === 0 ? '#0a0a18' : '#0d0d1a'} />
|
||||
))}
|
||||
|
||||
{/* Left column background */}
|
||||
<rect x={0} y={0} width={LEFT_W} height={SVG_H} fill="#0d0d1a" />
|
||||
<line x1={LEFT_W} y1={0} x2={LEFT_W} y2={SVG_H} stroke="#181828" strokeWidth={1} />
|
||||
|
||||
{/* Week grid lines */}
|
||||
{weekTicks.map((x, i) => (
|
||||
<line key={i} x1={x} y1={HEAD_H} x2={x} y2={SVG_H} stroke="#181828" strokeWidth={1} strokeDasharray="2,4" clipPath="url(#clip-chart)" />
|
||||
))}
|
||||
|
||||
{/* Month header */}
|
||||
<rect x={0} y={0} width={SVG_W} height={HEAD_H} fill="#0d0d1a" />
|
||||
<line x1={0} y1={HEAD_H} x2={SVG_W} y2={HEAD_H} stroke="#181828" />
|
||||
{months.map((m, i) => (
|
||||
<g key={i}>
|
||||
<line x1={m.x} y1={0} x2={m.x} y2={HEAD_H} stroke="#252540" />
|
||||
<text x={m.x + 6} y={22} fill="#64748b" fontSize={10} fontWeight={600}>{m.label}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Today line */}
|
||||
<line x1={todayX} y1={HEAD_H} x2={todayX} y2={SVG_H} stroke="#6366f1" strokeWidth={1.5} strokeDasharray="4,3" clipPath="url(#clip-chart)" />
|
||||
<text x={todayX + 3} y={HEAD_H - 6} fill="#6366f1" fontSize={9}>Today</text>
|
||||
|
||||
{/* Project span bar at header bottom */}
|
||||
{project.startDate && project.dueDate && (() => {
|
||||
const ps = dayX(new Date(project.startDate))
|
||||
const pe = dayX(new Date(project.dueDate))
|
||||
return <rect x={ps} y={HEAD_H - 8} width={Math.max(pe - ps, 4)} height={4} rx={2} fill={project.color || '#6366f1'} opacity={0.7} clipPath="url(#clip-chart)" />
|
||||
})()}
|
||||
|
||||
{/* Task rows */}
|
||||
{taskRows.map((t, i) => {
|
||||
const y = HEAD_H + i * ROW_H
|
||||
const due = new Date(t.dueDate)
|
||||
const taskStart = t.startDate ? new Date(t.startDate)
|
||||
: t.estimatedHours ? addDays(due, -Math.ceil(t.estimatedHours / 8))
|
||||
: addDays(due, -1)
|
||||
const x1 = Math.max(dayX(taskStart), LEFT_W + 2)
|
||||
const x2 = Math.max(dayX(due), x1 + 6)
|
||||
const barW = x2 - x1
|
||||
const barColor = STATUS_COLOR[t.status] || '#3d4166'
|
||||
const priColor = PRI_COLOR[t.priority] || '#475569'
|
||||
|
||||
return (
|
||||
<g key={t.id} onMouseEnter={(e) => setTooltip({ t, x: e.clientX, y: e.clientY })} onMouseLeave={() => setTooltip(null)}>
|
||||
{/* Left group: priority stripe + name */}
|
||||
<rect x={0} y={y} width={LEFT_W} height={ROW_H} fill="transparent" />
|
||||
<rect x={0} y={y} width={3} height={ROW_H} fill={priColor} opacity={0.7} />
|
||||
{/* status dot */}
|
||||
<circle cx={12} cy={y + ROW_H / 2} r={4}
|
||||
fill={t.status === 'done' ? barColor : 'none'}
|
||||
stroke={barColor} strokeWidth={1.5} />
|
||||
{t.status === 'done' && <polyline points={`${10},${y + ROW_H / 2} ${12},${y + ROW_H / 2 + 2} ${15},${y + ROW_H / 2 - 2}`} fill="none" stroke="#fff" strokeWidth={1} />}
|
||||
{t.status === 'in-progress' && <circle cx={12} cy={y + ROW_H / 2} r={2} fill={barColor} />}
|
||||
<text x={22} y={y + ROW_H / 2 + 4} fill={t.status === 'done' ? '#475569' : '#cbd5e1'} fontSize={11}
|
||||
textDecoration={t.status === 'done' ? 'line-through' : 'none'}>
|
||||
{t.title.length > 20 ? t.title.slice(0, 19) + '…' : t.title}
|
||||
</text>
|
||||
|
||||
{/* Bar */}
|
||||
<rect x={x1} y={y + 9} width={barW} height={ROW_H - 18} rx={3} fill={barColor} opacity={0.85}
|
||||
clipPath="url(#clip-chart)" style={{ cursor: 'pointer' }} />
|
||||
|
||||
{/* Progress overlay */}
|
||||
{t.estimatedHours > 0 && t.actualHours > 0 && (
|
||||
<rect x={x1} y={y + 9}
|
||||
width={Math.min(barW * (t.actualHours / t.estimatedHours), barW)}
|
||||
height={ROW_H - 18} rx={3} fill="#fff" opacity={0.15}
|
||||
clipPath="url(#clip-chart)" />
|
||||
)}
|
||||
|
||||
{/* Due date dot */}
|
||||
<circle cx={dayX(due)} cy={y + ROW_H / 2} r={3} fill={barColor} clipPath="url(#clip-chart)" />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Milestone rows */}
|
||||
{msRows.map((ms, i) => {
|
||||
const y = HEAD_H + (taskRows.length + i) * ROW_H
|
||||
const mx = dayX(new Date(ms.date))
|
||||
const D = 7
|
||||
|
||||
return (
|
||||
<g key={ms.id}>
|
||||
<rect x={0} y={y} width={LEFT_W} height={ROW_H} fill="transparent" />
|
||||
<rect x={0} y={y} width={3} height={ROW_H} fill="#8b5cf6" opacity={0.7} />
|
||||
<text x={22} y={y + ROW_H / 2 + 4} fill={ms.completed ? '#475569' : '#c4b5fd'} fontSize={11}
|
||||
textDecoration={ms.completed ? 'line-through' : 'none'}>
|
||||
{ms.title.length > 20 ? ms.title.slice(0, 19) + '…' : ms.title}
|
||||
</text>
|
||||
{/* Diamond */}
|
||||
<polygon points={`${mx},${y + ROW_H / 2 - D} ${mx + D},${y + ROW_H / 2} ${mx},${y + ROW_H / 2 + D} ${mx - D},${y + ROW_H / 2}`}
|
||||
fill={ms.completed ? '#8b5cf6' : 'none'} stroke="#8b5cf6" strokeWidth={1.5}
|
||||
clipPath="url(#clip-chart)" />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Row dividers */}
|
||||
{[...taskRows, ...msRows].map((_, i) => (
|
||||
<line key={i} x1={0} y1={HEAD_H + (i + 1) * ROW_H} x2={SVG_W} y2={HEAD_H + (i + 1) * ROW_H}
|
||||
stroke="#181828" strokeWidth={0.5} />
|
||||
))}
|
||||
|
||||
{/* Section labels */}
|
||||
{taskRows.length > 0 && (
|
||||
<text x={6} y={HEAD_H - 10} fill="#475569" fontSize={9} fontWeight={700} textTransform="uppercase">TASKS</text>
|
||||
)}
|
||||
{msRows.length > 0 && (
|
||||
<text x={6} y={HEAD_H + taskRows.length * ROW_H - 10} fill="#7c3aed" fontSize={9} fontWeight={700}>MILESTONES</text>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div style={{
|
||||
position: 'fixed', top: tooltip.y + 10, left: tooltip.x + 10,
|
||||
background: '#1e2030', border: '1px solid #252540', borderRadius: 8,
|
||||
padding: '8px 12px', zIndex: 9999, fontSize: 11, color: '#e2e8f0',
|
||||
pointerEvents: 'none', maxWidth: 200,
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>{tooltip.t.title}</div>
|
||||
<div style={{ color: '#64748b' }}>Status: <span style={{ color: STATUS_COLOR[tooltip.t.status] }}>{tooltip.t.status}</span></div>
|
||||
{tooltip.t.dueDate && <div style={{ color: '#64748b' }}>Due: {new Date(tooltip.t.dueDate).toLocaleDateString()}</div>}
|
||||
{tooltip.t.estimatedHours && <div style={{ color: '#64748b' }}>Est: {tooltip.t.estimatedHours}h</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskRows.length === 0 && msRows.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0', color: '#475569', fontSize: 13 }}>
|
||||
Add tasks or milestones with dates to see the Gantt chart.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user