Compare commits
2 Commits
feature/em
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0941708d0 | ||
|
|
769a64f612 |
14
src/App.tsx
14
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { SEED_TASKS } from './data';
|
||||
import { SEED_TASKS, STATUS_LABELS } from './data';
|
||||
import type { Task, User, Status } from './data';
|
||||
import { LoginPage } from './Login';
|
||||
import { Sidebar } from './Sidebar';
|
||||
@@ -80,6 +80,13 @@ export default function App() {
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: t.status === 'done' ? 'todo' : 'done' as Status } : t));
|
||||
};
|
||||
|
||||
const handleMoveTask = (taskId: string, newStatus: Status) => {
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? {
|
||||
...t, status: newStatus,
|
||||
activity: [...t.activity, { id: `a${Date.now()}`, text: `🔄 ${currentUser.name} moved task to ${STATUS_LABELS[newStatus]}`, timestamp: new Date().toISOString() }]
|
||||
} : t));
|
||||
};
|
||||
|
||||
const displayPage = VIEW_PAGES.includes(activePage) ? activeView : activePage;
|
||||
const filteredMyTasks = tasks.filter(t => t.assignee === currentUser.id);
|
||||
|
||||
@@ -100,7 +107,8 @@ export default function App() {
|
||||
)}
|
||||
{displayPage === 'kanban' && (
|
||||
<KanbanBoard tasks={tasks} currentUser={currentUser} onTaskClick={handleTaskClick}
|
||||
onAddTask={handleKanbanAdd} filterUser={filterUser} searchQuery={searchQuery} />
|
||||
onAddTask={handleKanbanAdd} filterUser={filterUser} searchQuery={searchQuery}
|
||||
onMoveTask={handleMoveTask} />
|
||||
)}
|
||||
{displayPage === 'list' && (
|
||||
<ListView tasks={tasks} currentUser={currentUser} onTaskClick={handleTaskClick}
|
||||
@@ -112,7 +120,7 @@ export default function App() {
|
||||
filterUser={null} searchQuery={searchQuery} onToggleDone={handleToggleDone} />
|
||||
)}
|
||||
{displayPage === 'teamtasks' && <TeamTasksPage tasks={tasks} currentUser={currentUser} />}
|
||||
{displayPage === 'reports' && <ReportsPage tasks={tasks} />}
|
||||
{displayPage === 'reports' && <ReportsPage tasks={tasks} currentUser={currentUser} />}
|
||||
{displayPage === 'members' && <MembersPage tasks={tasks} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import type { Task, User, Status } from './data';
|
||||
import { PRIORITY_COLORS, STATUS_COLORS, STATUS_LABELS } from './data';
|
||||
import { Avatar, PriorityBadge, StatusBadge, ProgressBar } from './Shared';
|
||||
|
||||
function TaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
|
||||
function TaskCard({ task, onClick, onDragStart }: { task: Task; onClick: () => void; onDragStart: (e: React.DragEvent, task: Task) => void }) {
|
||||
const p = PRIORITY_COLORS[task.priority];
|
||||
const due = new Date(task.dueDate + 'T00:00:00');
|
||||
const overdue = due < new Date() && task.status !== 'done';
|
||||
const commCount = task.comments.length;
|
||||
return (
|
||||
<div className="task-card" style={{ borderLeftColor: p.color }} onClick={onClick}>
|
||||
<div className="task-card" style={{ borderLeftColor: p.color, cursor: 'grab' }}
|
||||
draggable
|
||||
onDragStart={e => onDragStart(e, task)}
|
||||
onDragEnd={e => (e.currentTarget as HTMLElement).style.opacity = '1'}
|
||||
onClick={onClick}>
|
||||
<div className="task-card-row">
|
||||
<span className="task-card-title">{task.title}</span>
|
||||
<Avatar userId={task.assignee} size={24} />
|
||||
@@ -27,12 +32,20 @@ function TaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTask }: {
|
||||
function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTask, onDragStart, onDrop, isDragOver, onDragOver, onDragLeave }: {
|
||||
status: Status; statusLabel: string; tasks: Task[]; color: string;
|
||||
onTaskClick: (t: Task) => void; onAddTask: (s: Status) => void;
|
||||
onDragStart: (e: React.DragEvent, task: Task) => void;
|
||||
onDrop: (e: React.DragEvent, status: Status) => void;
|
||||
isDragOver: boolean;
|
||||
onDragOver: (e: React.DragEvent, status: Status) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="kanban-column">
|
||||
<div className={`kanban-column ${isDragOver ? 'kanban-column-drag-over' : ''}`}
|
||||
onDragOver={e => onDragOver(e, status)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={e => onDrop(e, status)}>
|
||||
<div className="kanban-col-header">
|
||||
<div className="kanban-col-dot" style={{ background: color }} />
|
||||
<span className="kanban-col-label">{statusLabel}</span>
|
||||
@@ -41,9 +54,11 @@ function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTas
|
||||
</div>
|
||||
<div className="kanban-col-body">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="kanban-empty">No tasks here · Click + to add one</div>
|
||||
<div className={`kanban-empty ${isDragOver ? 'kanban-empty-active' : ''}`}>
|
||||
{isDragOver ? '⬇ Drop task here' : 'No tasks here · Click + to add one'}
|
||||
</div>
|
||||
) : (
|
||||
tasks.map(t => <TaskCard key={t.id} task={t} onClick={() => onTaskClick(t)} />)
|
||||
tasks.map(t => <TaskCard key={t.id} task={t} onClick={() => onTaskClick(t)} onDragStart={onDragStart} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,20 +68,58 @@ function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTas
|
||||
interface KanbanProps {
|
||||
tasks: Task[]; currentUser: User; onTaskClick: (t: Task) => void;
|
||||
onAddTask: (s: Status) => void; filterUser: string | null; searchQuery: string;
|
||||
onMoveTask: (taskId: string, newStatus: Status) => void;
|
||||
}
|
||||
|
||||
export function KanbanBoard({ tasks, currentUser, onTaskClick, onAddTask, filterUser, searchQuery }: KanbanProps) {
|
||||
export function KanbanBoard({ tasks, currentUser, onTaskClick, onAddTask, filterUser, searchQuery, onMoveTask }: KanbanProps) {
|
||||
const [dragOverColumn, setDragOverColumn] = useState<Status | null>(null);
|
||||
|
||||
let filtered = tasks;
|
||||
if (currentUser.role === 'employee') filtered = filtered.filter(t => t.assignee === currentUser.id);
|
||||
if (filterUser) filtered = filtered.filter(t => t.assignee === filterUser);
|
||||
if (searchQuery) filtered = filtered.filter(t => t.title.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, task: Task) => {
|
||||
e.dataTransfer.setData('text/plain', task.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
(e.currentTarget as HTMLElement).style.opacity = '0.4';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, status: Status) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverColumn(status);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
// Only clear if leaving the column entirely (not entering a child)
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
if (!related || !(e.currentTarget as HTMLElement).contains(related)) {
|
||||
setDragOverColumn(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, newStatus: Status) => {
|
||||
e.preventDefault();
|
||||
const taskId = e.dataTransfer.getData('text/plain');
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task && task.status !== newStatus) {
|
||||
onMoveTask(taskId, newStatus);
|
||||
}
|
||||
setDragOverColumn(null);
|
||||
};
|
||||
|
||||
const statuses: Status[] = ['todo', 'inprogress', 'review', 'done'];
|
||||
return (
|
||||
<div className="kanban-board">
|
||||
{statuses.map(s => (
|
||||
<KanbanColumn key={s} status={s} statusLabel={STATUS_LABELS[s]} color={STATUS_COLORS[s]}
|
||||
tasks={filtered.filter(t => t.status === s)} onTaskClick={onTaskClick} onAddTask={onAddTask} />
|
||||
tasks={filtered.filter(t => t.status === s)} onTaskClick={onTaskClick} onAddTask={onAddTask}
|
||||
onDragStart={handleDragStart}
|
||||
onDrop={handleDrop}
|
||||
isDragOver={dragOverColumn === s}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
316
src/Reports.tsx
316
src/Reports.tsx
@@ -1,20 +1,69 @@
|
||||
import type { Task } from './data';
|
||||
import { USERS, STATUS_COLORS, PRIORITY_COLORS } from './data';
|
||||
import { BarChart, Bar, PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { useState } from 'react';
|
||||
import type { Task, User } from './data';
|
||||
import { USERS, STATUS_COLORS, STATUS_LABELS, PRIORITY_COLORS } from './data';
|
||||
import {
|
||||
BarChart, Bar, PieChart, Pie, Cell, AreaChart, Area, Line,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, RadarChart, Radar,
|
||||
PolarGrid, PolarAngleAxis, PolarRadiusAxis,
|
||||
} from 'recharts';
|
||||
|
||||
/* ── dark tooltip shared across all charts ── */
|
||||
const tooltipStyle = {
|
||||
contentStyle: { background: '#0f172a', border: '1px solid #334155', borderRadius: 8, color: '#e2e8f0', fontSize: 12 },
|
||||
itemStyle: { color: '#e2e8f0' },
|
||||
labelStyle: { color: '#94a3b8' },
|
||||
cursor: { fill: 'rgba(99,102,241,0.08)' }, // ← FIX: was white
|
||||
wrapperStyle: { outline: 'none' },
|
||||
};
|
||||
const tooltipLine = { ...tooltipStyle, cursor: { stroke: '#6366f1', strokeWidth: 1 } };
|
||||
|
||||
/* ── CSV export helper (CTO only) ── */
|
||||
function exportToCSV(tasks: Task[], filename: string) {
|
||||
const header = 'ID,Title,Status,Priority,Assignee,Due Date,Tags,Subtasks Done,Subtasks Total,Comments\n';
|
||||
const rows = tasks.map(t => {
|
||||
const assignee = USERS.find(u => u.id === t.assignee)?.name ?? t.assignee;
|
||||
const subDone = t.subtasks.filter(s => s.done).length;
|
||||
return `"${t.id}","${t.title}","${STATUS_LABELS[t.status]}","${t.priority}","${assignee}","${t.dueDate}","${t.tags.join('; ')}",${subDone},${t.subtasks.length},${t.comments.length}`;
|
||||
}).join('\n');
|
||||
const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportJSON(tasks: Task[], filename: string) {
|
||||
const blob = new Blob([JSON.stringify(tasks, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/* ── main component ── */
|
||||
interface ReportsProps { tasks: Task[]; currentUser: User; }
|
||||
|
||||
export function ReportsPage({ tasks, currentUser }: ReportsProps) {
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const isCTO = currentUser.role === 'cto' || currentUser.role === 'manager';
|
||||
|
||||
export function ReportsPage({ tasks }: { tasks: Task[] }) {
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter(t => t.status === 'done').length;
|
||||
const inProgress = tasks.filter(t => t.status === 'inprogress').length;
|
||||
const inReview = tasks.filter(t => t.status === 'review').length;
|
||||
const overdue = tasks.filter(t => new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length;
|
||||
const critical = tasks.filter(t => t.priority === 'critical' && t.status !== 'done').length;
|
||||
const avgSubtaskCompletion = total
|
||||
? Math.round(tasks.reduce((acc, t) => {
|
||||
if (!t.subtasks.length) return acc;
|
||||
return acc + (t.subtasks.filter(s => s.done).length / t.subtasks.length) * 100;
|
||||
}, 0) / tasks.filter(t => t.subtasks.length > 0).length || 0)
|
||||
: 0;
|
||||
const totalComments = tasks.reduce((a, t) => a + t.comments.length, 0);
|
||||
|
||||
// Tasks per member (stacked by status)
|
||||
/* ── chart data ── */
|
||||
|
||||
// 1 · Tasks per member (stacked bar)
|
||||
const memberData = USERS.map(u => {
|
||||
const ut = tasks.filter(t => t.assignee === u.id);
|
||||
return {
|
||||
@@ -26,93 +75,282 @@ export function ReportsPage({ tasks }: { tasks: Task[] }) {
|
||||
};
|
||||
});
|
||||
|
||||
// Priority distribution
|
||||
// 2 · Priority donut
|
||||
const prioData = (['critical', 'high', 'medium', 'low'] as const).map(p => ({
|
||||
name: p, value: tasks.filter(t => t.priority === p).length, color: PRIORITY_COLORS[p].color,
|
||||
name: p.charAt(0).toUpperCase() + p.slice(1), value: tasks.filter(t => t.priority === p).length, color: PRIORITY_COLORS[p].color,
|
||||
}));
|
||||
|
||||
// Completions mock
|
||||
// 3 · Completions this week (area chart)
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const completionData = days.map((d, i) => ({ name: d, completed: [1, 0, 2, 1, 3, 0, 1][i] }));
|
||||
const completionData = days.map((d, i) => ({ name: d, completed: [1, 0, 2, 1, 3, 0, 1][i], target: 2 }));
|
||||
|
||||
// Overdue by member
|
||||
// 4 · Overdue by member (horizontal bar)
|
||||
const overdueData = USERS.map(u => ({
|
||||
name: u.name.split(' ')[0],
|
||||
overdue: tasks.filter(t => t.assignee === u.id && new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length,
|
||||
})).filter(d => d.overdue > 0);
|
||||
|
||||
// 5 · Member performance radar
|
||||
const radarData = USERS.map(u => {
|
||||
const ut = tasks.filter(t => t.assignee === u.id);
|
||||
const done = ut.filter(t => t.status === 'done').length;
|
||||
const ot = ut.filter(t => new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length;
|
||||
const subDone = ut.reduce((a, t) => a + t.subtasks.filter(s => s.done).length, 0);
|
||||
const subTotal = ut.reduce((a, t) => a + t.subtasks.length, 0);
|
||||
return {
|
||||
name: u.name.split(' ')[0],
|
||||
tasks: ut.length,
|
||||
completed: done,
|
||||
onTime: ut.length - ot,
|
||||
subtasks: subTotal ? Math.round((subDone / subTotal) * 100) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 6 · Status flow (what % of tasks in each status)
|
||||
const statusFlow = (['todo', 'inprogress', 'review', 'done'] as const).map(s => ({
|
||||
name: STATUS_LABELS[s],
|
||||
count: tasks.filter(t => t.status === s).length,
|
||||
color: STATUS_COLORS[s],
|
||||
pct: total ? Math.round((tasks.filter(t => t.status === s).length / total) * 100) : 0,
|
||||
}));
|
||||
|
||||
// 7 · Tag frequency
|
||||
const tagMap: Record<string, number> = {};
|
||||
tasks.forEach(t => t.tags.forEach(tag => { tagMap[tag] = (tagMap[tag] || 0) + 1; }));
|
||||
const tagData = Object.entries(tagMap).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([name, count]) => ({ name, count }));
|
||||
const tagColors = ['#6366f1', '#818cf8', '#a78bfa', '#c4b5fd', '#22c55e', '#f59e0b', '#ef4444', '#ec4899'];
|
||||
|
||||
return (
|
||||
<div className="reports">
|
||||
<div className="stats-row">
|
||||
{/* HEADER with export */}
|
||||
<div className="reports-header">
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700 }}>Reports & Analytics</h2>
|
||||
{isCTO && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button className="new-task-btn" style={{ fontSize: 12 }} onClick={() => setExportOpen(!exportOpen)}>
|
||||
📊 Export Data ▾
|
||||
</button>
|
||||
{exportOpen && (
|
||||
<div className="list-dropdown" style={{ right: 0, top: '110%', minWidth: 180 }}>
|
||||
<button className="list-dropdown-item" onClick={() => { exportToCSV(tasks, 'scrum-tasks.csv'); setExportOpen(false); }}>
|
||||
📄 Export as CSV
|
||||
</button>
|
||||
<button className="list-dropdown-item" onClick={() => { exportJSON(tasks, 'scrum-tasks.json'); setExportOpen(false); }}>
|
||||
🗂 Export as JSON
|
||||
</button>
|
||||
<button className="list-dropdown-item" onClick={() => {
|
||||
const summary = `SCRUM REPORT — ${new Date().toLocaleDateString()}\n\nTotal Tasks: ${total}\nCompleted: ${completed} (${total ? Math.round(completed / total * 100) : 0}%)\nIn Progress: ${inProgress}\nIn Review: ${inReview}\nOverdue: ${overdue}\nCritical Open: ${critical}\nAvg Subtask Completion: ${avgSubtaskCompletion}%\nTotal Comments: ${totalComments}\n\n--- BY MEMBER ---\n${USERS.map(u => {
|
||||
const ut = tasks.filter(t => t.assignee === u.id);
|
||||
const d = ut.filter(t => t.status === 'done').length;
|
||||
return `${u.name}: ${ut.length} tasks, ${d} done, ${ut.length - d} active`;
|
||||
}).join('\n')}`;
|
||||
const blob = new Blob([summary], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'scrum-summary.txt'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setExportOpen(false);
|
||||
}}>
|
||||
📋 Export Summary (.txt)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* STAT CARDS (extended) */}
|
||||
<div className="stats-row" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}>
|
||||
{[
|
||||
{ label: 'Total Tasks', num: total, border: '#6366f1' },
|
||||
{ label: 'Completed', num: completed, border: '#22c55e' },
|
||||
{ label: 'Overdue', num: overdue, border: '#ef4444' },
|
||||
{ label: 'Critical Open', num: critical, border: '#f97316' },
|
||||
{ label: 'Total Tasks', num: total, border: '#6366f1', icon: '📋' },
|
||||
{ label: 'Completed', num: completed, border: '#22c55e', icon: '✅' },
|
||||
{ label: 'In Progress', num: inProgress, border: '#818cf8', icon: '⏳' },
|
||||
{ label: 'In Review', num: inReview, border: '#f59e0b', icon: '👀' },
|
||||
{ label: 'Overdue', num: overdue, border: '#ef4444', icon: '🔴' },
|
||||
{ label: 'Critical', num: critical, border: '#f97316', icon: '🔥' },
|
||||
{ label: 'Subtask %', num: `${avgSubtaskCompletion}%`, border: '#a78bfa', icon: '📊' },
|
||||
{ label: 'Comments', num: totalComments, border: '#ec4899', icon: '💬' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="stat-card" style={{ borderTop: `3px solid ${s.border}` }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div className="stat-card-num">{s.num}</div>
|
||||
<span style={{ fontSize: 22 }}>{s.icon}</span>
|
||||
</div>
|
||||
<div className="stat-card-label">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* STATUS PIPELINE */}
|
||||
<div className="chart-card" style={{ marginBottom: 16 }}>
|
||||
<div className="chart-card-title">Status Pipeline</div>
|
||||
<div style={{ display: 'flex', gap: 4, height: 32, borderRadius: 8, overflow: 'hidden' }}>
|
||||
{statusFlow.map(s => (
|
||||
s.pct > 0 ? (
|
||||
<div key={s.name} style={{ width: `${s.pct}%`, background: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, fontWeight: 700, color: '#fff', minWidth: 30, transition: 'width 0.5s' }}>
|
||||
{s.pct}%
|
||||
</div>
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 10 }}>
|
||||
{statusFlow.map(s => (
|
||||
<span key={s.name} style={{ fontSize: 11, color: s.color, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: s.color, display: 'inline-block' }} />
|
||||
{s.name} ({s.count})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CHARTS GRID */}
|
||||
<div className="charts-grid">
|
||||
{/* 1 · Tasks per Member */}
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-title">Tasks per Member</div>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={memberData}>
|
||||
<XAxis dataKey="name" tick={{ fill: '#64748b', fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} />
|
||||
<ResponsiveContainer width="100%" height={270}>
|
||||
<BarChart data={memberData} barSize={28}>
|
||||
<XAxis dataKey="name" tick={{ fill: '#64748b', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip {...tooltipStyle} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
|
||||
<Bar dataKey="todo" stackId="a" fill={STATUS_COLORS.todo} name="To Do" />
|
||||
<Bar dataKey="todo" stackId="a" fill={STATUS_COLORS.todo} name="To Do" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="inprogress" stackId="a" fill={STATUS_COLORS.inprogress} name="In Progress" />
|
||||
<Bar dataKey="review" stackId="a" fill={STATUS_COLORS.review} name="Review" />
|
||||
<Bar dataKey="done" stackId="a" fill={STATUS_COLORS.done} name="Done" />
|
||||
<Bar dataKey="done" stackId="a" fill={STATUS_COLORS.done} name="Done" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 2 · Priority Distribution */}
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-title">Priority Distribution</div>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<ResponsiveContainer width="100%" height={270}>
|
||||
<PieChart>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Pie data={prioData} cx="50%" cy="50%" innerRadius={55} outerRadius={85} paddingAngle={3} dataKey="value" label={((entry: any) => `${entry.name} ${((entry.percent ?? 0) * 100).toFixed(0)}%`) as any}>
|
||||
{prioData.map(d => <Cell key={d.name} fill={d.color} />)}
|
||||
<Pie data={prioData} cx="50%" cy="50%" innerRadius={55} outerRadius={90} paddingAngle={3} dataKey="value"
|
||||
label={((entry: any) => `${entry.name} ${((entry.percent ?? 0) * 100).toFixed(0)}%`) as any}
|
||||
labelLine={{ stroke: '#334155' }}>
|
||||
{prioData.map(d => <Cell key={d.name} fill={d.color} stroke="none" />)}
|
||||
</Pie>
|
||||
<Tooltip {...tooltipStyle} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ textAlign: 'center', fontSize: 22, fontWeight: 800, marginTop: -140, position: 'relative', pointerEvents: 'none', color: '#f1f5f9' }}>{total}</div>
|
||||
<div style={{ textAlign: 'center', fontSize: 24, fontWeight: 800, marginTop: -155, position: 'relative', pointerEvents: 'none', color: '#f1f5f9' }}>
|
||||
{total}
|
||||
<div style={{ fontSize: 10, color: '#64748b', fontWeight: 500 }}>tasks</div>
|
||||
</div>
|
||||
<div style={{ height: 100 }} />
|
||||
</div>
|
||||
|
||||
{/* 3 · Completion Trend (Area) */}
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-title">Completions This Week</div>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={completionData}>
|
||||
<XAxis dataKey="name" tick={{ fill: '#64748b', fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} />
|
||||
<Tooltip {...tooltipStyle} />
|
||||
<Line type="monotone" dataKey="completed" stroke="#6366f1" strokeWidth={2} dot={{ fill: '#22c55e', r: 4 }} />
|
||||
</LineChart>
|
||||
<div className="chart-card-title">Completion Trend <span style={{ fontSize: 10, color: '#64748b', fontWeight: 400 }}>vs target</span></div>
|
||||
<ResponsiveContainer width="100%" height={270}>
|
||||
<AreaChart data={completionData}>
|
||||
<defs>
|
||||
<linearGradient id="completedGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="name" tick={{ fill: '#64748b', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip {...tooltipLine} />
|
||||
<Line type="monotone" dataKey="target" stroke="#334155" strokeWidth={1} strokeDasharray="5 5" dot={false} />
|
||||
<Area type="monotone" dataKey="completed" stroke="#6366f1" strokeWidth={2} fill="url(#completedGrad)" dot={{ fill: '#6366f1', r: 4, strokeWidth: 2, stroke: '#0f172a' }} activeDot={{ r: 6, fill: '#818cf8' }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 4 · Overdue by Member */}
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-title">Overdue by Member</div>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={overdueData} layout="vertical">
|
||||
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 11 }} />
|
||||
<YAxis dataKey="name" type="category" tick={{ fill: '#64748b', fontSize: 11 }} width={60} />
|
||||
<div className="chart-card-title">Overdue by Member <span style={{ fontSize: 10, color: '#ef4444', fontWeight: 400 }}>⚠ needs attention</span></div>
|
||||
<ResponsiveContainer width="100%" height={270}>
|
||||
<BarChart data={overdueData} layout="vertical" barSize={20}>
|
||||
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis dataKey="name" type="category" tick={{ fill: '#64748b', fontSize: 11 }} width={60} axisLine={false} tickLine={false} />
|
||||
<Tooltip {...tooltipStyle} />
|
||||
<Bar dataKey="overdue" fill="#ef4444" radius={[0, 4, 4, 0]} />
|
||||
<Bar dataKey="overdue" fill="#ef4444" radius={[0, 6, 6, 0]} background={{ fill: 'rgba(239,68,68,0.06)', radius: 6 }} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
{overdueData.length === 0 && <div style={{ textAlign: 'center', color: '#22c55e', fontSize: 13, padding: 20 }}>🎉 No overdue tasks!</div>}
|
||||
</div>
|
||||
|
||||
{/* 5 · Member Performance Radar */}
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-title">Member Performance</div>
|
||||
<ResponsiveContainer width="100%" height={270}>
|
||||
<RadarChart data={radarData}>
|
||||
<PolarGrid stroke="#1e293b" />
|
||||
<PolarAngleAxis dataKey="name" tick={{ fill: '#94a3b8', fontSize: 11 }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 'auto']} tick={{ fill: '#475569', fontSize: 9 }} />
|
||||
<Radar name="Total" dataKey="tasks" stroke="#6366f1" fill="#6366f1" fillOpacity={0.15} strokeWidth={2} />
|
||||
<Radar name="Completed" dataKey="completed" stroke="#22c55e" fill="#22c55e" fillOpacity={0.15} strokeWidth={2} />
|
||||
<Radar name="On Time" dataKey="onTime" stroke="#f59e0b" fill="#f59e0b" fillOpacity={0.1} strokeWidth={1} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
|
||||
<Tooltip {...tooltipStyle} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 6 · Tag Distribution */}
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-title">Top Tags</div>
|
||||
{tagData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={270}>
|
||||
<BarChart data={tagData} barSize={22}>
|
||||
<XAxis dataKey="name" tick={{ fill: '#64748b', fontSize: 10 }} axisLine={false} tickLine={false} angle={-20} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||
<Tooltip {...tooltipStyle} />
|
||||
<Bar dataKey="count" name="Tasks" radius={[4, 4, 0, 0]}>
|
||||
{tagData.map((_d, i) => <Cell key={i} fill={tagColors[i % tagColors.length]} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : <div style={{ textAlign: 'center', color: '#64748b', fontSize: 12, padding: 40 }}>No tags assigned yet</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* INSIGHTS SECTION (CTO/Manager only) */}
|
||||
{isCTO && (
|
||||
<div className="insights-section">
|
||||
<div className="chart-card-title" style={{ marginBottom: 12 }}>💡 Key Insights</div>
|
||||
<div className="insights-grid">
|
||||
{(() => {
|
||||
const insights: { icon: string; text: string; type: 'warning' | 'success' | 'info' }[] = [];
|
||||
// Completion rate
|
||||
const compRate = total ? Math.round((completed / total) * 100) : 0;
|
||||
if (compRate >= 70) insights.push({ icon: '🎯', text: `Great completion rate: ${compRate}% of tasks are done.`, type: 'success' });
|
||||
else if (compRate < 40) insights.push({ icon: '⚠️', text: `Low completion rate: only ${compRate}% of tasks are done.`, type: 'warning' });
|
||||
else insights.push({ icon: '📈', text: `Completion rate is ${compRate}% — keep pushing!`, type: 'info' });
|
||||
|
||||
// Overdue
|
||||
if (overdue > 0) insights.push({ icon: '🔴', text: `${overdue} task${overdue > 1 ? 's are' : ' is'} overdue and needs attention.`, type: 'warning' });
|
||||
else insights.push({ icon: '✅', text: 'No overdue tasks — the team is on track!', type: 'success' });
|
||||
|
||||
// Busiest member
|
||||
const busiest = USERS.map(u => ({ name: u.name, count: tasks.filter(t => t.assignee === u.id && t.status !== 'done').length }))
|
||||
.sort((a, b) => b.count - a.count)[0];
|
||||
if (busiest && busiest.count > 3) insights.push({ icon: '🏋️', text: `${busiest.name} has the heaviest load with ${busiest.count} active tasks.`, type: 'warning' });
|
||||
|
||||
// Critical items
|
||||
if (critical > 0) insights.push({ icon: '🔥', text: `${critical} critical task${critical > 1 ? 's' : ''} still open — prioritize these.`, type: 'warning' });
|
||||
|
||||
// Comments activity
|
||||
if (totalComments > 5) insights.push({ icon: '💬', text: `Good collaboration: ${totalComments} comments across all tasks.`, type: 'success' });
|
||||
else insights.push({ icon: '🤫', text: `Only ${totalComments} comments total — encourage more team communication.`, type: 'info' });
|
||||
|
||||
return insights.map((ins, i) => (
|
||||
<div key={i} className={`insight-card insight-${ins.type}`}>
|
||||
<span style={{ fontSize: 18 }}>{ins.icon}</span>
|
||||
<span style={{ fontSize: 12, color: '#e2e8f0', flex: 1 }}>{ins.text}</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
2155
src/index.css
2155
src/index.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user