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:
@@ -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',
|
||||
|
||||
350
frontend/src/pages/ProjectHealthPage.tsx
Normal file
350
frontend/src/pages/ProjectHealthPage.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
311
frontend/src/pages/WorkloadPage.tsx
Normal file
311
frontend/src/pages/WorkloadPage.tsx
Normal 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">
|
||||
← 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 →
|
||||
</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',
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user