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:
190
src/Calendar.tsx
Normal file
190
src/Calendar.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import type { Task, User } from './data';
|
||||
import { USERS, PRIORITY_COLORS } from './data';
|
||||
import { Avatar } from './Shared';
|
||||
|
||||
interface CalendarProps {
|
||||
tasks: Task[]; currentUser: User; calMonth: { year: number; month: number }; calView: string;
|
||||
onMonthChange: (m: { year: number; month: number }) => void; onViewChange: (v: string) => void;
|
||||
onTaskClick: (t: Task) => void; onDayClick: (date: string, el: HTMLElement) => void;
|
||||
filterUser: string | null; searchQuery: string;
|
||||
}
|
||||
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
|
||||
|
||||
function filterTasks(tasks: Task[], user: User, filterUser: string | null, search: string) {
|
||||
let t = tasks;
|
||||
if (user.role === 'employee') t = t.filter(x => x.assignee === user.id);
|
||||
if (filterUser) t = t.filter(x => x.assignee === filterUser);
|
||||
if (search) t = t.filter(x => x.title.toLowerCase().includes(search.toLowerCase()));
|
||||
return t;
|
||||
}
|
||||
|
||||
function getMonthDays(year: number, month: number) {
|
||||
const first = new Date(year, month, 1);
|
||||
const startDay = first.getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const prevMonthDays = new Date(year, month, 0).getDate();
|
||||
const cells: { date: Date; isCurrentMonth: boolean }[] = [];
|
||||
for (let i = startDay - 1; i >= 0; i--) cells.push({ date: new Date(year, month - 1, prevMonthDays - i), isCurrentMonth: false });
|
||||
for (let i = 1; i <= daysInMonth; i++) cells.push({ date: new Date(year, month, i), isCurrentMonth: true });
|
||||
const rem = 42 - cells.length;
|
||||
for (let i = 1; i <= rem; i++) cells.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
|
||||
return cells;
|
||||
}
|
||||
|
||||
function getWeekDays(_year: number, _month: number) {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay();
|
||||
const start = new Date(today);
|
||||
start.setDate(today.getDate() - dayOfWeek);
|
||||
const cells: Date[] = [];
|
||||
for (let i = 0; i < 7; i++) { const d = new Date(start); d.setDate(start.getDate() + i); cells.push(d); }
|
||||
return cells;
|
||||
}
|
||||
|
||||
function dateStr(d: Date) { return d.toISOString().split('T')[0]; }
|
||||
function isToday(d: Date) { const t = new Date(); return d.getDate() === t.getDate() && d.getMonth() === t.getMonth() && d.getFullYear() === t.getFullYear(); }
|
||||
|
||||
function TaskChip({ task, onClick }: { task: Task; onClick: () => void }) {
|
||||
const p = PRIORITY_COLORS[task.priority];
|
||||
return (
|
||||
<div className="task-chip" style={{ background: p.bg, borderLeftColor: p.color }} onClick={e => { e.stopPropagation(); onClick(); }}>
|
||||
<span className="task-chip-dot" style={{ background: p.color }} />
|
||||
<span className="task-chip-title">{task.title}</span>
|
||||
<Avatar userId={task.assignee} size={14} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MorePopover({ tasks, onTaskClick, onClose }: { tasks: Task[]; onTaskClick: (t: Task) => void; onClose: () => void }) {
|
||||
return (
|
||||
<div className="more-popover" onClick={e => e.stopPropagation()}>
|
||||
<div className="more-popover-title">{tasks.length} tasks</div>
|
||||
{tasks.map(t => <TaskChip key={t.id} task={t} onClick={() => { onTaskClick(t); onClose(); }} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickAddPanel({ date, onAdd, onOpenFull, onClose }: { date: string; onAdd: (t: Partial<Task>) => void; onOpenFull: () => void; onClose: () => void }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [assignee, setAssignee] = useState('u1');
|
||||
const [priority, setPriority] = useState<'medium' | 'low' | 'high' | 'critical'>('medium');
|
||||
|
||||
const submit = () => {
|
||||
if (!title.trim()) return;
|
||||
onAdd({ title, assignee, priority, dueDate: date, status: 'todo', description: '', tags: [], subtasks: [], comments: [], activity: [] });
|
||||
setTitle('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="quick-add-panel" onClick={e => e.stopPropagation()}>
|
||||
<button className="quick-add-close" onClick={onClose}>✕</button>
|
||||
<div className="quick-add-header">📅 Add Task — {new Date(date + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</div>
|
||||
<input className="quick-add-input" placeholder="Task title..." value={title} autoFocus
|
||||
onChange={e => setTitle(e.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} />
|
||||
<div className="quick-add-row">
|
||||
<select className="quick-add-select" value={assignee} onChange={e => setAssignee(e.target.value)}>
|
||||
{USERS.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
</select>
|
||||
<select className="quick-add-select" value={priority} onChange={e => setPriority(e.target.value as any)}>
|
||||
{['critical', 'high', 'medium', 'low'].map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="quick-add-actions">
|
||||
<button className="quick-add-submit" onClick={submit}>Add Task</button>
|
||||
<button className="quick-add-link" onClick={onOpenFull}>Open Full Form</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarView({ tasks, currentUser, calMonth, calView, onMonthChange, onViewChange, onTaskClick, onDayClick, filterUser, searchQuery }: CalendarProps) {
|
||||
const [morePopover, setMorePopover] = useState<{ date: string; tasks: Task[] } | null>(null);
|
||||
const filtered = filterTasks(tasks, currentUser, filterUser, searchQuery);
|
||||
|
||||
const prevMonth = () => {
|
||||
if (calView === 'week') { const d = new Date(); d.setDate(d.getDate() - 7); onMonthChange({ year: d.getFullYear(), month: d.getMonth() }); return; }
|
||||
const m = calMonth.month === 0 ? 11 : calMonth.month - 1;
|
||||
const y = calMonth.month === 0 ? calMonth.year - 1 : calMonth.year;
|
||||
onMonthChange({ year: y, month: m });
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (calView === 'week') { const d = new Date(); d.setDate(d.getDate() + 7); onMonthChange({ year: d.getFullYear(), month: d.getMonth() }); return; }
|
||||
const m = calMonth.month === 11 ? 0 : calMonth.month + 1;
|
||||
const y = calMonth.month === 11 ? calMonth.year + 1 : calMonth.year;
|
||||
onMonthChange({ year: y, month: m });
|
||||
};
|
||||
const goToday = () => { const n = new Date(); onMonthChange({ year: n.getFullYear(), month: n.getMonth() }); };
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div className="calendar-toolbar">
|
||||
<div className="cal-nav">
|
||||
<button className="cal-nav-btn" onClick={prevMonth}>←</button>
|
||||
<span className="cal-month-label">{MONTHS[calMonth.month]} {calMonth.year}</span>
|
||||
<button className="cal-nav-btn" onClick={nextMonth}>→</button>
|
||||
<button className="cal-today-btn" onClick={goToday}>Today</button>
|
||||
</div>
|
||||
<div className="cal-view-toggle">
|
||||
<button className={`cal-view-btn ${calView === 'month' ? 'active' : ''}`} onClick={() => onViewChange('month')}>Month</button>
|
||||
<button className={`cal-view-btn ${calView === 'week' ? 'active' : ''}`} onClick={() => onViewChange('week')}>Week</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{calView === 'month' ? (
|
||||
<div className="month-grid" style={{ gridTemplateRows: 'auto repeat(6, 1fr)' }}>
|
||||
{DAYS.map(d => <div key={d} className="month-grid-header">{d}</div>)}
|
||||
{getMonthDays(calMonth.year, calMonth.month).map((cell, i) => {
|
||||
const ds = dateStr(cell.date);
|
||||
const dayTasks = filtered.filter(t => t.dueDate === ds);
|
||||
const show = dayTasks.slice(0, 3);
|
||||
const extra = dayTasks.length - 3;
|
||||
return (
|
||||
<div key={i} className="day-cell" onClick={e => { if (!(e.target as HTMLElement).closest('.task-chip,.more-tasks-link,.more-popover')) onDayClick(ds, e.currentTarget); }}>
|
||||
<div className={`day-number ${isToday(cell.date) ? 'today' : ''} ${!cell.isCurrentMonth ? 'other-month' : ''}`}>
|
||||
{cell.date.getDate()}
|
||||
</div>
|
||||
<div className="day-tasks">
|
||||
{show.map(t => <TaskChip key={t.id} task={t} onClick={() => onTaskClick(t)} />)}
|
||||
{extra > 0 && (
|
||||
<span className="more-tasks-link" onClick={e => { e.stopPropagation(); setMorePopover({ date: ds, tasks: dayTasks }); }}>
|
||||
+{extra} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{morePopover?.date === ds && <MorePopover tasks={morePopover.tasks} onTaskClick={onTaskClick} onClose={() => setMorePopover(null)} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="week-grid" style={{ gridTemplateRows: 'auto 1fr' }}>
|
||||
{getWeekDays(calMonth.year, calMonth.month).map((d, i) => (
|
||||
<div key={i} className={`week-header-cell ${isToday(d) ? 'today' : ''}`}>
|
||||
{DAYS[d.getDay()]} {d.getDate()}
|
||||
</div>
|
||||
))}
|
||||
{getWeekDays(calMonth.year, calMonth.month).map((d, i) => {
|
||||
const ds = dateStr(d);
|
||||
const dayTasks = filtered.filter(t => t.dueDate === ds);
|
||||
return (
|
||||
<div key={i} className="week-day-cell" onClick={e => { if (!(e.target as HTMLElement).closest('.task-chip,.week-chip')) onDayClick(ds, e.currentTarget); }}>
|
||||
{dayTasks.map(t => {
|
||||
const p = PRIORITY_COLORS[t.priority];
|
||||
return (
|
||||
<div key={t.id} className="week-chip" style={{ background: p.bg, borderLeftColor: p.color }} onClick={e => { e.stopPropagation(); onTaskClick(t); }}>
|
||||
<span className="task-chip-dot" style={{ background: p.color }} />
|
||||
<span className="task-chip-title">{t.title}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user