"use client" import * as React from 'react' import { ArrowDown, ArrowUp, Flag, Plus, Trash2, TriangleAlert } from 'lucide-react' import { Button } from '@open-mercato/ui/primitives/button' import { IconButton } from '@open-mercato/ui/primitives/icon-button' import { Input } from '@open-mercato/ui/primitives/input' import { Spinner } from '@open-mercato/ui/primitives/spinner' import { apiCall } from '@open-mercato/ui/backend/utils/apiCall' import { useT } from '@open-mercato/shared/lib/i18n/context' import { PROCESS_MILESTONES_MAX, type ProcessMilestone } from '../../../data/validators' import { collectMilestoneIssues, moveMilestone, orderedMilestones, withSequentialOrder, } from '../../../lib/tasks/milestones' const KEYS_DATALIST_ID = 'om-process-milestone-keys' type Translate = ReturnType export type MilestoneEditorProps = { value: ProcessMilestone[] onChange: (next: ProcessMilestone[]) => void /** The bound workflow, whose steps declare which milestone keys can ever be emitted. */ workflowId: string | null disabled?: boolean t: Translate } /** A milestone key is a business identifier, not a slug of the label — but a slug is a good first draft. */ function suggestKey(label: string): string { return label .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 100) } /** * The milestone keys the bound workflow's steps actually emit. * * `null` means UNRESOLVED (the workflow is unknown, the module is absent, or the * caller may not read definitions) — the drift diagnostic stays silent then, * because "we could not look" is not "nothing emits it". */ export async function fetchEmittedMilestoneKeys(workflowId: string): Promise { const call = await apiCall<{ data?: Array> }>( `/api/workflows/definitions?workflowId=${encodeURIComponent(workflowId)}&limit=1`, undefined, { fallback: {} }, ) if (!call.ok) return null const first = Array.isArray(call.result?.data) ? call.result.data[0] : undefined if (!first) return null const definition = first.definition if (!definition || typeof definition !== 'object') return null const steps = (definition as { steps?: unknown }).steps if (!Array.isArray(steps)) return null const keys = new Set() for (const step of steps) { if (!step || typeof step !== 'object') continue const milestone = (step as Record).milestone if (typeof milestone === 'string' && milestone) keys.add(milestone) } return Array.from(keys).sort((a, b) => a.localeCompare(b)) } /** * The milestone editor. * * A milestone is a business EVENT the workflow emits, so what is authored here is * a VOCABULARY — a key and the business-facing label for it — and never a step * id. That is what lets a stage be announced after a parallel join, after a retry, * or after ten steps, and what stops a step rename from changing what a business * reader sees. * * Milestone rows mutate the PARENT definition, so the parent's optimistic-lock * header applies and no per-child override is needed: the caller owns the save. */ export function MilestoneEditor({ value, onChange, workflowId, disabled, t, }: MilestoneEditorProps) { const [emitted, setEmitted] = React.useState(null) // A workflow still in flight is NOT an unresolved one: reporting drift — or // saying the workflow could not be loaded — while the request is open would be // false for as long as it takes to answer. const [emittedLoading, setEmittedLoading] = React.useState(false) React.useEffect(() => { let cancelled = false if (!workflowId) { setEmitted(null) setEmittedLoading(false) return () => { cancelled = true } } setEmittedLoading(true) void fetchEmittedMilestoneKeys(workflowId).then((resolved) => { if (cancelled) return setEmitted(resolved) setEmittedLoading(false) }) return () => { cancelled = true } }, [workflowId]) const emittedKeys = React.useMemo(() => (emitted ? new Set(emitted) : null), [emitted]) const rows = React.useMemo(() => orderedMilestones(value), [value]) const issues = React.useMemo( () => collectMilestoneIssues({ milestones: rows, emittedKeys, translate: (key, fallback, params) => t(key, fallback, params), }), [rows, emittedKeys, t], ) const neverEmitted = React.useMemo( () => new Set(rows.filter((row) => emittedKeys && row.key && !emittedKeys.has(row.key)).map((row) => row.key)), [rows, emittedKeys], ) const atCap = rows.length >= PROCESS_MILESTONES_MAX const replaceAt = React.useCallback( (index: number, next: ProcessMilestone) => { onChange(withSequentialOrder(rows.map((row, position) => (position === index ? next : row)))) }, [onChange, rows], ) const addMilestone = React.useCallback(() => { onChange(withSequentialOrder([...rows, { key: '', label: '', order: rows.length }])) }, [onChange, rows]) const removeAt = React.useCallback( (index: number) => { onChange(withSequentialOrder(rows.filter((_, position) => position !== index))) }, [onChange, rows], ) const move = React.useCallback( (from: number, to: number) => { onChange(moveMilestone(rows, from, to)) }, [onChange, rows], ) return (
{(emitted ?? []).map((key) => ( {rows.length === 0 ? (

{t('agent_orchestrator.processDefinitions.milestones.empty')}

) : null} {rows.map((milestone, index) => (
{index + 1} { const label = event.target.value // The key is derived only while it is still untouched: once an // author names it, the workflow emits THAT string and a later // label edit must not silently break the match. const key = milestone.key ? milestone.key : suggestKey(label) replaceAt(index, { ...milestone, label, key }) }} /> replaceAt(index, { ...milestone, key: event.target.value })} /> move(index, index - 1)} > move(index, index + 1)} > removeAt(index)} >
{milestone.key && neverEmitted.has(milestone.key) ? (

{t('agent_orchestrator.processDefinitions.milestones.problems.rowHint')}

) : null}
))} {issues.length > 0 ? (

{t('agent_orchestrator.processDefinitions.milestones.problems.title')}

{issues.map((issue) => (

{issue.message}

))}

{t('agent_orchestrator.processDefinitions.milestones.problems.stillSaveable')}

) : null}
{atCap ? ( {t('agent_orchestrator.processDefinitions.milestones.cap', undefined, { max: String(PROCESS_MILESTONES_MAX), })} ) : null} {emittedLoading ? ( {t('agent_orchestrator.processDefinitions.milestones.emittedLoading')} ) : null} {!emittedLoading && emitted === null && workflowId ? ( {t('agent_orchestrator.processDefinitions.milestones.emittedUnresolved')} ) : null}
) }