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 | 85 | "/accounts", "/accounts/", "/accounts/**", |
| 86 | 86 | "/journal-entries", "/journal-entries/", "/journal-entries/**", |
| 87 | 87 | |
| 88 | + // Workflow | |
| 89 | + "/workflow", "/workflow/", "/workflow/**", | |
| 90 | + | |
| 88 | 91 | // System / identity |
| 89 | 92 | "/users", "/users/", "/users/**", |
| 90 | 93 | "/roles", "/roles/", "/roles/**", |
| 94 | + | |
| 95 | + // Admin | |
| 96 | + "/admin", "/admin/", "/admin/**", | |
| 91 | 97 | ], |
| 92 | 98 | ) |
| 93 | 99 | fun spa(): String = "forward:/index.html" | ... | ... |
web/src/App.tsx
| ... | ... | @@ -44,6 +44,8 @@ import { JournalEntriesPage } from '@/pages/JournalEntriesPage' |
| 44 | 44 | import { FormDesignerPage } from '@/pages/FormDesignerPage' |
| 45 | 45 | import { ListViewDesignerPage } from '@/pages/ListViewDesignerPage' |
| 46 | 46 | import { MetadataAdminPage } from '@/pages/MetadataAdminPage' |
| 47 | +import { UserTasksPage } from '@/pages/UserTasksPage' | |
| 48 | +import { TaskDetailPage } from '@/pages/TaskDetailPage' | |
| 47 | 49 | |
| 48 | 50 | export default function App() { |
| 49 | 51 | return ( |
| ... | ... | @@ -84,6 +86,8 @@ export default function App() { |
| 84 | 86 | <Route path="work-orders/new" element={<CreateWorkOrderPage />} /> |
| 85 | 87 | <Route path="work-orders/:id" element={<WorkOrderDetailPage />} /> |
| 86 | 88 | <Route path="shop-floor" element={<ShopFloorPage />} /> |
| 89 | + <Route path="workflow/tasks" element={<UserTasksPage />} /> | |
| 90 | + <Route path="workflow/tasks/:taskId" element={<TaskDetailPage />} /> | |
| 87 | 91 | <Route path="accounts" element={<AccountsPage />} /> |
| 88 | 92 | <Route path="journal-entries" element={<JournalEntriesPage />} /> |
| 89 | 93 | <Route path="admin/metadata" element={<MetadataAdminPage />} /> | ... | ... |
web/src/api/client.ts
| ... | ... | @@ -38,6 +38,7 @@ import type { |
| 38 | 38 | Partner, |
| 39 | 39 | PurchaseOrder, |
| 40 | 40 | Role, |
| 41 | + RuleDefinition, | |
| 41 | 42 | SalesOrder, |
| 42 | 43 | ShopFloorEntry, |
| 43 | 44 | StockBalance, |
| ... | ... | @@ -45,6 +46,8 @@ import type { |
| 45 | 46 | TokenPair, |
| 46 | 47 | Uom, |
| 47 | 48 | User, |
| 49 | + UserTaskDetail, | |
| 50 | + UserTaskSummary, | |
| 48 | 51 | WorkOrder, |
| 49 | 52 | } from '@/types/api' |
| 50 | 53 | |
| ... | ... | @@ -286,6 +289,15 @@ export const production = { |
| 286 | 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 | 301 | // ─── Finance ───────────────────────────────────────────────────────── |
| 290 | 302 | |
| 291 | 303 | // ─── Metadata admin ───────────────────────────────────────────────── |
| ... | ... | @@ -314,6 +326,12 @@ export const metadata = { |
| 314 | 326 | apiFetch<CustomFieldDef>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'PUT', body: JSON.stringify(body) }), |
| 315 | 327 | deleteCustomField: (key: string) => |
| 316 | 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 | 337 | // ─── Finance ───────────────────────────────────────────────────────── | ... | ... |
web/src/i18n/messages.ts
| ... | ... | @@ -126,6 +126,22 @@ export const en = { |
| 126 | 126 | 'label.targetEntity': 'Target Entity', |
| 127 | 127 | 'label.fieldType': 'Field Type', |
| 128 | 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 | 145 | } as const |
| 130 | 146 | |
| 131 | 147 | export const zhCN: Record<MessageKey, string> = { |
| ... | ... | @@ -243,6 +259,22 @@ export const zhCN: Record<MessageKey, string> = { |
| 243 | 259 | 'label.targetEntity': '目标实体', |
| 244 | 260 | 'label.fieldType': '字段类型', |
| 245 | 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 | 280 | export const locales = { | ... | ... |
web/src/layout/AppLayout.tsx
| ... | ... | @@ -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 | 69 | headingKey: 'nav.system', |
| 64 | 70 | items: [ |
| 65 | 71 | { to: '/users', labelKey: 'nav.users' }, | ... | ... |
web/src/pages/MetadataAdminPage.tsx
| ... | ... | @@ -16,6 +16,9 @@ import type { |
| 16 | 16 | ListViewDefinition, |
| 17 | 17 | MetadataEntity, |
| 18 | 18 | MetadataPermission, |
| 19 | + RuleDefinition, | |
| 20 | + RuleCondition, | |
| 21 | + RuleAction, | |
| 19 | 22 | } from '@/types/api' |
| 20 | 23 | import { PageHeader } from '@/components/PageHeader' |
| 21 | 24 | import { Loading } from '@/components/Loading' |
| ... | ... | @@ -61,6 +64,30 @@ type TabId = |
| 61 | 64 | | 'menus' |
| 62 | 65 | | 'forms' |
| 63 | 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 | 92 | const TYPE_KINDS = [ |
| 66 | 93 | 'string', |
| ... | ... | @@ -93,6 +120,20 @@ export function MetadataAdminPage() { |
| 93 | 120 | const [menus, setMenus] = useState<MenuRow[]>([]) |
| 94 | 121 | const [forms, setForms] = useState<FormDefinition[]>([]) |
| 95 | 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 | 138 | // Custom-field inline form state |
| 98 | 139 | const [cfFormOpen, setCfFormOpen] = useState(false) |
| ... | ... | @@ -128,6 +169,8 @@ export function MetadataAdminPage() { |
| 128 | 169 | return metadata.listForms().then(setForms).then(() => {}) |
| 129 | 170 | case 'listViews': |
| 130 | 171 | return metadata.listListViews().then(setListViews).then(() => {}) |
| 172 | + case 'rules': | |
| 173 | + return metadata.listRules().then(setRules).then(() => {}) | |
| 131 | 174 | } |
| 132 | 175 | })() |
| 133 | 176 | promise |
| ... | ... | @@ -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 | 330 | // ── Form / ListView delete helpers ──────────────────────────────── |
| 217 | 331 | |
| 218 | 332 | const onFormDelete = async (slug: string) => { |
| ... | ... | @@ -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 | 529 | // ── Tab bar ─────────────────────────────────────────────────────── |
| 364 | 530 | |
| 365 | 531 | const tabs: { id: TabId; label: string }[] = [ |
| ... | ... | @@ -369,6 +535,7 @@ export function MetadataAdminPage() { |
| 369 | 535 | { id: 'menus', label: t('tab.menus') }, |
| 370 | 536 | { id: 'forms', label: t('tab.forms') }, |
| 371 | 537 | { id: 'listViews', label: t('tab.listViews') }, |
| 538 | + { id: 'rules', label: t('tab.rules') }, | |
| 372 | 539 | ] |
| 373 | 540 | |
| 374 | 541 | // ── Tab content renderer ────────────────────────────────────────── |
| ... | ... | @@ -571,6 +738,253 @@ export function MetadataAdminPage() { |
| 571 | 738 | /> |
| 572 | 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 | 1009 | }`} |
| 596 | 1010 | onClick={() => { |
| 597 | 1011 | resetCfForm() |
| 1012 | + resetRuleForm() | |
| 598 | 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 | 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 | 330 | // ─── Metadata Definitions ─────────────────────────────────────────── |
| 289 | 331 | |
| 290 | 332 | export type FormPurpose = 'create' | 'edit' | 'user-task' | 'view' | ... | ... |