feat: feature log CRUD endpoints, timestamps, and project list sorting
- Add dedicated REST endpoints for feature CRUD (GET/POST/PUT/DELETE /api/projects/:id/features) - Add normalizeFeatureEntry() with updatedAt, updatedBy, shippedAt lifecycle - Auto-set shippedAt when status transitions to 'shipped' - Frontend api.js: getFeatures, createFeature, updateFeature, deleteFeature methods - App.jsx: optimistic CRUD handlers using dedicated feature endpoints - Project list: feature-status filter dropdown + 5-way sort (feature activity, feature status, project status, due date, name) - Project cards: feature log preview (latest entry + in-flight count) - FeaturesTab: filter by status, sort by updatedAt desc - FeatureRow: show updatedAt, updatedBy, shippedAt metadata; updatedBy edit field
This commit is contained in:
@@ -43,10 +43,42 @@ const toInitials = (name = '') =>
|
|||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
|
|
||||||
|
const FEATURE_TYPES = ['idea', 'feature', 'fix', 'removal'];
|
||||||
|
const FEATURE_STATUSES = ['backlog', 'planned', 'in-progress', 'shipped', 'dropped'];
|
||||||
|
|
||||||
|
const normalizeFeatureEntry = (feature = {}, previousFeature = null) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const title = String(feature?.title || previousFeature?.title || '').trim() || 'Untitled entry';
|
||||||
|
const type = FEATURE_TYPES.includes(feature?.type) ? feature.type : (FEATURE_TYPES.includes(previousFeature?.type) ? previousFeature.type : 'idea');
|
||||||
|
const status = FEATURE_STATUSES.includes(feature?.status) ? feature.status : (FEATURE_STATUSES.includes(previousFeature?.status) ? previousFeature.status : 'backlog');
|
||||||
|
const createdAt = feature?.createdAt || previousFeature?.createdAt || now;
|
||||||
|
const updatedAt = feature?.updatedAt || previousFeature?.updatedAt || feature?.createdAt || previousFeature?.createdAt || now;
|
||||||
|
const updatedBy = typeof feature?.updatedBy === 'string'
|
||||||
|
? feature.updatedBy.trim()
|
||||||
|
: typeof previousFeature?.updatedBy === 'string'
|
||||||
|
? previousFeature.updatedBy.trim()
|
||||||
|
: '';
|
||||||
|
const shippedAt = feature?.shippedAt || previousFeature?.shippedAt || (status === 'shipped' ? (updatedAt || createdAt || now) : '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: feature?.id || previousFeature?.id || randomUUID(),
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
note: feature?.note || feature?.description || previousFeature?.note || '',
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
updatedBy,
|
||||||
|
shippedAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const ensureProjectShape = (project) => {
|
const ensureProjectShape = (project) => {
|
||||||
if (!Array.isArray(project.members)) project.members = [];
|
if (!Array.isArray(project.members)) project.members = [];
|
||||||
if (!Array.isArray(project.tasks)) project.tasks = [];
|
if (!Array.isArray(project.tasks)) project.tasks = [];
|
||||||
if (!Array.isArray(project.milestones)) project.milestones = [];
|
if (!Array.isArray(project.milestones)) project.milestones = [];
|
||||||
|
if (!Array.isArray(project.features)) project.features = [];
|
||||||
|
project.features = project.features.map(feature => normalizeFeatureEntry(feature));
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -262,7 +294,7 @@ app.get('/api/projects', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/projects', limiter, requireApiKey, async (req, res) => {
|
app.post('/api/projects', limiter, requireApiKey, async (req, res) => {
|
||||||
const project = { id: Date.now().toString(), members: [], tasks: [], milestones: [], ...req.body };
|
const project = { id: Date.now().toString(), members: [], tasks: [], milestones: [], features: [], ...req.body };
|
||||||
ensureProjectShape(project);
|
ensureProjectShape(project);
|
||||||
db.data.projects.push(project);
|
db.data.projects.push(project);
|
||||||
await db.write();
|
await db.write();
|
||||||
@@ -373,6 +405,58 @@ app.delete('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, asy
|
|||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Feature log CRUD
|
||||||
|
app.get('/api/projects/:projectId/features', (req, res) => {
|
||||||
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
ensureProjectShape(project);
|
||||||
|
res.json(project.features);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:projectId/features', limiter, requireApiKey, async (req, res) => {
|
||||||
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
ensureProjectShape(project);
|
||||||
|
|
||||||
|
const feature = normalizeFeatureEntry(req.body);
|
||||||
|
project.features.push(feature);
|
||||||
|
await db.write();
|
||||||
|
res.status(201).json(feature);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/projects/:projectId/features/:featureId', limiter, requireApiKey, async (req, res) => {
|
||||||
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
ensureProjectShape(project);
|
||||||
|
const idx = project.features.findIndex(feature => feature.id === req.params.featureId);
|
||||||
|
if (idx === -1) return res.status(404).json({ error: 'Feature entry not found' });
|
||||||
|
|
||||||
|
const current = project.features[idx];
|
||||||
|
project.features[idx] = normalizeFeatureEntry({
|
||||||
|
...current,
|
||||||
|
...req.body,
|
||||||
|
id: req.params.featureId,
|
||||||
|
createdAt: current.createdAt,
|
||||||
|
shippedAt: req.body.status === 'shipped'
|
||||||
|
? (req.body.shippedAt || current.shippedAt || new Date().toISOString())
|
||||||
|
: (req.body.status && req.body.status !== 'shipped' ? '' : current.shippedAt),
|
||||||
|
}, current);
|
||||||
|
|
||||||
|
await db.write();
|
||||||
|
res.json(project.features[idx]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:projectId/features/:featureId', limiter, requireApiKey, async (req, res) => {
|
||||||
|
const project = db.data.projects.find(p => p.id === req.params.projectId);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
ensureProjectShape(project);
|
||||||
|
const idx = project.features.findIndex(feature => feature.id === req.params.featureId);
|
||||||
|
if (idx === -1) return res.status(404).json({ error: 'Feature entry not found' });
|
||||||
|
project.features.splice(idx, 1);
|
||||||
|
await db.write();
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Static uploads ──────────────────────────────────────────────────────────
|
// ── Static uploads ──────────────────────────────────────────────────────────
|
||||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { api } from './api.js'
|
import { api, ATTACHMENT_MAX_FILE_SIZE_LABEL } from './api.js'
|
||||||
import GanttView from './components/GanttView.jsx'
|
import GanttView from './components/GanttView.jsx'
|
||||||
import CalendarView from './components/CalendarView.jsx'
|
import CalendarView from './components/CalendarView.jsx'
|
||||||
import BurndownChart from './components/BurndownChart.jsx'
|
import BurndownChart from './components/BurndownChart.jsx'
|
||||||
@@ -58,10 +58,43 @@ const SKEY = 'pm_hub_v1'
|
|||||||
const COLORS = ['#6366f1','#8b5cf6','#ec4899','#f97316','#10b981','#3b82f6','#f59e0b','#ef4444']
|
const COLORS = ['#6366f1','#8b5cf6','#ec4899','#f97316','#10b981','#3b82f6','#f59e0b','#ef4444']
|
||||||
const STATUS = { planning: { label: 'Planning', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' }, active: { label: 'Active', color: '#10b981', bg: 'rgba(16,185,129,0.12)' }, 'on-hold': { label: 'On Hold', color: '#f97316', bg: 'rgba(249,115,22,0.12)' }, completed: { label: 'Completed', color: '#818cf8', bg: 'rgba(129,140,248,0.12)' } }
|
const STATUS = { planning: { label: 'Planning', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' }, active: { label: 'Active', color: '#10b981', bg: 'rgba(16,185,129,0.12)' }, 'on-hold': { label: 'On Hold', color: '#f97316', bg: 'rgba(249,115,22,0.12)' }, completed: { label: 'Completed', color: '#818cf8', bg: 'rgba(129,140,248,0.12)' } }
|
||||||
const PRI = { low: { label: 'Low', color: '#10b981' }, medium: { label: 'Med', color: '#f59e0b' }, high: { label: 'High', color: '#ef4444' } }
|
const PRI = { low: { label: 'Low', color: '#10b981' }, medium: { label: 'Med', color: '#f59e0b' }, high: { label: 'High', color: '#ef4444' } }
|
||||||
|
const FEATURE_TYPE = {
|
||||||
|
idea: { label: 'Idea', color: '#38bdf8', bg: 'rgba(56,189,248,0.12)' },
|
||||||
|
feature: { label: 'Feature', color: '#818cf8', bg: 'rgba(129,140,248,0.12)' },
|
||||||
|
fix: { label: 'Fix', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||||
|
removal: { label: 'Removal', color: '#f87171', bg: 'rgba(248,113,113,0.12)' },
|
||||||
|
}
|
||||||
|
const FEATURE_STATUS = {
|
||||||
|
backlog: { label: 'Backlog', color: '#64748b', bg: 'rgba(100,116,139,0.12)' },
|
||||||
|
planned: { label: 'Planned', color: '#38bdf8', bg: 'rgba(56,189,248,0.12)' },
|
||||||
|
'in-progress': { label: 'In Progress', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
|
||||||
|
shipped: { label: 'Shipped', color: '#10b981', bg: 'rgba(16,185,129,0.12)' },
|
||||||
|
dropped: { label: 'Dropped', color: '#f87171', bg: 'rgba(248,113,113,0.12)' },
|
||||||
|
}
|
||||||
const uid = () => Math.random().toString(36).slice(2,9)
|
const uid = () => Math.random().toString(36).slice(2,9)
|
||||||
const prog = tasks => !tasks.length ? 0 : Math.round(tasks.filter(t => t.status === 'done').length / tasks.length * 100)
|
const prog = tasks => !tasks.length ? 0 : Math.round(tasks.filter(t => t.status === 'done').length / tasks.length * 100)
|
||||||
const overdue = d => d && new Date(d) < new Date()
|
const overdue = d => d && new Date(d) < new Date()
|
||||||
const fmt = d => d ? new Date(d).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}) : '—'
|
const fmt = d => d ? new Date(d).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}) : '—'
|
||||||
|
const fmtDateTime = d => d ? new Date(d).toLocaleString('en-US',{month:'short',day:'numeric',year:'numeric',hour:'numeric',minute:'2-digit'}) : '—'
|
||||||
|
const featureStatusRank = status => ({ 'in-progress': 0, planned: 1, backlog: 2, shipped: 3, dropped: 4 }[status] ?? 5)
|
||||||
|
const normalizeFeature = feature => ({
|
||||||
|
id: feature?.id || uid(),
|
||||||
|
title: String(feature?.title || '').trim() || 'Untitled entry',
|
||||||
|
type: FEATURE_TYPE[feature?.type] ? feature.type : 'idea',
|
||||||
|
status: FEATURE_STATUS[feature?.status] ? feature.status : 'backlog',
|
||||||
|
note: feature?.note || feature?.description || '',
|
||||||
|
createdAt: feature?.createdAt || new Date().toISOString(),
|
||||||
|
updatedAt: feature?.updatedAt || feature?.createdAt || new Date().toISOString(),
|
||||||
|
updatedBy: typeof feature?.updatedBy === 'string' ? feature.updatedBy : '',
|
||||||
|
shippedAt: feature?.shippedAt || (feature?.status === 'shipped' ? (feature?.updatedAt || feature?.createdAt || new Date().toISOString()) : ''),
|
||||||
|
})
|
||||||
|
const normalizeProject = project => ({
|
||||||
|
...project,
|
||||||
|
members: Array.isArray(project?.members) ? project.members : [],
|
||||||
|
tasks: Array.isArray(project?.tasks) ? project.tasks : [],
|
||||||
|
milestones: Array.isArray(project?.milestones) ? project.milestones : [],
|
||||||
|
features: Array.isArray(project?.features) ? project.features.map(normalizeFeature) : [],
|
||||||
|
})
|
||||||
|
|
||||||
// Sample data (full from original inline HTML)
|
// Sample data (full from original inline HTML)
|
||||||
const AP = {id:"authentiapol",name:"AuthentiPol",description:"React Native + PWA delivering 0-100 Authenticity Score from public data. Non-partisan Say vs Do vs Funded.",status:"active",color:"#6366f1",startDate:"2026-03-14",dueDate:"2026-06-12",
|
const AP = {id:"authentiapol",name:"AuthentiPol",description:"React Native + PWA delivering 0-100 Authenticity Score from public data. Non-partisan Say vs Do vs Funded.",status:"active",color:"#6366f1",startDate:"2026-03-14",dueDate:"2026-06-12",
|
||||||
@@ -87,6 +120,11 @@ const AP = {id:"authentiapol",name:"AuthentiPol",description:"React Native + PWA
|
|||||||
{id:"ms4",title:"PWA soft launch (Day -7)",date:"2026-06-05",completed:false},
|
{id:"ms4",title:"PWA soft launch (Day -7)",date:"2026-06-05",completed:false},
|
||||||
{id:"ms5",title:"🚀 App Store + X live thread + email blast",date:"2026-06-12",completed:false},
|
{id:"ms5",title:"🚀 App Store + X live thread + email blast",date:"2026-06-12",completed:false},
|
||||||
{id:"ms6",title:"Press round-up + PostHog iteration (Day +7)",date:"2026-06-19",completed:false},
|
{id:"ms6",title:"Press round-up + PostHog iteration (Day +7)",date:"2026-06-19",completed:false},
|
||||||
|
],
|
||||||
|
features:[
|
||||||
|
{id:"af1",title:"Public politician scorecards",type:"feature",status:"planned",note:"Full public profile pages with score breakdowns, receipts, and revision history.",createdAt:"2026-03-16T09:00:00.000Z"},
|
||||||
|
{id:"af2",title:"False-positive vote matching cleanup",type:"fix",status:"in-progress",note:"Tighten promise-to-vote matching to reduce noisy mismatches in the scoring engine.",createdAt:"2026-03-16T14:30:00.000Z"},
|
||||||
|
{id:"af3",title:"Remove manual CSV import fallback",type:"removal",status:"backlog",note:"Retire the temporary admin import once the nightly ETL pipeline is stable.",createdAt:"2026-03-17T08:15:00.000Z"},
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,11 +132,13 @@ const SAMPLES = [
|
|||||||
{id:"s1",name:"Website Redesign",description:"Full overhaul with new branding and improved UX",status:"active",color:"#8b5cf6",startDate:"2026-01-15",dueDate:"2026-04-30",
|
{id:"s1",name:"Website Redesign",description:"Full overhaul with new branding and improved UX",status:"active",color:"#8b5cf6",startDate:"2026-01-15",dueDate:"2026-04-30",
|
||||||
members:[{id:"m1",name:"Alice Chen",role:"Designer",initials:"AC"},{id:"m2",name:"Bob Smith",role:"Developer",initials:"BS"}],
|
members:[{id:"m1",name:"Alice Chen",role:"Designer",initials:"AC"},{id:"m2",name:"Bob Smith",role:"Developer",initials:"BS"}],
|
||||||
tasks:[{id:"t1",title:"Wireframes",status:"done",priority:"high",dueDate:"2026-02-01",subtasks:[]},{id:"t2",title:"Design system",status:"done",priority:"high",dueDate:"2026-02-15",subtasks:[]},{id:"t3",title:"Frontend dev",status:"in-progress",priority:"high",dueDate:"2026-03-30",subtasks:[{id:"ss1",title:"Homepage",done:true},{id:"ss2",title:"About page",done:false}]},{id:"t4",title:"QA Testing",status:"todo",priority:"medium",dueDate:"2026-04-25",subtasks:[]}],
|
tasks:[{id:"t1",title:"Wireframes",status:"done",priority:"high",dueDate:"2026-02-01",subtasks:[]},{id:"t2",title:"Design system",status:"done",priority:"high",dueDate:"2026-02-15",subtasks:[]},{id:"t3",title:"Frontend dev",status:"in-progress",priority:"high",dueDate:"2026-03-30",subtasks:[{id:"ss1",title:"Homepage",done:true},{id:"ss2",title:"About page",done:false}]},{id:"t4",title:"QA Testing",status:"todo",priority:"medium",dueDate:"2026-04-25",subtasks:[]}],
|
||||||
milestones:[{id:"ms1",title:"Design approval",date:"2026-02-20",completed:true},{id:"ms2",title:"Go live",date:"2026-04-30",completed:false}]},
|
milestones:[{id:"ms1",title:"Design approval",date:"2026-02-20",completed:true},{id:"ms2",title:"Go live",date:"2026-04-30",completed:false}],
|
||||||
|
features:[{id:"sf1",title:"Dark-mode toggle review",type:"idea",status:"backlog",note:"Reassess once the new brand palette is locked.",createdAt:"2026-03-10T10:00:00.000Z"}]},
|
||||||
{id:"s2",name:"Q2 Marketing Campaign",description:"Multi-channel campaign for Q2 product launch",status:"planning",color:"#ec4899",startDate:"2026-03-01",dueDate:"2026-05-31",
|
{id:"s2",name:"Q2 Marketing Campaign",description:"Multi-channel campaign for Q2 product launch",status:"planning",color:"#ec4899",startDate:"2026-03-01",dueDate:"2026-05-31",
|
||||||
members:[{id:"m3",name:"Frank Johnson",role:"Marketing Lead",initials:"FJ"},{id:"m4",name:"Grace Kim",role:"Content Writer",initials:"GK"}],
|
members:[{id:"m3",name:"Frank Johnson",role:"Marketing Lead",initials:"FJ"},{id:"m4",name:"Grace Kim",role:"Content Writer",initials:"GK"}],
|
||||||
tasks:[{id:"t5",title:"Strategy document",status:"done",priority:"high",dueDate:"2026-03-10",subtasks:[]},{id:"t6",title:"Content calendar",status:"in-progress",priority:"medium",dueDate:"2026-03-20",subtasks:[]},{id:"t7",title:"Ad creatives",status:"todo",priority:"high",dueDate:"2026-04-01",subtasks:[]}],
|
tasks:[{id:"t5",title:"Strategy document",status:"done",priority:"high",dueDate:"2026-03-10",subtasks:[]},{id:"t6",title:"Content calendar",status:"in-progress",priority:"medium",dueDate:"2026-03-20",subtasks:[]},{id:"t7",title:"Ad creatives",status:"todo",priority:"high",dueDate:"2026-04-01",subtasks:[]}],
|
||||||
milestones:[{id:"ms3",title:"Campaign kickoff",date:"2026-04-01",completed:false}]},
|
milestones:[{id:"ms3",title:"Campaign kickoff",date:"2026-04-01",completed:false}],
|
||||||
|
features:[{id:"sf2",title:"Retire PDF-only briefs",type:"removal",status:"planned",note:"Replace with shared live briefing docs for campaign stakeholders.",createdAt:"2026-03-12T11:30:00.000Z"}]},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Styles helpers
|
// Styles helpers
|
||||||
@@ -122,6 +162,8 @@ function App(){
|
|||||||
const [serverOpen, setServerOpen] = useState(false)
|
const [serverOpen, setServerOpen] = useState(false)
|
||||||
const [apiUrlInput, setApiUrlInput] = useState('')
|
const [apiUrlInput, setApiUrlInput] = useState('')
|
||||||
const [apiUrlMsg, setApiUrlMsg] = useState('')
|
const [apiUrlMsg, setApiUrlMsg] = useState('')
|
||||||
|
const [featureStatusFilter, setFeatureStatusFilter] = useState('all')
|
||||||
|
const [projectSort, setProjectSort] = useState('feature-updated-desc')
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
(async()=>{
|
(async()=>{
|
||||||
@@ -132,17 +174,18 @@ function App(){
|
|||||||
|
|
||||||
try { const m = await api.getMembers(); setAllMembers(m) } catch {}
|
try { const m = await api.getMembers(); setAllMembers(m) } catch {}
|
||||||
try {
|
try {
|
||||||
const bp = await api.getProjects()
|
const bp = (await api.getProjects()).map(normalizeProject)
|
||||||
if(bp.length > 0){ setProjects(bp); setLoaded(true); return }
|
if(bp.length > 0){ setProjects(bp); setLoaded(true); return }
|
||||||
} catch {}
|
} catch {}
|
||||||
let ps
|
let ps
|
||||||
try{
|
try{
|
||||||
const r = await persist.get(SKEY)
|
const r = await persist.get(SKEY)
|
||||||
if(r?.value){
|
if(r?.value){
|
||||||
const saved = JSON.parse(r.value)
|
const saved = JSON.parse(r.value).map(normalizeProject)
|
||||||
ps = saved.some(p => p.id === 'authentiapol') ? saved : [AP, ...saved]
|
ps = saved.some(p => p.id === 'authentiapol') ? saved : [AP, ...saved]
|
||||||
} else ps = [AP, ...SAMPLES]
|
} else ps = [AP, ...SAMPLES]
|
||||||
} catch { ps = [AP, ...SAMPLES] }
|
} catch { ps = [AP, ...SAMPLES] }
|
||||||
|
ps = ps.map(normalizeProject)
|
||||||
setProjects(ps)
|
setProjects(ps)
|
||||||
try { for(const p of ps) await api.createProject(p) } catch {}
|
try { for(const p of ps) await api.createProject(p) } catch {}
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
@@ -152,12 +195,13 @@ function App(){
|
|||||||
useEffect(()=>{ if(!loaded) return; (async()=>{ try{ await persist.set(SKEY, JSON.stringify(projects)) }catch{} })() }, [projects, loaded])
|
useEffect(()=>{ if(!loaded) return; (async()=>{ try{ await persist.set(SKEY, JSON.stringify(projects)) }catch{} })() }, [projects, loaded])
|
||||||
|
|
||||||
const save = p => {
|
const save = p => {
|
||||||
|
const normalized = normalizeProject(p)
|
||||||
if(p.id){
|
if(p.id){
|
||||||
setProjects(ps => ps.map(x => x.id === p.id ? p : x))
|
setProjects(ps => ps.map(x => x.id === p.id ? normalized : x))
|
||||||
if(sel?.id === p.id) setSel(p)
|
if(sel?.id === p.id) setSel(normalized)
|
||||||
api.updateProject(p.id, p).catch(() => {})
|
api.updateProject(p.id, normalized).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
const n = {...p, id: uid()}
|
const n = normalizeProject({ ...normalized, id: uid() })
|
||||||
setProjects(ps => [...ps, n])
|
setProjects(ps => [...ps, n])
|
||||||
api.createProject(n).catch(() => {})
|
api.createProject(n).catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -173,45 +217,116 @@ function App(){
|
|||||||
const createTaskForProject = async (projectId, taskDraft) => {
|
const createTaskForProject = async (projectId, taskDraft) => {
|
||||||
try {
|
try {
|
||||||
const created = await api.createTask(projectId, taskDraft)
|
const created = await api.createTask(projectId, taskDraft)
|
||||||
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, created] } : p))
|
setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, tasks: [...p.tasks, created] }) : p))
|
||||||
if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, created] } : s)
|
if (sel?.id === projectId) setSel(s => s ? normalizeProject({ ...s, tasks: [...s.tasks, created] }) : s)
|
||||||
} catch {
|
} catch {
|
||||||
const local = { ...taskDraft, id: uid(), subtasks: taskDraft.subtasks || [], attachments: taskDraft.attachments || [] }
|
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))
|
setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, tasks: [...p.tasks, local] }) : p))
|
||||||
if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, local] } : s)
|
if (sel?.id === projectId) setSel(s => s ? normalizeProject({ ...s, tasks: [...s.tasks, local] }) : s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateTaskForProject = async (projectId, taskId, patch) => {
|
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))
|
setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, tasks: p.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) }) : p))
|
||||||
if (sel?.id === projectId) {
|
if (sel?.id === projectId) {
|
||||||
setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : s)
|
setSel(s => s ? normalizeProject({ ...s, tasks: s.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) }) : s)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateTask(projectId, taskId, patch)
|
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))
|
setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, tasks: p.tasks.map(t => t.id === taskId ? updated : t) }) : p))
|
||||||
if (sel?.id === projectId) {
|
if (sel?.id === projectId) {
|
||||||
setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? updated : t) } : s)
|
setSel(s => s ? normalizeProject({ ...s, tasks: s.tasks.map(t => t.id === taskId ? updated : t) }) : s)
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteTaskForProject = async (projectId, taskId) => {
|
const deleteTaskForProject = async (projectId, taskId) => {
|
||||||
setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.filter(t => t.id !== taskId) } : p))
|
setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, tasks: p.tasks.filter(t => t.id !== taskId) }) : p))
|
||||||
if (sel?.id === projectId) {
|
if (sel?.id === projectId) {
|
||||||
setSel(s => s ? { ...s, tasks: s.tasks.filter(t => t.id !== taskId) } : s)
|
setSel(s => s ? normalizeProject({ ...s, tasks: s.tasks.filter(t => t.id !== taskId) }) : s)
|
||||||
}
|
}
|
||||||
try { await api.deleteTask(projectId, taskId) } catch {}
|
try { await api.deleteTask(projectId, taskId) } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const change = u => {
|
const createFeatureForProject = async (projectId, featureDraft) => {
|
||||||
setProjects(ps => ps.map(p => p.id === u.id ? u : p))
|
const optimistic = normalizeFeature({ ...featureDraft, id: uid(), updatedAt: new Date().toISOString() })
|
||||||
setSel(u)
|
setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, features: [optimistic, ...p.features] }) : p))
|
||||||
api.updateProject(u.id, u).catch(() => {})
|
if (sel?.id === projectId) setSel(s => s ? normalizeProject({ ...s, features: [optimistic, ...s.features] }) : s)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = normalizeFeature(await api.createFeature(projectId, featureDraft))
|
||||||
|
setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, features: p.features.map(feature => feature.id === optimistic.id ? created : feature) }) : p))
|
||||||
|
if (sel?.id === projectId) setSel(s => s ? normalizeProject({ ...s, features: s.features.map(feature => feature.id === optimistic.id ? created : feature) }) : s)
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = projects.filter(p => { const ms = p.name.toLowerCase().includes(search.toLowerCase()) || p.description.toLowerCase().includes(search.toLowerCase()); return ms && (fSt === 'all' || p.status === fSt) })
|
const updateFeatureForProject = async (projectId, featureId, patch) => {
|
||||||
|
const patchWithMeta = { ...patch, updatedAt: new Date().toISOString() }
|
||||||
|
if (patch.status === 'shipped') patchWithMeta.shippedAt = patch.shippedAt || new Date().toISOString()
|
||||||
|
if (patch.status && patch.status !== 'shipped') patchWithMeta.shippedAt = ''
|
||||||
|
|
||||||
|
setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({
|
||||||
|
...p,
|
||||||
|
features: p.features.map(feature => feature.id === featureId ? { ...feature, ...patchWithMeta } : feature),
|
||||||
|
}) : p))
|
||||||
|
if (sel?.id === projectId) {
|
||||||
|
setSel(s => s ? normalizeProject({
|
||||||
|
...s,
|
||||||
|
features: s.features.map(feature => feature.id === featureId ? { ...feature, ...patchWithMeta } : feature),
|
||||||
|
}) : s)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = normalizeFeature(await api.updateFeature(projectId, featureId, patch))
|
||||||
|
setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({
|
||||||
|
...p,
|
||||||
|
features: p.features.map(feature => feature.id === featureId ? updated : feature),
|
||||||
|
}) : p))
|
||||||
|
if (sel?.id === projectId) {
|
||||||
|
setSel(s => s ? normalizeProject({
|
||||||
|
...s,
|
||||||
|
features: s.features.map(feature => feature.id === featureId ? updated : feature),
|
||||||
|
}) : s)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFeatureForProject = async (projectId, featureId) => {
|
||||||
|
setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, features: p.features.filter(feature => feature.id !== featureId) }) : p))
|
||||||
|
if (sel?.id === projectId) {
|
||||||
|
setSel(s => s ? normalizeProject({ ...s, features: s.features.filter(feature => feature.id !== featureId) }) : s)
|
||||||
|
}
|
||||||
|
try { await api.deleteFeature(projectId, featureId) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const change = u => {
|
||||||
|
const normalized = normalizeProject(u)
|
||||||
|
setProjects(ps => ps.map(p => p.id === normalized.id ? normalized : p))
|
||||||
|
setSel(normalized)
|
||||||
|
api.updateProject(normalized.id, normalized).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleProjects = [...projects]
|
||||||
|
.filter(p => {
|
||||||
|
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()) || p.description.toLowerCase().includes(search.toLowerCase())
|
||||||
|
const matchesProjectStatus = fSt === 'all' || p.status === fSt
|
||||||
|
const matchesFeatureStatus = featureStatusFilter === 'all' || p.features.some(feature => feature.status === featureStatusFilter)
|
||||||
|
return matchesSearch && matchesProjectStatus && matchesFeatureStatus
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (projectSort === 'name-asc') return a.name.localeCompare(b.name)
|
||||||
|
if (projectSort === 'due-asc') return String(a.dueDate || '9999-12-31').localeCompare(String(b.dueDate || '9999-12-31'))
|
||||||
|
if (projectSort === 'status') return (STATUS[a.status]?.label || a.status).localeCompare(STATUS[b.status]?.label || b.status)
|
||||||
|
if (projectSort === 'feature-status') {
|
||||||
|
const aRank = Math.min(...(a.features.map(feature => featureStatusRank(feature.status)).concat(99)))
|
||||||
|
const bRank = Math.min(...(b.features.map(feature => featureStatusRank(feature.status)).concat(99)))
|
||||||
|
return aRank - bRank
|
||||||
|
}
|
||||||
|
const aUpdated = Math.max(...a.features.map(feature => new Date(feature.updatedAt || feature.createdAt || 0).getTime()).concat(0))
|
||||||
|
const bUpdated = Math.max(...b.features.map(feature => new Date(feature.updatedAt || feature.createdAt || 0).getTime()).concat(0))
|
||||||
|
return bUpdated - aUpdated
|
||||||
|
})
|
||||||
|
|
||||||
const stats = { total: projects.length, active: projects.filter(p => p.status === 'active').length, completed: projects.filter(p => p.status === 'completed').length, overdue: projects.filter(p => overdue(p.dueDate) && p.status !== 'completed').length }
|
const stats = { total: projects.length, active: projects.filter(p => p.status === 'active').length, completed: projects.filter(p => p.status === 'completed').length, overdue: projects.filter(p => overdue(p.dueDate) && p.status !== 'completed').length }
|
||||||
|
|
||||||
@@ -236,7 +351,7 @@ function App(){
|
|||||||
<button onClick={() => { setApiUrlMsg(''); setServerOpen(true) }} style={{ ...btn(), background: 'transparent', color: '#94a3b8', border: '1px solid #252540', padding: '7px 12px', fontSize: 12, fontWeight: 500 }}>
|
<button onClick={() => { setApiUrlMsg(''); setServerOpen(true) }} style={{ ...btn(), background: 'transparent', color: '#94a3b8', border: '1px solid #252540', padding: '7px 12px', fontSize: 12, fontWeight: 500 }}>
|
||||||
Server
|
Server
|
||||||
</button>
|
</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 }}>
|
{view === 'projects' && <button onClick={()=>setEditing({name:'',description:'',status:'planning',color:COLORS[0],startDate:'',dueDate:'',members:[],tasks:[],milestones:[],features:[]})} style={{ ...btn(), background: '#6366f1', color: '#fff', padding: '8px 15px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<Plus size={14}/> New Project
|
<Plus size={14}/> New Project
|
||||||
</button>}
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
@@ -272,7 +387,7 @@ function App(){
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + Filter */}
|
{/* Search + Filter */}
|
||||||
<div style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
|
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
|
||||||
<Search size={13} style={{ position: 'absolute', left: 11, top: '50%', transform: 'translateY(-50%)', color: '#475569' }} />
|
<Search size={13} style={{ position: 'absolute', left: 11, top: '50%', transform: 'translateY(-50%)', color: '#475569' }} />
|
||||||
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search projects…" style={{ ...inp(), paddingLeft: 32 }} />
|
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search projects…" style={{ ...inp(), paddingLeft: 32 }} />
|
||||||
@@ -284,23 +399,34 @@ function App(){
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<select value={featureStatusFilter} onChange={e => setFeatureStatusFilter(e.target.value)} style={{ ...inp(), width: 160, padding: '7px 10px', fontSize: 11 }}>
|
||||||
|
<option value="all">All feature states</option>
|
||||||
|
{Object.entries(FEATURE_STATUS).map(([key, meta]) => <option key={key} value={key}>{meta.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={projectSort} onChange={e => setProjectSort(e.target.value)} style={{ ...inp(), width: 190, padding: '7px 10px', fontSize: 11 }}>
|
||||||
|
<option value="feature-updated-desc">Sort: Recent feature activity</option>
|
||||||
|
<option value="feature-status">Sort: Feature status</option>
|
||||||
|
<option value="status">Sort: Project status</option>
|
||||||
|
<option value="due-asc">Sort: Due date</option>
|
||||||
|
<option value="name-asc">Sort: Name</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cards */}
|
{/* Cards */}
|
||||||
{!filtered.length ? (
|
{!visibleProjects.length ? (
|
||||||
<div style={{ textAlign: 'center', padding: '80px 0', color: '#475569' }}>
|
<div style={{ textAlign: 'center', padding: '80px 0', color: '#475569' }}>
|
||||||
<div style={{ fontSize: 36, marginBottom: 10 }}>📋</div>
|
<div style={{ fontSize: 36, marginBottom: 10 }}>📋</div>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#64748b' }}>No projects found</div>
|
<div style={{ fontSize: 15, fontWeight: 600, color: '#64748b' }}>No projects found</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(310px,1fr))', gap: 18 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(310px,1fr))', gap: 18 }}>
|
||||||
{filtered.map(p => <Card key={p.id} project={p} onOpen={() => { setSel(p); setTab('tasks') }} onEdit={() => setEditing(p)} onDel={() => setDelId(p.id)} />)}
|
{visibleProjects.map(p => <Card key={p.id} project={p} onOpen={() => { setSel(p); setTab('tasks') }} onEdit={() => setEditing(p)} onDel={() => setDelId(p.id)} />)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sel && <Panel project={sel} tab={tab} setTab={setTab} onClose={() => setSel(null)} onEdit={() => setEditing(sel)} onChange={change} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} allMembers={allMembers} />}
|
{sel && <Panel project={sel} tab={tab} setTab={setTab} onClose={() => setSel(null)} onEdit={() => setEditing(sel)} onChange={change} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} onFeatureCreate={createFeatureForProject} onFeatureUpdate={updateFeatureForProject} onFeatureDelete={deleteFeatureForProject} allMembers={allMembers} />}
|
||||||
{editing !== null && <FormModal project={editing} onSave={save} onClose={() => setEditing(null)} />}
|
{editing !== null && <FormModal project={editing} onSave={save} onClose={() => setEditing(null)} />}
|
||||||
{delId && (
|
{delId && (
|
||||||
<Overlay>
|
<Overlay>
|
||||||
@@ -353,6 +479,8 @@ function App(){
|
|||||||
// ── Card ──
|
// ── Card ──
|
||||||
function Card({ project: p, onOpen, onEdit, onDel }){
|
function Card({ project: p, onOpen, onEdit, onDel }){
|
||||||
const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning, ov = overdue(p.dueDate) && p.status !== 'completed'
|
const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning, ov = overdue(p.dueDate) && p.status !== 'completed'
|
||||||
|
const latestFeature = [...p.features].sort((a, b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0))[0]
|
||||||
|
const inFlightFeatureCount = p.features.filter(feature => ['backlog', 'planned', 'in-progress'].includes(feature.status)).length
|
||||||
const [menu, setMenu] = useState(false)
|
const [menu, setMenu] = useState(false)
|
||||||
return (
|
return (
|
||||||
<div onClick={onOpen} onMouseEnter={e => e.currentTarget.style.borderColor = p.color} onMouseLeave={e => e.currentTarget.style.borderColor = '#181828'} style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 14, padding: '18px 18px 16px', cursor: 'pointer', transition: 'border-color 0.2s', position: 'relative', overflow: 'hidden' }}>
|
<div onClick={onOpen} onMouseEnter={e => e.currentTarget.style.borderColor = p.color} onMouseLeave={e => e.currentTarget.style.borderColor = '#181828'} style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 14, padding: '18px 18px 16px', cursor: 'pointer', transition: 'border-color 0.2s', position: 'relative', overflow: 'hidden' }}>
|
||||||
@@ -389,12 +517,26 @@ function Card({ project: p, onOpen, onEdit, onDel }){
|
|||||||
{p.members.length > 3 && <div style={{ width: 22, height: 22, borderRadius: '50%', background: '#181828', color: '#64748b', fontSize: 8, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', marginLeft: -5, border: '2px solid #0d0d1a' }}>+{p.members.length-3}</div>}
|
{p.members.length > 3 && <div style={{ width: 22, height: 22, borderRadius: '50%', background: '#181828', color: '#64748b', fontSize: 8, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', marginLeft: -5, border: '2px solid #0d0d1a' }}>+{p.members.length-3}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ marginTop: 13, borderTop: '1px solid #181828', paddingTop: 11 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 10, color: '#64748b', marginBottom: latestFeature ? 6 : 0 }}>
|
||||||
|
<span>Feature log</span>
|
||||||
|
<span>{p.features.length} entries · {inFlightFeatureCount} open</span>
|
||||||
|
</div>
|
||||||
|
{latestFeature ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 10, color: '#94a3b8' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 7px', borderRadius: 999, background: (FEATURE_STATUS[latestFeature.status] || FEATURE_STATUS.backlog).bg, color: (FEATURE_STATUS[latestFeature.status] || FEATURE_STATUS.backlog).color, fontWeight: 700 }}>{(FEATURE_STATUS[latestFeature.status] || FEATURE_STATUS.backlog).label}</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{latestFeature.title}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 10, color: '#475569' }}>No feature activity yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Detail Panel ──
|
// ── Detail Panel ──
|
||||||
function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreate, onTaskUpdate, onTaskDelete, allMembers = [] }){
|
function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreate, onTaskUpdate, onTaskDelete, onFeatureCreate, onFeatureUpdate, onFeatureDelete, allMembers = [] }){
|
||||||
const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning
|
const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning
|
||||||
const upTask = (id, u) => {
|
const upTask = (id, u) => {
|
||||||
const task = p.tasks.find(t => t.id === id)
|
const task = p.tasks.find(t => t.id === id)
|
||||||
@@ -424,6 +566,9 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreat
|
|||||||
const togMs = id => onChange({ ...p, milestones: p.milestones.map(m => m.id === id ? { ...m, completed: !m.completed } : m) })
|
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 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) })
|
const delMs = id => onChange({ ...p, milestones: p.milestones.filter(m => m.id !== id) })
|
||||||
|
const addFeature = feature => onFeatureCreate?.(p.id, feature)
|
||||||
|
const updateFeature = (featureId, patch) => onFeatureUpdate?.(p.id, featureId, patch)
|
||||||
|
const delFeature = featureId => onFeatureDelete?.(p.id, featureId)
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, display: 'flex' }}>
|
<div style={{ position: 'fixed', inset: 0, zIndex: 100, display: 'flex' }}>
|
||||||
<div style={{ flex: 1, background: 'rgba(0,0,0,0.55)' }} onClick={onClose} />
|
<div style={{ flex: 1, background: 'rgba(0,0,0,0.55)' }} onClick={onClose} />
|
||||||
@@ -446,16 +591,17 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreat
|
|||||||
<div style={{ height: 5, background: '#181828', borderRadius: 999 }}><div style={{ height: '100%', width: `${pr}%`, background: p.color, borderRadius: 999 }} /></div>
|
<div style={{ height: 5, background: '#181828', borderRadius: 999 }}><div style={{ height: '100%', width: `${pr}%`, background: p.color, borderRadius: 999 }} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 2, marginTop: 14, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 2, marginTop: 14, flexWrap: 'wrap' }}>
|
||||||
{['tasks','milestones','team','gantt','calendar','burndown'].map(t => (
|
{['tasks','features','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' }}>
|
<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' ? '📋 Tasks' : t === 'features' ? '🧩 Features' : 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>}
|
{(t === 'tasks' || t === 'features' || t === 'milestones' || t === 'team') && <span style={{ marginLeft: 3, background: '#181828', padding: '1px 5px', borderRadius: 999, fontSize: 9 }}>{t === 'tasks' ? p.tasks.length : t === 'features' ? p.features.length : t === 'milestones' ? p.milestones.length : p.members.length}</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '18px 22px', flex: 1 }}>
|
<div style={{ padding: '18px 22px', flex: 1 }}>
|
||||||
{tab === 'tasks' && <TasksTab project={p} onUpdate={upTask} onAdd={addTask} onDel={delTask} />}
|
{tab === 'tasks' && <TasksTab project={p} onUpdate={upTask} onAdd={addTask} onDel={delTask} />}
|
||||||
|
{tab === 'features' && <FeaturesTab project={p} onAdd={addFeature} onUpdate={updateFeature} onDel={delFeature} />}
|
||||||
{tab === 'milestones' && <MsTab project={p} onToggle={togMs} onAdd={addMs} onDel={delMs} />}
|
{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 === 'gantt' && <GanttView project={p} />}
|
||||||
@@ -582,6 +728,9 @@ function TaskRow({ task: t, color, members = [], projectId, onUpdate, onDel, exp
|
|||||||
<Plus size={10} />Upload file
|
<Plus size={10} />Upload file
|
||||||
<input type="file" style={{ display: 'none' }} onChange={handleUpload} />
|
<input type="file" style={{ display: 'none' }} onChange={handleUpload} />
|
||||||
</label>
|
</label>
|
||||||
|
<div style={{ fontSize: 9, color: '#475569', marginTop: 5 }}>
|
||||||
|
Max file size: {ATTACHMENT_MAX_FILE_SIZE_LABEL} per upload.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -647,6 +796,116 @@ function MsTab({ project: p, onToggle, onAdd, onDel }){
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FeaturesTab({ project: p, onAdd, onUpdate, onDel }){
|
||||||
|
const EMPTY = { title: '', type: 'idea', status: 'backlog', note: '', updatedBy: '' }
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(EMPTY)
|
||||||
|
const [filter, setFilter] = useState('all')
|
||||||
|
const [expanded, setExpanded] = useState(null)
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!draft.title.trim()) return
|
||||||
|
onAdd({ ...draft, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() })
|
||||||
|
setDraft(EMPTY)
|
||||||
|
setAdding(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...p.features]
|
||||||
|
.sort((a, b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0))
|
||||||
|
.filter(feature => filter === 'all' || feature.status === filter)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setAdding(true)} 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: 12 }}><Plus size={12}/> Add Feature Log Entry</button>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||||
|
{['all','backlog','planned','in-progress','shipped','dropped'].map(status => (
|
||||||
|
<button key={status} onClick={() => setFilter(status)} style={{ ...btn(), padding: '5px 10px', fontSize: 11, background: filter === status ? '#1d2440' : '#111827', color: filter === status ? '#bfdbfe' : '#64748b', border: '1px solid #1e2030' }}>
|
||||||
|
{status === 'all' ? 'All' : FEATURE_STATUS[status].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{adding && (
|
||||||
|
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 10, padding: 13, marginBottom: 14 }}>
|
||||||
|
<input value={draft.title} onChange={e => setDraft({ ...draft, title: e.target.value })} placeholder="Entry title…" style={inp({ marginBottom: 8 })} autoFocus />
|
||||||
|
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
|
||||||
|
<select value={draft.type} onChange={e => setDraft({ ...draft, type: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }}>
|
||||||
|
<option value="idea">Idea</option>
|
||||||
|
<option value="feature">Feature</option>
|
||||||
|
<option value="fix">Fix</option>
|
||||||
|
<option value="removal">Removal</option>
|
||||||
|
</select>
|
||||||
|
<select value={draft.status} onChange={e => setDraft({ ...draft, status: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }}>
|
||||||
|
<option value="backlog">Backlog</option>
|
||||||
|
<option value="planned">Planned</option>
|
||||||
|
<option value="in-progress">In Progress</option>
|
||||||
|
<option value="shipped">Shipped</option>
|
||||||
|
<option value="dropped">Dropped</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input value={draft.updatedBy} onChange={e => setDraft({ ...draft, updatedBy: e.target.value })} placeholder="Updated by (optional)…" style={inp({ marginBottom: 8 })} />
|
||||||
|
<textarea value={draft.note} onChange={e => setDraft({ ...draft, note: e.target.value })} placeholder="Context, rationale, bug notes, or removal details…" rows={4} style={inp({ resize: 'vertical', minHeight: 90, marginBottom: 9 })} />
|
||||||
|
<div style={{ display: 'flex', gap: 7 }}>
|
||||||
|
<button onClick={submit} style={{ ...btn(), flex: 1, padding: '7px', background: '#6366f1', color: '#fff' }}>Add Entry</button>
|
||||||
|
<button onClick={() => { setAdding(false); setDraft(EMPTY) }} style={{ ...btn(), flex: 1, padding: '7px', background: 'transparent', border: '1px solid #181828', color: '#64748b', fontWeight: 500 }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!items.length && !adding && <div style={{ textAlign: 'center', padding: '40px 0', color: '#475569', fontSize: 12 }}>No feature log entries yet.</div>}
|
||||||
|
{items.map(feature => <FeatureRow key={feature.id} entry={feature} expanded={expanded === feature.id} onToggle={() => setExpanded(expanded === feature.id ? null : feature.id)} onUpdate={onUpdate} onDel={onDel} />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureRow({ entry, expanded, onToggle, onUpdate, onDel }){
|
||||||
|
const typeMeta = FEATURE_TYPE[entry.type] || FEATURE_TYPE.idea
|
||||||
|
const statusMeta = FEATURE_STATUS[entry.status] || FEATURE_STATUS.backlog
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 8, marginBottom: 8, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '10px 11px', display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }} onClick={onToggle}>
|
||||||
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: typeMeta.color, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#e2e8f0', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{entry.title}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 4 }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 8px', borderRadius: 999, background: typeMeta.bg, color: typeMeta.color, fontSize: 10, fontWeight: 700 }}>{typeMeta.label}</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 8px', borderRadius: 999, background: statusMeta.bg, color: statusMeta.color, fontSize: 10, fontWeight: 700 }}>{statusMeta.label}</span>
|
||||||
|
<span style={{ fontSize: 10, color: '#475569', alignSelf: 'center' }}>Updated {fmtDateTime(entry.updatedAt || entry.createdAt)}</span>
|
||||||
|
{entry.updatedBy && <span style={{ fontSize: 10, color: '#475569', alignSelf: 'center' }}>by {entry.updatedBy}</span>}
|
||||||
|
{entry.shippedAt && <span style={{ fontSize: 10, color: '#10b981', alignSelf: 'center' }}>Shipped {fmt(entry.shippedAt)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={e => { e.stopPropagation(); onDel(entry.id) }} style={{ background: 'none', border: 'none', color: '#475569', cursor: 'pointer', padding: 0 }}><X size={11} /></button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ borderTop: '1px solid #181828', padding: '10px 11px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 7, marginBottom: 8 }}>
|
||||||
|
<select value={entry.type} onChange={e => onUpdate(entry.id, { type: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }}>
|
||||||
|
<option value="idea">Idea</option>
|
||||||
|
<option value="feature">Feature</option>
|
||||||
|
<option value="fix">Fix</option>
|
||||||
|
<option value="removal">Removal</option>
|
||||||
|
</select>
|
||||||
|
<select value={entry.status} onChange={e => onUpdate(entry.id, { status: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }}>
|
||||||
|
<option value="backlog">Backlog</option>
|
||||||
|
<option value="planned">Planned</option>
|
||||||
|
<option value="in-progress">In Progress</option>
|
||||||
|
<option value="shipped">Shipped</option>
|
||||||
|
<option value="dropped">Dropped</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input value={entry.title} onChange={e => onUpdate(entry.id, { title: e.target.value })} placeholder="Entry title…" style={inp({ marginBottom: 8 })} />
|
||||||
|
<input value={entry.updatedBy || ''} onChange={e => onUpdate(entry.id, { updatedBy: e.target.value })} placeholder="Updated by (optional)…" style={inp({ marginBottom: 8 })} />
|
||||||
|
<textarea value={entry.note || ''} onChange={e => onUpdate(entry.id, { note: e.target.value })} rows={4} placeholder="Add notes, acceptance details, or context…" style={inp({ resize: 'vertical', minHeight: 90, marginBottom: 8 })} />
|
||||||
|
<div style={{ fontSize: 10, color: '#475569' }}>
|
||||||
|
Created {fmtDateTime(entry.createdAt)}
|
||||||
|
{entry.shippedAt ? ` · Shipped ${fmtDateTime(entry.shippedAt)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function TeamTab({ project: p, onChange, allMembers = [] }){
|
function TeamTab({ project: p, onChange, allMembers = [] }){
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
const [mode, setMode] = useState('roster')
|
const [mode, setMode] = useState('roster')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const API_URL_STORAGE_KEY = 'project_hub_api_url'
|
const API_URL_STORAGE_KEY = 'project_hub_api_url'
|
||||||
const DEFAULT_API_URL = 'http://localhost:4000/api'
|
const DEFAULT_API_URL = 'http://localhost:4000/api'
|
||||||
|
export const ATTACHMENT_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
|
||||||
|
export const ATTACHMENT_MAX_FILE_SIZE_LABEL = '10 MB'
|
||||||
|
|
||||||
let cachedBaseUrl = null
|
let cachedBaseUrl = null
|
||||||
|
|
||||||
@@ -130,6 +132,13 @@ export const api = {
|
|||||||
request(`/projects/${projectId}/tasks/${taskId}`, { method: 'PUT', body: data, write: true }),
|
request(`/projects/${projectId}/tasks/${taskId}`, { method: 'PUT', body: data, write: true }),
|
||||||
deleteTask: (projectId, taskId) => request(`/projects/${projectId}/tasks/${taskId}`, { method: 'DELETE', write: true }),
|
deleteTask: (projectId, taskId) => request(`/projects/${projectId}/tasks/${taskId}`, { method: 'DELETE', write: true }),
|
||||||
|
|
||||||
|
// Features
|
||||||
|
getFeatures: projectId => request(`/projects/${projectId}/features`),
|
||||||
|
createFeature: (projectId, data) => request(`/projects/${projectId}/features`, { method: 'POST', body: data, write: true }),
|
||||||
|
updateFeature: (projectId, featureId, data) =>
|
||||||
|
request(`/projects/${projectId}/features/${featureId}`, { method: 'PUT', body: data, write: true }),
|
||||||
|
deleteFeature: (projectId, featureId) => request(`/projects/${projectId}/features/${featureId}`, { method: 'DELETE', write: true }),
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
uploadAttachment: (projectId, taskId, file) => {
|
uploadAttachment: (projectId, taskId, file) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
|
|||||||
Reference in New Issue
Block a user