feat: implement automation module

- Event-based triggers (Phase 1):
  - Trigger/TriggerLog models with field_change type
  - TriggerService for condition evaluation and action execution
  - Trigger CRUD API endpoints
  - Task integration (status, assignee, priority changes)
  - Frontend: TriggerList, TriggerForm components

- Weekly reports (Phase 2):
  - ScheduledReport/ReportHistory models
  - ReportService for stats generation
  - APScheduler for Friday 16:00 job
  - Report preview/generate/history API
  - Frontend: WeeklyReportPreview, ReportHistory components

- Tests: 23 new tests (14 triggers + 9 reports)
- OpenSpec: add-automation change archived

🤖 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
2025-12-29 22:59:00 +08:00
parent 3108fe1dff
commit 95c281d8e1
32 changed files with 3163 additions and 3 deletions

View File

@@ -0,0 +1,106 @@
import { useState, useEffect } from 'react'
import { reportsApi, ReportHistoryItem } from '../services/reports'
export function ReportHistory() {
const [reports, setReports] = useState<ReportHistoryItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [total, setTotal] = useState(0)
const fetchHistory = async () => {
try {
setLoading(true)
const data = await reportsApi.listReportHistory(10, 0)
setReports(data.reports)
setTotal(data.total)
setError(null)
} catch {
setError('Failed to load report history')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchHistory()
}, [])
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('zh-TW', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
if (loading) {
return <div className="p-4 text-center text-gray-500">Loading history...</div>
}
if (error) {
return (
<div className="p-4 text-center text-red-500">
{error}
<button onClick={fetchHistory} className="ml-2 text-blue-600 hover:underline">
Retry
</button>
</div>
)
}
if (reports.length === 0) {
return (
<div className="p-4 text-center text-gray-500">
No report history found. Reports are generated every Friday at 16:00.
</div>
)
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">Report History</h3>
<span className="text-sm text-gray-500">{total} reports</span>
</div>
<div className="space-y-2">
{reports.map(report => {
const summary = (report.content as Record<string, Record<string, number>>).summary || {}
return (
<div
key={report.id}
className={`border rounded-lg p-4 ${
report.status === 'failed' ? 'bg-red-50 border-red-200' : 'bg-white'
}`}
>
<div className="flex justify-between items-start">
<div>
<p className="font-medium">{formatDate(report.generated_at)}</p>
{report.status === 'sent' && summary && (
<p className="text-sm text-gray-600 mt-1">
Completed: {summary.completed_count || 0} |
In Progress: {summary.in_progress_count || 0} |
Overdue: {summary.overdue_count || 0}
</p>
)}
{report.status === 'failed' && report.error_message && (
<p className="text-sm text-red-600 mt-1">{report.error_message}</p>
)}
</div>
<span className={`px-2 py-0.5 text-xs rounded ${
report.status === 'sent'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{report.status}
</span>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,198 @@
import { useState, useEffect } from 'react'
import { triggersApi, Trigger, TriggerCreate, TriggerCondition, TriggerAction } from '../services/triggers'
interface TriggerFormProps {
projectId: string
trigger?: Trigger | null
onSave: () => void
onCancel: () => void
}
export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFormProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [field, setField] = useState('status_id')
const [operator, setOperator] = useState('changed_to')
const [value, setValue] = useState('')
const [target, setTarget] = useState('assignee')
const [template, setTemplate] = useState('')
const [isActive, setIsActive] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (trigger) {
setName(trigger.name)
setDescription(trigger.description || '')
setField(trigger.conditions.field)
setOperator(trigger.conditions.operator)
setValue(trigger.conditions.value)
if (trigger.actions.length > 0) {
setTarget(trigger.actions[0].target)
setTemplate(trigger.actions[0].template || '')
}
setIsActive(trigger.is_active)
}
}, [trigger])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
const conditions: TriggerCondition = { field, operator, value }
const actions: TriggerAction[] = [{ type: 'notify', target, template: template || undefined }]
try {
if (trigger) {
await triggersApi.updateTrigger(trigger.id, {
name,
description: description || undefined,
conditions,
actions,
is_active: isActive,
})
} else {
const data: TriggerCreate = {
name,
description: description || undefined,
trigger_type: 'field_change',
conditions,
actions,
is_active: isActive,
}
await triggersApi.createTrigger(projectId, data)
}
onSave()
} catch {
setError('Failed to save trigger')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description (optional)</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
rows={2}
/>
</div>
<fieldset className="border rounded-md p-3">
<legend className="text-sm font-medium text-gray-700 px-1">Condition</legend>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm text-gray-600">Field</label>
<select
value={field}
onChange={e => setField(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="status_id">Status</option>
<option value="assignee_id">Assignee</option>
<option value="priority">Priority</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-600">Operator</label>
<select
value={operator}
onChange={e => setOperator(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="changed_to">Changes to</option>
<option value="changed_from">Changes from</option>
<option value="equals">Equals</option>
<option value="not_equals">Not equals</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-600">Value</label>
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
placeholder="UUID or value"
required
/>
</div>
</div>
</fieldset>
<fieldset className="border rounded-md p-3">
<legend className="text-sm font-medium text-gray-700 px-1">Action</legend>
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-600">Notify</label>
<select
value={target}
onChange={e => setTarget(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
>
<option value="assignee">Task Assignee</option>
<option value="creator">Task Creator</option>
<option value="project_owner">Project Owner</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-600">Message template (optional)</label>
<input
type="text"
value={template}
onChange={e => setTemplate(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
placeholder="Variables: {task_title}, {old_value}, {new_value}"
/>
</div>
</div>
</fieldset>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={isActive}
onChange={e => setIsActive(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="isActive" className="text-sm text-gray-700">Active</label>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
>
{loading ? 'Saving...' : trigger ? 'Update' : 'Create'}
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,155 @@
import { useState, useEffect } from 'react'
import { triggersApi, Trigger } from '../services/triggers'
interface TriggerListProps {
projectId: string
onEdit?: (trigger: Trigger) => void
}
export function TriggerList({ projectId, onEdit }: TriggerListProps) {
const [triggers, setTriggers] = useState<Trigger[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchTriggers = async () => {
try {
setLoading(true)
const response = await triggersApi.listTriggers(projectId)
setTriggers(response.triggers)
setError(null)
} catch {
setError('Failed to load triggers')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTriggers()
}, [projectId])
const handleToggleActive = async (trigger: Trigger) => {
try {
await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active })
fetchTriggers()
} catch {
setError('Failed to update trigger')
}
}
const handleDelete = async (triggerId: string) => {
if (!confirm('Are you sure you want to delete this trigger?')) return
try {
await triggersApi.deleteTrigger(triggerId)
fetchTriggers()
} catch {
setError('Failed to delete trigger')
}
}
const getFieldLabel = (field: string) => {
switch (field) {
case 'status_id': return 'Status'
case 'assignee_id': return 'Assignee'
case 'priority': return 'Priority'
default: return field
}
}
const getOperatorLabel = (operator: string) => {
switch (operator) {
case 'equals': return 'equals'
case 'not_equals': return 'does not equal'
case 'changed_to': return 'changes to'
case 'changed_from': return 'changes from'
default: return operator
}
}
if (loading) {
return <div className="p-4 text-center text-gray-500">Loading triggers...</div>
}
if (error) {
return (
<div className="p-4 text-center text-red-500">
{error}
<button onClick={fetchTriggers} className="ml-2 text-blue-600 hover:underline">
Retry
</button>
</div>
)
}
if (triggers.length === 0) {
return (
<div className="p-4 text-center text-gray-500">
No triggers configured for this project.
</div>
)
}
return (
<div className="space-y-3">
{triggers.map(trigger => (
<div
key={trigger.id}
className={`border rounded-lg p-4 ${trigger.is_active ? 'bg-white' : 'bg-gray-50'}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{trigger.name}</h4>
<span className={`px-2 py-0.5 text-xs rounded ${
trigger.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{trigger.is_active ? 'Active' : 'Inactive'}
</span>
</div>
{trigger.description && (
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
)}
<div className="text-sm text-gray-600 mt-2">
<span className="font-medium">When: </span>
{getFieldLabel(trigger.conditions.field)} {getOperatorLabel(trigger.conditions.operator)} {trigger.conditions.value}
</div>
<div className="text-sm text-gray-600 mt-1">
<span className="font-medium">Then: </span>
{trigger.actions.map((a, i) => (
<span key={i}>
{a.type === 'notify' ? `Notify ${a.target}` : a.type}
{i < trigger.actions.length - 1 && ', '}
</span>
))}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleToggleActive(trigger)}
className="text-sm px-2 py-1 text-gray-600 hover:bg-gray-100 rounded"
>
{trigger.is_active ? 'Disable' : 'Enable'}
</button>
{onEdit && (
<button
onClick={() => onEdit(trigger)}
className="text-sm px-2 py-1 text-blue-600 hover:bg-blue-50 rounded"
>
Edit
</button>
)}
<button
onClick={() => handleDelete(trigger.id)}
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react'
import { reportsApi, WeeklyReportContent } from '../services/reports'
export function WeeklyReportPreview() {
const [report, setReport] = useState<WeeklyReportContent | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchPreview = async () => {
try {
setLoading(true)
const data = await reportsApi.previewWeeklyReport()
setReport(data)
setError(null)
} catch {
setError('Failed to load report preview')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchPreview()
}, [])
const handleGenerate = async () => {
try {
setGenerating(true)
await reportsApi.generateWeeklyReport()
alert('Report generated and notification sent!')
fetchPreview()
} catch {
setError('Failed to generate report')
} finally {
setGenerating(false)
}
}
if (loading) {
return <div className="p-4 text-center text-gray-500">Loading report preview...</div>
}
if (error) {
return (
<div className="p-4 text-center text-red-500">
{error}
<button onClick={fetchPreview} className="ml-2 text-blue-600 hover:underline">
Retry
</button>
</div>
)
}
if (!report) {
return <div className="p-4 text-center text-gray-500">No report data available</div>
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('zh-TW', {
month: 'short',
day: 'numeric',
})
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Weekly Report Preview</h3>
<p className="text-sm text-gray-500">
{formatDate(report.week_start)} - {formatDate(report.week_end)}
</p>
</div>
<button
onClick={handleGenerate}
disabled={generating}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
>
{generating ? 'Generating...' : 'Generate Now'}
</button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-3xl font-bold text-green-600">{report.summary.completed_count}</p>
<p className="text-sm text-green-800">Completed</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-3xl font-bold text-blue-600">{report.summary.in_progress_count}</p>
<p className="text-sm text-blue-800">In Progress</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-3xl font-bold text-red-600">{report.summary.overdue_count}</p>
<p className="text-sm text-red-800">Overdue</p>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-3xl font-bold text-gray-600">{report.summary.total_tasks}</p>
<p className="text-sm text-gray-800">Total Tasks</p>
</div>
</div>
{/* Project Details */}
{report.projects.length > 0 ? (
<div className="space-y-3">
<h4 className="font-medium">Projects</h4>
{report.projects.map(project => (
<div key={project.project_id} className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h5 className="font-medium">{project.project_title}</h5>
<span className="text-sm text-gray-500">
{project.completed_count}/{project.total_tasks} completed
</span>
</div>
<div className="flex gap-4 text-sm">
<span className="text-green-600">{project.completed_count} done</span>
<span className="text-blue-600">{project.in_progress_count} in progress</span>
{project.overdue_count > 0 && (
<span className="text-red-600">{project.overdue_count} overdue</span>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-center py-4">No projects found</p>
)}
</div>
)
}

View File

@@ -0,0 +1,77 @@
import api from './api'
export interface ReportSummary {
completed_count: number
in_progress_count: number
overdue_count: number
total_tasks: number
}
export interface ProjectSummary {
project_id: string
project_title: string
completed_count: number
in_progress_count: number
overdue_count: number
total_tasks: number
completed_tasks: Array<{ id: string; title: string }>
overdue_tasks: Array<{ id: string; title: string; due_date: string | null }>
}
export interface WeeklyReportContent {
week_start: string
week_end: string
generated_at: string
projects: ProjectSummary[]
summary: ReportSummary
}
export interface ReportHistoryItem {
id: string
report_id: string
generated_at: string
content: Record<string, unknown>
status: string
error_message?: string
}
export interface ReportHistoryListResponse {
reports: ReportHistoryItem[]
total: number
}
export interface GenerateReportResponse {
message: string
report_id: string
summary: ReportSummary
}
export const reportsApi = {
// Preview weekly report
previewWeeklyReport: async (): Promise<WeeklyReportContent> => {
const response = await api.get<WeeklyReportContent>('/reports/weekly/preview')
return response.data
},
// Generate weekly report manually
generateWeeklyReport: async (): Promise<GenerateReportResponse> => {
const response = await api.post<GenerateReportResponse>('/reports/weekly/generate')
return response.data
},
// List report history
listReportHistory: async (limit = 10, offset = 0): Promise<ReportHistoryListResponse> => {
const response = await api.get<ReportHistoryListResponse>('/reports/history', {
params: { limit, offset }
})
return response.data
},
// Get specific report detail
getReportDetail: async (reportId: string): Promise<ReportHistoryItem> => {
const response = await api.get<ReportHistoryItem>(`/reports/history/${reportId}`)
return response.data
},
}
export default reportsApi

View File

@@ -0,0 +1,111 @@
import api from './api'
export interface TriggerCondition {
field: string
operator: string
value: string
}
export interface TriggerAction {
type: string
target: string
template?: string
}
export interface Trigger {
id: string
project_id: string
name: string
description?: string
trigger_type: string
conditions: TriggerCondition
actions: TriggerAction[]
is_active: boolean
created_by?: string
created_at: string
updated_at: string
creator?: {
id: string
name: string
email: string
}
}
export interface TriggerCreate {
name: string
description?: string
trigger_type?: string
conditions: TriggerCondition
actions: TriggerAction[]
is_active?: boolean
}
export interface TriggerUpdate {
name?: string
description?: string
conditions?: TriggerCondition
actions?: TriggerAction[]
is_active?: boolean
}
export interface TriggerLog {
id: string
trigger_id: string
task_id?: string
executed_at: string
status: string
details?: Record<string, unknown>
error_message?: string
}
export interface TriggerListResponse {
triggers: Trigger[]
total: number
}
export interface TriggerLogListResponse {
logs: TriggerLog[]
total: number
}
export const triggersApi = {
// Create a new trigger
createTrigger: async (projectId: string, data: TriggerCreate): Promise<Trigger> => {
const response = await api.post<Trigger>(`/projects/${projectId}/triggers`, data)
return response.data
},
// List triggers for a project
listTriggers: async (projectId: string, isActive?: boolean): Promise<TriggerListResponse> => {
const params = isActive !== undefined ? { is_active: isActive } : {}
const response = await api.get<TriggerListResponse>(`/projects/${projectId}/triggers`, { params })
return response.data
},
// Get a specific trigger
getTrigger: async (triggerId: string): Promise<Trigger> => {
const response = await api.get<Trigger>(`/triggers/${triggerId}`)
return response.data
},
// Update a trigger
updateTrigger: async (triggerId: string, data: TriggerUpdate): Promise<Trigger> => {
const response = await api.put<Trigger>(`/triggers/${triggerId}`, data)
return response.data
},
// Delete a trigger
deleteTrigger: async (triggerId: string): Promise<void> => {
await api.delete(`/triggers/${triggerId}`)
},
// Get trigger execution logs
getTriggerLogs: async (triggerId: string, limit = 50, offset = 0): Promise<TriggerLogListResponse> => {
const response = await api.get<TriggerLogListResponse>(`/triggers/${triggerId}/logs`, {
params: { limit, offset }
})
return response.data
},
}
export default triggersApi