@@ -353,6 +479,8 @@ function App(){
// ── Card ──
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 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)
return (
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 &&
+{p.members.length-3}
}
+
+
+ Feature log
+ {p.features.length} entries · {inFlightFeatureCount} open
+
+ {latestFeature ? (
+
+ {(FEATURE_STATUS[latestFeature.status] || FEATURE_STATUS.backlog).label}
+ {latestFeature.title}
+
+ ) : (
+
No feature activity yet.
+ )}
+
)
}
// ── 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 upTask = (id, u) => {
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 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 addFeature = feature => onFeatureCreate?.(p.id, feature)
+ const updateFeature = (featureId, patch) => onFeatureUpdate?.(p.id, featureId, patch)
+ const delFeature = featureId => onFeatureDelete?.(p.id, featureId)
return (
@@ -446,16 +591,17 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreat
- {['tasks','milestones','team','gantt','calendar','burndown'].map(t => (
+ {['tasks','features','milestones','team','gantt','calendar','burndown'].map(t => (
))}
{tab === 'tasks' &&
}
+ {tab === 'features' &&
}
{tab === 'milestones' &&
}
{tab === 'team' &&
}
{tab === 'gantt' &&
}
@@ -582,6 +728,9 @@ function TaskRow({ task: t, color, members = [], projectId, onUpdate, onDel, exp
Upload file
+
+ Max file size: {ATTACHMENT_MAX_FILE_SIZE_LABEL} per upload.
+
>
)}
@@ -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 (
+
+
+
+ {['all','backlog','planned','in-progress','shipped','dropped'].map(status => (
+
+ ))}
+
+ {adding && (
+
+ )}
+ {!items.length && !adding &&
No feature log entries yet.
}
+ {items.map(feature =>
setExpanded(expanded === feature.id ? null : feature.id)} onUpdate={onUpdate} onDel={onDel} />)}
+
+ )
+}
+
+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 (
+
+
+
+
+
{entry.title}
+
+ {typeMeta.label}
+ {statusMeta.label}
+ Updated {fmtDateTime(entry.updatedAt || entry.createdAt)}
+ {entry.updatedBy && by {entry.updatedBy}}
+ {entry.shippedAt && Shipped {fmt(entry.shippedAt)}}
+
+
+
+
+ {expanded && (
+
+ )}
+
+ )
+}
+
function TeamTab({ project: p, onChange, allMembers = [] }){
const [adding, setAdding] = useState(false)
const [mode, setMode] = useState('roster')
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 89f6fbc..890dca9 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -1,5 +1,7 @@
const API_URL_STORAGE_KEY = 'project_hub_api_url'
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
@@ -130,6 +132,13 @@ export const api = {
request(`/projects/${projectId}/tasks/${taskId}`, { method: 'PUT', body: data, 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
uploadAttachment: (projectId, taskId, file) => {
const fd = new FormData()