1 Commits

Author SHA1 Message Date
tusuii
769a64f612 feat: add drag-and-drop to Kanban board
- Implement native HTML5 Drag and Drop API on task cards
- Cards show grab cursor and reduce opacity while dragging
- Drop zones highlight with indigo glow and pulse animation
- Moving a task updates its status and logs an activity entry
- Added handleMoveTask to App.tsx with STATUS_LABELS import
- CSS: drag-over styles, pulse keyframes, grab/grabbing cursors
2026-02-15 11:43:17 +05:30
4 changed files with 192 additions and 853 deletions

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { SEED_TASKS, USERS } from './data';
import { SEED_TASKS, STATUS_LABELS } from './data';
import type { Task, User, Status } from './data';
import { LoginPage } from './Login';
import { Sidebar } from './Sidebar';
@@ -25,9 +25,6 @@ export default function App() {
const now = new Date();
const [currentUser, setCurrentUser] = useState<User | null>(null);
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 [activeView, setActiveView] = useState('calendar');
const [activeTask, setActiveTask] = useState<Task | null>(null);
@@ -83,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);
@@ -103,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}
@@ -116,8 +121,7 @@ export default function App() {
)}
{displayPage === 'teamtasks' && <TeamTasksPage tasks={tasks} currentUser={currentUser} />}
{displayPage === 'reports' && <ReportsPage tasks={tasks} />}
{displayPage === 'members' && <MembersPage tasks={tasks} currentUser={currentUser}
users={managedUsers} onUpdateUsers={setManagedUsers} />}
{displayPage === 'members' && <MembersPage tasks={tasks} />}
</div>
</div>

View File

@@ -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>
);

View File

