feat: complete Scrum-manager MVP — dark-themed multi-user task manager
- Login with role-based auth (CTO/Manager/Employee) - Calendar view (month/week) with task chips and quick-add - Kanban board with status columns - Sortable list view with action menus - Task detail drawer with subtasks, comments, activity - Add task modal with validation - Dashboard with stats, workload, priority breakdown - Team tasks grouped by assignee - Reports page with recharts (bar, pie, line, horizontal bar) - Members page with invite modal - Search and assignee filter across views - ErrorBoundary for production error handling - Full dark design system via index.css
This commit is contained in:
234
src/TaskDrawer.tsx
Normal file
234
src/TaskDrawer.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useState } from 'react';
|
||||
import type { Task, User, Status, Priority } from './data';
|
||||
import { USERS, STATUS_LABELS, getUserById } from './data';
|
||||
import { Avatar, Tag, ProgressBar } from './Shared';
|
||||
|
||||
interface DrawerProps {
|
||||
task: Task; currentUser: User; onClose: () => void;
|
||||
onUpdate: (updated: Task) => void;
|
||||
}
|
||||
|
||||
export function TaskDrawer({ task, currentUser, onClose, onUpdate }: DrawerProps) {
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [subtaskText, setSubtaskText] = useState('');
|
||||
|
||||
const updateField = (field: string, value: any) => {
|
||||
const now = new Date().toISOString();
|
||||
const updated = {
|
||||
...task, [field]: value,
|
||||
activity: [...task.activity, { id: `a${Date.now()}`, text: `🔄 ${currentUser.name} changed ${field} to ${value}`, timestamp: now }]
|
||||
};
|
||||
onUpdate(updated);
|
||||
};
|
||||
|
||||
const toggleSubtask = (sid: string) => {
|
||||
const now = new Date().toISOString();
|
||||
const subtasks = task.subtasks.map(s => s.id === sid ? { ...s, done: !s.done } : s);
|
||||
const st = subtasks.find(s => s.id === sid)!;
|
||||
onUpdate({
|
||||
...task, subtasks,
|
||||
activity: [...task.activity, { id: `a${Date.now()}`, text: `${st.done ? '✅' : '↩️'} ${currentUser.name} ${st.done ? 'completed' : 'unchecked'} subtask "${st.title}"`, timestamp: now }]
|
||||
});
|
||||
};
|
||||
|
||||
const addSubtask = () => {
|
||||
if (!subtaskText.trim()) return;
|
||||
onUpdate({ ...task, subtasks: [...task.subtasks, { id: `s${Date.now()}`, title: subtaskText, done: false }] });
|
||||
setSubtaskText('');
|
||||
};
|
||||
|
||||
const addComment = () => {
|
||||
if (!commentText.trim()) return;
|
||||
const now = new Date().toISOString();
|
||||
onUpdate({
|
||||
...task,
|
||||
comments: [...task.comments, { id: `c${Date.now()}`, userId: currentUser.id, text: commentText, timestamp: now }],
|
||||
activity: [...task.activity, { id: `a${Date.now()}`, text: `💬 ${currentUser.name} added a comment`, timestamp: now }]
|
||||
});
|
||||
setCommentText('');
|
||||
};
|
||||
|
||||
const reporter = getUserById(task.reporter);
|
||||
const doneCount = task.subtasks.filter(s => s.done).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-backdrop" onClick={onClose} />
|
||||
<div className="drawer">
|
||||
<div className="drawer-header">
|
||||
<span className="drawer-header-label">Task Detail</span>
|
||||
<button className="drawer-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="drawer-body">
|
||||
<h2 className="drawer-title">{task.title}</h2>
|
||||
<p className="drawer-desc">{task.description}</p>
|
||||
|
||||
<div className="drawer-meta">
|
||||
<div>
|
||||
<div className="drawer-meta-label">Assignee</div>
|
||||
<div className="drawer-meta-val">
|
||||
<Avatar userId={task.assignee} size={20} />
|
||||
<select className="drawer-select" value={task.assignee} onChange={e => updateField('assignee', e.target.value)}>
|
||||
{USERS.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="drawer-meta-label">Reporter</div>
|
||||
<div className="drawer-meta-val"><Avatar userId={task.reporter} size={20} /> {reporter?.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="drawer-meta-label">Status</div>
|
||||
<select className="drawer-select" value={task.status} onChange={e => updateField('status', e.target.value)}>
|
||||
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="drawer-meta-label">Priority</div>
|
||||
<select className="drawer-select" value={task.priority} onChange={e => updateField('priority', e.target.value)}>
|
||||
{['critical', 'high', 'medium', 'low'].map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="drawer-meta-label">Due Date</div>
|
||||
<input type="date" className="drawer-select" value={task.dueDate} onChange={e => updateField('dueDate', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="drawer-meta-label">Tags</div>
|
||||
<div className="drawer-tags">{task.tags.map(t => <Tag key={t} label={t} />)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-title">Subtasks <span style={{ color: '#64748b', fontWeight: 400, fontSize: 12 }}>{doneCount} of {task.subtasks.length} complete</span></div>
|
||||
{task.subtasks.length > 0 && <ProgressBar subtasks={task.subtasks} />}
|
||||
{task.subtasks.map(s => (
|
||||
<div key={s.id} className="subtask-row" onClick={() => toggleSubtask(s.id)}>
|
||||
<input type="checkbox" className="subtask-checkbox" checked={s.done} readOnly />
|
||||
<span className={`subtask-text ${s.done ? 'done' : ''}`}>{s.title}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="subtask-add">
|
||||
<input placeholder="Add a subtask..." value={subtaskText} onChange={e => setSubtaskText(e.target.value)} onKeyDown={e => e.key === 'Enter' && addSubtask()} />
|
||||
<button onClick={addSubtask}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-title">Comments</div>
|
||||
{task.comments.map(c => {
|
||||
const cu = getUserById(c.userId);
|
||||
return (
|
||||
<div key={c.id} className="comment-item">
|
||||
<Avatar userId={c.userId} size={26} />
|
||||
<div className="comment-bubble">
|
||||
<div className="comment-header">
|
||||
<span className="comment-name">{cu?.name}</span>
|
||||
<span className="comment-time">{new Date(c.timestamp).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
<div className="comment-text">{c.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="comment-input-row">
|
||||
<Avatar userId={currentUser.id} size={26} />
|
||||
<input placeholder="Add a comment..." value={commentText} onChange={e => setCommentText(e.target.value)} onKeyDown={e => e.key === 'Enter' && addComment()} />
|
||||
<button onClick={addComment}>Post</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-title">Activity</div>
|
||||
{task.activity.map(a => (
|
||||
<div key={a.id} className="activity-item">
|
||||
<span className="activity-text">{a.text} · {new Date(a.timestamp).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
onClose: () => void;
|
||||
onAdd: (task: Task) => void;
|
||||
defaultDate?: string;
|
||||
defaultStatus?: Status;
|
||||
}
|
||||
|
||||
export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus }: ModalProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [assignee, setAssignee] = useState('u1');
|
||||
const [priority, setPriority] = useState<Priority>('medium');
|
||||
const [status, setStatus] = useState<Status>(defaultStatus || 'todo');
|
||||
const [dueDate, setDueDate] = useState(defaultDate || new Date().toISOString().split('T')[0]);
|
||||
const [tags, setTags] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const submit = () => {
|
||||
if (!title.trim()) { setError(true); return; }
|
||||
const task: Task = {
|
||||
id: `t${Date.now()}`, title, description: desc, status, priority,
|
||||
assignee, reporter: 'u1', dueDate,
|
||||
tags: tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [],
|
||||
subtasks: [], comments: [],
|
||||
activity: [{ id: `a${Date.now()}`, text: `📝 Task created`, timestamp: new Date().toISOString() }],
|
||||
};
|
||||
onAdd(task);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header"><h2>New Task</h2><button className="drawer-close" onClick={onClose}>✕</button></div>
|
||||
<div className="modal-body">
|
||||
<div className="modal-field">
|
||||
<label>Title *</label>
|
||||
<input className={`modal-input ${error && !title.trim() ? 'error' : ''}`} placeholder="Task title" value={title} onChange={e => { setTitle(e.target.value); setError(false); }} />
|
||||
</div>
|
||||
<div className="modal-field">
|
||||
<label>Description</label>
|
||||
<textarea className="modal-input modal-input-textarea" placeholder="Describe the task..." rows={3} value={desc} onChange={e => setDesc(e.target.value)} />
|
||||
</div>
|
||||
<div className="modal-grid">
|
||||
<div className="modal-field">
|
||||
<label>Assignee</label>
|
||||
<select className="modal-input" value={assignee} onChange={e => setAssignee(e.target.value)}>
|
||||
{USERS.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-field">
|
||||
<label>Priority</label>
|
||||
<select className="modal-input" value={priority} onChange={e => setPriority(e.target.value as Priority)}>
|
||||
{['critical', 'high', 'medium', 'low'].map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-field">
|
||||
<label>Status</label>
|
||||
<select className="modal-input" value={status} onChange={e => setStatus(e.target.value as Status)}>
|
||||
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-field">
|
||||
<label>Due Date</label>
|
||||
<input className="modal-input" type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-field">
|
||||
<label>Tags (comma separated)</label>
|
||||
<input className="modal-input" placeholder="devops, backend, ..." value={tags} onChange={e => setTags(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-ghost" onClick={onClose}>Cancel</button>
|
||||
<button className="btn-primary" onClick={submit}>Create Task</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user