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:
@@ -1,6 +1,9 @@
|
||||
import { useState, useRef, DragEvent, ChangeEvent } from 'react'
|
||||
import { useState, useRef, useEffect, DragEvent, ChangeEvent } from 'react'
|
||||
import { attachmentService } from '../services/attachments'
|
||||
|
||||
// Spinner animation keyframes - injected once via useEffect
|
||||
const SPINNER_KEYFRAMES_ID = 'attachment-upload-spinner-keyframes'
|
||||
|
||||
interface AttachmentUploadProps {
|
||||
taskId: string
|
||||
onUploadComplete?: () => void
|
||||
@@ -13,6 +16,31 @@ export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadP
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Inject spinner keyframes animation on mount, cleanup on unmount
|
||||
useEffect(() => {
|
||||
// Check if the style already exists to avoid duplicates
|
||||
if (document.getElementById(SPINNER_KEYFRAMES_ID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const styleSheet = document.createElement('style')
|
||||
styleSheet.id = SPINNER_KEYFRAMES_ID
|
||||
styleSheet.textContent = `
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(styleSheet)
|
||||
|
||||
return () => {
|
||||
const existingStyle = document.getElementById(SPINNER_KEYFRAMES_ID)
|
||||
if (existingStyle) {
|
||||
existingStyle.remove()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
@@ -181,14 +209,4 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
},
|
||||
}
|
||||
|
||||
// Add keyframes for spinner animation
|
||||
const styleSheet = document.createElement('style')
|
||||
styleSheet.textContent = `
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(styleSheet)
|
||||
|
||||
export default AttachmentUpload
|
||||
|
||||
293
frontend/src/components/KanbanBoard.tsx
Normal file
293
frontend/src/components/KanbanBoard.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
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 KanbanBoardProps {
|
||||
tasks: Task[]
|
||||
statuses: TaskStatus[]
|
||||
onStatusChange: (taskId: string, statusId: string) => void
|
||||
onTaskClick: (task: Task) => void
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
tasks,
|
||||
statuses,
|
||||
onStatusChange,
|
||||
onTaskClick,
|
||||
}: KanbanBoardProps) {
|
||||
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null)
|
||||
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null)
|
||||
|
||||
// Group tasks by status
|
||||
const tasksByStatus: Record<string, Task[]> = {}
|
||||
statuses.forEach((status) => {
|
||||
tasksByStatus[status.id] = tasks.filter((task) => task.status_id === status.id)
|
||||
})
|
||||
// Tasks without status
|
||||
const unassignedTasks = tasks.filter((task) => !task.status_id)
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, taskId: string) => {
|
||||
setDraggedTaskId(taskId)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', taskId)
|
||||
// Add a slight delay to allow the drag image to be captured
|
||||
const target = e.target as HTMLElement
|
||||
setTimeout(() => {
|
||||
target.style.opacity = '0.5'
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const handleDragEnd = (e: React.DragEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
target.style.opacity = '1'
|
||||
setDraggedTaskId(null)
|
||||
setDragOverColumnId(null)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, statusId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverColumnId !== statusId) {
|
||||
setDragOverColumnId(statusId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOverColumnId(null)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, statusId: string) => {
|
||||
e.preventDefault()
|
||||
const taskId = e.dataTransfer.getData('text/plain')
|
||||
if (taskId && draggedTaskId) {
|
||||
const task = tasks.find((t) => t.id === taskId)
|
||||
if (task && task.status_id !== statusId) {
|
||||
onStatusChange(taskId, statusId)
|
||||
}
|
||||
}
|
||||
setDraggedTaskId(null)
|
||||
setDragOverColumnId(null)
|
||||
}
|
||||
|
||||
const getPriorityColor = (priority: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
low: '#808080',
|
||||
medium: '#0066cc',
|
||||
high: '#ff9800',
|
||||
urgent: '#f44336',
|
||||
}
|
||||
return colors[priority] || colors.medium
|
||||
}
|
||||
|
||||
const renderTaskCard = (task: Task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
style={{
|
||||
...styles.taskCard,
|
||||
borderLeftColor: getPriorityColor(task.priority),
|
||||
opacity: draggedTaskId === task.id ? 0.5 : 1,
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, task.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => onTaskClick(task)}
|
||||
>
|
||||
<div style={styles.taskTitle}>{task.title}</div>
|
||||
{task.description && (
|
||||
<div style={styles.taskDescription}>
|
||||
{task.description.length > 80
|
||||
? task.description.substring(0, 80) + '...'
|
||||
: task.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={styles.taskMeta}>
|
||||
{task.assignee_name && (
|
||||
<span style={styles.assigneeBadge}>{task.assignee_name}</span>
|
||||
)}
|
||||
{task.due_date && (
|
||||
<span style={styles.dueDate}>
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{task.subtask_count > 0 && (
|
||||
<span style={styles.subtaskBadge}>{task.subtask_count} subtasks</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={styles.board}>
|
||||
{/* Unassigned column (if there are tasks without status) */}
|
||||
{unassignedTasks.length > 0 && (
|
||||
<div style={styles.column}>
|
||||
<div
|
||||
style={{
|
||||
...styles.columnHeader,
|
||||
backgroundColor: '#9e9e9e',
|
||||
}}
|
||||
>
|
||||
<span style={styles.columnTitle}>No Status</span>
|
||||
<span style={styles.taskCount}>{unassignedTasks.length}</span>
|
||||
</div>
|
||||
<div style={styles.taskList}>
|
||||
{unassignedTasks.map(renderTaskCard)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status columns */}
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.id}
|
||||
style={{
|
||||
...styles.column,
|
||||
...(dragOverColumnId === status.id ? styles.columnDragOver : {}),
|
||||
}}
|
||||
onDragOver={(e) => handleDragOver(e, status.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, status.id)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...styles.columnHeader,
|
||||
backgroundColor: status.color || '#e0e0e0',
|
||||
}}
|
||||
>
|
||||
<span style={styles.columnTitle}>{status.name}</span>
|
||||
<span style={styles.taskCount}>
|
||||
{tasksByStatus[status.id]?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.taskList}>
|
||||
{tasksByStatus[status.id]?.map(renderTaskCard)}
|
||||
{(!tasksByStatus[status.id] || tasksByStatus[status.id].length === 0) && (
|
||||
<div style={styles.emptyColumn}>
|
||||
Drop tasks here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
board: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
overflowX: 'auto',
|
||||
paddingBottom: '16px',
|
||||
minHeight: '500px',
|
||||
},
|
||||
column: {
|
||||
flex: '0 0 280px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
},
|
||||
columnDragOver: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
boxShadow: 'inset 0 0 0 2px #0066cc',
|
||||
},
|
||||
columnHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
},
|
||||
columnTitle: {
|
||||
fontSize: '14px',
|
||||
},
|
||||
taskCount: {
|
||||
fontSize: '12px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
},
|
||||
taskList: {
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
taskCard: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
padding: '12px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
cursor: 'grab',
|
||||
borderLeft: '4px solid',
|
||||
transition: 'box-shadow 0.2s ease, transform 0.2s ease',
|
||||
},
|
||||
taskTitle: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '6px',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
taskDescription: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginBottom: '8px',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
taskMeta: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
fontSize: '11px',
|
||||
},
|
||||
assigneeBadge: {
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1565c0',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
dueDate: {
|
||||
color: '#666',
|
||||
},
|
||||
subtaskBadge: {
|
||||
color: '#999',
|
||||
},
|
||||
emptyColumn: {
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
color: '#999',
|
||||
fontSize: '13px',
|
||||
border: '2px dashed #ddd',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
}
|
||||
|
||||
export default KanbanBoard
|
||||
@@ -19,6 +19,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard' },
|
||||
{ path: '/spaces', label: 'Spaces' },
|
||||
{ path: '/workload', label: 'Workload' },
|
||||
{ path: '/project-health', label: 'Health' },
|
||||
...(user?.is_system_admin ? [{ path: '/audit', label: 'Audit' }] : []),
|
||||
]
|
||||
|
||||
|
||||
322
frontend/src/components/ProjectHealthCard.tsx
Normal file
322
frontend/src/components/ProjectHealthCard.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { ProjectHealthItem, RiskLevel, ScheduleStatus, ResourceStatus } from '../services/projectHealth'
|
||||
|
||||
interface ProjectHealthCardProps {
|
||||
project: ProjectHealthItem
|
||||
onClick?: (projectId: string) => void
|
||||
}
|
||||
|
||||
// Color mapping for health scores
|
||||
function getHealthScoreColor(score: number): string {
|
||||
if (score >= 80) return '#4caf50' // Green
|
||||
if (score >= 60) return '#ff9800' // Yellow/Orange
|
||||
if (score >= 40) return '#ff5722' // Orange
|
||||
return '#f44336' // Red
|
||||
}
|
||||
|
||||
// Risk level colors and labels
|
||||
const riskLevelConfig: Record<RiskLevel, { color: string; bgColor: string; label: string }> = {
|
||||
low: { color: '#2e7d32', bgColor: '#e8f5e9', label: 'Low Risk' },
|
||||
medium: { color: '#f57c00', bgColor: '#fff3e0', label: 'Medium Risk' },
|
||||
high: { color: '#d84315', bgColor: '#fbe9e7', label: 'High Risk' },
|
||||
critical: { color: '#c62828', bgColor: '#ffebee', label: 'Critical' },
|
||||
}
|
||||
|
||||
// Schedule status labels
|
||||
const scheduleStatusLabels: Record<ScheduleStatus, string> = {
|
||||
on_track: 'On Track',
|
||||
at_risk: 'At Risk',
|
||||
delayed: 'Delayed',
|
||||
}
|
||||
|
||||
// Resource status labels
|
||||
const resourceStatusLabels: Record<ResourceStatus, string> = {
|
||||
adequate: 'Adequate',
|
||||
constrained: 'Constrained',
|
||||
overloaded: 'Overloaded',
|
||||
}
|
||||
|
||||
export function ProjectHealthCard({ project, onClick }: ProjectHealthCardProps) {
|
||||
const healthColor = getHealthScoreColor(project.health_score)
|
||||
const riskConfig = riskLevelConfig[project.risk_level]
|
||||
const progressPercent = project.task_count > 0
|
||||
? Math.round((project.completed_task_count / project.task_count) * 100)
|
||||
: 0
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick(project.project_id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={styles.card}
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleClick()
|
||||
}
|
||||
}}
|
||||
aria-label={`Project ${project.project_title}, health score ${project.health_score}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={styles.header}>
|
||||
<div style={styles.titleSection}>
|
||||
<h3 style={styles.title}>{project.project_title}</h3>
|
||||
{project.space_name && (
|
||||
<span style={styles.spaceName}>{project.space_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...styles.riskBadge,
|
||||
color: riskConfig.color,
|
||||
backgroundColor: riskConfig.bgColor,
|
||||
}}
|
||||
>
|
||||
{riskConfig.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health Score */}
|
||||
<div style={styles.scoreSection}>
|
||||
<div style={styles.scoreCircle}>
|
||||
<svg width="80" height="80" viewBox="0 0 80 80">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="35"
|
||||
fill="none"
|
||||
stroke="#e0e0e0"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="35"
|
||||
fill="none"
|
||||
stroke={healthColor}
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${(project.health_score / 100) * 220} 220`}
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
</svg>
|
||||
<div style={styles.scoreText}>
|
||||
<span style={{ ...styles.scoreValue, color: healthColor }}>
|
||||
{project.health_score}
|
||||
</span>
|
||||
<span style={styles.scoreLabel}>Health</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.statusSection}>
|
||||
<div style={styles.statusItem}>
|
||||
<span style={styles.statusLabel}>Schedule</span>
|
||||
<span style={styles.statusValue}>
|
||||
{scheduleStatusLabels[project.schedule_status]}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.statusItem}>
|
||||
<span style={styles.statusLabel}>Resources</span>
|
||||
<span style={styles.statusValue}>
|
||||
{resourceStatusLabels[project.resource_status]}
|
||||
</span>
|
||||
</div>
|
||||
{project.owner_name && (
|
||||
<div style={styles.statusItem}>
|
||||
<span style={styles.statusLabel}>Owner</span>
|
||||
<span style={styles.statusValue}>{project.owner_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Progress */}
|
||||
<div style={styles.progressSection}>
|
||||
<div style={styles.progressHeader}>
|
||||
<span style={styles.progressLabel}>Task Progress</span>
|
||||
<span style={styles.progressValue}>
|
||||
{project.completed_task_count} / {project.task_count}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.progressBarContainer}>
|
||||
<div
|
||||
style={{
|
||||
...styles.progressBar,
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: healthColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div style={styles.metricsSection}>
|
||||
<div style={styles.metricItem}>
|
||||
<span style={styles.metricValue}>{project.blocker_count}</span>
|
||||
<span style={styles.metricLabel}>Blockers</span>
|
||||
</div>
|
||||
<div style={styles.metricItem}>
|
||||
<span style={{ ...styles.metricValue, color: project.overdue_task_count > 0 ? '#f44336' : 'inherit' }}>
|
||||
{project.overdue_task_count}
|
||||
</span>
|
||||
<span style={styles.metricLabel}>Overdue</span>
|
||||
</div>
|
||||
<div style={styles.metricItem}>
|
||||
<span style={styles.metricValue}>{progressPercent}%</span>
|
||||
<span style={styles.metricLabel}>Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: { [key: string]: React.CSSProperties } = {
|
||||
card: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
padding: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'box-shadow 0.2s ease, transform 0.2s ease',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
titleSection: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
spaceName: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginTop: '4px',
|
||||
display: 'block',
|
||||
},
|
||||
riskBadge: {
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
marginLeft: '12px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
scoreSection: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
scoreCircle: {
|
||||
position: 'relative',
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
scoreText: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
scoreValue: {
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
display: 'block',
|
||||
lineHeight: 1,
|
||||
},
|
||||
scoreLabel: {
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
statusSection: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
statusItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
},
|
||||
statusValue: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
},
|
||||
progressSection: {
|
||||
marginBottom: '16px',
|
||||
},
|
||||
progressHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
},
|
||||
progressLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
},
|
||||
progressValue: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
},
|
||||
progressBarContainer: {
|
||||
height: '6px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
metricsSection: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: '16px',
|
||||
borderTop: '1px solid #eee',
|
||||
},
|
||||
metricItem: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
display: 'block',
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
}
|
||||
|
||||
export default ProjectHealthCard
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { auditService, AuditLog } from '../services/audit'
|
||||
|
||||
interface ResourceHistoryProps {
|
||||
@@ -12,11 +12,7 @@ export function ResourceHistory({ resourceType, resourceId, title = 'Change Hist
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [resourceType, resourceId])
|
||||
|
||||
const loadHistory = async () => {
|
||||
const loadHistory = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await auditService.getResourceHistory(resourceType, resourceId, 10)
|
||||
@@ -26,7 +22,11 @@ export function ResourceHistory({ resourceType, resourceId, title = 'Change Hist
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [resourceType, resourceId])
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [loadHistory])
|
||||
|
||||
const formatChanges = (changes: AuditLog['changes']): string => {
|
||||
if (!changes || changes.length === 0) return ''
|
||||
|
||||
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
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { triggersApi, Trigger } from '../services/triggers'
|
||||
|
||||
interface TriggerListProps {
|
||||
@@ -11,7 +11,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchTriggers = async () => {
|
||||
const fetchTriggers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await triggersApi.listTriggers(projectId)
|
||||
@@ -22,11 +22,11 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriggers()
|
||||
}, [projectId])
|
||||
}, [fetchTriggers])
|
||||
|
||||
const handleToggleActive = async (trigger: Trigger) => {
|
||||
try {
|
||||
|
||||
264
frontend/src/components/UserSelect.tsx
Normal file
264
frontend/src/components/UserSelect.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { usersApi, UserSearchResult } from '../services/collaboration'
|
||||
|
||||
interface UserSelectProps {
|
||||
value: string | null
|
||||
onChange: (userId: string | null, user: UserSearchResult | null) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function UserSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select assignee...',
|
||||
disabled = false,
|
||||
}: UserSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [users, setUsers] = useState<UserSearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<UserSearchResult | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Fetch users based on search query
|
||||
const searchUsers = useCallback(async (query: string) => {
|
||||
if (query.length < 1) {
|
||||
setUsers([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const results = await usersApi.search(query)
|
||||
setUsers(results)
|
||||
} catch (err) {
|
||||
console.error('Failed to search users:', err)
|
||||
setUsers([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (isOpen && searchQuery) {
|
||||
searchUsers(searchQuery)
|
||||
}
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, isOpen, searchUsers])
|
||||
|
||||
// Load initial users when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && !searchQuery) {
|
||||
searchUsers('a') // Load some initial users
|
||||
}
|
||||
}, [isOpen, searchQuery, searchUsers])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSelect = (user: UserSearchResult) => {
|
||||
setSelectedUser(user)
|
||||
onChange(user.id, user)
|
||||
setIsOpen(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setSelectedUser(null)
|
||||
onChange(null, null)
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!disabled) {
|
||||
setIsOpen(true)
|
||||
setTimeout(() => inputRef.current?.focus(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={styles.container}>
|
||||
<div
|
||||
style={{
|
||||
...styles.selectBox,
|
||||
...(disabled ? styles.disabled : {}),
|
||||
...(isOpen ? styles.focused : {}),
|
||||
}}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
{selectedUser ? (
|
||||
<div style={styles.selectedValue}>
|
||||
<span>{selectedUser.name}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
style={styles.clearButton}
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span style={styles.placeholder}>{placeholder}</span>
|
||||
)}
|
||||
<span style={styles.arrow}>{isOpen ? '\u25B2' : '\u25BC'}</span>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div style={styles.dropdown}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search users..."
|
||||
style={styles.searchInput}
|
||||
/>
|
||||
<div style={styles.userList}>
|
||||
{loading && <div style={styles.loadingItem}>Searching...</div>}
|
||||
{!loading && users.length === 0 && searchQuery && (
|
||||
<div style={styles.emptyItem}>No users found</div>
|
||||
)}
|
||||
{!loading && users.length === 0 && !searchQuery && (
|
||||
<div style={styles.emptyItem}>Type to search users</div>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
style={{
|
||||
...styles.userItem,
|
||||
...(value === user.id ? styles.userItemSelected : {}),
|
||||
}}
|
||||
onClick={() => handleSelect(user)}
|
||||
>
|
||||
<div style={styles.userName}>{user.name}</div>
|
||||
<div style={styles.userEmail}>{user.email}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
},
|
||||
selectBox: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'white',
|
||||
cursor: 'pointer',
|
||||
minHeight: '20px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
disabled: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
cursor: 'not-allowed',
|
||||
opacity: 0.7,
|
||||
},
|
||||
focused: {
|
||||
borderColor: '#0066cc',
|
||||
boxShadow: '0 0 0 2px rgba(0, 102, 204, 0.2)',
|
||||
},
|
||||
selectedValue: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flex: 1,
|
||||
},
|
||||
placeholder: {
|
||||
color: '#999',
|
||||
fontSize: '14px',
|
||||
},
|
||||
clearButton: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
arrow: {
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
},
|
||||
dropdown: {
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: '4px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
zIndex: 1000,
|
||||
maxHeight: '300px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
searchInput: {
|
||||
padding: '10px',
|
||||
border: 'none',
|
||||
borderBottom: '1px solid #eee',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
},
|
||||
userList: {
|
||||
overflowY: 'auto',
|
||||
maxHeight: '240px',
|
||||
},
|
||||
userItem: {
|
||||
padding: '10px 12px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid #f5f5f5',
|
||||
},
|
||||
userItemSelected: {
|
||||
backgroundColor: '#e6f0ff',
|
||||
},
|
||||
userName: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '2px',
|
||||
},
|
||||
userEmail: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
},
|
||||
loadingItem: {
|
||||
padding: '12px',
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
},
|
||||
emptyItem: {
|
||||
padding: '12px',
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
},
|
||||
}
|
||||
|
||||
export default UserSelect
|
||||
258
frontend/src/components/WorkloadHeatmap.tsx
Normal file
258
frontend/src/components/WorkloadHeatmap.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { WorkloadUser, LoadLevel } from '../services/workload'
|
||||
|
||||
interface WorkloadHeatmapProps {
|
||||
users: WorkloadUser[]
|
||||
weekStart: string
|
||||
weekEnd: string
|
||||
onUserClick: (userId: string, userName: string) => void
|
||||
}
|
||||
|
||||
// Color mapping for load levels
|
||||
const loadLevelColors: Record<LoadLevel, string> = {
|
||||
normal: '#4caf50',
|
||||
warning: '#ff9800',
|
||||
overloaded: '#f44336',
|
||||
unavailable: '#9e9e9e',
|
||||
}
|
||||
|
||||
const loadLevelLabels: Record<LoadLevel, string> = {
|
||||
normal: 'Normal',
|
||||
warning: 'Warning',
|
||||
overloaded: 'Overloaded',
|
||||
unavailable: 'Unavailable',
|
||||
}
|
||||
|
||||
export function WorkloadHeatmap({ users, weekStart, weekEnd, onUserClick }: WorkloadHeatmapProps) {
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// Group users by department
|
||||
const groupedByDepartment = users.reduce((acc, user) => {
|
||||
const dept = user.department_name || 'No Department'
|
||||
if (!acc[dept]) {
|
||||
acc[dept] = []
|
||||
}
|
||||
acc[dept].push(user)
|
||||
return acc
|
||||
}, {} as Record<string, WorkloadUser[]>)
|
||||
|
||||
const departments = Object.keys(groupedByDepartment).sort()
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div style={styles.emptyState}>
|
||||
<p>No workload data available for this week.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<span style={styles.weekRange}>
|
||||
{formatDate(weekStart)} - {formatDate(weekEnd)}
|
||||
</span>
|
||||
<div style={styles.legend}>
|
||||
{(Object.keys(loadLevelColors) as LoadLevel[]).map((level) => (
|
||||
<div key={level} style={styles.legendItem}>
|
||||
<span
|
||||
style={{
|
||||
...styles.legendColor,
|
||||
backgroundColor: loadLevelColors[level],
|
||||
}}
|
||||
/>
|
||||
<span style={styles.legendLabel}>{loadLevelLabels[level]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.tableContainer}>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Team Member</th>
|
||||
<th style={styles.th}>Department</th>
|
||||
<th style={styles.th}>Allocated</th>
|
||||
<th style={styles.th}>Capacity</th>
|
||||
<th style={styles.th}>Load</th>
|
||||
<th style={styles.th}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{departments.map((dept) =>
|
||||
groupedByDepartment[dept].map((user, index) => (
|
||||
<tr
|
||||
key={user.user_id}
|
||||
style={{
|
||||
...styles.tr,
|
||||
backgroundColor: index % 2 === 0 ? '#fff' : '#fafafa',
|
||||
}}
|
||||
onClick={() => onUserClick(user.user_id, user.user_name)}
|
||||
>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.userName}>{user.user_name}</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.department}>{user.department_name || '-'}</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.hours}>{user.allocated_hours}h</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.hours}>{user.capacity_hours}h</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<div style={styles.loadBarContainer}>
|
||||
<div
|
||||
style={{
|
||||
...styles.loadBar,
|
||||
width: `${Math.min(user.load_percentage, 100)}%`,
|
||||
backgroundColor: loadLevelColors[user.load_level],
|
||||
}}
|
||||
/>
|
||||
<span style={styles.loadPercentage}>{user.load_percentage}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
backgroundColor: loadLevelColors[user.load_level],
|
||||
}}
|
||||
>
|
||||
{loadLevelLabels[user.load_level]}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: { [key: string]: React.CSSProperties } = {
|
||||
container: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
weekRange: {
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
fontWeight: 500,
|
||||
},
|
||||
legend: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
},
|
||||
legendItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
},
|
||||
legendColor: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
legendLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
},
|
||||
tableContainer: {
|
||||
overflowX: 'auto',
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
},
|
||||
th: {
|
||||
textAlign: 'left',
|
||||
padding: '12px 16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
tr: {
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s ease',
|
||||
},
|
||||
td: {
|
||||
padding: '14px 16px',
|
||||
fontSize: '14px',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
userName: {
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
},
|
||||
department: {
|
||||
color: '#666',
|
||||
},
|
||||
hours: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
},
|
||||
loadBarContainer: {
|
||||
position: 'relative',
|
||||
width: '120px',
|
||||
height: '24px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
loadBar: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
loadPercentage: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
textShadow: '0 0 2px rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
statusBadge: {
|
||||
display: 'inline-block',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: 'white',
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
padding: '48px',
|
||||
color: '#666',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}
|
||||
|
||||
export default WorkloadHeatmap
|
||||
340
frontend/src/components/WorkloadUserDetail.tsx
Normal file
340
frontend/src/components/WorkloadUserDetail.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { UserWorkloadDetail, LoadLevel, workloadApi } from '../services/workload'
|
||||
|
||||
interface WorkloadUserDetailProps {
|
||||
userId: string
|
||||
userName: string
|
||||
weekStart: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// Color mapping for load levels
|
||||
const loadLevelColors: Record<LoadLevel, string> = {
|
||||
normal: '#4caf50',
|
||||
warning: '#ff9800',
|
||||
overloaded: '#f44336',
|
||||
unavailable: '#9e9e9e',
|
||||
}
|
||||
|
||||
const loadLevelLabels: Record<LoadLevel, string> = {
|
||||
normal: 'Normal',
|
||||
warning: 'Warning',
|
||||
overloaded: 'Overloaded',
|
||||
unavailable: 'Unavailable',
|
||||
}
|
||||
|
||||
export function WorkloadUserDetail({
|
||||
userId,
|
||||
userName,
|
||||
weekStart,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: WorkloadUserDetailProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [detail, setDetail] = useState<UserWorkloadDetail | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
loadUserDetail()
|
||||
}
|
||||
}, [isOpen, userId, weekStart])
|
||||
|
||||
const loadUserDetail = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await workloadApi.getUserWorkload(userId, weekStart)
|
||||
setDetail(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load user workload:', err)
|
||||
setError('Failed to load workload details')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={onClose}>
|
||||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={styles.header}>
|
||||
<div>
|
||||
<h2 style={styles.title}>{userName}</h2>
|
||||
<span style={styles.subtitle}>Workload Details</span>
|
||||
</div>
|
||||
<button style={styles.closeButton} onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={styles.loading}>Loading...</div>
|
||||
) : error ? (
|
||||
<div style={styles.error}>{error}</div>
|
||||
) : detail ? (
|
||||
<>
|
||||
{/* Summary Section */}
|
||||
<div style={styles.summarySection}>
|
||||
<div style={styles.summaryCard}>
|
||||
<span style={styles.summaryLabel}>Allocated Hours</span>
|
||||
<span style={styles.summaryValue}>{detail.summary.allocated_hours}h</span>
|
||||
</div>
|
||||
<div style={styles.summaryCard}>
|
||||
<span style={styles.summaryLabel}>Capacity</span>
|
||||
<span style={styles.summaryValue}>{detail.summary.capacity_hours}h</span>
|
||||
</div>
|
||||
<div style={styles.summaryCard}>
|
||||
<span style={styles.summaryLabel}>Load</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.summaryValue,
|
||||
color: loadLevelColors[detail.summary.load_level],
|
||||
}}
|
||||
>
|
||||
{detail.summary.load_percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.summaryCard}>
|
||||
<span style={styles.summaryLabel}>Status</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
backgroundColor: loadLevelColors[detail.summary.load_level],
|
||||
}}
|
||||
>
|
||||
{loadLevelLabels[detail.summary.load_level]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks Section */}
|
||||
<div style={styles.tasksSection}>
|
||||
<h3 style={styles.sectionTitle}>Tasks This Week</h3>
|
||||
{detail.tasks.length === 0 ? (
|
||||
<div style={styles.emptyTasks}>No tasks assigned for this week.</div>
|
||||
) : (
|
||||
<div style={styles.taskList}>
|
||||
{detail.tasks.map((task) => (
|
||||
<div key={task.task_id} style={styles.taskItem}>
|
||||
<div style={styles.taskMain}>
|
||||
<span style={styles.taskTitle}>{task.task_title}</span>
|
||||
<span style={styles.projectName}>{task.project_name}</span>
|
||||
</div>
|
||||
<div style={styles.taskMeta}>
|
||||
<span style={styles.timeEstimate}>{task.time_estimate}h</span>
|
||||
{task.due_date && (
|
||||
<span style={styles.dueDate}>Due: {formatDate(task.due_date)}</span>
|
||||
)}
|
||||
{task.status_name && (
|
||||
<span style={styles.status}>{task.status_name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total hours breakdown */}
|
||||
<div style={styles.totalSection}>
|
||||
<span style={styles.totalLabel}>Total Estimated Hours:</span>
|
||||
<span style={styles.totalValue}>
|
||||
{detail.tasks.reduce((sum, task) => sum + task.time_estimate, 0)}h
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: { [key: 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: '8px',
|
||||
width: '600px',
|
||||
maxWidth: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
title: {
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
color: '#333',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
closeButton: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#999',
|
||||
padding: '0',
|
||||
lineHeight: 1,
|
||||
},
|
||||
loading: {
|
||||
padding: '48px',
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
},
|
||||
error: {
|
||||
padding: '48px',
|
||||
textAlign: 'center',
|
||||
color: '#f44336',
|
||||
},
|
||||
summarySection: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '12px',
|
||||
padding: '20px 24px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
},
|
||||
summaryCard: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
},
|
||||
statusBadge: {
|
||||
display: 'inline-block',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: 'white',
|
||||
},
|
||||
tasksSection: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '20px 24px',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#666',
|
||||
margin: '0 0 12px 0',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
emptyTasks: {
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
color: '#999',
|
||||
fontSize: '14px',
|
||||
},
|
||||
taskList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
taskItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '6px',
|
||||
borderLeft: '3px solid #0066cc',
|
||||
},
|
||||
taskMain: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
},
|
||||
taskTitle: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: '#333',
|
||||
},
|
||||
projectName: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
},
|
||||
taskMeta: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
},
|
||||
timeEstimate: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#0066cc',
|
||||
},
|
||||
dueDate: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
},
|
||||
status: {
|
||||
fontSize: '11px',
|
||||
padding: '2px 8px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: '4px',
|
||||
color: '#666',
|
||||
},
|
||||
totalSection: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #eee',
|
||||
backgroundColor: '#f9f9f9',
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
},
|
||||
}
|
||||
|
||||
export default WorkloadUserDetail
|
||||
Reference in New Issue
Block a user