Commit 2799cc9be4ae5d5527571ed6a86aeb1aba70831d
1 parent
618a9781
feat(web): user-task pages + rules tab in metadata admin
Showing
9 changed files
with
712 additions
and
0 deletions
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt
| @@ -85,9 +85,15 @@ class SpaController { | @@ -85,9 +85,15 @@ class SpaController { | ||
| 85 | "/accounts", "/accounts/", "/accounts/**", | 85 | "/accounts", "/accounts/", "/accounts/**", |
| 86 | "/journal-entries", "/journal-entries/", "/journal-entries/**", | 86 | "/journal-entries", "/journal-entries/", "/journal-entries/**", |
| 87 | 87 | ||
| 88 | + // Workflow | ||
| 89 | + "/workflow", "/workflow/", "/workflow/**", | ||
| 90 | + | ||
| 88 | // System / identity | 91 | // System / identity |
| 89 | "/users", "/users/", "/users/**", | 92 | "/users", "/users/", "/users/**", |
| 90 | "/roles", "/roles/", "/roles/**", | 93 | "/roles", "/roles/", "/roles/**", |
| 94 | + | ||
| 95 | + // Admin | ||
| 96 | + "/admin", "/admin/", "/admin/**", | ||
| 91 | ], | 97 | ], |
| 92 | ) | 98 | ) |
| 93 | fun spa(): String = "forward:/index.html" | 99 | fun spa(): String = "forward:/index.html" |
web/src/App.tsx
| @@ -44,6 +44,8 @@ import { JournalEntriesPage } from '@/pages/JournalEntriesPage' | @@ -44,6 +44,8 @@ import { JournalEntriesPage } from '@/pages/JournalEntriesPage' | ||
| 44 | import { FormDesignerPage } from '@/pages/FormDesignerPage' | 44 | import { FormDesignerPage } from '@/pages/FormDesignerPage' |
| 45 | import { ListViewDesignerPage } from '@/pages/ListViewDesignerPage' | 45 | import { ListViewDesignerPage } from '@/pages/ListViewDesignerPage' |
| 46 | import { MetadataAdminPage } from '@/pages/MetadataAdminPage' | 46 | import { MetadataAdminPage } from '@/pages/MetadataAdminPage' |
| 47 | +import { UserTasksPage } from '@/pages/UserTasksPage' | ||
| 48 | +import { TaskDetailPage } from '@/pages/TaskDetailPage' | ||
| 47 | 49 | ||
| 48 | export default function App() { | 50 | export default function App() { |
| 49 | return ( | 51 | return ( |
| @@ -84,6 +86,8 @@ export default function App() { | @@ -84,6 +86,8 @@ export default function App() { | ||
| 84 | <Route path="work-orders/new" element={<CreateWorkOrderPage />} /> | 86 | <Route path="work-orders/new" element={<CreateWorkOrderPage />} /> |
| 85 | <Route path="work-orders/:id" element={<WorkOrderDetailPage />} /> | 87 | <Route path="work-orders/:id" element={<WorkOrderDetailPage />} /> |
| 86 | <Route path="shop-floor" element={<ShopFloorPage />} /> | 88 | <Route path="shop-floor" element={<ShopFloorPage />} /> |
| 89 | + <Route path="workflow/tasks" element={<UserTasksPage />} /> | ||
| 90 | + <Route path="workflow/tasks/:taskId" element={<TaskDetailPage />} /> | ||
| 87 | <Route path="accounts" element={<AccountsPage />} /> | 91 | <Route path="accounts" element={<AccountsPage />} /> |
| 88 | <Route path="journal-entries" element={<JournalEntriesPage />} /> | 92 | <Route path="journal-entries" element={<JournalEntriesPage />} /> |
| 89 | <Route path="admin/metadata" element={<MetadataAdminPage />} /> | 93 | <Route path="admin/metadata" element={<MetadataAdminPage />} /> |
web/src/api/client.ts
| @@ -38,6 +38,7 @@ import type { | @@ -38,6 +38,7 @@ import type { | ||
| 38 | Partner, | 38 | Partner, |
| 39 | PurchaseOrder, | 39 | PurchaseOrder, |
| 40 | Role, | 40 | Role, |
| 41 | + RuleDefinition, | ||
| 41 | SalesOrder, | 42 | SalesOrder, |
| 42 | ShopFloorEntry, | 43 | ShopFloorEntry, |
| 43 | StockBalance, | 44 | StockBalance, |
| @@ -45,6 +46,8 @@ import type { | @@ -45,6 +46,8 @@ import type { | ||
| 45 | TokenPair, | 46 | TokenPair, |
| 46 | Uom, | 47 | Uom, |
| 47 | User, | 48 | User, |
| 49 | + UserTaskDetail, | ||
| 50 | + UserTaskSummary, | ||
| 48 | WorkOrder, | 51 | WorkOrder, |
| 49 | } from '@/types/api' | 52 | } from '@/types/api' |
| 50 | 53 | ||
| @@ -286,6 +289,15 @@ export const production = { | @@ -286,6 +289,15 @@ export const production = { | ||
| 286 | apiFetch<ShopFloorEntry[]>('/api/v1/production/work-orders/shop-floor'), | 289 | apiFetch<ShopFloorEntry[]>('/api/v1/production/work-orders/shop-floor'), |
| 287 | } | 290 | } |
| 288 | 291 | ||
| 292 | +// ─── Workflow ──────────────────────────────────────────────────────── | ||
| 293 | + | ||
| 294 | +export const workflow = { | ||
| 295 | + listTasks: () => apiFetch<UserTaskSummary[]>('/api/v1/workflow/tasks'), | ||
| 296 | + getTask: (taskId: string) => apiFetch<UserTaskDetail>(`/api/v1/workflow/tasks/${taskId}`), | ||
| 297 | + completeTask: (taskId: string, variables: Record<string, unknown>) => | ||
| 298 | + apiFetch<void>(`/api/v1/workflow/tasks/${taskId}/complete`, { method: 'POST', body: JSON.stringify(variables) }, false), | ||
| 299 | +} | ||
| 300 | + | ||
| 289 | // ─── Finance ───────────────────────────────────────────────────────── | 301 | // ─── Finance ───────────────────────────────────────────────────────── |
| 290 | 302 | ||
| 291 | // ─── Metadata admin ───────────────────────────────────────────────── | 303 | // ─── Metadata admin ───────────────────────────────────────────────── |
| @@ -314,6 +326,12 @@ export const metadata = { | @@ -314,6 +326,12 @@ export const metadata = { | ||
| 314 | apiFetch<CustomFieldDef>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'PUT', body: JSON.stringify(body) }), | 326 | apiFetch<CustomFieldDef>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'PUT', body: JSON.stringify(body) }), |
| 315 | deleteCustomField: (key: string) => | 327 | deleteCustomField: (key: string) => |
| 316 | apiFetch<void>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'DELETE' }, false), | 328 | apiFetch<void>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'DELETE' }, false), |
| 329 | + listRules: () => apiFetch<RuleDefinition[]>('/api/v1/_meta/metadata/rules'), | ||
| 330 | + getRule: (slug: string) => apiFetch<RuleDefinition>(`/api/v1/_meta/metadata/rules/${slug}`), | ||
| 331 | + saveRule: (slug: string, body: Omit<RuleDefinition, 'source'>) => | ||
| 332 | + apiFetch<RuleDefinition>(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'PUT', body: JSON.stringify(body) }), | ||
| 333 | + deleteRule: (slug: string) => | ||
| 334 | + apiFetch<void>(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'DELETE' }, false), | ||
| 317 | } | 335 | } |
| 318 | 336 | ||
| 319 | // ─── Finance ───────────────────────────────────────────────────────── | 337 | // ─── Finance ───────────────────────────────────────────────────────── |
web/src/i18n/messages.ts
| @@ -126,6 +126,22 @@ export const en = { | @@ -126,6 +126,22 @@ export const en = { | ||
| 126 | 'label.targetEntity': 'Target Entity', | 126 | 'label.targetEntity': 'Target Entity', |
| 127 | 'label.fieldType': 'Field Type', | 127 | 'label.fieldType': 'Field Type', |
| 128 | 'confirm.delete': 'Are you sure?', | 128 | 'confirm.delete': 'Are you sure?', |
| 129 | + | ||
| 130 | + // ─── Workflow / tasks ───────────────────────────────────── | ||
| 131 | + 'nav.workflow': 'Workflow', | ||
| 132 | + 'nav.tasks': 'Tasks', | ||
| 133 | + 'page.tasks.title': 'Pending Tasks', | ||
| 134 | + 'page.tasks.subtitle': 'Workflow tasks waiting for action', | ||
| 135 | + 'page.taskDetail.title': 'Task Detail', | ||
| 136 | + | ||
| 137 | + // ─── Rules ──────────────────────────────────────────────── | ||
| 138 | + 'tab.rules': 'Rules', | ||
| 139 | + 'label.triggerEvent': 'Trigger Event', | ||
| 140 | + 'label.conditions': 'Conditions', | ||
| 141 | + 'label.actions': 'Actions', | ||
| 142 | + 'label.enabled': 'Enabled', | ||
| 143 | + 'label.conditionLogic': 'Logic', | ||
| 144 | + 'action.newRule': 'New Rule', | ||
| 129 | } as const | 145 | } as const |
| 130 | 146 | ||
| 131 | export const zhCN: Record<MessageKey, string> = { | 147 | export const zhCN: Record<MessageKey, string> = { |
| @@ -243,6 +259,22 @@ export const zhCN: Record<MessageKey, string> = { | @@ -243,6 +259,22 @@ export const zhCN: Record<MessageKey, string> = { | ||
| 243 | 'label.targetEntity': '目标实体', | 259 | 'label.targetEntity': '目标实体', |
| 244 | 'label.fieldType': '字段类型', | 260 | 'label.fieldType': '字段类型', |
| 245 | 'confirm.delete': '确定删除?', | 261 | 'confirm.delete': '确定删除?', |
| 262 | + | ||
| 263 | + // ─── 工作流 / 任务 ──────────────────────────────────────── | ||
| 264 | + 'nav.workflow': '工作流', | ||
| 265 | + 'nav.tasks': '任务', | ||
| 266 | + 'page.tasks.title': '待办任务', | ||
| 267 | + 'page.tasks.subtitle': '等待处理的工作流任务', | ||
| 268 | + 'page.taskDetail.title': '任务详情', | ||
| 269 | + | ||
| 270 | + // ─── 规则 ───────────────────────────────────────────────── | ||
| 271 | + 'tab.rules': '规则', | ||
| 272 | + 'label.triggerEvent': '触发事件', | ||
| 273 | + 'label.conditions': '条件', | ||
| 274 | + 'label.actions': '动作', | ||
| 275 | + 'label.enabled': '启用', | ||
| 276 | + 'label.conditionLogic': '逻辑', | ||
| 277 | + 'action.newRule': '新建规则', | ||
| 246 | } | 278 | } |
| 247 | 279 | ||
| 248 | export const locales = { | 280 | export const locales = { |
web/src/layout/AppLayout.tsx
| @@ -60,6 +60,12 @@ const NAV: NavGroup[] = [ | @@ -60,6 +60,12 @@ const NAV: NavGroup[] = [ | ||
| 60 | ], | 60 | ], |
| 61 | }, | 61 | }, |
| 62 | { | 62 | { |
| 63 | + headingKey: 'nav.workflow', | ||
| 64 | + items: [ | ||
| 65 | + { to: '/workflow/tasks', labelKey: 'nav.tasks' }, | ||
| 66 | + ], | ||
| 67 | + }, | ||
| 68 | + { | ||
| 63 | headingKey: 'nav.system', | 69 | headingKey: 'nav.system', |
| 64 | items: [ | 70 | items: [ |
| 65 | { to: '/users', labelKey: 'nav.users' }, | 71 | { to: '/users', labelKey: 'nav.users' }, |
web/src/pages/MetadataAdminPage.tsx
| @@ -16,6 +16,9 @@ import type { | @@ -16,6 +16,9 @@ import type { | ||
| 16 | ListViewDefinition, | 16 | ListViewDefinition, |
| 17 | MetadataEntity, | 17 | MetadataEntity, |
| 18 | MetadataPermission, | 18 | MetadataPermission, |
| 19 | + RuleDefinition, | ||
| 20 | + RuleCondition, | ||
| 21 | + RuleAction, | ||
| 19 | } from '@/types/api' | 22 | } from '@/types/api' |
| 20 | import { PageHeader } from '@/components/PageHeader' | 23 | import { PageHeader } from '@/components/PageHeader' |
| 21 | import { Loading } from '@/components/Loading' | 24 | import { Loading } from '@/components/Loading' |
| @@ -61,6 +64,30 @@ type TabId = | @@ -61,6 +64,30 @@ type TabId = | ||
| 61 | | 'menus' | 64 | | 'menus' |
| 62 | | 'forms' | 65 | | 'forms' |
| 63 | | 'listViews' | 66 | | 'listViews' |
| 67 | + | 'rules' | ||
| 68 | + | ||
| 69 | +const TRIGGER_EVENTS = [ | ||
| 70 | + 'SalesOrderConfirmedEvent', | ||
| 71 | + 'SalesOrderShippedEvent', | ||
| 72 | + 'SalesOrderCancelledEvent', | ||
| 73 | + 'PurchaseOrderConfirmedEvent', | ||
| 74 | + 'PurchaseOrderReceivedEvent', | ||
| 75 | + 'PurchaseOrderCancelledEvent', | ||
| 76 | + 'WorkOrderCreatedEvent', | ||
| 77 | + 'WorkOrderStartedEvent', | ||
| 78 | + 'WorkOrderCompletedEvent', | ||
| 79 | + 'WorkOrderCancelledEvent', | ||
| 80 | + 'InspectionRecordedEvent', | ||
| 81 | +] as const | ||
| 82 | + | ||
| 83 | +const CONDITION_OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'in'] as const | ||
| 84 | + | ||
| 85 | +function slugify(name: string): string { | ||
| 86 | + return name | ||
| 87 | + .toLowerCase() | ||
| 88 | + .replace(/[^a-z0-9]+/g, '-') | ||
| 89 | + .replace(/^-+|-+$/g, '') | ||
| 90 | +} | ||
| 64 | 91 | ||
| 65 | const TYPE_KINDS = [ | 92 | const TYPE_KINDS = [ |
| 66 | 'string', | 93 | 'string', |
| @@ -93,6 +120,20 @@ export function MetadataAdminPage() { | @@ -93,6 +120,20 @@ export function MetadataAdminPage() { | ||
| 93 | const [menus, setMenus] = useState<MenuRow[]>([]) | 120 | const [menus, setMenus] = useState<MenuRow[]>([]) |
| 94 | const [forms, setForms] = useState<FormDefinition[]>([]) | 121 | const [forms, setForms] = useState<FormDefinition[]>([]) |
| 95 | const [listViews, setListViews] = useState<ListViewDefinition[]>([]) | 122 | const [listViews, setListViews] = useState<ListViewDefinition[]>([]) |
| 123 | + const [rules, setRules] = useState<RuleDefinition[]>([]) | ||
| 124 | + | ||
| 125 | + // Rule inline form state | ||
| 126 | + const [ruleFormOpen, setRuleFormOpen] = useState(false) | ||
| 127 | + const [ruleEditingSlug, setRuleEditingSlug] = useState<string | null>(null) | ||
| 128 | + const [ruleName, setRuleName] = useState('') | ||
| 129 | + const [ruleDescription, setRuleDescription] = useState('') | ||
| 130 | + const [ruleTriggerEvent, setRuleTriggerEvent] = useState<string>(TRIGGER_EVENTS[0]) | ||
| 131 | + const [ruleConditionLogic, setRuleConditionLogic] = useState<'AND' | 'OR'>('AND') | ||
| 132 | + const [ruleConditions, setRuleConditions] = useState<RuleCondition[]>([]) | ||
| 133 | + const [ruleActions, setRuleActions] = useState<RuleAction[]>([]) | ||
| 134 | + const [ruleEnabled, setRuleEnabled] = useState(true) | ||
| 135 | + const [ruleVersion, setRuleVersion] = useState(1) | ||
| 136 | + const [ruleSaving, setRuleSaving] = useState(false) | ||
| 96 | 137 | ||
| 97 | // Custom-field inline form state | 138 | // Custom-field inline form state |
| 98 | const [cfFormOpen, setCfFormOpen] = useState(false) | 139 | const [cfFormOpen, setCfFormOpen] = useState(false) |
| @@ -128,6 +169,8 @@ export function MetadataAdminPage() { | @@ -128,6 +169,8 @@ export function MetadataAdminPage() { | ||
| 128 | return metadata.listForms().then(setForms).then(() => {}) | 169 | return metadata.listForms().then(setForms).then(() => {}) |
| 129 | case 'listViews': | 170 | case 'listViews': |
| 130 | return metadata.listListViews().then(setListViews).then(() => {}) | 171 | return metadata.listListViews().then(setListViews).then(() => {}) |
| 172 | + case 'rules': | ||
| 173 | + return metadata.listRules().then(setRules).then(() => {}) | ||
| 131 | } | 174 | } |
| 132 | })() | 175 | })() |
| 133 | promise | 176 | promise |
| @@ -213,6 +256,77 @@ export function MetadataAdminPage() { | @@ -213,6 +256,77 @@ export function MetadataAdminPage() { | ||
| 213 | } | 256 | } |
| 214 | } | 257 | } |
| 215 | 258 | ||
| 259 | + // ── Rule form helpers ────────────────────────────────────────────── | ||
| 260 | + | ||
| 261 | + const resetRuleForm = () => { | ||
| 262 | + setRuleFormOpen(false) | ||
| 263 | + setRuleEditingSlug(null) | ||
| 264 | + setRuleName('') | ||
| 265 | + setRuleDescription('') | ||
| 266 | + setRuleTriggerEvent(TRIGGER_EVENTS[0]) | ||
| 267 | + setRuleConditionLogic('AND') | ||
| 268 | + setRuleConditions([]) | ||
| 269 | + setRuleActions([]) | ||
| 270 | + setRuleEnabled(true) | ||
| 271 | + setRuleVersion(1) | ||
| 272 | + } | ||
| 273 | + | ||
| 274 | + const openRuleCreate = () => { | ||
| 275 | + resetRuleForm() | ||
| 276 | + setRuleFormOpen(true) | ||
| 277 | + } | ||
| 278 | + | ||
| 279 | + const openRuleEdit = (rule: RuleDefinition) => { | ||
| 280 | + setRuleEditingSlug(rule.slug) | ||
| 281 | + setRuleName(rule.name) | ||
| 282 | + setRuleDescription(rule.description ?? '') | ||
| 283 | + setRuleTriggerEvent(rule.triggerEvent) | ||
| 284 | + setRuleConditionLogic(rule.conditionLogic) | ||
| 285 | + setRuleConditions([...rule.conditions]) | ||
| 286 | + setRuleActions(rule.actions.map((a) => ({ type: a.type, config: { ...a.config } }))) | ||
| 287 | + setRuleEnabled(rule.enabled) | ||
| 288 | + setRuleVersion(rule.version) | ||
| 289 | + setRuleFormOpen(true) | ||
| 290 | + } | ||
| 291 | + | ||
| 292 | + const onRuleSubmit = async (e: FormEvent) => { | ||
| 293 | + e.preventDefault() | ||
| 294 | + setRuleSaving(true) | ||
| 295 | + setError(null) | ||
| 296 | + const slug = ruleEditingSlug ?? slugify(ruleName) | ||
| 297 | + const body: Omit<RuleDefinition, 'source'> = { | ||
| 298 | + slug, | ||
| 299 | + name: ruleName, | ||
| 300 | + description: ruleDescription || undefined, | ||
| 301 | + enabled: ruleEnabled, | ||
| 302 | + triggerEvent: ruleTriggerEvent, | ||
| 303 | + conditions: ruleConditions, | ||
| 304 | + conditionLogic: ruleConditionLogic, | ||
| 305 | + actions: ruleActions, | ||
| 306 | + version: ruleVersion, | ||
| 307 | + } | ||
| 308 | + try { | ||
| 309 | + await metadata.saveRule(slug, body) | ||
| 310 | + resetRuleForm() | ||
| 311 | + loadTab('rules') | ||
| 312 | + } catch (err: unknown) { | ||
| 313 | + setError(err instanceof Error ? err : new Error(String(err))) | ||
| 314 | + } finally { | ||
| 315 | + setRuleSaving(false) | ||
| 316 | + } | ||
| 317 | + } | ||
| 318 | + | ||
| 319 | + const onRuleDelete = async (slug: string) => { | ||
| 320 | + if (!window.confirm(t('confirm.delete'))) return | ||
| 321 | + setError(null) | ||
| 322 | + try { | ||
| 323 | + await metadata.deleteRule(slug) | ||
| 324 | + loadTab('rules') | ||
| 325 | + } catch (err: unknown) { | ||
| 326 | + setError(err instanceof Error ? err : new Error(String(err))) | ||
| 327 | + } | ||
| 328 | + } | ||
| 329 | + | ||
| 216 | // ── Form / ListView delete helpers ──────────────────────────────── | 330 | // ── Form / ListView delete helpers ──────────────────────────────── |
| 217 | 331 | ||
| 218 | const onFormDelete = async (slug: string) => { | 332 | const onFormDelete = async (slug: string) => { |
| @@ -360,6 +474,58 @@ export function MetadataAdminPage() { | @@ -360,6 +474,58 @@ export function MetadataAdminPage() { | ||
| 360 | }, | 474 | }, |
| 361 | ] | 475 | ] |
| 362 | 476 | ||
| 477 | + const ruleCols: Column<RuleDefinition>[] = [ | ||
| 478 | + { header: t('label.name'), key: 'name' }, | ||
| 479 | + { header: t('label.triggerEvent'), key: 'triggerEvent' }, | ||
| 480 | + { | ||
| 481 | + header: t('label.enabled'), | ||
| 482 | + key: 'enabled', | ||
| 483 | + render: (r) => ( | ||
| 484 | + <span | ||
| 485 | + className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${ | ||
| 486 | + r.enabled | ||
| 487 | + ? 'bg-emerald-100 text-emerald-700' | ||
| 488 | + : 'bg-rose-100 text-rose-700' | ||
| 489 | + }`} | ||
| 490 | + > | ||
| 491 | + {r.enabled ? 'Yes' : 'No'} | ||
| 492 | + </span> | ||
| 493 | + ), | ||
| 494 | + }, | ||
| 495 | + { | ||
| 496 | + header: t('label.conditions'), | ||
| 497 | + key: 'conditions', | ||
| 498 | + render: (r) => <span className="text-slate-500">{r.conditions.length}</span>, | ||
| 499 | + }, | ||
| 500 | + { | ||
| 501 | + header: t('label.actions'), | ||
| 502 | + key: 'actions', | ||
| 503 | + render: (r) => <span className="text-slate-500">{r.actions.length}</span>, | ||
| 504 | + }, | ||
| 505 | + { header: t('label.source'), key: 'source', render: (r) => <SourceBadge source={r.source ?? 'core'} /> }, | ||
| 506 | + { | ||
| 507 | + header: '', | ||
| 508 | + key: '_actions', | ||
| 509 | + render: (r) => | ||
| 510 | + r.source === 'user' ? ( | ||
| 511 | + <span className="flex gap-2"> | ||
| 512 | + <button | ||
| 513 | + className="text-xs text-blue-600 hover:underline" | ||
| 514 | + onClick={() => openRuleEdit(r)} | ||
| 515 | + > | ||
| 516 | + Edit | ||
| 517 | + </button> | ||
| 518 | + <button | ||
| 519 | + className="text-xs text-rose-600 hover:underline" | ||
| 520 | + onClick={() => onRuleDelete(r.slug)} | ||
| 521 | + > | ||
| 522 | + {t('action.delete')} | ||
| 523 | + </button> | ||
| 524 | + </span> | ||
| 525 | + ) : null, | ||
| 526 | + }, | ||
| 527 | + ] | ||
| 528 | + | ||
| 363 | // ── Tab bar ─────────────────────────────────────────────────────── | 529 | // ── Tab bar ─────────────────────────────────────────────────────── |
| 364 | 530 | ||
| 365 | const tabs: { id: TabId; label: string }[] = [ | 531 | const tabs: { id: TabId; label: string }[] = [ |
| @@ -369,6 +535,7 @@ export function MetadataAdminPage() { | @@ -369,6 +535,7 @@ export function MetadataAdminPage() { | ||
| 369 | { id: 'menus', label: t('tab.menus') }, | 535 | { id: 'menus', label: t('tab.menus') }, |
| 370 | { id: 'forms', label: t('tab.forms') }, | 536 | { id: 'forms', label: t('tab.forms') }, |
| 371 | { id: 'listViews', label: t('tab.listViews') }, | 537 | { id: 'listViews', label: t('tab.listViews') }, |
| 538 | + { id: 'rules', label: t('tab.rules') }, | ||
| 372 | ] | 539 | ] |
| 373 | 540 | ||
| 374 | // ── Tab content renderer ────────────────────────────────────────── | 541 | // ── Tab content renderer ────────────────────────────────────────── |
| @@ -571,6 +738,253 @@ export function MetadataAdminPage() { | @@ -571,6 +738,253 @@ export function MetadataAdminPage() { | ||
| 571 | /> | 738 | /> |
| 572 | </div> | 739 | </div> |
| 573 | ) | 740 | ) |
| 741 | + | ||
| 742 | + case 'rules': | ||
| 743 | + return ( | ||
| 744 | + <div> | ||
| 745 | + <div className="mb-3"> | ||
| 746 | + <button className="btn-primary" onClick={openRuleCreate}> | ||
| 747 | + {t('action.newRule')} | ||
| 748 | + </button> | ||
| 749 | + </div> | ||
| 750 | + {ruleFormOpen && ( | ||
| 751 | + <form | ||
| 752 | + onSubmit={onRuleSubmit} | ||
| 753 | + className="card p-4 space-y-3 mt-3 mb-4 max-w-3xl" | ||
| 754 | + > | ||
| 755 | + <div className="grid grid-cols-2 gap-3"> | ||
| 756 | + <div> | ||
| 757 | + <label className="block text-sm font-medium text-slate-700"> | ||
| 758 | + {t('label.name')} | ||
| 759 | + </label> | ||
| 760 | + <input | ||
| 761 | + type="text" | ||
| 762 | + required | ||
| 763 | + value={ruleName} | ||
| 764 | + onChange={(e) => setRuleName(e.target.value)} | ||
| 765 | + placeholder="My Rule" | ||
| 766 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" | ||
| 767 | + /> | ||
| 768 | + </div> | ||
| 769 | + <div> | ||
| 770 | + <label className="block text-sm font-medium text-slate-700"> | ||
| 771 | + {t('label.triggerEvent')} | ||
| 772 | + </label> | ||
| 773 | + <select | ||
| 774 | + value={ruleTriggerEvent} | ||
| 775 | + onChange={(e) => setRuleTriggerEvent(e.target.value)} | ||
| 776 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" | ||
| 777 | + > | ||
| 778 | + {TRIGGER_EVENTS.map((ev) => ( | ||
| 779 | + <option key={ev} value={ev}> | ||
| 780 | + {ev} | ||
| 781 | + </option> | ||
| 782 | + ))} | ||
| 783 | + </select> | ||
| 784 | + </div> | ||
| 785 | + </div> | ||
| 786 | + <div> | ||
| 787 | + <label className="block text-sm font-medium text-slate-700"> | ||
| 788 | + {t('label.description')} | ||
| 789 | + </label> | ||
| 790 | + <input | ||
| 791 | + type="text" | ||
| 792 | + value={ruleDescription} | ||
| 793 | + onChange={(e) => setRuleDescription(e.target.value)} | ||
| 794 | + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" | ||
| 795 | + /> | ||
| 796 | + </div> | ||
| 797 | + | ||
| 798 | + {/* Condition logic */} | ||
| 799 | + <div className="flex items-center gap-3"> | ||
| 800 | + <label className="text-sm font-medium text-slate-700"> | ||
| 801 | + {t('label.conditionLogic')} | ||
| 802 | + </label> | ||
| 803 | + <select | ||
| 804 | + value={ruleConditionLogic} | ||
| 805 | + onChange={(e) => | ||
| 806 | + setRuleConditionLogic(e.target.value as 'AND' | 'OR') | ||
| 807 | + } | ||
| 808 | + className="rounded-md border border-slate-300 px-3 py-1.5 text-sm" | ||
| 809 | + > | ||
| 810 | + <option value="AND">AND</option> | ||
| 811 | + <option value="OR">OR</option> | ||
| 812 | + </select> | ||
| 813 | + </div> | ||
| 814 | + | ||
| 815 | + {/* Conditions */} | ||
| 816 | + <div> | ||
| 817 | + <label className="block text-sm font-medium text-slate-700 mb-1"> | ||
| 818 | + {t('label.conditions')} | ||
| 819 | + </label> | ||
| 820 | + {ruleConditions.map((cond, i) => ( | ||
| 821 | + <div key={i} className="flex gap-2 mb-2 items-center"> | ||
| 822 | + <input | ||
| 823 | + type="text" | ||
| 824 | + placeholder="field" | ||
| 825 | + required | ||
| 826 | + value={cond.field} | ||
| 827 | + onChange={(e) => { | ||
| 828 | + const next = [...ruleConditions] | ||
| 829 | + next[i] = { ...next[i], field: e.target.value } | ||
| 830 | + setRuleConditions(next) | ||
| 831 | + }} | ||
| 832 | + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" | ||
| 833 | + /> | ||
| 834 | + <select | ||
| 835 | + value={cond.operator} | ||
| 836 | + onChange={(e) => { | ||
| 837 | + const next = [...ruleConditions] | ||
| 838 | + next[i] = { ...next[i], operator: e.target.value } | ||
| 839 | + setRuleConditions(next) | ||
| 840 | + }} | ||
| 841 | + className="rounded-md border border-slate-300 px-2 py-1.5 text-sm" | ||
| 842 | + > | ||
| 843 | + {CONDITION_OPERATORS.map((op) => ( | ||
| 844 | + <option key={op} value={op}> | ||
| 845 | + {op} | ||
| 846 | + </option> | ||
| 847 | + ))} | ||
| 848 | + </select> | ||
| 849 | + <input | ||
| 850 | + type="text" | ||
| 851 | + placeholder="value" | ||
| 852 | + required | ||
| 853 | + value={cond.value} | ||
| 854 | + onChange={(e) => { | ||
| 855 | + const next = [...ruleConditions] | ||
| 856 | + next[i] = { ...next[i], value: e.target.value } | ||
| 857 | + setRuleConditions(next) | ||
| 858 | + }} | ||
| 859 | + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" | ||
| 860 | + /> | ||
| 861 | + <button | ||
| 862 | + type="button" | ||
| 863 | + className="text-xs text-rose-600 hover:underline" | ||
| 864 | + onClick={() => | ||
| 865 | + setRuleConditions(ruleConditions.filter((_, j) => j !== i)) | ||
| 866 | + } | ||
| 867 | + > | ||
| 868 | + {t('action.delete')} | ||
| 869 | + </button> | ||
| 870 | + </div> | ||
| 871 | + ))} | ||
| 872 | + <button | ||
| 873 | + type="button" | ||
| 874 | + className="text-xs text-blue-600 hover:underline" | ||
| 875 | + onClick={() => | ||
| 876 | + setRuleConditions([ | ||
| 877 | + ...ruleConditions, | ||
| 878 | + { field: '', operator: 'eq', value: '' }, | ||
| 879 | + ]) | ||
| 880 | + } | ||
| 881 | + > | ||
| 882 | + + Add Condition | ||
| 883 | + </button> | ||
| 884 | + </div> | ||
| 885 | + | ||
| 886 | + {/* Actions */} | ||
| 887 | + <div> | ||
| 888 | + <label className="block text-sm font-medium text-slate-700 mb-1"> | ||
| 889 | + {t('label.actions')} | ||
| 890 | + </label> | ||
| 891 | + {ruleActions.map((act, i) => ( | ||
| 892 | + <div key={i} className="flex gap-2 mb-2 items-center"> | ||
| 893 | + <select | ||
| 894 | + value={act.type} | ||
| 895 | + onChange={(e) => { | ||
| 896 | + const next = [...ruleActions] | ||
| 897 | + next[i] = { ...next[i], type: e.target.value } | ||
| 898 | + setRuleActions(next) | ||
| 899 | + }} | ||
| 900 | + className="rounded-md border border-slate-300 px-2 py-1.5 text-sm" | ||
| 901 | + > | ||
| 902 | + <option value="log">log</option> | ||
| 903 | + </select> | ||
| 904 | + <input | ||
| 905 | + type="text" | ||
| 906 | + placeholder="key" | ||
| 907 | + value={Object.keys(act.config)[0] ?? ''} | ||
| 908 | + onChange={(e) => { | ||
| 909 | + const next = [...ruleActions] | ||
| 910 | + const oldVal = Object.values(act.config)[0] ?? '' | ||
| 911 | + next[i] = { ...next[i], config: { [e.target.value]: oldVal } } | ||
| 912 | + setRuleActions(next) | ||
| 913 | + }} | ||
| 914 | + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm font-mono" | ||
| 915 | + /> | ||
| 916 | + <input | ||
| 917 | + type="text" | ||
| 918 | + placeholder="value" | ||
| 919 | + value={Object.values(act.config)[0] ?? ''} | ||
| 920 | + onChange={(e) => { | ||
| 921 | + const next = [...ruleActions] | ||
| 922 | + const oldKey = Object.keys(act.config)[0] ?? 'message' | ||
| 923 | + next[i] = { ...next[i], config: { [oldKey]: e.target.value } } | ||
| 924 | + setRuleActions(next) | ||
| 925 | + }} | ||
| 926 | + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" | ||
| 927 | + /> | ||
| 928 | + <button | ||
| 929 | + type="button" | ||
| 930 | + className="text-xs text-rose-600 hover:underline" | ||
| 931 | + onClick={() => | ||
| 932 | + setRuleActions(ruleActions.filter((_, j) => j !== i)) | ||
| 933 | + } | ||
| 934 | + > | ||
| 935 | + {t('action.delete')} | ||
| 936 | + </button> | ||
| 937 | + </div> | ||
| 938 | + ))} | ||
| 939 | + <button | ||
| 940 | + type="button" | ||
| 941 | + className="text-xs text-blue-600 hover:underline" | ||
| 942 | + onClick={() => | ||
| 943 | + setRuleActions([ | ||
| 944 | + ...ruleActions, | ||
| 945 | + { type: 'log', config: { message: '' } }, | ||
| 946 | + ]) | ||
| 947 | + } | ||
| 948 | + > | ||
| 949 | + + Add Action | ||
| 950 | + </button> | ||
| 951 | + </div> | ||
| 952 | + | ||
| 953 | + {/* Enabled */} | ||
| 954 | + <label className="flex items-center gap-1.5 text-sm text-slate-700"> | ||
| 955 | + <input | ||
| 956 | + type="checkbox" | ||
| 957 | + checked={ruleEnabled} | ||
| 958 | + onChange={(e) => setRuleEnabled(e.target.checked)} | ||
| 959 | + /> | ||
| 960 | + {t('label.enabled')} | ||
| 961 | + </label> | ||
| 962 | + | ||
| 963 | + <div className="flex gap-2"> | ||
| 964 | + <button | ||
| 965 | + type="submit" | ||
| 966 | + className="btn-primary" | ||
| 967 | + disabled={ruleSaving} | ||
| 968 | + > | ||
| 969 | + {ruleSaving ? '...' : t('action.save')} | ||
| 970 | + </button> | ||
| 971 | + <button | ||
| 972 | + type="button" | ||
| 973 | + className="btn-secondary" | ||
| 974 | + onClick={resetRuleForm} | ||
| 975 | + > | ||
| 976 | + {t('action.cancel')} | ||
| 977 | + </button> | ||
| 978 | + </div> | ||
| 979 | + </form> | ||
| 980 | + )} | ||
| 981 | + <DataTable | ||
| 982 | + rows={rules} | ||
| 983 | + columns={ruleCols} | ||
| 984 | + rowKey={(r) => r.slug} | ||
| 985 | + /> | ||
| 986 | + </div> | ||
| 987 | + ) | ||
| 574 | } | 988 | } |
| 575 | } | 989 | } |
| 576 | 990 | ||
| @@ -595,6 +1009,7 @@ export function MetadataAdminPage() { | @@ -595,6 +1009,7 @@ export function MetadataAdminPage() { | ||
| 595 | }`} | 1009 | }`} |
| 596 | onClick={() => { | 1010 | onClick={() => { |
| 597 | resetCfForm() | 1011 | resetCfForm() |
| 1012 | + resetRuleForm() | ||
| 598 | setActiveTab(tab.id) | 1013 | setActiveTab(tab.id) |
| 599 | }} | 1014 | }} |
| 600 | > | 1015 | > |
web/src/pages/TaskDetailPage.tsx
0 → 100644
| 1 | +// Workflow task detail page. | ||
| 2 | +// | ||
| 3 | +// Fetches a single user task by ID. If the task carries a formKey | ||
| 4 | +// starting with "vibe:", strips the prefix and renders the matching | ||
| 5 | +// MetadataFormRenderer so the user sees a rich, metadata-driven | ||
| 6 | +// form. Otherwise falls back to a read-only JSON dump of the task | ||
| 7 | +// variables with a simple "Complete" button. | ||
| 8 | + | ||
| 9 | +import { useEffect, useState } from 'react' | ||
| 10 | +import { useNavigate, useParams } from 'react-router-dom' | ||
| 11 | +import { workflow } from '@/api/client' | ||
| 12 | +import type { UserTaskDetail } from '@/types/api' | ||
| 13 | +import { PageHeader } from '@/components/PageHeader' | ||
| 14 | +import { Loading } from '@/components/Loading' | ||
| 15 | +import { ErrorBox } from '@/components/ErrorBox' | ||
| 16 | +import { MetadataFormRenderer } from '@/components/MetadataFormRenderer' | ||
| 17 | +import { useT } from '@/i18n/LocaleContext' | ||
| 18 | + | ||
| 19 | +export function TaskDetailPage() { | ||
| 20 | + const t = useT() | ||
| 21 | + const navigate = useNavigate() | ||
| 22 | + const { taskId = '' } = useParams<{ taskId: string }>() | ||
| 23 | + const [task, setTask] = useState<UserTaskDetail | null>(null) | ||
| 24 | + const [loading, setLoading] = useState(true) | ||
| 25 | + const [acting, setActing] = useState(false) | ||
| 26 | + const [error, setError] = useState<Error | null>(null) | ||
| 27 | + | ||
| 28 | + useEffect(() => { | ||
| 29 | + setLoading(true) | ||
| 30 | + setError(null) | ||
| 31 | + workflow | ||
| 32 | + .getTask(taskId) | ||
| 33 | + .then(setTask) | ||
| 34 | + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) | ||
| 35 | + .finally(() => setLoading(false)) | ||
| 36 | + }, [taskId]) | ||
| 37 | + | ||
| 38 | + const handleComplete = async (formData?: Record<string, unknown>) => { | ||
| 39 | + setActing(true) | ||
| 40 | + setError(null) | ||
| 41 | + try { | ||
| 42 | + await workflow.completeTask(taskId, formData ?? {}) | ||
| 43 | + navigate('/workflow/tasks') | ||
| 44 | + } catch (e: unknown) { | ||
| 45 | + setError(e instanceof Error ? e : new Error(String(e))) | ||
| 46 | + } finally { | ||
| 47 | + setActing(false) | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + if (loading) return <Loading /> | ||
| 52 | + if (error && !task) return <ErrorBox error={error} /> | ||
| 53 | + if (!task) return <ErrorBox error={new Error('Task not found')} /> | ||
| 54 | + | ||
| 55 | + // Determine whether we have a vibe: form key | ||
| 56 | + const vibeFormSlug = | ||
| 57 | + task.formKey && task.formKey.startsWith('vibe:') | ||
| 58 | + ? task.formKey.slice('vibe:'.length) | ||
| 59 | + : null | ||
| 60 | + | ||
| 61 | + return ( | ||
| 62 | + <div> | ||
| 63 | + <PageHeader | ||
| 64 | + title={t('page.taskDetail.title')} | ||
| 65 | + subtitle={task.taskName} | ||
| 66 | + actions={ | ||
| 67 | + <button | ||
| 68 | + className="btn-secondary" | ||
| 69 | + onClick={() => navigate('/workflow/tasks')} | ||
| 70 | + > | ||
| 71 | + {t('action.back')} | ||
| 72 | + </button> | ||
| 73 | + } | ||
| 74 | + /> | ||
| 75 | + | ||
| 76 | + {error && <ErrorBox error={error} />} | ||
| 77 | + | ||
| 78 | + <div className="card p-4 space-y-4"> | ||
| 79 | + <dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm max-w-lg"> | ||
| 80 | + <dt className="font-medium text-slate-500">Task ID</dt> | ||
| 81 | + <dd className="font-mono">{task.taskId}</dd> | ||
| 82 | + <dt className="font-medium text-slate-500">Process</dt> | ||
| 83 | + <dd>{task.processDefinitionKey}</dd> | ||
| 84 | + <dt className="font-medium text-slate-500">Created</dt> | ||
| 85 | + <dd>{task.createTime}</dd> | ||
| 86 | + <dt className="font-medium text-slate-500">Assignee</dt> | ||
| 87 | + <dd>{task.assignee ?? '\u2014'}</dd> | ||
| 88 | + <dt className="font-medium text-slate-500">Form Key</dt> | ||
| 89 | + <dd className="font-mono">{task.formKey ?? '\u2014'}</dd> | ||
| 90 | + </dl> | ||
| 91 | + | ||
| 92 | + <hr className="border-slate-200" /> | ||
| 93 | + | ||
| 94 | + {vibeFormSlug ? ( | ||
| 95 | + <MetadataFormRenderer | ||
| 96 | + slug={vibeFormSlug} | ||
| 97 | + initialValues={task.variables} | ||
| 98 | + onSubmit={handleComplete} | ||
| 99 | + /> | ||
| 100 | + ) : ( | ||
| 101 | + <div className="space-y-4"> | ||
| 102 | + <div> | ||
| 103 | + <h3 className="text-sm font-medium text-slate-700 mb-2">Variables</h3> | ||
| 104 | + <pre className="rounded-md bg-slate-50 border border-slate-200 p-3 text-xs overflow-x-auto max-h-64"> | ||
| 105 | + {JSON.stringify(task.variables, null, 2)} | ||
| 106 | + </pre> | ||
| 107 | + </div> | ||
| 108 | + <button | ||
| 109 | + className="btn-primary" | ||
| 110 | + disabled={acting} | ||
| 111 | + onClick={() => handleComplete()} | ||
| 112 | + > | ||
| 113 | + {acting ? '...' : t('action.complete')} | ||
| 114 | + </button> | ||
| 115 | + </div> | ||
| 116 | + )} | ||
| 117 | + </div> | ||
| 118 | + </div> | ||
| 119 | + ) | ||
| 120 | +} |
web/src/pages/UserTasksPage.tsx
0 → 100644
| 1 | +// Workflow user-tasks list page. | ||
| 2 | +// | ||
| 3 | +// Fetches pending human tasks from the embedded workflow engine | ||
| 4 | +// and renders them in a DataTable. Clicking a row navigates to | ||
| 5 | +// the task detail page where the user can complete the task. | ||
| 6 | + | ||
| 7 | +import { useEffect, useState } from 'react' | ||
| 8 | +import { useNavigate } from 'react-router-dom' | ||
| 9 | +import { workflow } from '@/api/client' | ||
| 10 | +import type { UserTaskSummary } from '@/types/api' | ||
| 11 | +import { PageHeader } from '@/components/PageHeader' | ||
| 12 | +import { Loading } from '@/components/Loading' | ||
| 13 | +import { ErrorBox } from '@/components/ErrorBox' | ||
| 14 | +import { DataTable, type Column } from '@/components/DataTable' | ||
| 15 | +import { useT } from '@/i18n/LocaleContext' | ||
| 16 | + | ||
| 17 | +export function UserTasksPage() { | ||
| 18 | + const t = useT() | ||
| 19 | + const navigate = useNavigate() | ||
| 20 | + const [rows, setRows] = useState<UserTaskSummary[]>([]) | ||
| 21 | + const [loading, setLoading] = useState(true) | ||
| 22 | + const [error, setError] = useState<Error | null>(null) | ||
| 23 | + | ||
| 24 | + useEffect(() => { | ||
| 25 | + workflow | ||
| 26 | + .listTasks() | ||
| 27 | + .then(setRows) | ||
| 28 | + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) | ||
| 29 | + .finally(() => setLoading(false)) | ||
| 30 | + }, []) | ||
| 31 | + | ||
| 32 | + const columns: Column<UserTaskSummary>[] = [ | ||
| 33 | + { | ||
| 34 | + header: 'Task Name', | ||
| 35 | + key: 'taskName', | ||
| 36 | + render: (r) => ( | ||
| 37 | + <button | ||
| 38 | + className="text-brand-600 hover:underline" | ||
| 39 | + onClick={() => navigate(`/workflow/tasks/${r.taskId}`)} | ||
| 40 | + > | ||
| 41 | + {r.taskName} | ||
| 42 | + </button> | ||
| 43 | + ), | ||
| 44 | + }, | ||
| 45 | + { header: 'Process', key: 'processDefinitionKey' }, | ||
| 46 | + { header: 'Form Key', key: 'formKey', render: (r) => r.formKey ?? '\u2014' }, | ||
| 47 | + { header: 'Created', key: 'createTime' }, | ||
| 48 | + { header: 'Assignee', key: 'assignee', render: (r) => r.assignee ?? '\u2014' }, | ||
| 49 | + ] | ||
| 50 | + | ||
| 51 | + return ( | ||
| 52 | + <div> | ||
| 53 | + <PageHeader | ||
| 54 | + title={t('page.tasks.title')} | ||
| 55 | + subtitle={t('page.tasks.subtitle')} | ||
| 56 | + /> | ||
| 57 | + {loading && <Loading />} | ||
| 58 | + {error && <ErrorBox error={error} />} | ||
| 59 | + {!loading && !error && ( | ||
| 60 | + <DataTable | ||
| 61 | + rows={rows} | ||
| 62 | + columns={columns} | ||
| 63 | + rowKey={(r) => r.taskId} | ||
| 64 | + empty={<div className="p-6 text-sm text-slate-400">No pending tasks</div>} | ||
| 65 | + /> | ||
| 66 | + )} | ||
| 67 | + </div> | ||
| 68 | + ) | ||
| 69 | +} |
web/src/types/api.ts
| @@ -285,6 +285,48 @@ export interface JournalEntry { | @@ -285,6 +285,48 @@ export interface JournalEntry { | ||
| 285 | lines: JournalEntryLine[] | 285 | lines: JournalEntryLine[] |
| 286 | } | 286 | } |
| 287 | 287 | ||
| 288 | +// ─── Workflow (user tasks) ────────────────────────────────────────── | ||
| 289 | + | ||
| 290 | +export interface UserTaskSummary { | ||
| 291 | + taskId: string | ||
| 292 | + taskName: string | ||
| 293 | + formKey: string | null | ||
| 294 | + processDefinitionKey: string | ||
| 295 | + processInstanceId: string | ||
| 296 | + createTime: string | ||
| 297 | + assignee: string | null | ||
| 298 | +} | ||
| 299 | + | ||
| 300 | +export interface UserTaskDetail extends UserTaskSummary { | ||
| 301 | + variables: Record<string, unknown> | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +// ─── Rules ───────────────────────────────────────────────────────── | ||
| 305 | + | ||
| 306 | +export interface RuleCondition { | ||
| 307 | + field: string | ||
| 308 | + operator: string | ||
| 309 | + value: string | ||
| 310 | +} | ||
| 311 | + | ||
| 312 | +export interface RuleAction { | ||
| 313 | + type: string | ||
| 314 | + config: Record<string, string> | ||
| 315 | +} | ||
| 316 | + | ||
| 317 | +export interface RuleDefinition { | ||
| 318 | + slug: string | ||
| 319 | + name: string | ||
| 320 | + description?: string | ||
| 321 | + enabled: boolean | ||
| 322 | + triggerEvent: string | ||
| 323 | + conditions: RuleCondition[] | ||
| 324 | + conditionLogic: 'AND' | 'OR' | ||
| 325 | + actions: RuleAction[] | ||
| 326 | + version: number | ||
| 327 | + source?: string | ||
| 328 | +} | ||
| 329 | + | ||
| 288 | // ─── Metadata Definitions ─────────────────────────────────────────── | 330 | // ─── Metadata Definitions ─────────────────────────────────────────── |
| 289 | 331 | ||
| 290 | export type FormPurpose = 'create' | 'edit' | 'user-task' | 'view' | 332 | export type FormPurpose = 'create' | 'edit' | 'user-task' | 'view' |