Commit 2799cc9be4ae5d5527571ed6a86aeb1aba70831d

Authored by zichun
1 parent 618a9781

feat(web): user-task pages + rules tab in metadata admin

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&lt;MessageKey, string&gt; = { @@ -243,6 +259,22 @@ export const zhCN: Record&lt;MessageKey, string&gt; = {
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'