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:
beabigegg
2026-01-04 21:49:52 +08:00
parent 64874d5425
commit 9b220523ff
90 changed files with 9426 additions and 194 deletions

View File

@@ -6,6 +6,8 @@ import Spaces from './pages/Spaces'
import Projects from './pages/Projects'
import Tasks from './pages/Tasks'
import AuditPage from './pages/AuditPage'
import WorkloadPage from './pages/WorkloadPage'
import ProjectHealthPage from './pages/ProjectHealthPage'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
@@ -72,6 +74,26 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/workload"
element={
<ProtectedRoute>
<Layout>
<WorkloadPage />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/project-health"
element={
<ProtectedRoute>
<Layout>
<ProjectHealthPage />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
)
}

View File

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

View 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

View File

@@ -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' }] : []),
]

View 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

View File

@@ -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 ''

View 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

View File

@@ -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 {

View 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

View 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

View 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

View File

@@ -140,7 +140,7 @@ export default function AuditPage() {
})
}
const handleExport = async () => {
const handleExportCSV = async () => {
try {
const blob = await auditService.exportAuditLogs(filters)
const url = window.URL.createObjectURL(blob)
@@ -156,6 +156,88 @@ export default function AuditPage() {
}
}
const handleExportPDF = () => {
// Create a printable version of the audit logs
const printWindow = window.open('', '_blank')
if (!printWindow) {
console.error('Failed to open print window. Please allow popups.')
return
}
const formatDate = (dateStr: string) => new Date(dateStr).toLocaleString()
const getSensitivityColor = (level: string) => {
const colors: Record<string, string> = {
low: '#28a745',
medium: '#ffc107',
high: '#fd7e14',
critical: '#dc3545',
}
return colors[level] || '#6c757d'
}
const tableRows = logs.map(log => `
<tr>
<td>${formatDate(log.created_at)}</td>
<td>${log.event_type}</td>
<td>${log.resource_type} / ${log.resource_id?.substring(0, 8) || '-'}</td>
<td>${log.user_name || 'System'}</td>
<td><span style="background-color: ${getSensitivityColor(log.sensitivity_level)}; color: ${log.sensitivity_level === 'medium' ? '#000' : '#fff'}; padding: 2px 8px; border-radius: 4px; font-size: 12px;">${log.sensitivity_level}</span></td>
</tr>
`).join('')
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<title>Audit Logs - ${new Date().toISOString().split('T')[0]}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
.meta { color: #666; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f8f9fa; font-weight: bold; }
tr:nth-child(even) { background-color: #f9f9f9; }
@media print {
body { margin: 0; }
.no-print { display: none; }
}
</style>
</head>
<body>
<h1>Audit Logs Report</h1>
<div class="meta">
<p>Generated: ${new Date().toLocaleString()}</p>
<p>Total Records: ${total}</p>
<p>Showing: ${logs.length} records</p>
</div>
<table>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Resource</th>
<th>User</th>
<th>Sensitivity</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
<script>
window.onload = function() {
window.print();
}
</script>
</body>
</html>
`
printWindow.document.write(htmlContent)
printWindow.document.close()
}
const handlePageChange = (newOffset: number) => {
setFilters({ ...filters, offset: newOffset })
}
@@ -224,9 +306,12 @@ export default function AuditPage() {
<button onClick={handleApplyFilters} style={styles.filterButton}>
Apply Filters
</button>
<button onClick={handleExport} style={styles.exportButton}>
<button onClick={handleExportCSV} style={styles.exportButton}>
Export CSV
</button>
<button onClick={handleExportPDF} style={styles.exportPdfButton}>
Export PDF
</button>
</div>
</div>
@@ -358,6 +443,14 @@ const styles: Record<string, React.CSSProperties> = {
borderRadius: '4px',
cursor: 'pointer',
},
exportPdfButton: {
padding: '8px 16px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
},
summary: {
marginBottom: '16px',
color: '#666',

View File

@@ -0,0 +1,350 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { ProjectHealthCard } from '../components/ProjectHealthCard'
import {
projectHealthApi,
ProjectHealthDashboardResponse,
ProjectHealthItem,
RiskLevel,
} from '../services/projectHealth'
type SortOption = 'risk_high' | 'risk_low' | 'health_high' | 'health_low' | 'name'
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'risk_high', label: 'Risk: High to Low' },
{ value: 'risk_low', label: 'Risk: Low to High' },
{ value: 'health_high', label: 'Health: High to Low' },
{ value: 'health_low', label: 'Health: Low to High' },
{ value: 'name', label: 'Name: A to Z' },
]
// Risk level priority for sorting (higher number = higher risk)
const riskLevelPriority: Record<RiskLevel, number> = {
low: 1,
medium: 2,
high: 3,
critical: 4,
}
export default function ProjectHealthPage() {
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [dashboardData, setDashboardData] = useState<ProjectHealthDashboardResponse | null>(null)
const [sortBy, setSortBy] = useState<SortOption>('risk_high')
const loadDashboard = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await projectHealthApi.getDashboard()
setDashboardData(data)
} catch (err) {
console.error('Failed to load project health dashboard:', err)
setError('Failed to load project health data. Please try again.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadDashboard()
}, [loadDashboard])
// Sort projects based on selected option
const sortedProjects = useMemo(() => {
if (!dashboardData?.projects) return []
const projects = [...dashboardData.projects]
switch (sortBy) {
case 'risk_high':
return projects.sort(
(a, b) => riskLevelPriority[b.risk_level] - riskLevelPriority[a.risk_level]
)
case 'risk_low':
return projects.sort(
(a, b) => riskLevelPriority[a.risk_level] - riskLevelPriority[b.risk_level]
)
case 'health_high':
return projects.sort((a, b) => b.health_score - a.health_score)
case 'health_low':
return projects.sort((a, b) => a.health_score - b.health_score)
case 'name':
return projects.sort((a, b) => a.project_title.localeCompare(b.project_title))
default:
return projects
}
}, [dashboardData?.projects, sortBy])
const handleProjectClick = (projectId: string) => {
navigate(`/projects/${projectId}`)
}
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSortBy(e.target.value as SortOption)
}
// Get health score color for summary display
const getScoreColor = (score: number): string => {
if (score >= 80) return '#4caf50'
if (score >= 60) return '#ff9800'
if (score >= 40) return '#ff5722'
return '#f44336'
}
return (
<div style={styles.container}>
{/* Header */}
<div style={styles.header}>
<div>
<h1 style={styles.title}>Project Health Dashboard</h1>
<p style={styles.subtitle}>
Monitor project health status and risk levels across all projects
</p>
</div>
</div>
{/* Summary Stats */}
{dashboardData?.summary && (
<div style={styles.summaryContainer}>
<div style={styles.summaryCard}>
<span style={styles.summaryValue}>{dashboardData.summary.total_projects}</span>
<span style={styles.summaryLabel}>Total Projects</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: '#4caf50' }}>
{dashboardData.summary.healthy_count}
</span>
<span style={styles.summaryLabel}>Healthy</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: '#ff9800' }}>
{dashboardData.summary.at_risk_count}
</span>
<span style={styles.summaryLabel}>At Risk</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: '#f44336' }}>
{dashboardData.summary.critical_count}
</span>
<span style={styles.summaryLabel}>Critical</span>
</div>
<div style={styles.summaryCard}>
<span
style={{
...styles.summaryValue,
color: getScoreColor(dashboardData.summary.average_health_score),
}}
>
{Math.round(dashboardData.summary.average_health_score)}
</span>
<span style={styles.summaryLabel}>Avg. Health</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: dashboardData.summary.projects_with_blockers > 0 ? '#f44336' : '#666' }}>
{dashboardData.summary.projects_with_blockers}
</span>
<span style={styles.summaryLabel}>With Blockers</span>
</div>
<div style={styles.summaryCard}>
<span style={{ ...styles.summaryValue, color: dashboardData.summary.projects_delayed > 0 ? '#ff9800' : '#666' }}>
{dashboardData.summary.projects_delayed}
</span>
<span style={styles.summaryLabel}>Delayed</span>
</div>
</div>
)}
{/* Sort Controls */}
{dashboardData && dashboardData.projects.length > 0 && (
<div style={styles.controlsContainer}>
<div style={styles.sortControl}>
<label htmlFor="sort-select" style={styles.sortLabel}>
Sort by:
</label>
<select
id="sort-select"
value={sortBy}
onChange={handleSortChange}
style={styles.sortSelect}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<span style={styles.projectCount}>
{dashboardData.projects.length} project{dashboardData.projects.length !== 1 ? 's' : ''}
</span>
</div>
)}
{/* Content */}
{loading ? (
<div style={styles.loadingContainer}>
<div style={styles.loading}>Loading project health data...</div>
</div>
) : error ? (
<div style={styles.errorContainer}>
<p style={styles.error}>{error}</p>
<button onClick={loadDashboard} style={styles.retryButton}>
Retry
</button>
</div>
) : sortedProjects.length === 0 ? (
<div style={styles.emptyContainer}>
<p style={styles.emptyText}>No projects found.</p>
<p style={styles.emptySubtext}>
Create a project to start tracking health status.
</p>
</div>
) : (
<div style={styles.gridContainer}>
{sortedProjects.map((project: ProjectHealthItem) => (
<ProjectHealthCard
key={project.id}
project={project}
onClick={handleProjectClick}
/>
))}
</div>
)}
</div>
)
}
const styles: { [key: string]: React.CSSProperties } = {
container: {
padding: '24px',
maxWidth: '1400px',
margin: '0 auto',
},
header: {
marginBottom: '24px',
},
title: {
fontSize: '24px',
fontWeight: 600,
margin: 0,
color: '#333',
},
subtitle: {
fontSize: '14px',
color: '#666',
margin: '4px 0 0 0',
},
summaryContainer: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: '16px',
marginBottom: '24px',
},
summaryCard: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
},
summaryValue: {
fontSize: '28px',
fontWeight: 600,
color: '#333',
},
summaryLabel: {
fontSize: '12px',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.5px',
textAlign: 'center',
},
controlsContainer: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
padding: '12px 16px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
},
sortControl: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
sortLabel: {
fontSize: '14px',
color: '#666',
},
sortSelect: {
padding: '8px 12px',
fontSize: '14px',
border: '1px solid #ddd',
borderRadius: '4px',
backgroundColor: 'white',
cursor: 'pointer',
},
projectCount: {
fontSize: '14px',
color: '#666',
},
loadingContainer: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '48px',
textAlign: 'center',
},
loading: {
color: '#666',
},
errorContainer: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '48px',
textAlign: 'center',
},
error: {
color: '#f44336',
marginBottom: '16px',
},
retryButton: {
padding: '10px 20px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
},
emptyContainer: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '48px',
textAlign: 'center',
},
emptyText: {
fontSize: '16px',
color: '#333',
margin: '0 0 8px 0',
},
emptySubtext: {
fontSize: '14px',
color: '#666',
margin: 0,
},
gridContainer: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: '20px',
},
}

View File

@@ -41,8 +41,8 @@ export default function Projects() {
const loadData = async () => {
try {
const [spaceRes, projectsRes] = await Promise.all([
api.get(`/api/spaces/${spaceId}`),
api.get(`/api/spaces/${spaceId}/projects`),
api.get(`/spaces/${spaceId}`),
api.get(`/spaces/${spaceId}/projects`),
])
setSpace(spaceRes.data)
setProjects(projectsRes.data)
@@ -58,7 +58,7 @@ export default function Projects() {
setCreating(true)
try {
await api.post(`/api/spaces/${spaceId}/projects`, newProject)
await api.post(`/spaces/${spaceId}/projects`, newProject)
setShowCreateModal(false)
setNewProject({ title: '', description: '', security_level: 'department' })
loadData()

View File

@@ -26,7 +26,7 @@ export default function Spaces() {
const loadSpaces = async () => {
try {
const response = await api.get('/api/spaces')
const response = await api.get('/spaces')
setSpaces(response.data)
} catch (err) {
console.error('Failed to load spaces:', err)
@@ -40,7 +40,7 @@ export default function Spaces() {
setCreating(true)
try {
await api.post('/api/spaces', newSpace)
await api.post('/spaces', newSpace)
setShowCreateModal(false)
setNewSpace({ name: '', description: '' })
loadSpaces()

View File

@@ -1,6 +1,10 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../services/api'
import { KanbanBoard } from '../components/KanbanBoard'
import { TaskDetailModal } from '../components/TaskDetailModal'
import { UserSelect } from '../components/UserSelect'
import { UserSearchResult } from '../services/collaboration'
interface Task {
id: string
@@ -13,6 +17,7 @@ interface Task {
assignee_id: string | null
assignee_name: string | null
due_date: string | null
time_estimate: number | null
subtask_count: number
}
@@ -29,6 +34,10 @@ interface Project {
space_id: string
}
type ViewMode = 'list' | 'kanban'
const VIEW_MODE_STORAGE_KEY = 'tasks-view-mode'
export default function Tasks() {
const { projectId } = useParams()
const navigate = useNavigate()
@@ -37,23 +46,38 @@ export default function Tasks() {
const [statuses, setStatuses] = useState<TaskStatus[]>([])
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY)
return (saved === 'kanban' || saved === 'list') ? saved : 'list'
})
const [newTask, setNewTask] = useState({
title: '',
description: '',
priority: 'medium',
assignee_id: '',
due_date: '',
time_estimate: '',
})
const [, setSelectedAssignee] = useState<UserSearchResult | null>(null)
const [creating, setCreating] = useState(false)
const [selectedTask, setSelectedTask] = useState<Task | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
useEffect(() => {
loadData()
}, [projectId])
// Persist view mode
useEffect(() => {
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode)
}, [viewMode])
const loadData = async () => {
try {
const [projectRes, tasksRes, statusesRes] = await Promise.all([
api.get(`/api/projects/${projectId}`),
api.get(`/api/projects/${projectId}/tasks`),
api.get(`/api/projects/${projectId}/statuses`),
api.get(`/projects/${projectId}`),
api.get(`/projects/${projectId}/tasks`),
api.get(`/projects/${projectId}/statuses`),
])
setProject(projectRes.data)
setTasks(tasksRes.data.tasks)
@@ -70,9 +94,33 @@ export default function Tasks() {
setCreating(true)
try {
await api.post(`/api/projects/${projectId}/tasks`, newTask)
const payload: Record<string, unknown> = {
title: newTask.title,
description: newTask.description || null,
priority: newTask.priority,
}
if (newTask.assignee_id) {
payload.assignee_id = newTask.assignee_id
}
if (newTask.due_date) {
payload.due_date = newTask.due_date
}
if (newTask.time_estimate) {
payload.time_estimate = Number(newTask.time_estimate)
}
await api.post(`/projects/${projectId}/tasks`, payload)
setShowCreateModal(false)
setNewTask({ title: '', description: '', priority: 'medium' })
setNewTask({
title: '',
description: '',
priority: 'medium',
assignee_id: '',
due_date: '',
time_estimate: '',
})
setSelectedAssignee(null)
loadData()
} catch (err) {
console.error('Failed to create task:', err)
@@ -83,13 +131,32 @@ export default function Tasks() {
const handleStatusChange = async (taskId: string, statusId: string) => {
try {
await api.patch(`/api/tasks/${taskId}/status`, { status_id: statusId })
await api.patch(`/tasks/${taskId}/status`, { status_id: statusId })
loadData()
} catch (err) {
console.error('Failed to update status:', err)
}
}
const handleTaskClick = (task: Task) => {
setSelectedTask(task)
setShowDetailModal(true)
}
const handleAssigneeChange = (userId: string | null, user: UserSearchResult | null) => {
setNewTask({ ...newTask, assignee_id: userId || '' })
setSelectedAssignee(user)
}
const handleCloseDetailModal = () => {
setShowDetailModal(false)
setSelectedTask(null)
}
const handleTaskUpdate = () => {
loadData()
}
const getPriorityStyle = (priority: string): React.CSSProperties => {
const colors: { [key: string]: string } = {
low: '#808080',
@@ -127,57 +194,106 @@ export default function Tasks() {
<div style={styles.header}>
<h1 style={styles.title}>Tasks</h1>
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
+ New Task
</button>
</div>
<div style={styles.taskList}>
{tasks.map((task) => (
<div key={task.id} style={styles.taskRow}>
<div style={getPriorityStyle(task.priority)} />
<div style={styles.taskContent}>
<div style={styles.taskTitle}>{task.title}</div>
<div style={styles.taskMeta}>
{task.assignee_name && (
<span style={styles.assignee}>{task.assignee_name}</span>
)}
{task.due_date && (
<span style={styles.dueDate}>
Due: {new Date(task.due_date).toLocaleDateString()}
</span>
)}
{task.subtask_count > 0 && (
<span style={styles.subtaskCount}>
{task.subtask_count} subtasks
</span>
)}
</div>
</div>
<select
value={task.status_id || ''}
onChange={(e) => handleStatusChange(task.id, e.target.value)}
<div style={styles.headerActions}>
{/* View Toggle */}
<div style={styles.viewToggle}>
<button
onClick={() => setViewMode('list')}
style={{
...styles.statusSelect,
backgroundColor: task.status_color || '#f5f5f5',
...styles.viewButton,
...(viewMode === 'list' ? styles.viewButtonActive : {}),
}}
aria-label="List view"
>
{statuses.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
</option>
))}
</select>
List
</button>
<button
onClick={() => setViewMode('kanban')}
style={{
...styles.viewButton,
...(viewMode === 'kanban' ? styles.viewButtonActive : {}),
}}
aria-label="Kanban view"
>
Kanban
</button>
</div>
))}
{tasks.length === 0 && (
<div style={styles.empty}>
<p>No tasks yet. Create your first task!</p>
</div>
)}
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
+ New Task
</button>
</div>
</div>
{/* Conditional rendering based on view mode */}
{viewMode === 'list' ? (
<div style={styles.taskList}>
{tasks.map((task) => (
<div
key={task.id}
style={styles.taskRow}
onClick={() => handleTaskClick(task)}
>
<div style={getPriorityStyle(task.priority)} />
<div style={styles.taskContent}>
<div style={styles.taskTitle}>{task.title}</div>
<div style={styles.taskMeta}>
{task.assignee_name && (
<span style={styles.assignee}>{task.assignee_name}</span>
)}
{task.due_date && (
<span style={styles.dueDate}>
Due: {new Date(task.due_date).toLocaleDateString()}
</span>
)}
{task.time_estimate && (
<span style={styles.timeEstimate}>
Est: {task.time_estimate}h
</span>
)}
{task.subtask_count > 0 && (
<span style={styles.subtaskCount}>
{task.subtask_count} subtasks
</span>
)}
</div>
</div>
<select
value={task.status_id || ''}
onChange={(e) => {
e.stopPropagation()
handleStatusChange(task.id, e.target.value)
}}
onClick={(e) => e.stopPropagation()}
style={{
...styles.statusSelect,
backgroundColor: task.status_color || '#f5f5f5',
}}
>
{statuses.map((status) => (
<option key={status.id} value={status.id}>
{status.name}
</option>
))}
</select>
</div>
))}
{tasks.length === 0 && (
<div style={styles.empty}>
<p>No tasks yet. Create your first task!</p>
</div>
)}
</div>
) : (
<KanbanBoard
tasks={tasks}
statuses={statuses}
onStatusChange={handleStatusChange}
onTaskClick={handleTaskClick}
/>
)}
{/* Create Task Modal */}
{showCreateModal && (
<div style={styles.modalOverlay}>
<div style={styles.modal}>
@@ -195,6 +311,7 @@ export default function Tasks() {
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
style={styles.textarea}
/>
<label style={styles.label}>Priority</label>
<select
value={newTask.priority}
@@ -206,6 +323,34 @@ export default function Tasks() {
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<label style={styles.label}>Assignee</label>
<UserSelect
value={newTask.assignee_id}
onChange={handleAssigneeChange}
placeholder="Select assignee..."
/>
<div style={styles.fieldSpacer} />
<label style={styles.label}>Due Date</label>
<input
type="date"
value={newTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
style={styles.input}
/>
<label style={styles.label}>Time Estimate (hours)</label>
<input
type="number"
min="0"
step="0.5"
placeholder="e.g., 2.5"
value={newTask.time_estimate}
onChange={(e) => setNewTask({ ...newTask, time_estimate: e.target.value })}
style={styles.input}
/>
<div style={styles.modalActions}>
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
Cancel
@@ -221,6 +366,17 @@ export default function Tasks() {
</div>
</div>
)}
{/* Task Detail Modal */}
{selectedTask && (
<TaskDetailModal
task={selectedTask}
statuses={statuses}
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
onUpdate={handleTaskUpdate}
/>
)}
</div>
)
}
@@ -254,6 +410,30 @@ const styles: { [key: string]: React.CSSProperties } = {
fontWeight: 600,
margin: 0,
},
headerActions: {
display: 'flex',
gap: '12px',
alignItems: 'center',
},
viewToggle: {
display: 'flex',
border: '1px solid #ddd',
borderRadius: '6px',
overflow: 'hidden',
},
viewButton: {
padding: '8px 16px',
backgroundColor: 'white',
border: 'none',
cursor: 'pointer',
fontSize: '14px',
color: '#666',
transition: 'background-color 0.2s, color 0.2s',
},
viewButtonActive: {
backgroundColor: '#0066cc',
color: 'white',
},
createButton: {
padding: '10px 20px',
backgroundColor: '#0066cc',
@@ -276,6 +456,8 @@ const styles: { [key: string]: React.CSSProperties } = {
padding: '16px',
borderBottom: '1px solid #eee',
gap: '12px',
cursor: 'pointer',
transition: 'background-color 0.15s ease',
},
taskContent: {
flex: 1,
@@ -297,6 +479,9 @@ const styles: { [key: string]: React.CSSProperties } = {
borderRadius: '4px',
},
dueDate: {},
timeEstimate: {
color: '#0066cc',
},
subtaskCount: {
color: '#999',
},
@@ -329,13 +514,16 @@ const styles: { [key: string]: React.CSSProperties } = {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
},
modal: {
backgroundColor: 'white',
padding: '24px',
borderRadius: '8px',
width: '400px',
width: '450px',
maxWidth: '90%',
maxHeight: '90vh',
overflowY: 'auto',
},
modalTitle: {
marginBottom: '16px',
@@ -369,16 +557,20 @@ const styles: { [key: string]: React.CSSProperties } = {
select: {
width: '100%',
padding: '10px',
marginBottom: '16px',
marginBottom: '12px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px',
boxSizing: 'border-box',
},
fieldSpacer: {
height: '12px',
},
modalActions: {
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
marginTop: '16px',
},
cancelButton: {
padding: '10px 20px',

View File

@@ -0,0 +1,311 @@
import { useState, useEffect, useCallback } from 'react'
import { WorkloadHeatmap } from '../components/WorkloadHeatmap'
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
import { workloadApi, WorkloadHeatmapResponse } from '../services/workload'
// Helper to get Monday of a given week
function getMonday(date: Date): Date {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
d.setDate(diff)
d.setHours(0, 0, 0, 0)
return d
}
// Format date as YYYY-MM-DD
function formatDateParam(date: Date): string {
return date.toISOString().split('T')[0]
}
// Format date for display
function formatWeekDisplay(date: Date): string {
return date.toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export default function WorkloadPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [heatmapData, setHeatmapData] = useState<WorkloadHeatmapResponse | null>(null)
const [selectedWeek, setSelectedWeek] = useState<Date>(() => getMonday(new Date()))
const [selectedUser, setSelectedUser] = useState<{ id: string; name: string } | null>(null)
const [showUserDetail, setShowUserDetail] = useState(false)
const loadHeatmap = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await workloadApi.getHeatmap(formatDateParam(selectedWeek))
setHeatmapData(data)
} catch (err) {
console.error('Failed to load workload heatmap:', err)
setError('Failed to load workload data. Please try again.')
} finally {
setLoading(false)
}
}, [selectedWeek])
useEffect(() => {
loadHeatmap()
}, [loadHeatmap])
const handlePrevWeek = () => {
setSelectedWeek((prev) => {
const newDate = new Date(prev)
newDate.setDate(newDate.getDate() - 7)
return newDate
})
}
const handleNextWeek = () => {
setSelectedWeek((prev) => {
const newDate = new Date(prev)
newDate.setDate(newDate.getDate() + 7)
return newDate
})
}
const handleToday = () => {
setSelectedWeek(getMonday(new Date()))
}
const handleUserClick = (userId: string, userName: string) => {
setSelectedUser({ id: userId, name: userName })
setShowUserDetail(true)
}
const handleCloseUserDetail = () => {
setShowUserDetail(false)
setSelectedUser(null)
}
const isCurrentWeek = () => {
const currentMonday = getMonday(new Date())
return selectedWeek.getTime() === currentMonday.getTime()
}
return (
<div style={styles.container}>
<div style={styles.header}>
<div>
<h1 style={styles.title}>Team Workload</h1>
<p style={styles.subtitle}>
Monitor team capacity and task distribution
</p>
</div>
</div>
{/* Week Navigation */}
<div style={styles.weekNav}>
<button onClick={handlePrevWeek} style={styles.navButton} aria-label="Previous week">
&larr; Previous
</button>
<div style={styles.weekDisplay}>
<span style={styles.weekLabel}>Week of</span>
<span style={styles.weekDate}>{formatWeekDisplay(selectedWeek)}</span>
</div>
<button onClick={handleNextWeek} style={styles.navButton} aria-label="Next week">
Next &rarr;
</button>
{!isCurrentWeek() && (
<button onClick={handleToday} style={styles.todayButton}>
Today
</button>
)}
</div>
{/* Content */}
{loading ? (
<div style={styles.loadingContainer}>
<div style={styles.loading}>Loading workload data...</div>
</div>
) : error ? (
<div style={styles.errorContainer}>
<p style={styles.error}>{error}</p>
<button onClick={loadHeatmap} style={styles.retryButton}>
Retry
</button>
</div>
) : heatmapData ? (
<WorkloadHeatmap
users={heatmapData.users}
weekStart={heatmapData.week_start}
weekEnd={heatmapData.week_end}
onUserClick={handleUserClick}
/>
) : null}
{/* Summary Stats */}
{heatmapData && heatmapData.users.length > 0 && (
<div style={styles.statsContainer}>
<div style={styles.statCard}>
<span style={styles.statValue}>{heatmapData.users.length}</span>
<span style={styles.statLabel}>Team Members</span>
</div>
<div style={styles.statCard}>
<span style={styles.statValue}>
{heatmapData.users.filter((u) => u.load_level === 'overloaded').length}
</span>
<span style={styles.statLabel}>Overloaded</span>
</div>
<div style={styles.statCard}>
<span style={styles.statValue}>
{heatmapData.users.filter((u) => u.load_level === 'warning').length}
</span>
<span style={styles.statLabel}>At Risk</span>
</div>
<div style={styles.statCard}>
<span style={styles.statValue}>
{Math.round(
heatmapData.users.reduce((sum, u) => sum + u.load_percentage, 0) /
heatmapData.users.length
)}%
</span>
<span style={styles.statLabel}>Avg. Load</span>
</div>
</div>
)}
{/* User Detail Modal */}
{selectedUser && (
<WorkloadUserDetail
userId={selectedUser.id}
userName={selectedUser.name}
weekStart={formatDateParam(selectedWeek)}
isOpen={showUserDetail}
onClose={handleCloseUserDetail}
/>
)}
</div>
)
}
const styles: { [key: string]: React.CSSProperties } = {
container: {
padding: '24px',
maxWidth: '1200px',
margin: '0 auto',
},
header: {
marginBottom: '24px',
},
title: {
fontSize: '24px',
fontWeight: 600,
margin: 0,
color: '#333',
},
subtitle: {
fontSize: '14px',
color: '#666',
margin: '4px 0 0 0',
},
weekNav: {
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '24px',
padding: '16px 20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
},
navButton: {
padding: '8px 16px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
color: '#333',
transition: 'background-color 0.2s',
},
weekDisplay: {
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
weekLabel: {
fontSize: '12px',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
weekDate: {
fontSize: '16px',
fontWeight: 600,
color: '#333',
},
todayButton: {
padding: '8px 16px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
},
loadingContainer: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '48px',
textAlign: 'center',
},
loading: {
color: '#666',
},
errorContainer: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '48px',
textAlign: 'center',
},
error: {
color: '#f44336',
marginBottom: '16px',
},
retryButton: {
padding: '10px 20px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
},
statsContainer: {
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '16px',
marginTop: '24px',
},
statCard: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
padding: '20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
},
statValue: {
fontSize: '28px',
fontWeight: 600,
color: '#333',
},
statLabel: {
fontSize: '12px',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.5px',
},
}

View File

@@ -46,7 +46,7 @@ export const attachmentService = {
const formData = new FormData()
formData.append('file', file)
const response = await api.post(`/api/tasks/${taskId}/attachments`, formData, {
const response = await api.post(`/tasks/${taskId}/attachments`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -55,19 +55,19 @@ export const attachmentService = {
},
async listAttachments(taskId: string): Promise<AttachmentListResponse> {
const response = await api.get(`/api/tasks/${taskId}/attachments`)
const response = await api.get(`/tasks/${taskId}/attachments`)
return response.data
},
async getAttachment(attachmentId: string): Promise<AttachmentDetail> {
const response = await api.get(`/api/attachments/${attachmentId}`)
const response = await api.get(`/attachments/${attachmentId}`)
return response.data
},
async downloadAttachment(attachmentId: string, version?: number): Promise<void> {
const url = version
? `/api/attachments/${attachmentId}/download?version=${version}`
: `/api/attachments/${attachmentId}/download`
? `/attachments/${attachmentId}/download?version=${version}`
: `/attachments/${attachmentId}/download`
const response = await api.get(url, {
responseType: 'blob',
@@ -96,16 +96,16 @@ export const attachmentService = {
},
async deleteAttachment(attachmentId: string): Promise<void> {
await api.delete(`/api/attachments/${attachmentId}`)
await api.delete(`/attachments/${attachmentId}`)
},
async getVersionHistory(attachmentId: string): Promise<VersionHistoryResponse> {
const response = await api.get(`/api/attachments/${attachmentId}/versions`)
const response = await api.get(`/attachments/${attachmentId}/versions`)
return response.data
},
async restoreVersion(attachmentId: string, version: number): Promise<void> {
await api.post(`/api/attachments/${attachmentId}/restore/${version}`)
await api.post(`/attachments/${attachmentId}/restore/${version}`)
},
formatFileSize(bytes: number): string {

View File

@@ -0,0 +1,60 @@
import api from './api'
// Types for Project Health API responses
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'
export type ScheduleStatus = 'on_track' | 'at_risk' | 'delayed'
export type ResourceStatus = 'adequate' | 'constrained' | 'overloaded'
export interface ProjectHealthItem {
id: string
project_id: string
health_score: number
risk_level: RiskLevel
schedule_status: ScheduleStatus
resource_status: ResourceStatus
last_updated: string
project_title: string
project_status: string
owner_name: string | null
space_name: string | null
task_count: number
completed_task_count: number
blocker_count: number
overdue_task_count: number
}
export interface ProjectHealthSummary {
total_projects: number
healthy_count: number
at_risk_count: number
critical_count: number
average_health_score: number
projects_with_blockers: number
projects_delayed: number
}
export interface ProjectHealthDashboardResponse {
projects: ProjectHealthItem[]
summary: ProjectHealthSummary
}
// API functions
export const projectHealthApi = {
/**
* Get project health dashboard with all projects
*/
getDashboard: async (): Promise<ProjectHealthDashboardResponse> => {
const response = await api.get<ProjectHealthDashboardResponse>('/projects/health/dashboard')
return response.data
},
/**
* Get health status for a single project
*/
getProjectHealth: async (projectId: string): Promise<ProjectHealthItem> => {
const response = await api.get<ProjectHealthItem>(`/projects/health/${projectId}`)
return response.data
},
}
export default projectHealthApi

View File

@@ -0,0 +1,78 @@
import api from './api'
// Types for Workload API responses
export type LoadLevel = 'normal' | 'warning' | 'overloaded' | 'unavailable'
export interface WorkloadUser {
user_id: string
user_name: string
department_name: string | null
allocated_hours: number
capacity_hours: number
load_percentage: number
load_level: LoadLevel
}
export interface WorkloadHeatmapResponse {
week_start: string
week_end: string
users: WorkloadUser[]
}
export interface WorkloadTask {
task_id: string
task_title: string
project_id: string
project_name: string
time_estimate: number
due_date: string | null
status_name: string | null
}
export interface WorkloadSummary {
allocated_hours: number
capacity_hours: number
load_percentage: number
load_level: LoadLevel
}
export interface UserWorkloadDetail {
user_id: string
user_name: string
week_start: string
week_end: string
summary: WorkloadSummary
tasks: WorkloadTask[]
}
// API functions
export const workloadApi = {
/**
* Get workload heatmap for all users in a specific week
*/
getHeatmap: async (weekStart?: string): Promise<WorkloadHeatmapResponse> => {
const params = weekStart ? { week_start: weekStart } : {}
const response = await api.get<WorkloadHeatmapResponse>('/workload/heatmap', { params })
return response.data
},
/**
* Get detailed workload for a specific user
*/
getUserWorkload: async (userId: string, weekStart?: string): Promise<UserWorkloadDetail> => {
const params = weekStart ? { week_start: weekStart } : {}
const response = await api.get<UserWorkloadDetail>(`/workload/user/${userId}`, { params })
return response.data
},
/**
* Get current user's workload
*/
getMyWorkload: async (weekStart?: string): Promise<UserWorkloadDetail> => {
const params = weekStart ? { week_start: weekStart } : {}
const response = await api.get<UserWorkloadDetail>('/workload/me', { params })
return response.data
},
}
export default workloadApi