@@ -1,432 +1,109 @@
import React, { useState } from 'react';
import type { Task, User } from './data';
import { USERS, PRIORITY_COLORS } from './data';
import { Avatar, StatusBadge, RoleBadge } from './Shared';
import { Avatar, StatusBadge } from './Shared';
/* ── Team Tasks Page ── */
export function TeamTasksPage({ tasks }: { tasks: Task[]; currentUser: User }) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [groupBy, setGroupBy] = useState<'member' | 'dept'>('member');
const members = USERS;
const departments = [...new Set(USERS.map(u => u.dept))];
return (
<div className="team-tasks">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ fontSize: 18, fontWeight: 700 }}>Team Tasks</h2>
<div style={{ display: 'flex', gap: 6 }}>
<button className={`list-sort-btn ${groupBy === 'member' ? 'active' : ''}`} onClick={() => setGroupBy('member')}>👤 By Member</button>
<button className={`list-sort-btn ${groupBy === 'dept' ? 'active' : ''}`} onClick={() => setGroupBy('dept')}>🏢 By Dept</button>
</div>
</div>
{groupBy === 'member' ? (
members.map(m => {
const mTasks = tasks.filter(t => t.assignee === m.id);
const isOpen = expanded[m.id] !== false;
return (
<div key={m.id} className="team-group">
<div className="team-group-header" onClick={() => setExpanded(e => ({ ...e, [m.id]: !isOpen }))}>
<Avatar userId={m.id} size={28} />
<span className="team-group-name">{m.name}</span>
<RoleBadge role={m.role} />
<span className="team-group-count">({mTasks.length} tasks)</span>
<span style={{ color: '#64748b' }}>{isOpen ? '▼' : '▶'}</span>
</div>
{isOpen && (
<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>
)}
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16 }}>Team Tasks</h2>
{members.map(m => {
const mTasks = tasks.filter(t => t.assignee === m.id);
const isOpen = expanded[m.id] !== false;
return (
<div key={m.id} className="team-group">
<div className="team-group-header" onClick={() => setExpanded(e => ({ ...e, [m.id]: !isOpen }))}>
<Avatar userId={m.id} size={28} />
<span className="team-group-name">{m.name}</span>
<span className="team-group-count">({mTasks.length} tasks)</span>
<span style={{ color: '#64748b' }}>{isOpen ? '▼' : '▶'}</span>
</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>
{isOpen && (
<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>
))}
</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>
);
}
/* ── TYPES ── */
interface ManagedUser {
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));
};
export function MembersPage({ tasks }: { tasks: Task[] }) {
const [expanded, setExpanded] = useState<string | null>(null);
const [showInvite, setShowInvite] = useState(false);
return (
<div className="members-page">
{/* HEADER */}
<div className="members-header">
<div>
<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>
<h2>Team Members</h2>
<button className="btn-ghost" onClick={() => setShowInvite(true)}>+ Invite Member</button>
</div>
{/* DEPARTMENT OVERVIEW */}
<div className="dept-overview">
{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 => {
<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>
<tbody>
{USERS.map(u => {
const ut = tasks.filter(t => t.assignee === u.id);
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 (
<div key={u.id} className={`member-card ${!u.active ? 'member-card-inactive' : ''}`}>
<div className="member-card-top">
<Avatar userId={u.id} size={48} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>{u.name}</div>
<div style={{ fontSize: 11, color: '#64748b' }}>{u.email}</div>
</div>
{isAuthority && u.id !== currentUser.id && (
<button className="action-icon-btn" onClick={() => setEditUser(u)}></button>
)}
</div>
<div className="member-card-badges">
<RoleBadge role={u.role} />
<span className="tag-pill">{u.dept}</span>
<span className={`status-dot ${u.active ? 'status-dot-active' : 'status-dot-inactive'}`}>
{u.active ? 'Active' : 'Inactive'}
</span>
</div>
<div className="member-card-stats">
<div><span style={{ fontWeight: 700 }}>{ut.length}</span> tasks</div>
<div><span style={{ fontWeight: 700, color: '#22c55e' }}>{done}</span> done</div>
<div><span style={{ fontWeight: 700, color: '#818cf8' }}>{ut.length - done}</span> active</div>
</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 key={u.id}>
<tr onClick={() => setExpanded(expanded === u.id ? null : u.id)}>
<td><Avatar userId={u.id} size={28} /></td>
<td>{u.name}</td>
<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>
<td>{u.dept}</td>
<td>{ut.length}</td>
<td>{done}</td>
<td>{active}</td>
</tr>
{expanded === u.id && (
<tr><td colSpan={7}>
<div className="member-expand">
{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} />
</div>
))}
{ut.length === 0 && <span style={{ color: '#64748b', fontSize: 12 }}>No tasks assigned</span>}
</div>
</td></tr>
)}
{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>
</React.Fragment>
);
})}
</div>
)}
</tbody>
</table>
{/* ADD USER MODAL */}
{showAdd && <AddUserModal onClose={() => setShowAdd(false)} onAdd={handleAddUser} />}
{/* EDIT USER MODAL */}
{editUser && <EditUserModal user={editUser} onClose={() => setEditUser(null)} onSave={handleEditUser} isCTO={currentUser.role === 'cto'} />}
{/* CONFIRM DIALOG */}
{confirmAction && (
<div className="modal-backdrop" onClick={() => setConfirmAction(null)}>
<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>
{showInvite && (
<div className="modal-backdrop" onClick={() => setShowInvite(false)}>
<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>
<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 className="modal-field"><label>Email</label><input className="modal-input" placeholder="member@company.io" /></div>
<div className="modal-field">
<label>Role</label>
<select className="modal-input"><option value="employee">Employee</option><option value="manager">Manager</option></select>
</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>
)}
@@ -434,141 +111,3 @@ export function MembersPage({ tasks, currentUser, users, onUpdateUsers }: {
);
}
/* ── 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>
);
}

View File

@@ -1014,6 +1014,7 @@ body {
padding: 10px;
flex: 1;
overflow-y: auto;
min-height: 80px;
}
.kanban-empty {
@@ -1023,20 +1024,57 @@ body {
text-align: center;
color: var(--text-muted);
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 {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 10px;
cursor: pointer;
cursor: grab;
transition: all 0.15s;
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 {
background: var(--border);
transform: translateY(-1px);
@@ -1820,7 +1858,7 @@ body {
margin-bottom: 16px;
}
/* MEMBERS / USER MANAGEMENT */
/* MEMBERS */
.members-page {
padding: 20px;
}
@@ -1829,120 +1867,14 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: 20px;
}
.members-header h2 {
font-size: 18px;
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 {
width: 100%;
border-collapse: collapse;
@@ -1970,202 +1902,13 @@ body {
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 {
padding: 12px 16px;
background: var(--bg-card);
border-radius: 8px;
}
.member-expand-header {
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;
.invite-modal {
width: 380px;
}
/* MORE TASKS POPOVER */