feat: complete issue fixes and implement remaining features
## Critical Issues (CRIT-001~003) - All Fixed
- JWT secret key validation with pydantic field_validator
- Login audit logging for success/failure attempts
- Frontend API path prefix removal
## High Priority Issues (HIGH-001~008) - All Fixed
- Project soft delete using is_active flag
- Redis session token bytes handling
- Rate limiting with slowapi (5 req/min for login)
- Attachment API permission checks
- Kanban view with drag-and-drop
- Workload heatmap UI (WorkloadPage, WorkloadHeatmap)
- TaskDetailModal integrating Comments/Attachments
- UserSelect component for task assignment
## Medium Priority Issues (MED-001~012) - All Fixed
- MED-001~005: DB commits, N+1 queries, datetime, error format, blocker flag
- MED-006: Project health dashboard (HealthService, ProjectHealthPage)
- MED-007: Capacity update API (PUT /api/users/{id}/capacity)
- MED-008: Schedule triggers (cron parsing, deadline reminders)
- MED-009: Watermark feature (image/PDF watermarking)
- MED-010~012: useEffect deps, DOM operations, PDF export
## New Files
- backend/app/api/health/ - Project health API
- backend/app/services/health_service.py
- backend/app/services/trigger_scheduler.py
- backend/app/services/watermark_service.py
- backend/app/core/rate_limiter.py
- frontend/src/pages/ProjectHealthPage.tsx
- frontend/src/components/ProjectHealthCard.tsx
- frontend/src/components/KanbanBoard.tsx
- frontend/src/components/WorkloadHeatmap.tsx
## Tests
- 113 new tests passing (health: 32, users: 14, triggers: 35, watermark: 32)
## OpenSpec Archives
- add-project-health-dashboard
- add-capacity-update-api
- add-schedule-triggers
- add-watermark-feature
- add-rate-limiting
- enhance-frontend-ux
- add-resource-management-ui
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
576
frontend/src/components/TaskDetailModal.tsx
Normal file
576
frontend/src/components/TaskDetailModal.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import api from '../services/api'
|
||||
import { Comments } from './Comments'
|
||||
import { TaskAttachments } from './TaskAttachments'
|
||||
import { UserSelect } from './UserSelect'
|
||||
import { UserSearchResult } from '../services/collaboration'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
priority: string
|
||||
status_id: string | null
|
||||
status_name: string | null
|
||||
status_color: string | null
|
||||
assignee_id: string | null
|
||||
assignee_name: string | null
|
||||
due_date: string | null
|
||||
time_estimate: number | null
|
||||
subtask_count: number
|
||||
}
|
||||
|
||||
interface TaskStatus {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
is_done: boolean
|
||||
}
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Task
|
||||
statuses: TaskStatus[]
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
export function TaskDetailModal({
|
||||
task,
|
||||
statuses,
|
||||
isOpen,
|
||||
onClose,
|
||||
onUpdate,
|
||||
}: TaskDetailModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editForm, setEditForm] = useState({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
priority: task.priority,
|
||||
status_id: task.status_id || '',
|
||||
assignee_id: task.assignee_id || '',
|
||||
due_date: task.due_date ? task.due_date.split('T')[0] : '',
|
||||
time_estimate: task.time_estimate || '',
|
||||
})
|
||||
const [, setSelectedAssignee] = useState<UserSearchResult | null>(
|
||||
task.assignee_id && task.assignee_name
|
||||
? { id: task.assignee_id, name: task.assignee_name, email: '' }
|
||||
: null
|
||||
)
|
||||
|
||||
// Reset form when task changes
|
||||
useEffect(() => {
|
||||
setEditForm({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
priority: task.priority,
|
||||
status_id: task.status_id || '',
|
||||
assignee_id: task.assignee_id || '',
|
||||
due_date: task.due_date ? task.due_date.split('T')[0] : '',
|
||||
time_estimate: task.time_estimate || '',
|
||||
})
|
||||
setSelectedAssignee(
|
||||
task.assignee_id && task.assignee_name
|
||||
? { id: task.assignee_id, name: task.assignee_name, email: '' }
|
||||
: null
|
||||
)
|
||||
setIsEditing(false)
|
||||
}, [task])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
title: editForm.title,
|
||||
description: editForm.description || null,
|
||||
priority: editForm.priority,
|
||||
}
|
||||
|
||||
if (editForm.status_id) {
|
||||
payload.status_id = editForm.status_id
|
||||
}
|
||||
if (editForm.assignee_id) {
|
||||
payload.assignee_id = editForm.assignee_id
|
||||
} else {
|
||||
payload.assignee_id = null
|
||||
}
|
||||
if (editForm.due_date) {
|
||||
payload.due_date = editForm.due_date
|
||||
} else {
|
||||
payload.due_date = null
|
||||
}
|
||||
if (editForm.time_estimate) {
|
||||
payload.time_estimate = Number(editForm.time_estimate)
|
||||
} else {
|
||||
payload.time_estimate = null
|
||||
}
|
||||
|
||||
await api.patch(`/tasks/${task.id}`, payload)
|
||||
setIsEditing(false)
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
console.error('Failed to update task:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssigneeChange = (userId: string | null, user: UserSearchResult | null) => {
|
||||
setEditForm({ ...editForm, assignee_id: userId || '' })
|
||||
setSelectedAssignee(user)
|
||||
}
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityColor = (priority: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
low: '#808080',
|
||||
medium: '#0066cc',
|
||||
high: '#ff9800',
|
||||
urgent: '#f44336',
|
||||
}
|
||||
return colors[priority] || colors.medium
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div style={styles.modal}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerLeft}>
|
||||
<div
|
||||
style={{
|
||||
...styles.priorityIndicator,
|
||||
backgroundColor: getPriorityColor(task.priority),
|
||||
}}
|
||||
/>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.title}
|
||||
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
|
||||
style={styles.titleInput}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h2 style={styles.title}>{task.title}</h2>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.headerActions}>
|
||||
{!isEditing ? (
|
||||
<button onClick={() => setIsEditing(true)} style={styles.editButton}>
|
||||
Edit
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsEditing(false)}
|
||||
style={styles.cancelButton}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={styles.saveButton}
|
||||
disabled={saving || !editForm.title.trim()}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.content}>
|
||||
<div style={styles.mainSection}>
|
||||
{/* Description */}
|
||||
<div style={styles.field}>
|
||||
<label style={styles.fieldLabel}>Description</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, description: e.target.value })
|
||||
}
|
||||
style={styles.textarea}
|
||||
placeholder="Add a description..."
|
||||
/>
|
||||
) : (
|
||||
<div style={styles.descriptionText}>
|
||||
{task.description || 'No description'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments Section */}
|
||||
<div style={styles.section}>
|
||||
<Comments taskId={task.id} />
|
||||
</div>
|
||||
|
||||
{/* Attachments Section */}
|
||||
<div style={styles.section}>
|
||||
<TaskAttachments taskId={task.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.sidebar}>
|
||||
{/* Status */}
|
||||
<div style={styles.sidebarField}>
|
||||
<label style={styles.sidebarLabel}>Status</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editForm.status_id}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, status_id: e.target.value })
|
||||
}
|
||||
style={styles.select}
|
||||
>
|
||||
<option value="">No Status</option>
|
||||
{statuses.map((status) => (
|
||||
<option key={status.id} value={status.id}>
|
||||
{status.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
backgroundColor: task.status_color || '#e0e0e0',
|
||||
}}
|
||||
>
|
||||
{task.status_name || 'No Status'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div style={styles.sidebarField}>
|
||||
<label style={styles.sidebarLabel}>Priority</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editForm.priority}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, priority: e.target.value })
|
||||
}
|
||||
style={styles.select}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
...styles.priorityBadge,
|
||||
borderColor: getPriorityColor(task.priority),
|
||||
color: getPriorityColor(task.priority),
|
||||
}}
|
||||
>
|
||||
{task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div style={styles.sidebarField}>
|
||||
<label style={styles.sidebarLabel}>Assignee</label>
|
||||
{isEditing ? (
|
||||
<UserSelect
|
||||
value={editForm.assignee_id}
|
||||
onChange={handleAssigneeChange}
|
||||
placeholder="Select assignee..."
|
||||
/>
|
||||
) : (
|
||||
<div style={styles.assigneeDisplay}>
|
||||
{task.assignee_name || 'Unassigned'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
<div style={styles.sidebarField}>
|
||||
<label style={styles.sidebarLabel}>Due Date</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="date"
|
||||
value={editForm.due_date}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, due_date: e.target.value })
|
||||
}
|
||||
style={styles.dateInput}
|
||||
/>
|
||||
) : (
|
||||
<div style={styles.dueDateDisplay}>
|
||||
{task.due_date
|
||||
? new Date(task.due_date).toLocaleDateString()
|
||||
: 'No due date'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time Estimate */}
|
||||
<div style={styles.sidebarField}>
|
||||
<label style={styles.sidebarLabel}>Time Estimate (hours)</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
value={editForm.time_estimate}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, time_estimate: e.target.value })
|
||||
}
|
||||
style={styles.numberInput}
|
||||
placeholder="e.g., 2.5"
|
||||
/>
|
||||
) : (
|
||||
<div style={styles.timeEstimateDisplay}>
|
||||
{task.time_estimate ? `${task.time_estimate} hours` : 'Not estimated'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtasks Info */}
|
||||
{task.subtask_count > 0 && (
|
||||
<div style={styles.sidebarField}>
|
||||
<label style={styles.sidebarLabel}>Subtasks</label>
|
||||
<div style={styles.subtaskInfo}>{task.subtask_count} subtask(s)</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
width: '90%',
|
||||
maxWidth: '900px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
headerLeft: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
flex: 1,
|
||||
},
|
||||
priorityIndicator: {
|
||||
width: '6px',
|
||||
height: '32px',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
title: {
|
||||
margin: 0,
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
},
|
||||
titleInput: {
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '8px 12px',
|
||||
flex: 1,
|
||||
marginRight: '12px',
|
||||
},
|
||||
headerActions: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
},
|
||||
editButton: {
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
},
|
||||
saveButton: {
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#0066cc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
},
|
||||
cancelButton: {
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
},
|
||||
closeButton: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
marginLeft: '8px',
|
||||
},
|
||||
content: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
mainSection: {
|
||||
flex: 1,
|
||||
padding: '24px',
|
||||
overflowY: 'auto',
|
||||
borderRight: '1px solid #eee',
|
||||
},
|
||||
sidebar: {
|
||||
width: '280px',
|
||||
padding: '24px',
|
||||
backgroundColor: '#fafafa',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
field: {
|
||||
marginBottom: '24px',
|
||||
},
|
||||
fieldLabel: {
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#666',
|
||||
marginBottom: '8px',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
textarea: {
|
||||
width: '100%',
|
||||
minHeight: '100px',
|
||||
padding: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
descriptionText: {
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.6,
|
||||
color: '#333',
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
section: {
|
||||
marginBottom: '24px',
|
||||
},
|
||||
sidebarField: {
|
||||
marginBottom: '20px',
|
||||
},
|
||||
sidebarLabel: {
|
||||
display: 'block',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#888',
|
||||
marginBottom: '6px',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
select: {
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
statusBadge: {
|
||||
display: 'inline-block',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'white',
|
||||
},
|
||||
priorityBadge: {
|
||||
display: 'inline-block',
|
||||
padding: '6px 12px',
|
||||
border: '2px solid',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
},
|
||||
assigneeDisplay: {
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
},
|
||||
dueDateDisplay: {
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
},
|
||||
timeEstimateDisplay: {
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
},
|
||||
subtaskInfo: {
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
dateInput: {
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
numberInput: {
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
}
|
||||
|
||||
export default TaskDetailModal
|
||||
Reference in New Issue
Block a user