diff --git a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt index f0a48ad..d6d64bc 100644 --- a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt +++ b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt @@ -85,9 +85,15 @@ class SpaController { "/accounts", "/accounts/", "/accounts/**", "/journal-entries", "/journal-entries/", "/journal-entries/**", + // Workflow + "/workflow", "/workflow/", "/workflow/**", + // System / identity "/users", "/users/", "/users/**", "/roles", "/roles/", "/roles/**", + + // Admin + "/admin", "/admin/", "/admin/**", ], ) fun spa(): String = "forward:/index.html" diff --git a/web/src/App.tsx b/web/src/App.tsx index ad74a93..dd0baeb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -44,6 +44,8 @@ import { JournalEntriesPage } from '@/pages/JournalEntriesPage' import { FormDesignerPage } from '@/pages/FormDesignerPage' import { ListViewDesignerPage } from '@/pages/ListViewDesignerPage' import { MetadataAdminPage } from '@/pages/MetadataAdminPage' +import { UserTasksPage } from '@/pages/UserTasksPage' +import { TaskDetailPage } from '@/pages/TaskDetailPage' export default function App() { return ( @@ -84,6 +86,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3727bcd..c2bd791 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -38,6 +38,7 @@ import type { Partner, PurchaseOrder, Role, + RuleDefinition, SalesOrder, ShopFloorEntry, StockBalance, @@ -45,6 +46,8 @@ import type { TokenPair, Uom, User, + UserTaskDetail, + UserTaskSummary, WorkOrder, } from '@/types/api' @@ -286,6 +289,15 @@ export const production = { apiFetch('/api/v1/production/work-orders/shop-floor'), } +// ─── Workflow ──────────────────────────────────────────────────────── + +export const workflow = { + listTasks: () => apiFetch('/api/v1/workflow/tasks'), + getTask: (taskId: string) => apiFetch(`/api/v1/workflow/tasks/${taskId}`), + completeTask: (taskId: string, variables: Record) => + apiFetch(`/api/v1/workflow/tasks/${taskId}/complete`, { method: 'POST', body: JSON.stringify(variables) }, false), +} + // ─── Finance ───────────────────────────────────────────────────────── // ─── Metadata admin ───────────────────────────────────────────────── @@ -314,6 +326,12 @@ export const metadata = { apiFetch(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'PUT', body: JSON.stringify(body) }), deleteCustomField: (key: string) => apiFetch(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'DELETE' }, false), + listRules: () => apiFetch('/api/v1/_meta/metadata/rules'), + getRule: (slug: string) => apiFetch(`/api/v1/_meta/metadata/rules/${slug}`), + saveRule: (slug: string, body: Omit) => + apiFetch(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'PUT', body: JSON.stringify(body) }), + deleteRule: (slug: string) => + apiFetch(`/api/v1/_meta/metadata/rules/${slug}`, { method: 'DELETE' }, false), } // ─── Finance ───────────────────────────────────────────────────────── diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index 5eae9bb..00af7ef 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -126,6 +126,22 @@ export const en = { 'label.targetEntity': 'Target Entity', 'label.fieldType': 'Field Type', 'confirm.delete': 'Are you sure?', + + // ─── Workflow / tasks ───────────────────────────────────── + 'nav.workflow': 'Workflow', + 'nav.tasks': 'Tasks', + 'page.tasks.title': 'Pending Tasks', + 'page.tasks.subtitle': 'Workflow tasks waiting for action', + 'page.taskDetail.title': 'Task Detail', + + // ─── Rules ──────────────────────────────────────────────── + 'tab.rules': 'Rules', + 'label.triggerEvent': 'Trigger Event', + 'label.conditions': 'Conditions', + 'label.actions': 'Actions', + 'label.enabled': 'Enabled', + 'label.conditionLogic': 'Logic', + 'action.newRule': 'New Rule', } as const export const zhCN: Record = { @@ -243,6 +259,22 @@ export const zhCN: Record = { 'label.targetEntity': '目标实体', 'label.fieldType': '字段类型', 'confirm.delete': '确定删除?', + + // ─── 工作流 / 任务 ──────────────────────────────────────── + 'nav.workflow': '工作流', + 'nav.tasks': '任务', + 'page.tasks.title': '待办任务', + 'page.tasks.subtitle': '等待处理的工作流任务', + 'page.taskDetail.title': '任务详情', + + // ─── 规则 ───────────────────────────────────────────────── + 'tab.rules': '规则', + 'label.triggerEvent': '触发事件', + 'label.conditions': '条件', + 'label.actions': '动作', + 'label.enabled': '启用', + 'label.conditionLogic': '逻辑', + 'action.newRule': '新建规则', } export const locales = { diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx index d238347..de112bd 100644 --- a/web/src/layout/AppLayout.tsx +++ b/web/src/layout/AppLayout.tsx @@ -60,6 +60,12 @@ const NAV: NavGroup[] = [ ], }, { + headingKey: 'nav.workflow', + items: [ + { to: '/workflow/tasks', labelKey: 'nav.tasks' }, + ], + }, + { headingKey: 'nav.system', items: [ { to: '/users', labelKey: 'nav.users' }, diff --git a/web/src/pages/MetadataAdminPage.tsx b/web/src/pages/MetadataAdminPage.tsx index 8d867d5..eef63fb 100644 --- a/web/src/pages/MetadataAdminPage.tsx +++ b/web/src/pages/MetadataAdminPage.tsx @@ -16,6 +16,9 @@ import type { ListViewDefinition, MetadataEntity, MetadataPermission, + RuleDefinition, + RuleCondition, + RuleAction, } from '@/types/api' import { PageHeader } from '@/components/PageHeader' import { Loading } from '@/components/Loading' @@ -61,6 +64,30 @@ type TabId = | 'menus' | 'forms' | 'listViews' + | 'rules' + +const TRIGGER_EVENTS = [ + 'SalesOrderConfirmedEvent', + 'SalesOrderShippedEvent', + 'SalesOrderCancelledEvent', + 'PurchaseOrderConfirmedEvent', + 'PurchaseOrderReceivedEvent', + 'PurchaseOrderCancelledEvent', + 'WorkOrderCreatedEvent', + 'WorkOrderStartedEvent', + 'WorkOrderCompletedEvent', + 'WorkOrderCancelledEvent', + 'InspectionRecordedEvent', +] as const + +const CONDITION_OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'in'] as const + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} const TYPE_KINDS = [ 'string', @@ -93,6 +120,20 @@ export function MetadataAdminPage() { const [menus, setMenus] = useState([]) const [forms, setForms] = useState([]) const [listViews, setListViews] = useState([]) + const [rules, setRules] = useState([]) + + // Rule inline form state + const [ruleFormOpen, setRuleFormOpen] = useState(false) + const [ruleEditingSlug, setRuleEditingSlug] = useState(null) + const [ruleName, setRuleName] = useState('') + const [ruleDescription, setRuleDescription] = useState('') + const [ruleTriggerEvent, setRuleTriggerEvent] = useState(TRIGGER_EVENTS[0]) + const [ruleConditionLogic, setRuleConditionLogic] = useState<'AND' | 'OR'>('AND') + const [ruleConditions, setRuleConditions] = useState([]) + const [ruleActions, setRuleActions] = useState([]) + const [ruleEnabled, setRuleEnabled] = useState(true) + const [ruleVersion, setRuleVersion] = useState(1) + const [ruleSaving, setRuleSaving] = useState(false) // Custom-field inline form state const [cfFormOpen, setCfFormOpen] = useState(false) @@ -128,6 +169,8 @@ export function MetadataAdminPage() { return metadata.listForms().then(setForms).then(() => {}) case 'listViews': return metadata.listListViews().then(setListViews).then(() => {}) + case 'rules': + return metadata.listRules().then(setRules).then(() => {}) } })() promise @@ -213,6 +256,77 @@ export function MetadataAdminPage() { } } + // ── Rule form helpers ────────────────────────────────────────────── + + const resetRuleForm = () => { + setRuleFormOpen(false) + setRuleEditingSlug(null) + setRuleName('') + setRuleDescription('') + setRuleTriggerEvent(TRIGGER_EVENTS[0]) + setRuleConditionLogic('AND') + setRuleConditions([]) + setRuleActions([]) + setRuleEnabled(true) + setRuleVersion(1) + } + + const openRuleCreate = () => { + resetRuleForm() + setRuleFormOpen(true) + } + + const openRuleEdit = (rule: RuleDefinition) => { + setRuleEditingSlug(rule.slug) + setRuleName(rule.name) + setRuleDescription(rule.description ?? '') + setRuleTriggerEvent(rule.triggerEvent) + setRuleConditionLogic(rule.conditionLogic) + setRuleConditions([...rule.conditions]) + setRuleActions(rule.actions.map((a) => ({ type: a.type, config: { ...a.config } }))) + setRuleEnabled(rule.enabled) + setRuleVersion(rule.version) + setRuleFormOpen(true) + } + + const onRuleSubmit = async (e: FormEvent) => { + e.preventDefault() + setRuleSaving(true) + setError(null) + const slug = ruleEditingSlug ?? slugify(ruleName) + const body: Omit = { + slug, + name: ruleName, + description: ruleDescription || undefined, + enabled: ruleEnabled, + triggerEvent: ruleTriggerEvent, + conditions: ruleConditions, + conditionLogic: ruleConditionLogic, + actions: ruleActions, + version: ruleVersion, + } + try { + await metadata.saveRule(slug, body) + resetRuleForm() + loadTab('rules') + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setRuleSaving(false) + } + } + + const onRuleDelete = async (slug: string) => { + if (!window.confirm(t('confirm.delete'))) return + setError(null) + try { + await metadata.deleteRule(slug) + loadTab('rules') + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } + } + // ── Form / ListView delete helpers ──────────────────────────────── const onFormDelete = async (slug: string) => { @@ -360,6 +474,58 @@ export function MetadataAdminPage() { }, ] + const ruleCols: Column[] = [ + { header: t('label.name'), key: 'name' }, + { header: t('label.triggerEvent'), key: 'triggerEvent' }, + { + header: t('label.enabled'), + key: 'enabled', + render: (r) => ( + + {r.enabled ? 'Yes' : 'No'} + + ), + }, + { + header: t('label.conditions'), + key: 'conditions', + render: (r) => {r.conditions.length}, + }, + { + header: t('label.actions'), + key: 'actions', + render: (r) => {r.actions.length}, + }, + { header: t('label.source'), key: 'source', render: (r) => }, + { + header: '', + key: '_actions', + render: (r) => + r.source === 'user' ? ( + + + + + ) : null, + }, + ] + // ── Tab bar ─────────────────────────────────────────────────────── const tabs: { id: TabId; label: string }[] = [ @@ -369,6 +535,7 @@ export function MetadataAdminPage() { { id: 'menus', label: t('tab.menus') }, { id: 'forms', label: t('tab.forms') }, { id: 'listViews', label: t('tab.listViews') }, + { id: 'rules', label: t('tab.rules') }, ] // ── Tab content renderer ────────────────────────────────────────── @@ -571,6 +738,253 @@ export function MetadataAdminPage() { /> ) + + case 'rules': + return ( +
+
+ +
+ {ruleFormOpen && ( +
+
+
+ + setRuleName(e.target.value)} + placeholder="My Rule" + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> +
+
+ + +
+
+
+ + setRuleDescription(e.target.value)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> +
+ + {/* Condition logic */} +
+ + +
+ + {/* Conditions */} +
+ + {ruleConditions.map((cond, i) => ( +
+ { + const next = [...ruleConditions] + next[i] = { ...next[i], field: e.target.value } + setRuleConditions(next) + }} + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" + /> + + { + const next = [...ruleConditions] + next[i] = { ...next[i], value: e.target.value } + setRuleConditions(next) + }} + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" + /> + +
+ ))} + +
+ + {/* Actions */} +
+ + {ruleActions.map((act, i) => ( +
+ + { + const next = [...ruleActions] + const oldVal = Object.values(act.config)[0] ?? '' + next[i] = { ...next[i], config: { [e.target.value]: oldVal } } + setRuleActions(next) + }} + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm font-mono" + /> + { + const next = [...ruleActions] + const oldKey = Object.keys(act.config)[0] ?? 'message' + next[i] = { ...next[i], config: { [oldKey]: e.target.value } } + setRuleActions(next) + }} + className="flex-1 rounded-md border border-slate-300 px-2 py-1.5 text-sm" + /> + +
+ ))} + +
+ + {/* Enabled */} + + +
+ + +
+
+ )} + r.slug} + /> +
+ ) } } @@ -595,6 +1009,7 @@ export function MetadataAdminPage() { }`} onClick={() => { resetCfForm() + resetRuleForm() setActiveTab(tab.id) }} > diff --git a/web/src/pages/TaskDetailPage.tsx b/web/src/pages/TaskDetailPage.tsx new file mode 100644 index 0000000..4db2141 --- /dev/null +++ b/web/src/pages/TaskDetailPage.tsx @@ -0,0 +1,120 @@ +// Workflow task detail page. +// +// Fetches a single user task by ID. If the task carries a formKey +// starting with "vibe:", strips the prefix and renders the matching +// MetadataFormRenderer so the user sees a rich, metadata-driven +// form. Otherwise falls back to a read-only JSON dump of the task +// variables with a simple "Complete" button. + +import { useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { workflow } from '@/api/client' +import type { UserTaskDetail } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' +import { MetadataFormRenderer } from '@/components/MetadataFormRenderer' +import { useT } from '@/i18n/LocaleContext' + +export function TaskDetailPage() { + const t = useT() + const navigate = useNavigate() + const { taskId = '' } = useParams<{ taskId: string }>() + const [task, setTask] = useState(null) + const [loading, setLoading] = useState(true) + const [acting, setActing] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + setLoading(true) + setError(null) + workflow + .getTask(taskId) + .then(setTask) + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) + .finally(() => setLoading(false)) + }, [taskId]) + + const handleComplete = async (formData?: Record) => { + setActing(true) + setError(null) + try { + await workflow.completeTask(taskId, formData ?? {}) + navigate('/workflow/tasks') + } catch (e: unknown) { + setError(e instanceof Error ? e : new Error(String(e))) + } finally { + setActing(false) + } + } + + if (loading) return + if (error && !task) return + if (!task) return + + // Determine whether we have a vibe: form key + const vibeFormSlug = + task.formKey && task.formKey.startsWith('vibe:') + ? task.formKey.slice('vibe:'.length) + : null + + return ( +
+ navigate('/workflow/tasks')} + > + {t('action.back')} + + } + /> + + {error && } + +
+
+
Task ID
+
{task.taskId}
+
Process
+
{task.processDefinitionKey}
+
Created
+
{task.createTime}
+
Assignee
+
{task.assignee ?? '\u2014'}
+
Form Key
+
{task.formKey ?? '\u2014'}
+
+ +
+ + {vibeFormSlug ? ( + + ) : ( +
+
+

Variables

+
+                {JSON.stringify(task.variables, null, 2)}
+              
+
+ +
+ )} +
+
+ ) +} diff --git a/web/src/pages/UserTasksPage.tsx b/web/src/pages/UserTasksPage.tsx new file mode 100644 index 0000000..bd13484 --- /dev/null +++ b/web/src/pages/UserTasksPage.tsx @@ -0,0 +1,69 @@ +// Workflow user-tasks list page. +// +// Fetches pending human tasks from the embedded workflow engine +// and renders them in a DataTable. Clicking a row navigates to +// the task detail page where the user can complete the task. + +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { workflow } from '@/api/client' +import type { UserTaskSummary } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' +import { DataTable, type Column } from '@/components/DataTable' +import { useT } from '@/i18n/LocaleContext' + +export function UserTasksPage() { + const t = useT() + const navigate = useNavigate() + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + workflow + .listTasks() + .then(setRows) + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) + .finally(() => setLoading(false)) + }, []) + + const columns: Column[] = [ + { + header: 'Task Name', + key: 'taskName', + render: (r) => ( + + ), + }, + { header: 'Process', key: 'processDefinitionKey' }, + { header: 'Form Key', key: 'formKey', render: (r) => r.formKey ?? '\u2014' }, + { header: 'Created', key: 'createTime' }, + { header: 'Assignee', key: 'assignee', render: (r) => r.assignee ?? '\u2014' }, + ] + + return ( +
+ + {loading && } + {error && } + {!loading && !error && ( + r.taskId} + empty={
No pending tasks
} + /> + )} +
+ ) +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 85b5627..3768f52 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -285,6 +285,48 @@ export interface JournalEntry { lines: JournalEntryLine[] } +// ─── Workflow (user tasks) ────────────────────────────────────────── + +export interface UserTaskSummary { + taskId: string + taskName: string + formKey: string | null + processDefinitionKey: string + processInstanceId: string + createTime: string + assignee: string | null +} + +export interface UserTaskDetail extends UserTaskSummary { + variables: Record +} + +// ─── Rules ───────────────────────────────────────────────────────── + +export interface RuleCondition { + field: string + operator: string + value: string +} + +export interface RuleAction { + type: string + config: Record +} + +export interface RuleDefinition { + slug: string + name: string + description?: string + enabled: boolean + triggerEvent: string + conditions: RuleCondition[] + conditionLogic: 'AND' | 'OR' + actions: RuleAction[] + version: number + source?: string +} + // ─── Metadata Definitions ─────────────────────────────────────────── export type FormPurpose = 'create' | 'edit' | 'user-task' | 'view'