Compare commits
2 Commits
feature/re
...
feature/us
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87d5bfe23a | ||
|
|
dc097811b9 |
20
src/App.tsx
20
src/App.tsx
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { SEED_TASKS, STATUS_LABELS } from './data';
|
import { SEED_TASKS, USERS } from './data';
|
||||||
import type { Task, User, Status } from './data';
|
import type { Task, User, Status } from './data';
|
||||||
import { LoginPage } from './Login';
|
import { LoginPage } from './Login';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
@@ -25,6 +25,9 @@ export default function App() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
const [tasks, setTasks] = useState<Task[]>(SEED_TASKS);
|
const [tasks, setTasks] = useState<Task[]>(SEED_TASKS);
|
||||||
|
const [managedUsers, setManagedUsers] = useState(() =>
|
||||||
|
USERS.map(u => ({ ...u, active: true, joinedDate: '2025-12-01' }))
|
||||||
|
);
|
||||||
const [activePage, setActivePage] = useState('calendar');
|
const [activePage, setActivePage] = useState('calendar');
|
||||||
const [activeView, setActiveView] = useState('calendar');
|
const [activeView, setActiveView] = useState('calendar');
|
||||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||||
@@ -80,13 +83,6 @@ export default function App() {
|
|||||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: t.status === 'done' ? 'todo' : 'done' as Status } : t));
|
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 displayPage = VIEW_PAGES.includes(activePage) ? activeView : activePage;
|
||||||
const filteredMyTasks = tasks.filter(t => t.assignee === currentUser.id);
|
const filteredMyTasks = tasks.filter(t => t.assignee === currentUser.id);
|
||||||
|
|
||||||
@@ -107,8 +103,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
{displayPage === 'kanban' && (
|
{displayPage === 'kanban' && (
|
||||||
<KanbanBoard tasks={tasks} currentUser={currentUser} onTaskClick={handleTaskClick}
|
<KanbanBoard tasks={tasks} currentUser={currentUser} onTaskClick={handleTaskClick}
|
||||||
onAddTask={handleKanbanAdd} filterUser={filterUser} searchQuery={searchQuery}
|
onAddTask={handleKanbanAdd} filterUser={filterUser} searchQuery={searchQuery} />
|
||||||
onMoveTask={handleMoveTask} />
|
|
||||||
)}
|
)}
|
||||||
{displayPage === 'list' && (
|
{displayPage === 'list' && (
|
||||||
<ListView tasks={tasks} currentUser={currentUser} onTaskClick={handleTaskClick}
|
<ListView tasks={tasks} currentUser={currentUser} onTaskClick={handleTaskClick}
|
||||||
@@ -120,8 +115,9 @@ export default function App() {
|
|||||||
filterUser={null} searchQuery={searchQuery} onToggleDone={handleToggleDone} />
|
filterUser={null} searchQuery={searchQuery} onToggleDone={handleToggleDone} />
|
||||||
)}
|
)}
|
||||||
{displayPage === 'teamtasks' && <TeamTasksPage tasks={tasks} currentUser={currentUser} />}
|
{displayPage === 'teamtasks' && <TeamTasksPage tasks={tasks} currentUser={currentUser} />}
|
||||||
{displayPage === 'reports' && <ReportsPage tasks={tasks} currentUser={currentUser} />}
|
{displayPage === 'reports' && <ReportsPage tasks={tasks} />}
|
||||||
{displayPage === 'members' && <MembersPage tasks={tasks} />}
|
{displayPage === 'members' && <MembersPage tasks={tasks} currentUser={currentUser}
|
||||||
|
users={managedUsers} onUpdateUsers={setManagedUsers} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { Task, User, Status } from './data';
|
import type { Task, User, Status } from './data';
|
||||||
import { PRIORITY_COLORS, STATUS_COLORS, STATUS_LABELS } from './data';
|
import { PRIORITY_COLORS, STATUS_COLORS, STATUS_LABELS } from './data';
|
||||||
import { Avatar, PriorityBadge, StatusBadge, ProgressBar } from './Shared';
|
import { Avatar, PriorityBadge, StatusBadge, ProgressBar } from './Shared';
|
||||||
|
|
||||||
function TaskCard({ task, onClick, onDragStart }: { task: Task; onClick: () => void; onDragStart: (e: React.DragEvent, task: Task) => void }) {
|
function TaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
|
||||||
const p = PRIORITY_COLORS[task.priority];
|
const p = PRIORITY_COLORS[task.priority];
|
||||||
const due = new Date(task.dueDate + 'T00:00:00');
|
const due = new Date(task.dueDate + 'T00:00:00');
|
||||||
const overdue = due < new Date() && task.status !== 'done';
|
const overdue = due < new Date() && task.status !== 'done';
|
||||||
const commCount = task.comments.length;
|
const commCount = task.comments.length;
|
||||||
return (
|
return (
|
||||||
<div className="task-card" style={{ borderLeftColor: p.color, cursor: 'grab' }}
|
<div className="task-card" style={{ borderLeftColor: p.color }} onClick={onClick}>
|
||||||
draggable
|
|
||||||
onDragStart={e => onDragStart(e, task)}
|
|
||||||
onDragEnd={e => (e.currentTarget as HTMLElement).style.opacity = '1'}
|
|
||||||
onClick={onClick}>
|
|
||||||
<div className="task-card-row">
|
<div className="task-card-row">
|
||||||
<span className="task-card-title">{task.title}</span>
|
<span className="task-card-title">{task.title}</span>
|
||||||
<Avatar userId={task.assignee} size={24} />
|
<Avatar userId={task.assignee} size={24} />
|
||||||
@@ -32,20 +27,12 @@ function TaskCard({ task, onClick, onDragStart }: { task: Task; onClick: () => v
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTask, onDragStart, onDrop, isDragOver, onDragOver, onDragLeave }: {
|
function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTask }: {
|
||||||
status: Status; statusLabel: string; tasks: Task[]; color: string;
|
status: Status; statusLabel: string; tasks: Task[]; color: string;
|
||||||
onTaskClick: (t: Task) => void; onAddTask: (s: Status) => void;
|
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 (
|
return (
|
||||||
<div className={`kanban-column ${isDragOver ? 'kanban-column-drag-over' : ''}`}
|
<div className="kanban-column">
|
||||||
onDragOver={e => onDragOver(e, status)}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={e => onDrop(e, status)}>
|
|
||||||
<div className="kanban-col-header">
|
<div className="kanban-col-header">
|
||||||
<div className="kanban-col-dot" style={{ background: color }} />
|
<div className="kanban-col-dot" style={{ background: color }} />
|
||||||
<span className="kanban-col-label">{statusLabel}</span>
|
<span className="kanban-col-label">{statusLabel}</span>
|
||||||
@@ -54,11 +41,9 @@ function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTas
|
|||||||
</div>
|
</div>
|
||||||
<div className="kanban-col-body">
|
<div className="kanban-col-body">
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div className={`kanban-empty ${isDragOver ? 'kanban-empty-active' : ''}`}>
|
<div className="kanban-empty">No tasks here · Click + to add one</div>
|
||||||
{isDragOver ? '⬇ Drop task here' : 'No tasks here · Click + to add one'}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
tasks.map(t => <TaskCard key={t.id} task={t} onClick={() => onTaskClick(t)} onDragStart={onDragStart} />)
|
tasks.map(t => <TaskCard key={t.id} task={t} onClick={() => onTaskClick(t)} />)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,58 +53,20 @@ function KanbanColumn({ status, statusLabel, tasks, color, onTaskClick, onAddTas
|
|||||||
interface KanbanProps {
|
interface KanbanProps {
|
||||||
tasks: Task[]; currentUser: User; onTaskClick: (t: Task) => void;
|
tasks: Task[]; currentUser: User; onTaskClick: (t: Task) => void;
|
||||||
onAddTask: (s: Status) => void; filterUser: string | null; searchQuery: string;
|
onAddTask: (s: Status) => void; filterUser: string | null; searchQuery: string;
|
||||||
onMoveTask: (taskId: string, newStatus: Status) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanBoard({ tasks, currentUser, onTaskClick, onAddTask, filterUser, searchQuery, onMoveTask }: KanbanProps) {
|
export function KanbanBoard({ tasks, currentUser, onTaskClick, onAddTask, filterUser, searchQuery }: KanbanProps) {
|
||||||
const [dragOverColumn, setDragOverColumn] = useState<Status | null>(null);
|
|
||||||
|
|
||||||
let filtered = tasks;
|
let filtered = tasks;
|
||||||
if (currentUser.role === 'employee') filtered = filtered.filter(t => t.assignee === currentUser.id);
|
if (currentUser.role === 'employee') filtered = filtered.filter(t => t.assignee === currentUser.id);
|
||||||
if (filterUser) filtered = filtered.filter(t => t.assignee === filterUser);
|
if (filterUser) filtered = filtered.filter(t => t.assignee === filterUser);
|
||||||
if (searchQuery) filtered = filtered.filter(t => t.title.toLowerCase().includes(searchQuery.toLowerCase()));
|
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'];
|
const statuses: Status[] = ['todo', 'inprogress', 'review', 'done'];
|
||||||
return (
|
return (
|
||||||
<div className="kanban-board">
|
<div className="kanban-board">
|
||||||
{statuses.map(s => (
|
{statuses.map(s => (
|
||||||
<KanbanColumn key={s} status={s} statusLabel={STATUS_LABELS[s]} color={STATUS_COLORS[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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
617
src/Pages.tsx
617
src/Pages.tsx
@@ -1,109 +1,432 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { Task, User } from './data';
|
import type { Task, User } from './data';
|
||||||
import { USERS, PRIORITY_COLORS } from './data';
|
import { USERS, PRIORITY_COLORS } from './data';
|
||||||
import { Avatar, StatusBadge } from './Shared';
|
import { Avatar, StatusBadge, RoleBadge } from './Shared';
|
||||||
|
|
||||||
|
/* ── Team Tasks Page ── */
|
||||||
export function TeamTasksPage({ tasks }: { tasks: Task[]; currentUser: User }) {
|
export function TeamTasksPage({ tasks }: { tasks: Task[]; currentUser: User }) {
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const [groupBy, setGroupBy] = useState<'member' | 'dept'>('member');
|
||||||
|
|
||||||
const members = USERS;
|
const members = USERS;
|
||||||
|
const departments = [...new Set(USERS.map(u => u.dept))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="team-tasks">
|
<div className="team-tasks">
|
||||||
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16 }}>Team Tasks</h2>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
{members.map(m => {
|
<h2 style={{ fontSize: 18, fontWeight: 700 }}>Team Tasks</h2>
|
||||||
const mTasks = tasks.filter(t => t.assignee === m.id);
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
const isOpen = expanded[m.id] !== false;
|
<button className={`list-sort-btn ${groupBy === 'member' ? 'active' : ''}`} onClick={() => setGroupBy('member')}>👤 By Member</button>
|
||||||
return (
|
<button className={`list-sort-btn ${groupBy === 'dept' ? 'active' : ''}`} onClick={() => setGroupBy('dept')}>🏢 By Dept</button>
|
||||||
<div key={m.id} className="team-group">
|
</div>
|
||||||
<div className="team-group-header" onClick={() => setExpanded(e => ({ ...e, [m.id]: !isOpen }))}>
|
</div>
|
||||||
<Avatar userId={m.id} size={28} />
|
{groupBy === 'member' ? (
|
||||||
<span className="team-group-name">{m.name}</span>
|
members.map(m => {
|
||||||
<span className="team-group-count">({mTasks.length} tasks)</span>
|
const mTasks = tasks.filter(t => t.assignee === m.id);
|
||||||
<span style={{ color: '#64748b' }}>{isOpen ? '▼' : '▶'}</span>
|
const isOpen = expanded[m.id] !== false;
|
||||||
</div>
|
return (
|
||||||
{isOpen && (
|
<div key={m.id} className="team-group">
|
||||||
<div className="team-group-tasks">
|
<div className="team-group-header" onClick={() => setExpanded(e => ({ ...e, [m.id]: !isOpen }))}>
|
||||||
{mTasks.map(t => (
|
<Avatar userId={m.id} size={28} />
|
||||||
<div key={t.id} className="team-task-row">
|
<span className="team-group-name">{m.name}</span>
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: PRIORITY_COLORS[t.priority].color }} />
|
<RoleBadge role={m.role} />
|
||||||
<span className="team-task-title">{t.title}</span>
|
<span className="team-group-count">({mTasks.length} tasks)</span>
|
||||||
<StatusBadge status={t.status} />
|
<span style={{ color: '#64748b' }}>{isOpen ? '▼' : '▶'}</span>
|
||||||
<span style={{ fontSize: 11, color: '#64748b' }}>
|
|
||||||
{new Date(t.dueDate + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
{t.subtasks.length > 0 && <span style={{ fontSize: 10, color: '#64748b' }}>📋 {t.subtasks.filter(s => s.done).length}/{t.subtasks.length}</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{isOpen && (
|
||||||
</div>
|
<div className="team-group-tasks">
|
||||||
);
|
{mTasks.map(t => (
|
||||||
})}
|
<div key={t.id} className="team-task-row">
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: PRIORITY_COLORS[t.priority].color }} />
|
||||||
|
<span className="team-task-title">{t.title}</span>
|
||||||
|
<StatusBadge status={t.status} />
|
||||||
|
<span style={{ fontSize: 11, color: '#64748b' }}>
|
||||||
|
{new Date(t.dueDate + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
{t.subtasks.length > 0 && <span style={{ fontSize: 10, color: '#64748b' }}>📋 {t.subtasks.filter(s => s.done).length}/{t.subtasks.length}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{mTasks.length === 0 && <span style={{ color: '#64748b', fontSize: 12, fontStyle: 'italic' }}>No tasks assigned</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
departments.map(dept => {
|
||||||
|
const deptMembers = members.filter(m => m.dept === dept);
|
||||||
|
const deptTasks = tasks.filter(t => deptMembers.some(m => m.id === t.assignee));
|
||||||
|
const isOpen = expanded[dept] !== false;
|
||||||
|
return (
|
||||||
|
<div key={dept} className="team-group">
|
||||||
|
<div className="team-group-header" onClick={() => setExpanded(e => ({ ...e, [dept]: !isOpen }))}>
|
||||||
|
<span style={{ fontSize: 18 }}>🏢</span>
|
||||||
|
<span className="team-group-name">{dept}</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#64748b' }}>{deptMembers.length} members</span>
|
||||||
|
<span className="team-group-count">({deptTasks.length} tasks)</span>
|
||||||
|
<span style={{ color: '#64748b' }}>{isOpen ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="team-group-tasks">
|
||||||
|
{deptMembers.map(m => {
|
||||||
|
const mTasks = tasks.filter(t => t.assignee === m.id);
|
||||||
|
return (
|
||||||
|
<div key={m.id} style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<Avatar userId={m.id} size={22} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#e2e8f0' }}>{m.name}</span>
|
||||||
|
<span style={{ fontSize: 10, color: '#64748b' }}>{mTasks.length} tasks</span>
|
||||||
|
</div>
|
||||||
|
{mTasks.map(t => (
|
||||||
|
<div key={t.id} className="team-task-row" style={{ paddingLeft: 30 }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: PRIORITY_COLORS[t.priority].color }} />
|
||||||
|
<span className="team-task-title" style={{ fontSize: 12 }}>{t.title}</span>
|
||||||
|
<StatusBadge status={t.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MembersPage({ tasks }: { tasks: Task[] }) {
|
/* ── TYPES ── */
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
interface ManagedUser {
|
||||||
const [showInvite, setShowInvite] = useState(false);
|
id: string; name: string; role: string; email: string;
|
||||||
|
pass: string; color: string; avatar: string; dept: string;
|
||||||
|
active: boolean; joinedDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = ['employee', 'manager', 'cto'] as const;
|
||||||
|
const DEPT_OPTIONS = ['Leadership', 'DevOps', 'Backend', 'Frontend', 'QA', 'Design', 'Data', 'Security'];
|
||||||
|
const ROLE_COLORS: Record<string, string> = { cto: '#818cf8', manager: '#fb923c', employee: '#22c55e' };
|
||||||
|
|
||||||
|
/* ── Members Page with full user management ── */
|
||||||
|
export function MembersPage({ tasks, currentUser, users, onUpdateUsers }: {
|
||||||
|
tasks: Task[];
|
||||||
|
currentUser: User;
|
||||||
|
users: ManagedUser[];
|
||||||
|
onUpdateUsers: (users: ManagedUser[]) => void;
|
||||||
|
}) {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [editUser, setEditUser] = useState<ManagedUser | null>(null);
|
||||||
|
const [confirmAction, setConfirmAction] = useState<{ userId: string; action: 'deactivate' | 'reactivate' | 'delete' } | null>(null);
|
||||||
|
const [searchQ, setSearchQ] = useState('');
|
||||||
|
const [filterRole, setFilterRole] = useState<string>('all');
|
||||||
|
const [filterDept, setFilterDept] = useState<string>('all');
|
||||||
|
const [view, setView] = useState<'table' | 'grid'>('table');
|
||||||
|
|
||||||
|
const isAuthority = currentUser.role === 'cto' || currentUser.role === 'manager';
|
||||||
|
|
||||||
|
// filter users
|
||||||
|
let filtered = users;
|
||||||
|
if (searchQ) filtered = filtered.filter(u => u.name.toLowerCase().includes(searchQ.toLowerCase()) || u.email.toLowerCase().includes(searchQ.toLowerCase()));
|
||||||
|
if (filterRole !== 'all') filtered = filtered.filter(u => u.role === filterRole);
|
||||||
|
if (filterDept !== 'all') filtered = filtered.filter(u => u.dept === filterDept);
|
||||||
|
|
||||||
|
const departments = [...new Set(users.map(u => u.dept))];
|
||||||
|
const activeCount = users.filter(u => u.active).length;
|
||||||
|
const deptStats = departments.map(d => ({ dept: d, count: users.filter(u => u.dept === d).length }));
|
||||||
|
|
||||||
|
const handleAddUser = (newUser: Omit<ManagedUser, 'id' | 'avatar' | 'active' | 'joinedDate'>) => {
|
||||||
|
const u: ManagedUser = {
|
||||||
|
...newUser,
|
||||||
|
id: `u${Date.now()}`,
|
||||||
|
avatar: newUser.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2),
|
||||||
|
active: true,
|
||||||
|
joinedDate: new Date().toISOString().split('T')[0],
|
||||||
|
};
|
||||||
|
onUpdateUsers([...users, u]);
|
||||||
|
setShowAdd(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditUser = (updated: ManagedUser) => {
|
||||||
|
onUpdateUsers(users.map(u => u.id === updated.id ? updated : u));
|
||||||
|
setEditUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = (userId: string) => {
|
||||||
|
onUpdateUsers(users.map(u => u.id === userId ? { ...u, active: !u.active } : u));
|
||||||
|
setConfirmAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = (userId: string) => {
|
||||||
|
onUpdateUsers(users.filter(u => u.id !== userId));
|
||||||
|
setConfirmAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeRole = (userId: string, newRole: string) => {
|
||||||
|
onUpdateUsers(users.map(u => u.id === userId ? { ...u, role: newRole } : u));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeDept = (userId: string, newDept: string) => {
|
||||||
|
onUpdateUsers(users.map(u => u.id === userId ? { ...u, dept: newDept } : u));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="members-page">
|
<div className="members-page">
|
||||||
|
{/* HEADER */}
|
||||||
<div className="members-header">
|
<div className="members-header">
|
||||||
<h2>Team Members</h2>
|
<div>
|
||||||
<button className="btn-ghost" onClick={() => setShowInvite(true)}>+ Invite Member</button>
|
<h2 style={{ fontSize: 18, fontWeight: 700 }}>User Management</h2>
|
||||||
|
<span style={{ fontSize: 12, color: '#64748b' }}>{activeCount} active · {users.length - activeCount} inactive · {departments.length} departments</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
{isAuthority && <button className="btn-primary" onClick={() => setShowAdd(true)}>+ Add Member</button>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table className="members-table">
|
|
||||||
<thead><tr><th>Avatar</th><th>Full Name</th><th>Role</th><th>Dept</th><th>Assigned</th><th>Done</th><th>Active</th></tr></thead>
|
{/* DEPARTMENT OVERVIEW */}
|
||||||
<tbody>
|
<div className="dept-overview">
|
||||||
{USERS.map(u => {
|
{deptStats.map(d => (
|
||||||
|
<div key={d.dept} className={`dept-chip ${filterDept === d.dept ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilterDept(filterDept === d.dept ? 'all' : d.dept)}>
|
||||||
|
<span style={{ fontSize: 14 }}>🏢</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{d.dept}</span>
|
||||||
|
<span className="dept-chip-count">{d.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TOOLBAR */}
|
||||||
|
<div className="members-toolbar">
|
||||||
|
<div className="members-search">
|
||||||
|
<span style={{ fontSize: 14, opacity: 0.5 }}>🔍</span>
|
||||||
|
<input placeholder="Search by name or email..." value={searchQ} onChange={e => setSearchQ(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<select className="members-filter-select" value={filterRole} onChange={e => setFilterRole(e.target.value)}>
|
||||||
|
<option value="all">All Roles</option>
|
||||||
|
{ROLE_OPTIONS.map(r => <option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="view-toggle">
|
||||||
|
<button className={view === 'table' ? 'active' : ''} onClick={() => setView('table')}>☰</button>
|
||||||
|
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')}>⊞</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TABLE VIEW */}
|
||||||
|
{view === 'table' && (
|
||||||
|
<table className="members-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 40 }}></th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Tasks</th>
|
||||||
|
<th>Status</th>
|
||||||
|
{isAuthority && <th>Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map(u => {
|
||||||
|
const ut = tasks.filter(t => t.assignee === u.id);
|
||||||
|
const done = ut.filter(t => t.status === 'done').length;
|
||||||
|
const isExpanded = expandedId === u.id;
|
||||||
|
const isSelf = u.id === currentUser.id;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={u.id}>
|
||||||
|
<tr onClick={() => setExpandedId(isExpanded ? null : u.id)}
|
||||||
|
style={{ opacity: u.active ? 1 : 0.5 }}>
|
||||||
|
<td><Avatar userId={u.id} size={30} /></td>
|
||||||
|
<td>
|
||||||
|
<div style={{ fontWeight: 600 }}>{u.name}</div>
|
||||||
|
{!u.active && <span style={{ fontSize: 9, color: '#ef4444', fontWeight: 700 }}>DEACTIVATED</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ color: '#94a3b8', fontSize: 12 }}>{u.email}</td>
|
||||||
|
<td>
|
||||||
|
{isAuthority && !isSelf ? (
|
||||||
|
<select className="inline-select" value={u.role}
|
||||||
|
style={{ color: ROLE_COLORS[u.role], borderColor: ROLE_COLORS[u.role] + '44' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onChange={e => handleChangeRole(u.id, e.target.value)}>
|
||||||
|
{ROLE_OPTIONS.map(r => <option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span style={{ background: `${ROLE_COLORS[u.role]}22`, color: ROLE_COLORS[u.role], padding: '2px 8px', borderRadius: 10, fontSize: 10, fontWeight: 600 }}>
|
||||||
|
{u.role.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isAuthority && !isSelf ? (
|
||||||
|
<select className="inline-select" value={u.dept}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onChange={e => handleChangeDept(u.id, e.target.value)}>
|
||||||
|
{DEPT_OPTIONS.map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 12 }}>{u.dept}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontSize: 12 }}>{done}/{ut.length}</span>
|
||||||
|
{ut.length > 0 && (
|
||||||
|
<div style={{ width: 60, height: 4, background: '#1e293b', borderRadius: 2, marginTop: 4 }}>
|
||||||
|
<div style={{ width: `${(done / ut.length) * 100}%`, height: '100%', background: '#22c55e', borderRadius: 2 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`status-dot ${u.active ? 'status-dot-active' : 'status-dot-inactive'}`}>
|
||||||
|
{u.active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{isAuthority && (
|
||||||
|
<td onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button className="action-icon-btn" title="Edit" onClick={() => setEditUser(u)}>✏️</button>
|
||||||
|
{!isSelf && (
|
||||||
|
<>
|
||||||
|
<button className="action-icon-btn" title={u.active ? 'Deactivate' : 'Reactivate'}
|
||||||
|
onClick={() => setConfirmAction({ userId: u.id, action: u.active ? 'deactivate' : 'reactivate' })}>
|
||||||
|
{u.active ? '🚫' : '✅'}
|
||||||
|
</button>
|
||||||
|
<button className="action-icon-btn" title="Delete"
|
||||||
|
onClick={() => setConfirmAction({ userId: u.id, action: 'delete' })}>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr><td colSpan={isAuthority ? 8 : 7}>
|
||||||
|
<div className="member-expand">
|
||||||
|
<div className="member-expand-header">
|
||||||
|
<div className="member-expand-stat">
|
||||||
|
<span className="stat-num">{ut.length}</span>
|
||||||
|
<span className="stat-label">Total Tasks</span>
|
||||||
|
</div>
|
||||||
|
<div className="member-expand-stat">
|
||||||
|
<span className="stat-num" style={{ color: '#22c55e' }}>{done}</span>
|
||||||
|
<span className="stat-label">Completed</span>
|
||||||
|
</div>
|
||||||
|
<div className="member-expand-stat">
|
||||||
|
<span className="stat-num" style={{ color: '#818cf8' }}>{ut.filter(t => t.status === 'inprogress').length}</span>
|
||||||
|
<span className="stat-label">In Progress</span>
|
||||||
|
</div>
|
||||||
|
<div className="member-expand-stat">
|
||||||
|
<span className="stat-num" style={{ color: '#ef4444' }}>
|
||||||
|
{ut.filter(t => new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length}
|
||||||
|
</span>
|
||||||
|
<span className="stat-label">Overdue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ut.map(t => (
|
||||||
|
<div key={t.id} className="team-task-row">
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: PRIORITY_COLORS[t.priority].color }} />
|
||||||
|
<span className="team-task-title">{t.title}</span>
|
||||||
|
<StatusBadge status={t.status} />
|
||||||
|
<span style={{ fontSize: 11, color: '#64748b' }}>
|
||||||
|
{new Date(t.dueDate + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ut.length === 0 && <span style={{ color: '#64748b', fontSize: 12, fontStyle: 'italic' }}>No tasks assigned</span>}
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GRID VIEW */}
|
||||||
|
{view === 'grid' && (
|
||||||
|
<div className="members-grid">
|
||||||
|
{filtered.map(u => {
|
||||||
const ut = tasks.filter(t => t.assignee === u.id);
|
const ut = tasks.filter(t => t.assignee === u.id);
|
||||||
const done = ut.filter(t => t.status === 'done').length;
|
const done = ut.filter(t => t.status === 'done').length;
|
||||||
const active = ut.filter(t => t.status !== 'done').length;
|
|
||||||
const roleColors: Record<string, string> = { cto: '#818cf8', manager: '#fb923c', employee: '#22c55e' };
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={u.id}>
|
<div key={u.id} className={`member-card ${!u.active ? 'member-card-inactive' : ''}`}>
|
||||||
<tr onClick={() => setExpanded(expanded === u.id ? null : u.id)}>
|
<div className="member-card-top">
|
||||||
<td><Avatar userId={u.id} size={28} /></td>
|
<Avatar userId={u.id} size={48} />
|
||||||
<td>{u.name}</td>
|
<div style={{ flex: 1 }}>
|
||||||
<td><span style={{ background: `${roleColors[u.role]}22`, color: roleColors[u.role], padding: '2px 8px', borderRadius: 10, fontSize: 10, fontWeight: 600 }}>{u.role.toUpperCase()}</span></td>
|
<div style={{ fontWeight: 700, fontSize: 14 }}>{u.name}</div>
|
||||||
<td>{u.dept}</td>
|
<div style={{ fontSize: 11, color: '#64748b' }}>{u.email}</div>
|
||||||
<td>{ut.length}</td>
|
</div>
|
||||||
<td>{done}</td>
|
{isAuthority && u.id !== currentUser.id && (
|
||||||
<td>{active}</td>
|
<button className="action-icon-btn" onClick={() => setEditUser(u)}>✏️</button>
|
||||||
</tr>
|
)}
|
||||||
{expanded === u.id && (
|
</div>
|
||||||
<tr><td colSpan={7}>
|
<div className="member-card-badges">
|
||||||
<div className="member-expand">
|
<RoleBadge role={u.role} />
|
||||||
{ut.map(t => (
|
<span className="tag-pill">{u.dept}</span>
|
||||||
<div key={t.id} className="team-task-row">
|
<span className={`status-dot ${u.active ? 'status-dot-active' : 'status-dot-inactive'}`}>
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: PRIORITY_COLORS[t.priority].color }} />
|
{u.active ? 'Active' : 'Inactive'}
|
||||||
<span className="team-task-title">{t.title}</span>
|
</span>
|
||||||
<StatusBadge status={t.status} />
|
</div>
|
||||||
</div>
|
<div className="member-card-stats">
|
||||||
))}
|
<div><span style={{ fontWeight: 700 }}>{ut.length}</span> tasks</div>
|
||||||
{ut.length === 0 && <span style={{ color: '#64748b', fontSize: 12 }}>No tasks assigned</span>}
|
<div><span style={{ fontWeight: 700, color: '#22c55e' }}>{done}</span> done</div>
|
||||||
</div>
|
<div><span style={{ fontWeight: 700, color: '#818cf8' }}>{ut.length - done}</span> active</div>
|
||||||
</td></tr>
|
</div>
|
||||||
|
{ut.length > 0 && (
|
||||||
|
<div style={{ height: 4, background: '#1e293b', borderRadius: 2 }}>
|
||||||
|
<div style={{ width: `${(done / ut.length) * 100}%`, height: '100%', background: '#22c55e', borderRadius: 2, transition: 'width 0.3s' }} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
{isAuthority && u.id !== currentUser.id && (
|
||||||
|
<div className="member-card-actions">
|
||||||
|
<button className="action-icon-btn" onClick={() => setConfirmAction({ userId: u.id, action: u.active ? 'deactivate' : 'reactivate' })}>
|
||||||
|
{u.active ? '🚫 Deactivate' : '✅ Reactivate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
)}
|
||||||
|
|
||||||
{showInvite && (
|
{/* ADD USER MODAL */}
|
||||||
<div className="modal-backdrop" onClick={() => setShowInvite(false)}>
|
{showAdd && <AddUserModal onClose={() => setShowAdd(false)} onAdd={handleAddUser} />}
|
||||||
<div className="modal invite-modal" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="modal-header"><h2>Invite Member</h2><button className="drawer-close" onClick={() => setShowInvite(false)}>✕</button></div>
|
{/* EDIT USER MODAL */}
|
||||||
<div className="modal-body">
|
{editUser && <EditUserModal user={editUser} onClose={() => setEditUser(null)} onSave={handleEditUser} isCTO={currentUser.role === 'cto'} />}
|
||||||
<div className="modal-field"><label>Email</label><input className="modal-input" placeholder="member@company.io" /></div>
|
|
||||||
<div className="modal-field">
|
{/* CONFIRM DIALOG */}
|
||||||
<label>Role</label>
|
{confirmAction && (
|
||||||
<select className="modal-input"><option value="employee">Employee</option><option value="manager">Manager</option></select>
|
<div className="modal-backdrop" onClick={() => setConfirmAction(null)}>
|
||||||
</div>
|
<div className="modal" style={{ width: 380 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{confirmAction.action === 'delete' ? '🗑️ Delete User' : confirmAction.action === 'deactivate' ? '🚫 Deactivate User' : '✅ Reactivate User'}</h2>
|
||||||
|
<button className="drawer-close" onClick={() => setConfirmAction(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p style={{ fontSize: 13, color: '#94a3b8', lineHeight: 1.6 }}>
|
||||||
|
{confirmAction.action === 'delete'
|
||||||
|
? `Are you sure you want to permanently delete ${users.find(u => u.id === confirmAction.userId)?.name}? This action cannot be undone.`
|
||||||
|
: confirmAction.action === 'deactivate'
|
||||||
|
? `Are you sure you want to deactivate ${users.find(u => u.id === confirmAction.userId)?.name}? They will lose access but their data will be preserved.`
|
||||||
|
: `Reactivate ${users.find(u => u.id === confirmAction.userId)?.name} and restore their access?`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn-ghost" onClick={() => setConfirmAction(null)}>Cancel</button>
|
||||||
|
<button className={confirmAction.action === 'delete' ? 'btn-danger' : 'btn-primary'}
|
||||||
|
onClick={() => confirmAction.action === 'delete'
|
||||||
|
? handleDeleteUser(confirmAction.userId)
|
||||||
|
: handleToggleActive(confirmAction.userId)
|
||||||
|
}>
|
||||||
|
{confirmAction.action === 'delete' ? 'Delete' : confirmAction.action === 'deactivate' ? 'Deactivate' : 'Reactivate'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer"><button className="btn-ghost" onClick={() => setShowInvite(false)}>Cancel</button><button className="btn-primary" onClick={() => setShowInvite(false)}>Send Invite</button></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -111,3 +434,141 @@ export function MembersPage({ tasks }: { tasks: Task[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Add User Modal ── */
|
||||||
|
function AddUserModal({ onClose, onAdd }: {
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (u: Omit<ManagedUser, 'id' | 'avatar' | 'active' | 'joinedDate'>) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [role, setRole] = useState('employee');
|
||||||
|
const [dept, setDept] = useState('DevOps');
|
||||||
|
const [pass, setPass] = useState('');
|
||||||
|
const [errors, setErrors] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const AVATAR_COLORS = ['#818cf8', '#f59e0b', '#34d399', '#f472b6', '#fb923c', '#6366f1', '#ec4899', '#14b8a6'];
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const errs: Record<string, boolean> = {};
|
||||||
|
if (!name.trim()) errs.name = true;
|
||||||
|
if (!email.trim() || !email.includes('@')) errs.email = true;
|
||||||
|
if (!pass.trim() || pass.length < 4) errs.pass = true;
|
||||||
|
setErrors(errs);
|
||||||
|
return Object.keys(errs).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!validate()) return;
|
||||||
|
onAdd({
|
||||||
|
name: name.trim(), email: email.trim(), role, dept, pass,
|
||||||
|
color: AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" style={{ width: 440 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>👤 Add Team Member</h2>
|
||||||
|
<button className="drawer-close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>Full Name *</label>
|
||||||
|
<input className={`modal-input ${errors.name ? 'error' : ''}`} placeholder="John Doe" value={name} onChange={e => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>Email *</label>
|
||||||
|
<input className={`modal-input ${errors.email ? 'error' : ''}`} placeholder="john@company.io" value={email} onChange={e => setEmail(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-grid">
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>Role</label>
|
||||||
|
<select className="modal-input" value={role} onChange={e => setRole(e.target.value)}>
|
||||||
|
{ROLE_OPTIONS.map(r => <option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>Department</label>
|
||||||
|
<select className="modal-input" value={dept} onChange={e => setDept(e.target.value)}>
|
||||||
|
{DEPT_OPTIONS.map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>Password * <span style={{ fontSize: 10, color: '#64748b' }}>(min 4 chars)</span></label>
|
||||||
|
<input type="password" className={`modal-input ${errors.pass ? 'error' : ''}`} placeholder="••••••••" value={pass} onChange={e => setPass(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn-ghost" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn-primary" onClick={handleSubmit}>Add Member</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Edit User Modal ── */
|
||||||
|
function EditUserModal({ user, onClose, onSave, isCTO }: {
|
||||||
|
user: ManagedUser; onClose: () => void; onSave: (u: ManagedUser) => void; isCTO: boolean;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(user.name);
|
||||||
|
const [email, setEmail] = useState(user.email);
|
||||||
|
const [role, setRole] = useState(user.role);
|
||||||
|
const [dept, setDept] = useState(user.dept);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave({
|
||||||
|
...user, name: name.trim(), email: email.trim(), role, dept,
|
||||||
|
avatar: name.trim().split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" style={{ width: 440 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>✏️ Edit Member</h2>
|
||||||
|
<button className="drawer-close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, padding: 12, background: 'var(--bg-card)', borderRadius: 10 }}>
|
||||||
|
<Avatar userId={user.id} size={42} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 14 }}>{user.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#64748b' }}>Joined {user.joinedDate || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>Full Name</label>
|
||||||
|
<input className="modal-input" value={name} onChange={e => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input className="modal-input" value={email} onChange={e => setEmail(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-grid">
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>Role</label>
|
||||||
|
<select className="modal-input" value={role} onChange={e => setRole(e.target.value)} disabled={!isCTO}>
|
||||||
|
{ROLE_OPTIONS.map(r => <option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>)}
|
||||||
|
</select>
|
||||||
|
{!isCTO && <span style={{ fontSize: 10, color: '#64748b' }}>Only CTO can change roles</span>}
|
||||||
|
</div>
|
||||||
|
<div className="modal-field">
|
||||||
|
<label>Department</label>
|
||||||
|
<select className="modal-input" value={dept} onChange={e => setDept(e.target.value)}>
|
||||||
|
{DEPT_OPTIONS.map(d => <option key={d} value={d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn-ghost" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn-primary" onClick={handleSave}>Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
318
src/Reports.tsx
318
src/Reports.tsx
@@ -1,69 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import type { Task } from './data';
|
||||||
import type { Task, User } from './data';
|
import { USERS, STATUS_COLORS, PRIORITY_COLORS } from './data';
|
||||||
import { USERS, STATUS_COLORS, STATUS_LABELS, PRIORITY_COLORS } from './data';
|
import { BarChart, Bar, PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
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 = {
|
const tooltipStyle = {
|
||||||
contentStyle: { background: '#0f172a', border: '1px solid #334155', borderRadius: 8, color: '#e2e8f0', fontSize: 12 },
|
contentStyle: { background: '#0f172a', border: '1px solid #334155', borderRadius: 8, color: '#e2e8f0', fontSize: 12 },
|
||||||
itemStyle: { color: '#e2e8f0' },
|
itemStyle: { color: '#e2e8f0' },
|
||||||
labelStyle: { color: '#94a3b8' },
|
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 total = tasks.length;
|
||||||
const completed = tasks.filter(t => t.status === 'done').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 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 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);
|
|
||||||
|
|
||||||
/* ── chart data ── */
|
// Tasks per member (stacked by status)
|
||||||
|
|
||||||
// 1 · Tasks per member (stacked bar)
|
|
||||||
const memberData = USERS.map(u => {
|
const memberData = USERS.map(u => {
|
||||||
const ut = tasks.filter(t => t.assignee === u.id);
|
const ut = tasks.filter(t => t.assignee === u.id);
|
||||||
return {
|
return {
|
||||||
@@ -75,282 +26,93 @@ export function ReportsPage({ tasks, currentUser }: ReportsProps) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2 · Priority donut
|
// Priority distribution
|
||||||
const prioData = (['critical', 'high', 'medium', 'low'] as const).map(p => ({
|
const prioData = (['critical', 'high', 'medium', 'low'] as const).map(p => ({
|
||||||
name: p.charAt(0).toUpperCase() + p.slice(1), value: tasks.filter(t => t.priority === p).length, color: PRIORITY_COLORS[p].color,
|
name: p, value: tasks.filter(t => t.priority === p).length, color: PRIORITY_COLORS[p].color,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 3 · Completions this week (area chart)
|
// Completions mock
|
||||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
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], target: 2 }));
|
const completionData = days.map((d, i) => ({ name: d, completed: [1, 0, 2, 1, 3, 0, 1][i] }));
|
||||||
|
|
||||||
// 4 · Overdue by member (horizontal bar)
|
// Overdue by member
|
||||||
const overdueData = USERS.map(u => ({
|
const overdueData = USERS.map(u => ({
|
||||||
name: u.name.split(' ')[0],
|
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,
|
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);
|
})).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 (
|
return (
|
||||||
<div className="reports">
|
<div className="reports">
|
||||||
{/* HEADER with export */}
|
<div className="stats-row">
|
||||||
<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', icon: '📋' },
|
{ label: 'Total Tasks', num: total, border: '#6366f1' },
|
||||||
{ label: 'Completed', num: completed, border: '#22c55e', icon: '✅' },
|
{ label: 'Completed', num: completed, border: '#22c55e' },
|
||||||
{ label: 'In Progress', num: inProgress, border: '#818cf8', icon: '⏳' },
|
{ label: 'Overdue', num: overdue, border: '#ef4444' },
|
||||||
{ label: 'In Review', num: inReview, border: '#f59e0b', icon: '👀' },
|
{ label: 'Critical Open', num: critical, border: '#f97316' },
|
||||||
{ 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 => (
|
].map(s => (
|
||||||
<div key={s.label} className="stat-card" style={{ borderTop: `3px solid ${s.border}` }}>
|
<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>
|
||||||
<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 className="stat-card-label">{s.label}</div>
|
||||||
</div>
|
</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">
|
<div className="charts-grid">
|
||||||
{/* 1 · Tasks per Member */}
|
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<div className="chart-card-title">Tasks per Member</div>
|
<div className="chart-card-title">Tasks per Member</div>
|
||||||
<ResponsiveContainer width="100%" height={270}>
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
<BarChart data={memberData} barSize={28}>
|
<BarChart data={memberData}>
|
||||||
<XAxis dataKey="name" tick={{ fill: '#64748b', fontSize: 11 }} axisLine={false} tickLine={false} />
|
<XAxis dataKey="name" tick={{ fill: '#64748b', fontSize: 11 }} />
|
||||||
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} axisLine={false} tickLine={false} />
|
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} />
|
||||||
<Tooltip {...tooltipStyle} />
|
<Tooltip {...tooltipStyle} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
|
<Legend wrapperStyle={{ fontSize: 11, color: '#94a3b8' }} />
|
||||||
<Bar dataKey="todo" stackId="a" fill={STATUS_COLORS.todo} name="To Do" radius={[0, 0, 0, 0]} />
|
<Bar dataKey="todo" stackId="a" fill={STATUS_COLORS.todo} name="To Do" />
|
||||||
<Bar dataKey="inprogress" stackId="a" fill={STATUS_COLORS.inprogress} name="In Progress" />
|
<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="review" stackId="a" fill={STATUS_COLORS.review} name="Review" />
|
||||||
<Bar dataKey="done" stackId="a" fill={STATUS_COLORS.done} name="Done" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="done" stackId="a" fill={STATUS_COLORS.done} name="Done" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2 · Priority Distribution */}
|
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<div className="chart-card-title">Priority Distribution</div>
|
<div className="chart-card-title">Priority Distribution</div>
|
||||||
<ResponsiveContainer width="100%" height={270}>
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Pie data={prioData} cx="50%" cy="50%" innerRadius={55} outerRadius={90} paddingAngle={3} dataKey="value"
|
<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}>
|
||||||
label={((entry: any) => `${entry.name} ${((entry.percent ?? 0) * 100).toFixed(0)}%`) as any}
|
{prioData.map(d => <Cell key={d.name} fill={d.color} />)}
|
||||||
labelLine={{ stroke: '#334155' }}>
|
|
||||||
{prioData.map(d => <Cell key={d.name} fill={d.color} stroke="none" />)}
|
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip {...tooltipStyle} />
|
<Tooltip {...tooltipStyle} />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div style={{ textAlign: 'center', fontSize: 24, fontWeight: 800, marginTop: -155, position: 'relative', pointerEvents: 'none', color: '#f1f5f9' }}>
|
<div style={{ textAlign: 'center', fontSize: 22, fontWeight: 800, marginTop: -140, position: 'relative', pointerEvents: 'none', color: '#f1f5f9' }}>{total}</div>
|
||||||
{total}
|
|
||||||
<div style={{ fontSize: 10, color: '#64748b', fontWeight: 500 }}>tasks</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ height: 100 }} />
|
<div style={{ height: 100 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3 · Completion Trend (Area) */}
|
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<div className="chart-card-title">Completion Trend <span style={{ fontSize: 10, color: '#64748b', fontWeight: 400 }}>vs target</span></div>
|
<div className="chart-card-title">Completions This Week</div>
|
||||||
<ResponsiveContainer width="100%" height={270}>
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
<AreaChart data={completionData}>
|
<LineChart data={completionData}>
|
||||||
<defs>
|
<XAxis dataKey="name" tick={{ fill: '#64748b', fontSize: 11 }} />
|
||||||
<linearGradient id="completedGrad" x1="0" y1="0" x2="0" y2="1">
|
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} />
|
||||||
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
<Tooltip {...tooltipStyle} />
|
||||||
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
<Line type="monotone" dataKey="completed" stroke="#6366f1" strokeWidth={2} dot={{ fill: '#22c55e', r: 4 }} />
|
||||||
</linearGradient>
|
</LineChart>
|
||||||
</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>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4 · Overdue by Member */}
|
|
||||||
<div className="chart-card">
|
<div className="chart-card">
|
||||||
<div className="chart-card-title">Overdue by Member <span style={{ fontSize: 10, color: '#ef4444', fontWeight: 400 }}>⚠ needs attention</span></div>
|
<div className="chart-card-title">Overdue by Member</div>
|
||||||
<ResponsiveContainer width="100%" height={270}>
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
<BarChart data={overdueData} layout="vertical" barSize={20}>
|
<BarChart data={overdueData} layout="vertical">
|
||||||
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 11 }} axisLine={false} tickLine={false} />
|
<XAxis type="number" tick={{ fill: '#64748b', fontSize: 11 }} />
|
||||||
<YAxis dataKey="name" type="category" tick={{ fill: '#64748b', fontSize: 11 }} width={60} axisLine={false} tickLine={false} />
|
<YAxis dataKey="name" type="category" tick={{ fill: '#64748b', fontSize: 11 }} width={60} />
|
||||||
<Tooltip {...tooltipStyle} />
|
<Tooltip {...tooltipStyle} />
|
||||||
<Bar dataKey="overdue" fill="#ef4444" radius={[0, 6, 6, 0]} background={{ fill: 'rgba(239,68,68,0.06)', radius: 6 }} />
|
<Bar dataKey="overdue" fill="#ef4444" radius={[0, 4, 4, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
397
src/index.css
397
src/index.css
@@ -1014,7 +1014,6 @@ body {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 80px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-empty {
|
.kanban-empty {
|
||||||
@@ -1024,57 +1023,20 @@ body {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* DRAG AND DROP */
|
|
||||||
.kanban-column-drag-over {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 24px var(--accent-glow), inset 0 0 12px rgba(99, 102, 241, 0.08);
|
|
||||||
background: rgba(99, 102, 241, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column-drag-over .kanban-col-body {
|
|
||||||
background: rgba(99, 102, 241, 0.03);
|
|
||||||
border-radius: 0 0 12px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-empty-active {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
animation: dragPulse 1s ease infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TASK CARD */
|
||||||
.task-card {
|
.task-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
cursor: grab;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
border-left: 3px solid;
|
border-left: 3px solid;
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dragPulse {
|
|
||||||
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TASK CARD */
|
|
||||||
.task-card:hover {
|
.task-card:hover {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -1839,13 +1801,6 @@ body {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reports-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-grid {
|
.charts-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -1865,52 +1820,7 @@ body {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* INSIGHTS */
|
/* MEMBERS / USER MANAGEMENT */
|
||||||
.insights-section {
|
|
||||||
margin-top: 20px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insights-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insight-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-left: 3px solid var(--border);
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insight-card:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.insight-warning {
|
|
||||||
border-left-color: #f59e0b;
|
|
||||||
background: rgba(245, 158, 11, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.insight-success {
|
|
||||||
border-left-color: #22c55e;
|
|
||||||
background: rgba(34, 197, 94, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.insight-info {
|
|
||||||
border-left-color: #6366f1;
|
|
||||||
background: rgba(99, 102, 241, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MEMBERS */
|
|
||||||
.members-page {
|
.members-page {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
@@ -1919,14 +1829,120 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.members-header h2 {
|
.members-header h2 {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Department overview chips */
|
||||||
|
.dept-overview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(99, 102, 241, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-chip.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-chip-count {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.members-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-search input {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #e2e8f0;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-filter-select {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle button {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: none;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
.members-table {
|
.members-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -1954,13 +1970,202 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline select for role/dept editing */
|
||||||
|
.inline-select {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-select:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action icon buttons */
|
||||||
|
.action-icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon-btn:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dots */
|
||||||
|
.status-dot {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot-active {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot-active::before {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot-inactive {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot-inactive::before {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded row */
|
||||||
.member-expand {
|
.member-expand {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-modal {
|
.member-expand-header {
|
||||||
width: 380px;
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-expand-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid view */
|
||||||
|
.members-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 12px rgba(99, 102, 241, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card-inactive {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 10px 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger button */
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal grid (side by side fields) */
|
||||||
|
.modal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error states */
|
||||||
|
.modal-input.error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Role badge */
|
||||||
|
.role-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* MORE TASKS POPOVER */
|
/* MORE TASKS POPOVER */
|
||||||
|
|||||||
Reference in New Issue
Block a user