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:
106
frontend/src/components/ReportHistory.tsx
Normal file
106
frontend/src/components/ReportHistory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
198
frontend/src/components/TriggerForm.tsx
Normal file
198
frontend/src/components/TriggerForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
frontend/src/components/TriggerList.tsx
Normal file
155
frontend/src/components/TriggerList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
frontend/src/components/WeeklyReportPreview.tsx
Normal file
131
frontend/src/components/WeeklyReportPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
frontend/src/services/reports.ts
Normal file
77
frontend/src/services/reports.ts
Normal 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
|
||||
111
frontend/src/services/triggers.ts
Normal file
111
frontend/src/services/triggers.ts
Normal 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
|
||||
Reference in New Issue
Block a user