feat: add more roles (tech_lead, scrum_master, product_owner, designer, qa)
- Registration form: added 5 new role options to dropdown - Sidebar: new roles get proper nav access via ALL_ROLES/LEADER_ROLES - Dashboard: isLeader check expanded to include new leadership roles - Shared/Pages: role badge colors added for all new roles - Invite modal: expanded role dropdown
This commit is contained in:
30
src/App.tsx
30
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiFetchTasks, apiFetchUsers, apiCreateTask, apiUpdateTask, apiAddActivity } from './api';
|
||||
import { apiFetchTasks, apiFetchUsers, apiCreateTask, apiUpdateTask, apiAddActivity, apiAddDependency, apiToggleDependency, apiRemoveDependency } from './api';
|
||||
import type { Task, User, Status } from './data';
|
||||
import { STATUS_LABELS } from './data';
|
||||
import { LoginPage } from './Login';
|
||||
@@ -103,6 +103,7 @@ export default function App() {
|
||||
reporter: currentUser.id,
|
||||
dueDate: task.dueDate,
|
||||
tags: task.tags,
|
||||
dependencies: (task.dependencies || []).map(d => ({ dependsOnUserId: d.dependsOnUserId, description: d.description })),
|
||||
});
|
||||
setTasks(prev => [...prev, created]);
|
||||
} catch (err) {
|
||||
@@ -160,6 +161,31 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDep = async (taskId: string, dep: { dependsOnUserId: string; description: string }) => {
|
||||
try {
|
||||
const newDep = await apiAddDependency(taskId, dep);
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, dependencies: [...(t.dependencies || []), newDep] } : t));
|
||||
if (activeTask?.id === taskId) setActiveTask(prev => prev ? { ...prev, dependencies: [...(prev.dependencies || []), newDep] } : prev);
|
||||
} catch (err) { console.error('Failed to add dependency:', err); }
|
||||
};
|
||||
|
||||
const handleToggleDep = async (taskId: string, depId: string, resolved: boolean) => {
|
||||
try {
|
||||
await apiToggleDependency(taskId, depId, resolved);
|
||||
const updateDeps = (deps: any[]) => deps.map((d: any) => d.id === depId ? { ...d, resolved } : d);
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, dependencies: updateDeps(t.dependencies || []) } : t));
|
||||
if (activeTask?.id === taskId) setActiveTask(prev => prev ? { ...prev, dependencies: updateDeps(prev.dependencies || []) } : prev);
|
||||
} catch (err) { console.error('Failed to toggle dependency:', err); }
|
||||
};
|
||||
|
||||
const handleRemoveDep = async (taskId: string, depId: string) => {
|
||||
try {
|
||||
await apiRemoveDependency(taskId, depId);
|
||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, dependencies: (t.dependencies || []).filter((d: any) => d.id !== depId) } : t));
|
||||
if (activeTask?.id === taskId) setActiveTask(prev => prev ? { ...prev, dependencies: (prev.dependencies || []).filter((d: any) => d.id !== depId) } : prev);
|
||||
} catch (err) { console.error('Failed to remove dependency:', err); }
|
||||
};
|
||||
|
||||
const displayPage = VIEW_PAGES.includes(activePage) ? activeView : activePage;
|
||||
const filteredMyTasks = tasks.filter(t => t.assignee === currentUser.id);
|
||||
|
||||
@@ -211,7 +237,7 @@ export default function App() {
|
||||
<BottomToggleBar activeView={activeView} onViewChange={handleViewChange} />
|
||||
)}
|
||||
|
||||
{activeTask && <TaskDrawer task={activeTask} currentUser={currentUser} onClose={() => setActiveTask(null)} onUpdate={handleUpdateTask} users={users} />}
|
||||
{activeTask && <TaskDrawer task={activeTask} currentUser={currentUser} onClose={() => setActiveTask(null)} onUpdate={handleUpdateTask} onAddDependency={handleAddDep} onToggleDependency={handleToggleDep} onRemoveDependency={handleRemoveDep} users={users} />}
|
||||
{showAddModal && <AddTaskModal onClose={() => setShowAddModal(false)} onAdd={handleAddTask} defaultDate={addModalDefaults.date} defaultStatus={addModalDefaults.status} users={users} currentUser={currentUser} />}
|
||||
|
||||
{quickAddDay && (
|
||||
|
||||
@@ -8,7 +8,7 @@ export function DashboardPage({ tasks, currentUser, users }: { tasks: Task[]; cu
|
||||
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 isLeader = currentUser.role === 'cto' || currentUser.role === 'manager';
|
||||
const isLeader = ['cto', 'manager', 'tech_lead', 'scrum_master', 'product_owner'].includes(currentUser.role);
|
||||
const myTasks = tasks.filter(t => t.assignee === currentUser.id);
|
||||
const myDone = myTasks.filter(t => t.status === 'done').length;
|
||||
|
||||
|
||||
@@ -57,39 +57,44 @@ export function LoginPage({ onLogin }: { onLogin: (u: User) => void }) {
|
||||
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<label className="login-label">Name</label>
|
||||
<label className="login-label" htmlFor="register-name">Name</label>
|
||||
<div className="login-input-wrap">
|
||||
<input className={`login-input ${error ? 'error' : ''}`} type="text" placeholder="Your full name"
|
||||
<input id="register-name" className={`login-input ${error ? 'error' : ''}`} type="text" placeholder="Your full name"
|
||||
value={name} onChange={e => { setName(e.target.value); setError(''); }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="login-label">Email</label>
|
||||
<label className="login-label" htmlFor="login-email">Email</label>
|
||||
<div className="login-input-wrap">
|
||||
<input className={`login-input ${error ? 'error' : ''}`} type="email" placeholder="you@company.io"
|
||||
<input id="login-email" className={`login-input ${error ? 'error' : ''}`} type="email" placeholder="you@company.io"
|
||||
value={email} onChange={e => { setEmail(e.target.value); setError(''); }} />
|
||||
</div>
|
||||
<label className="login-label">Password</label>
|
||||
<label className="login-label" htmlFor="login-password">Password</label>
|
||||
<div className="login-input-wrap">
|
||||
<input className={`login-input ${error ? 'error' : ''}`} type={showPass ? 'text' : 'password'} placeholder="••••••••"
|
||||
<input id="login-password" className={`login-input ${error ? 'error' : ''}`} type={showPass ? 'text' : 'password'} placeholder="••••••••"
|
||||
value={pass} onChange={e => { setPass(e.target.value); setError(''); }} />
|
||||
<button type="button" className="login-eye" onClick={() => setShowPass(!showPass)}>{showPass ? '🙈' : '👁'}</button>
|
||||
</div>
|
||||
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<label className="login-label">Role</label>
|
||||
<label className="login-label" htmlFor="register-role">Role</label>
|
||||
<div className="login-input-wrap">
|
||||
<select className="login-input" value={role} onChange={e => setRole(e.target.value)}>
|
||||
<select id="register-role" className="login-input" value={role} onChange={e => setRole(e.target.value)}>
|
||||
<option value="employee">Employee</option>
|
||||
<option value="tech_lead">Tech Lead</option>
|
||||
<option value="scrum_master">Scrum Master</option>
|
||||
<option value="product_owner">Product Owner</option>
|
||||
<option value="designer">Designer</option>
|
||||
<option value="qa">QA Engineer</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="cto">CTO</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="login-label">Department</label>
|
||||
<label className="login-label" htmlFor="register-dept">Department</label>
|
||||
<div className="login-input-wrap">
|
||||
<input className="login-input" type="text" placeholder="e.g. Backend, Frontend, DevOps"
|
||||
<input id="register-dept" className="login-input" type="text" placeholder="e.g. Backend, Frontend, DevOps"
|
||||
value={dept} onChange={e => setDept(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function MembersPage({ tasks, users }: { tasks: Task[]; users: User[] })
|
||||
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' };
|
||||
const roleColors: Record<string, string> = { cto: '#818cf8', manager: '#fb923c', tech_lead: '#06b6d4', scrum_master: '#a855f7', product_owner: '#ec4899', designer: '#f43f5e', qa: '#14b8a6', employee: '#22c55e' };
|
||||
return (
|
||||
<React.Fragment key={u.id}>
|
||||
<tr onClick={() => setExpanded(expanded === u.id ? null : u.id)}>
|
||||
@@ -99,7 +99,7 @@ export function MembersPage({ tasks, users }: { tasks: Task[]; users: User[] })
|
||||
<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>
|
||||
<select className="modal-input"><option value="employee">Employee</option><option value="tech_lead">Tech Lead</option><option value="scrum_master">Scrum Master</option><option value="product_owner">Product Owner</option><option value="designer">Designer</option><option value="qa">QA Engineer</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>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ProgressBar({ subtasks }: { subtasks: Subtask[] }) {
|
||||
}
|
||||
|
||||
export function RoleBadge({ role }: { role: string }) {
|
||||
const colors: Record<string, string> = { cto: '#818cf8', manager: '#fb923c', employee: '#22c55e' };
|
||||
const colors: Record<string, string> = { cto: '#818cf8', manager: '#fb923c', tech_lead: '#06b6d4', scrum_master: '#a855f7', product_owner: '#ec4899', designer: '#f43f5e', qa: '#14b8a6', employee: '#22c55e' };
|
||||
const c = colors[role] || '#64748b';
|
||||
return <span className="role-badge" style={{ background: `${c}22`, color: c }}>{role.toUpperCase()}</span>;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@ import type { User } from './data';
|
||||
import { Avatar } from './Shared';
|
||||
import { RoleBadge } from './Shared';
|
||||
|
||||
const ALL_ROLES = ['cto', 'manager', 'tech_lead', 'scrum_master', 'product_owner', 'employee', 'designer', 'qa'];
|
||||
const LEADER_ROLES = ['cto', 'manager', 'tech_lead', 'scrum_master', 'product_owner'];
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'dashboard', icon: '⊞', label: 'Dashboard', roles: ['cto', 'manager', 'employee'] },
|
||||
{ id: 'calendar', icon: '📅', label: 'Calendar', roles: ['cto', 'manager', 'employee'] },
|
||||
{ id: 'kanban', icon: '▦', label: 'Kanban Board', roles: ['cto', 'manager', 'employee'] },
|
||||
{ id: 'mytasks', icon: '✓', label: 'My Tasks', roles: ['employee'] },
|
||||
{ id: 'teamtasks', icon: '👥', label: 'Team Tasks', roles: ['cto', 'manager'] },
|
||||
{ id: 'reports', icon: '📊', label: 'Reports', roles: ['cto', 'manager'] },
|
||||
{ id: 'dashboard', icon: '⊞', label: 'Dashboard', roles: ALL_ROLES },
|
||||
{ id: 'calendar', icon: '📅', label: 'Calendar', roles: ALL_ROLES },
|
||||
{ id: 'kanban', icon: '▦', label: 'Kanban Board', roles: ALL_ROLES },
|
||||
{ id: 'mytasks', icon: '✓', label: 'My Tasks', roles: ['employee', 'designer', 'qa'] },
|
||||
{ id: 'teamtasks', icon: '👥', label: 'Team Tasks', roles: LEADER_ROLES },
|
||||
{ id: 'reports', icon: '📊', label: 'Reports', roles: LEADER_ROLES },
|
||||
{ id: 'members', icon: '👤', label: 'Members', roles: ['cto'] },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,12 +6,17 @@ import { Avatar, Tag, ProgressBar } from './Shared';
|
||||
interface DrawerProps {
|
||||
task: Task; currentUser: User; onClose: () => void;
|
||||
onUpdate: (updated: Task) => void;
|
||||
onAddDependency: (taskId: string, dep: { dependsOnUserId: string; description: string }) => void;
|
||||
onToggleDependency: (taskId: string, depId: string, resolved: boolean) => void;
|
||||
onRemoveDependency: (taskId: string, depId: string) => void;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export function TaskDrawer({ task, currentUser, onClose, onUpdate, users }: DrawerProps) {
|
||||
export function TaskDrawer({ task, currentUser, onClose, onUpdate, onAddDependency, onToggleDependency, onRemoveDependency, users }: DrawerProps) {
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [subtaskText, setSubtaskText] = useState('');
|
||||
const [depUser, setDepUser] = useState('');
|
||||
const [depDesc, setDepDesc] = useState('');
|
||||
|
||||
const updateField = (field: string, value: any) => {
|
||||
const now = new Date().toISOString();
|
||||
@@ -49,8 +54,16 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate, users }: Draw
|
||||
setCommentText('');
|
||||
};
|
||||
|
||||
const handleAddDep = () => {
|
||||
if (!depDesc.trim()) return;
|
||||
onAddDependency(task.id, { dependsOnUserId: depUser, description: depDesc });
|
||||
setDepDesc('');
|
||||
setDepUser('');
|
||||
};
|
||||
|
||||
const reporter = getUserById(users, task.reporter);
|
||||
const doneCount = task.subtasks.filter(s => s.done).length;
|
||||
const unresolvedDeps = (task.dependencies || []).filter(d => !d.resolved).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,7 +82,7 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate, users }: Draw
|
||||
<div className="drawer-meta-label">Assignee</div>
|
||||
<div className="drawer-meta-val">
|
||||
<Avatar userId={task.assignee} size={20} users={users} />
|
||||
<select className="drawer-select" value={task.assignee} onChange={e => updateField('assignee', e.target.value)}>
|
||||
<select className="drawer-select" aria-label="Assignee" value={task.assignee} onChange={e => updateField('assignee', e.target.value)}>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -80,13 +93,13 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate, users }: Draw
|
||||
</div>
|
||||
<div>
|
||||
<div className="drawer-meta-label">Status</div>
|
||||
<select className="drawer-select" value={task.status} onChange={e => updateField('status', e.target.value)}>
|
||||
<select className="drawer-select" aria-label="Status" value={task.status} onChange={e => updateField('status', e.target.value)}>
|
||||
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div className="drawer-meta-label">Priority</div>
|
||||
<select className="drawer-select" value={task.priority} onChange={e => updateField('priority', e.target.value)}>
|
||||
<select className="drawer-select" aria-label="Priority" value={task.priority} onChange={e => updateField('priority', e.target.value)}>
|
||||
{['critical', 'high', 'medium', 'low'].map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@@ -100,6 +113,50 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate, users }: Draw
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies Section */}
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-title">
|
||||
🔗 Dependencies
|
||||
{unresolvedDeps > 0 && <span className="dep-unresolved-badge">{unresolvedDeps} blocking</span>}
|
||||
</div>
|
||||
{(task.dependencies || []).length === 0 && (
|
||||
<div className="dep-empty">No dependencies yet</div>
|
||||
)}
|
||||
{(task.dependencies || []).map(dep => {
|
||||
const depUser = getUserById(users, dep.dependsOnUserId);
|
||||
return (
|
||||
<div key={dep.id} className={`dep-item ${dep.resolved ? 'dep-resolved' : 'dep-unresolved'}`}>
|
||||
<button
|
||||
className={`dep-check ${dep.resolved ? 'checked' : ''}`}
|
||||
onClick={() => onToggleDependency(task.id, dep.id, !dep.resolved)}
|
||||
title={dep.resolved ? 'Mark unresolved' : 'Mark resolved'}
|
||||
>
|
||||
{dep.resolved ? '✓' : ''}
|
||||
</button>
|
||||
<div className="dep-info">
|
||||
{depUser && (
|
||||
<span className="dep-user">
|
||||
<Avatar userId={dep.dependsOnUserId} size={18} users={users} />
|
||||
<span>{depUser.name}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className={`dep-desc ${dep.resolved ? 'done' : ''}`}>{dep.description}</span>
|
||||
</div>
|
||||
<button className="dep-remove" onClick={() => onRemoveDependency(task.id, dep.id)} title="Remove">✕</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="dep-add-row">
|
||||
<select className="dep-add-select" value={depUser} onChange={e => setDepUser(e.target.value)}>
|
||||
<option value="">Anyone</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
</select>
|
||||
<input className="dep-add-input" placeholder="Describe the dependency..." value={depDesc}
|
||||
onChange={e => setDepDesc(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAddDep()} />
|
||||
<button className="dep-add-btn" onClick={handleAddDep}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-section">
|
||||
<div className="drawer-section-title">Subtasks <span style={{ color: '#64748b', fontWeight: 400, fontSize: 12 }}>{doneCount} of {task.subtasks.length} complete</span></div>
|
||||
{task.subtasks.length > 0 && <ProgressBar subtasks={task.subtasks} />}
|
||||
@@ -162,16 +219,38 @@ interface ModalProps {
|
||||
currentUser: User;
|
||||
}
|
||||
|
||||
interface PendingDep {
|
||||
id: string;
|
||||
dependsOnUserId: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus, users, currentUser }: ModalProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [assignee, setAssignee] = useState(users[0]?.id || '');
|
||||
const [assignee, setAssignee] = useState(currentUser.id);
|
||||
const [priority, setPriority] = useState<Priority>('medium');
|
||||
const [status, setStatus] = useState<Status>(defaultStatus || 'todo');
|
||||
const [dueDate, setDueDate] = useState(defaultDate || new Date().toISOString().split('T')[0]);
|
||||
const [tags, setTags] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// Dependencies state
|
||||
const [deps, setDeps] = useState<PendingDep[]>([]);
|
||||
const [depUser, setDepUser] = useState('');
|
||||
const [depDesc, setDepDesc] = useState('');
|
||||
|
||||
const addDep = () => {
|
||||
if (!depDesc.trim()) return;
|
||||
setDeps(prev => [...prev, { id: `pd${Date.now()}`, dependsOnUserId: depUser, description: depDesc }]);
|
||||
setDepDesc('');
|
||||
setDepUser('');
|
||||
};
|
||||
|
||||
const removeDep = (id: string) => {
|
||||
setDeps(prev => prev.filter(d => d.id !== id));
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!title.trim()) { setError(true); return; }
|
||||
const task: Task = {
|
||||
@@ -180,6 +259,7 @@ export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus, users
|
||||
tags: tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [],
|
||||
subtasks: [], comments: [],
|
||||
activity: [{ id: `a${Date.now()}`, text: `📝 Task created`, timestamp: new Date().toISOString() }],
|
||||
dependencies: deps.map(d => ({ id: d.id, dependsOnUserId: d.dependsOnUserId, description: d.description, resolved: false })),
|
||||
};
|
||||
onAdd(task);
|
||||
onClose();
|
||||
@@ -200,7 +280,7 @@ export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus, users
|
||||
</div>
|
||||
<div className="modal-grid">
|
||||
<div className="modal-field">
|
||||
<label>Assignee</label>
|
||||
<label>Assign To</label>
|
||||
<select className="modal-input" value={assignee} onChange={e => setAssignee(e.target.value)}>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
</select>
|
||||
@@ -226,6 +306,33 @@ export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus, users
|
||||
<label>Tags (comma separated)</label>
|
||||
<input className="modal-input" placeholder="devops, backend, ..." value={tags} onChange={e => setTags(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* Dependencies Section */}
|
||||
<div className="modal-field">
|
||||
<label>🔗 Dependencies / Blockers</label>
|
||||
<div className="modal-deps-list">
|
||||
{deps.map(d => {
|
||||
const u = getUserById(users, d.dependsOnUserId);
|
||||
return (
|
||||
<div key={d.id} className="modal-dep-item">
|
||||
<span className="modal-dep-icon">⏳</span>
|
||||
{u && <span className="modal-dep-user">{u.avatar} {u.name}:</span>}
|
||||
<span className="modal-dep-desc">{d.description}</span>
|
||||
<button className="modal-dep-remove" onClick={() => removeDep(d.id)}>✕</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="modal-dep-add">
|
||||
<select className="modal-dep-select" value={depUser} onChange={e => setDepUser(e.target.value)}>
|
||||
<option value="">Blocked by (anyone)</option>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
|
||||
</select>
|
||||
<input className="modal-dep-input" placeholder="e.g. Need API endpoints from backend team"
|
||||
value={depDesc} onChange={e => setDepDesc(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addDep())} />
|
||||
<button className="modal-dep-btn" onClick={addDep} type="button">+ Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn-ghost" onClick={onClose}>Cancel</button>
|
||||
|
||||
54
src/__tests__/App.test.tsx
Normal file
54
src/__tests__/App.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import App from '../App';
|
||||
import * as api from '../api';
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../api', () => ({
|
||||
apiFetchTasks: vi.fn(),
|
||||
apiFetchUsers: vi.fn(),
|
||||
apiCreateTask: vi.fn(),
|
||||
apiUpdateTask: vi.fn(),
|
||||
apiAddActivity: vi.fn(),
|
||||
apiLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('App Component', () => {
|
||||
it('renders login page when no user is logged in', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByRole('button', { name: /sig\s*n\s*in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders main content after login', async () => {
|
||||
const mockUser = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'admin', dept: 'Engineering' };
|
||||
const mockTasks = [{ id: 't1', title: 'Task 1', status: 'todo' }];
|
||||
const mockUsers = [mockUser];
|
||||
|
||||
// Mock API responses
|
||||
(api.apiLogin as any).mockResolvedValue(mockUser);
|
||||
(api.apiFetchTasks as any).mockResolvedValue(mockTasks);
|
||||
(api.apiFetchUsers as any).mockResolvedValue(mockUsers);
|
||||
|
||||
render(<App />);
|
||||
|
||||
// Simulate login
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const loginButton = screen.getByRole('button', { name: /sig\s*n\s*in/i });
|
||||
|
||||
await userEvent.type(emailInput, 'test@example.com');
|
||||
await userEvent.type(passwordInput, 'password');
|
||||
await userEvent.click(loginButton);
|
||||
|
||||
// Wait for data loading
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check if main content is rendered (e.g., Sidebar, Calendar)
|
||||
expect(screen.getByText('Calendar')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test User')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
67
src/__tests__/Kanban.test.tsx
Normal file
67
src/__tests__/Kanban.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { KanbanBoard } from '../Kanban';
|
||||
import type { Task, User } from '../data';
|
||||
|
||||
// Mock Shared components
|
||||
vi.mock('../Shared', () => ({
|
||||
Avatar: ({ userId }: any) => <div data-testid="avatar">{userId}</div>,
|
||||
PriorityBadge: ({ level }: any) => <div>{level}</div>,
|
||||
StatusBadge: ({ status }: any) => <div>{status}</div>,
|
||||
ProgressBar: () => <div>Progress</div>
|
||||
}));
|
||||
|
||||
describe('KanbanBoard Component', () => {
|
||||
const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
|
||||
const mockUsers = [mockUser];
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{ id: 't1', title: 'Task 1', description: 'Desc', status: 'todo', priority: 'medium', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
|
||||
{ id: 't2', title: 'Task 2', description: 'Desc', status: 'inprogress', priority: 'high', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
|
||||
];
|
||||
|
||||
const mockOnTaskClick = vi.fn();
|
||||
const mockOnAddTask = vi.fn();
|
||||
const mockOnMoveTask = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders columns and tasks', () => {
|
||||
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
expect(screen.getByText('To Do')).toBeInTheDocument();
|
||||
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review')).toBeInTheDocument();
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters tasks by search query', () => {
|
||||
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="Task 1" users={mockUsers} />);
|
||||
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onAddTask when + button clicked', () => {
|
||||
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
// There are multiple + buttons (one per column)
|
||||
const addButtons = screen.getAllByText('+');
|
||||
fireEvent.click(addButtons[0]); // Click first column (Todo) add button
|
||||
|
||||
expect(mockOnAddTask).toHaveBeenCalledWith('todo');
|
||||
});
|
||||
|
||||
it('calls onTaskClick when task clicked', () => {
|
||||
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Task 1'));
|
||||
expect(mockOnTaskClick).toHaveBeenCalledWith(mockTasks[0]);
|
||||
});
|
||||
});
|
||||
57
src/__tests__/ListView.test.tsx
Normal file
57
src/__tests__/ListView.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ListView } from '../ListView';
|
||||
import type { Task, User } from '../data';
|
||||
|
||||
describe('ListView Component', () => {
|
||||
const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
|
||||
const mockUsers = [mockUser];
|
||||
|
||||
// Test data
|
||||
const mockTasks: Task[] = [
|
||||
{ id: 't1', title: 'Task 1', description: 'Desc', status: 'todo', priority: 'medium', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
|
||||
{ id: 't2', title: 'Task 2', description: 'Desc', status: 'done', priority: 'high', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
|
||||
];
|
||||
|
||||
const mockOnTaskClick = vi.fn();
|
||||
const mockOnToggleDone = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders tasks in list', () => {
|
||||
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
||||
// Check for headers (using getAllByText because buttons also have these labels)
|
||||
expect(screen.getAllByText('Title').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Assignee').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('filters tasks by search', () => {
|
||||
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="Task 1" users={mockUsers} />);
|
||||
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles task click', () => {
|
||||
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Task 1'));
|
||||
expect(mockOnTaskClick).toHaveBeenCalledWith(mockTasks[0]);
|
||||
});
|
||||
|
||||
it('handles toggle done', () => {
|
||||
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="" users={mockUsers} />);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[0]);
|
||||
|
||||
expect(mockOnToggleDone).toHaveBeenCalledWith('t1');
|
||||
});
|
||||
});
|
||||
107
src/__tests__/Login.test.tsx
Normal file
107
src/__tests__/Login.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LoginPage } from '../Login';
|
||||
import * as api from '../api';
|
||||
|
||||
// Mock API
|
||||
vi.mock('../api', () => ({
|
||||
apiLogin: vi.fn(),
|
||||
apiRegister: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('LoginPage Component', () => {
|
||||
const mockOnLogin = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnLogin.mockClear();
|
||||
});
|
||||
|
||||
it('renders login form by default', () => {
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
expect(screen.getByText(/scrum-manager/i)).toBeInTheDocument(); // Logo check
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/no account\? register here/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
it('switches to register mode', async () => {
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
await userEvent.click(screen.getByText(/no account\? register here/i));
|
||||
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/already have an account\? sign in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles successful login', async () => {
|
||||
const mockUser = { id: 'u1', name: 'Test', email: 'test@example.com', role: 'emp', dept: 'dev' };
|
||||
(api.apiLogin as any).mockResolvedValue(mockUser);
|
||||
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
|
||||
await userEvent.type(screen.getByLabelText(/password/i), 'password');
|
||||
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.apiLogin).toHaveBeenCalledWith('test@example.com', 'password');
|
||||
expect(mockOnLogin).toHaveBeenCalledWith(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles login failure', async () => {
|
||||
(api.apiLogin as any).mockRejectedValue(new Error('Invalid credentials'));
|
||||
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/email/i), 'wrong@example.com');
|
||||
await userEvent.type(screen.getByLabelText(/password/i), 'wrongpass');
|
||||
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(mockOnLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles successful registration', async () => {
|
||||
const mockUser = { id: 'u2', name: 'New User', email: 'new@example.com', role: 'employee', dept: 'dev' };
|
||||
(api.apiRegister as any).mockResolvedValue(mockUser);
|
||||
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
await userEvent.click(screen.getByText(/no account\? register here/i));
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/name/i), 'New User');
|
||||
await userEvent.type(screen.getByLabelText(/email/i), 'new@example.com');
|
||||
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
|
||||
await userEvent.type(screen.getByLabelText(/department/i), 'DevOps'); // "e.g. Backend..." placeholder, checking label
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /create account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.apiRegister).toHaveBeenCalledWith({
|
||||
name: 'New User',
|
||||
email: 'new@example.com',
|
||||
password: 'password123',
|
||||
role: 'employee', // Default
|
||||
dept: 'DevOps'
|
||||
});
|
||||
expect(mockOnLogin).toHaveBeenCalledWith(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
it('validates registration inputs', async () => {
|
||||
render(<LoginPage onLogin={mockOnLogin} />);
|
||||
await userEvent.click(screen.getByText(/no account\? register here/i));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /create account/i }));
|
||||
|
||||
expect(screen.getByText(/all fields are required/i)).toBeInTheDocument();
|
||||
expect(api.apiRegister).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
108
src/__tests__/TaskDrawer.test.tsx
Normal file
108
src/__tests__/TaskDrawer.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TaskDrawer } from '../TaskDrawer';
|
||||
import type { Task, User } from '../data';
|
||||
|
||||
// Mock Shared components to avoid testing their specific implementation here
|
||||
vi.mock('../Shared', () => ({
|
||||
Avatar: ({ userId }: any) => <div data-testid="avatar">{userId}</div>,
|
||||
Tag: ({ label }: any) => <div>{label}</div>,
|
||||
ProgressBar: () => <div>Progress</div>
|
||||
}));
|
||||
|
||||
describe('TaskDrawer Component', () => {
|
||||
const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
|
||||
const mockUsers = [mockUser, { id: 'u2', name: 'Other User', email: 'other@example.com', role: 'emp', dept: 'dev', avatar: 'OU', color: '#fff' }];
|
||||
|
||||
const mockTask: Task = {
|
||||
id: 't1',
|
||||
title: 'Test Task',
|
||||
description: 'Test Description',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
assignee: 'u1',
|
||||
reporter: 'u1',
|
||||
dueDate: '2023-12-31',
|
||||
tags: ['bug'],
|
||||
subtasks: [],
|
||||
comments: [],
|
||||
activity: [],
|
||||
dependencies: []
|
||||
};
|
||||
|
||||
const mockOnUpdate = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnAddDep = vi.fn();
|
||||
const mockOnToggleDep = vi.fn();
|
||||
const mockOnRemoveDep = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders task details correctly', () => {
|
||||
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
|
||||
|
||||
// screen.debug();
|
||||
|
||||
expect(screen.getByText('Test Task')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Description')).toBeInTheDocument();
|
||||
// Check for mocked avatar with userId
|
||||
const avatars = screen.getAllByTestId('avatar');
|
||||
expect(avatars.length).toBeGreaterThan(0);
|
||||
expect(avatars[0]).toHaveTextContent('u1');
|
||||
// expect(screen.getByText(/Test User/i)).toBeInTheDocument(); // Assignee name might be tricky in Select/Div
|
||||
expect(screen.getByText('bug')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates title/description calling onUpdate', async () => {
|
||||
// Title isn't editable in the drawer based on code, only via modal or just display?
|
||||
// Checking code: <h2 className="drawer-title">{task.title}</h2>. It's not an input.
|
||||
// But status, priority, assignee are selects.
|
||||
|
||||
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
|
||||
|
||||
// Update status
|
||||
const statusSelect = screen.getByRole('combobox', { name: /status/i });
|
||||
await userEvent.selectOptions(statusSelect, 'done');
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 't1',
|
||||
status: 'done'
|
||||
}));
|
||||
});
|
||||
|
||||
it('adds a subtask', async () => {
|
||||
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/add a subtask/i);
|
||||
await userEvent.type(input, 'New Subtask{enter}');
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
subtasks: expect.arrayContaining([expect.objectContaining({ title: 'New Subtask', done: false })])
|
||||
}));
|
||||
});
|
||||
|
||||
it('adds a comment', async () => {
|
||||
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
|
||||
|
||||
const input = screen.getByPlaceholderText(/add a comment/i);
|
||||
await userEvent.type(input, 'This is a comment{enter}');
|
||||
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
comments: expect.arrayContaining([expect.objectContaining({ text: 'This is a comment', userId: 'u1' })])
|
||||
}));
|
||||
});
|
||||
|
||||
it('adds a dependency', async () => {
|
||||
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
|
||||
|
||||
const descInput = screen.getByPlaceholderText(/describe the dependency/i);
|
||||
await userEvent.type(descInput, 'Blocked by API{enter}');
|
||||
|
||||
// Default select is "Anyone" (empty string).
|
||||
expect(mockOnAddDep).toHaveBeenCalledWith('t1', { dependsOnUserId: '', description: 'Blocked by API' });
|
||||
});
|
||||
});
|
||||
22
src/api.ts
22
src/api.ts
@@ -42,6 +42,7 @@ export async function apiCreateTask(task: {
|
||||
title: string; description?: string; status?: string; priority?: string;
|
||||
assignee?: string; reporter?: string; dueDate?: string; tags?: string[];
|
||||
subtasks?: { title: string; done: boolean }[];
|
||||
dependencies?: { dependsOnUserId: string; description: string }[];
|
||||
}) {
|
||||
return request('/tasks', {
|
||||
method: 'POST',
|
||||
@@ -83,3 +84,24 @@ export async function apiAddActivity(taskId: string, text: string) {
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
// Dependencies
|
||||
export async function apiAddDependency(taskId: string, dep: { dependsOnUserId: string; description: string }) {
|
||||
return request(`/tasks/${taskId}/dependencies`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dep),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiToggleDependency(taskId: string, depId: string, resolved: boolean) {
|
||||
return request(`/tasks/${taskId}/dependencies/${depId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ resolved }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiRemoveDependency(taskId: string, depId: string) {
|
||||
return request(`/tasks/${taskId}/dependencies/${depId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ export interface User {
|
||||
export interface Subtask { id: string; title: string; done: boolean }
|
||||
export interface Comment { id: string; userId: string; text: string; timestamp: string }
|
||||
export interface Activity { id: string; text: string; timestamp: string }
|
||||
export interface Dependency { id: string; dependsOnUserId: string; description: string; resolved: boolean }
|
||||
export interface Task {
|
||||
id: string; title: string; description: string; status: Status; priority: Priority;
|
||||
assignee: string; reporter: string; dueDate: string; tags: string[];
|
||||
subtasks: Subtask[]; comments: Comment[]; activity: Activity[];
|
||||
dependencies: Dependency[];
|
||||
}
|
||||
|
||||
export const PRIORITY_COLORS: Record<Priority, { color: string; bg: string }> = {
|
||||
|
||||
275
src/index.css
275
src/index.css
@@ -1655,6 +1655,281 @@ body {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* DEPENDENCIES */
|
||||
.dep-unresolved-badge {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.dep-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
padding: 8px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dep-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.dep-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.dep-unresolved {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
.dep-resolved {
|
||||
border-left: 3px solid #22c55e;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dep-check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--text-muted);
|
||||
background: none;
|
||||
color: #22c55e;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dep-check.checked {
|
||||
border-color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.dep-check:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dep-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dep-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dep-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dep-desc.done {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dep-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.dep-item:hover .dep-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dep-remove:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.dep-add-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dep-add-select {
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
width: 140px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dep-add-select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dep-add-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dep-add-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dep-add-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.dep-add-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Modal dependency styles */
|
||||
.modal-deps-list {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-dep-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
border-left: 3px solid #f59e0b;
|
||||
}
|
||||
|
||||
.modal-dep-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-dep-user {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modal-dep-desc {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-dep-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-dep-remove:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.modal-dep-add {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.modal-dep-select {
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
width: 160px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-dep-select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-dep-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-dep-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-dep-btn {
|
||||
padding: 8px 14px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.modal-dep-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* DASHBOARD */
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
|
||||
2
src/test/setup.ts
Normal file
2
src/test/setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
Reference in New Issue
Block a user