@haiiro/docuflow 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,47 @@
1
1
  import { useState } from 'react';
2
2
  import { defineFrontComponent } from 'twenty-sdk/define';
3
3
  import { AppPath, enqueueSnackbar, navigate } from 'twenty-sdk/front-component';
4
- import { IconPencil, IconPlayerPlay, IconSettings, IconTrash } from 'twenty-ui/icon';
5
- import { Tag } from 'twenty-ui/primitives/data-display';
4
+ import { IconPencil, IconPlayerPlay, IconPlayerStop, IconRocket, IconSettings, IconTrash } from 'twenty-ui/icon';
6
5
  import { useTheme } from 'twenty-ui/theme';
7
6
 
8
- import { callAppRoute, createRecord, deleteRecord, getErrorMessage, listRecords, updateRecord } from 'src/front/api';
7
+ import {
8
+ callAppRoute,
9
+ countRecords,
10
+ createRecord,
11
+ deleteRecords,
12
+ getErrorMessage,
13
+ listRecords,
14
+ updateRecord,
15
+ } from 'src/front/api';
16
+ import {
17
+ formatDate,
18
+ formatDateTime,
19
+ formatMemberName,
20
+ RUN_STATUS_COLORS,
21
+ RUN_STATUS_LABELS,
22
+ STEP_TYPE_LABELS,
23
+ } from 'src/front/format';
9
24
  import { useAsync, useDebouncedValue } from 'src/front/hooks/use-async';
10
- import { DataTable, DocflowRoot, Message, SplitView, type TableSort, Toolbar } from 'src/front/ui/components';
11
- import { Button, PromptDialog, SearchWithFilters } from 'src/front/ui/kit';
25
+ import { useBulkAction } from 'src/front/hooks/use-bulk-action';
26
+ import { useWorkspaceMembers } from 'src/front/hooks/use-workspace-members';
27
+ import { type DocflowWorkflowRun, type WorkspaceMember } from 'src/front/types';
28
+ import {
29
+ DataTable,
30
+ DocflowRoot,
31
+ formatTotal,
32
+ ListToolbar,
33
+ Message,
34
+ SectionTitle,
35
+ SelectionBar,
36
+ SplitView,
37
+ type TableColumn,
38
+ type TableSort,
39
+ useHiddenColumns,
40
+ useRowSelection,
41
+ } from 'src/front/ui/components';
12
42
  import { FormFieldInputs } from 'src/front/ui/form-field-inputs';
43
+ import { Button, type MenuAction, MenuButton, PromptDialog, StatusChip, ViewTabs } from 'src/front/ui/kit';
44
+ import { Timeline } from 'src/front/ui/timeline';
13
45
  import { restAnd, restEq, restIlike, restIn } from 'src/shared/api/rest-filter.util';
14
46
  import { DOCFLOW_SECTION } from 'src/shared/docflow.constants';
15
47
  import { type FormField, type FormValues } from 'src/shared/form/form-field.type';
@@ -21,18 +53,23 @@ import {
21
53
  isLaunchParametersStep,
22
54
  parseLaunchFields,
23
55
  } from 'src/shared/workflow/launch-step.util';
56
+ import { orderRunSteps } from 'src/shared/workflow/run-timeline.util';
24
57
  import { type WorkflowStep, type WorkflowTrigger } from 'src/shared/workflow/workflow-run.type';
58
+ import { getFormStepAssignment, getFormStepFields, isFormStep } from 'src/shared/workflow/workflow-run.util';
25
59
  import { PROCESSES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
26
60
 
27
61
  const PROCESSES_LIMIT = 200;
28
62
  const SEARCH_DEBOUNCE_MS = 300;
29
63
  const DRAFT_WAIT_ATTEMPTS = 10;
30
64
  const DRAFT_WAIT_MS = 400;
65
+ const RECENT_RUNS_LIMIT = 5;
66
+ const PROCESS_NOUN: [string, string, string] = ['процесс', 'процесса', 'процессов'];
31
67
 
32
68
  type Panel = { kind: 'closed' } | { kind: 'process'; workflowId: string };
33
- type StatusFilter = 'all' | 'active' | 'draft';
69
+ type ProcessView = 'all' | 'active' | 'draft';
70
+ type ParametersFilter = 'any' | 'with' | 'without';
34
71
 
35
- type WorkflowRecord = { id: string; name?: string | null; smbLaunchFields?: unknown };
72
+ type WorkflowRecord = { id: string; name?: string | null; smbLaunchFields?: unknown; updatedAt?: string | null };
36
73
  type WorkflowVersionRecord = {
37
74
  id: string;
38
75
  workflowId: string;
@@ -46,13 +83,38 @@ type DocflowProcess = {
46
83
  // The published version people launch; none while the process is a draft
47
84
  workflowVersionId: string | undefined;
48
85
  name: string;
86
+ // Launched from here: published and started by hand
49
87
  isPublished: boolean;
88
+ // Has a published version, whatever starts it
89
+ isActive: boolean;
90
+ // A draft edited after the last publishing
91
+ hasUnpublishedChanges: boolean;
50
92
  launchFields: FormField[];
51
- stageNames: string[];
93
+ // The steps in the order a run goes through them
94
+ stages: WorkflowStep[];
95
+ updatedAt?: string | null;
52
96
  };
53
97
 
54
- const stageNamesOf = (version: WorkflowVersionRecord | undefined) =>
55
- (version?.steps ?? []).filter((step) => !isLaunchParametersStep(step)).map((step) => step.name);
98
+ type RunCounts = { total: number; running: number };
99
+
100
+ const COLUMNS: TableColumn[] = [
101
+ { key: 'name', label: 'Процесс', width: '26%', sortKey: 'name' },
102
+ { key: 'status', label: 'Статус', width: '13%', sortKey: 'status' },
103
+ { key: 'parameters', label: 'Параметры', width: '10%', sortKey: 'parameters' },
104
+ { key: 'stageCount', label: 'Этапов', width: '8%', sortKey: 'stageCount' },
105
+ { key: 'runs', label: 'Запуски', width: '12%', sortKey: 'runs' },
106
+ { key: 'stages', label: 'Этапы' },
107
+ { key: 'updatedAt', label: 'Изменён', width: '11%', sortKey: 'updatedAt' },
108
+ ];
109
+
110
+ const VIEWS: { value: ProcessView; label: string }[] = [
111
+ { value: 'all', label: 'Все' },
112
+ { value: 'active', label: 'Опубликованные' },
113
+ { value: 'draft', label: 'Черновики' },
114
+ ];
115
+
116
+ const stagesOf = (version: WorkflowVersionRecord | undefined) =>
117
+ orderRunSteps({ flow: { trigger: version?.trigger, steps: version?.steps } }).filter((step) => !isLaunchParametersStep(step));
56
118
 
57
119
  // Docflow processes are the standard workflows with the docflow section:
58
120
  // published ones started by hand are launched here, drafts are shown to be
@@ -73,38 +135,225 @@ const loadProcesses = async (searchQuery: string): Promise<DocflowProcess[]> =>
73
135
 
74
136
  const { records: versions } = await listRecords<WorkflowVersionRecord>('workflowVersions', {
75
137
  filter: restAnd(
76
- restIn('status', ['ACTIVE', 'DRAFT']),
138
+ restIn('status', ['ACTIVE', 'DRAFT', 'DEACTIVATED']),
77
139
  restIn('workflowId', workflows.map(({ id }) => id)),
78
140
  ),
79
- limit: PROCESSES_LIMIT * 2,
141
+ // Newest first: the latest of the versions taken off comes first
142
+ order_by: 'createdAt[DescNullsLast]',
143
+ limit: PROCESSES_LIMIT * 5,
80
144
  });
81
145
 
82
146
  return workflows.map((workflow) => {
83
- const active = versions.find(
84
- (version) => version.workflowId === workflow.id && version.status === 'ACTIVE' && isLaunchableTrigger(version.trigger),
85
- );
147
+ const active = versions.find((version) => version.workflowId === workflow.id && version.status === 'ACTIVE');
86
148
  const draft = versions.find((version) => version.workflowId === workflow.id && version.status === 'DRAFT');
149
+ // Taken off publishing: its steps still show
150
+ const deactivated = versions.find((version) => version.workflowId === workflow.id && version.status === 'DEACTIVATED');
151
+ const isLaunchable = active !== undefined && isLaunchableTrigger(active.trigger);
87
152
 
88
153
  return {
89
154
  workflowId: workflow.id,
90
- workflowVersionId: active?.id,
155
+ workflowVersionId: isLaunchable ? active.id : undefined,
91
156
  name: workflow.name?.trim() || 'Без названия',
92
- isPublished: active !== undefined,
157
+ isPublished: isLaunchable,
158
+ isActive: active !== undefined,
159
+ hasUnpublishedChanges: active !== undefined && draft !== undefined,
93
160
  launchFields: parseLaunchFields(workflow.smbLaunchFields),
94
- stageNames: stageNamesOf(active ?? draft),
161
+ stages: stagesOf(active ?? draft ?? deactivated),
162
+ updatedAt: workflow.updatedAt,
95
163
  };
96
164
  });
97
165
  };
98
166
 
167
+ // Runs per process: all of them and the ones still going
168
+ const loadRunCounts = async (workflowIds: string[]): Promise<Record<string, RunCounts>> =>
169
+ Object.fromEntries(
170
+ await Promise.all(
171
+ workflowIds.map(async (workflowId) => {
172
+ const [total, running] = await Promise.all([
173
+ countRecords('workflowRuns', restEq('workflowId', workflowId)),
174
+ countRecords('workflowRuns', restAnd(restEq('workflowId', workflowId), restEq('status', 'RUNNING'))),
175
+ ]);
176
+
177
+ return [workflowId, { total, running }];
178
+ }),
179
+ ),
180
+ );
181
+
99
182
  const openEditor = (workflowId: string) =>
100
183
  navigate(AppPath.RecordShowPage, { objectNameSingular: 'workflow', objectRecordId: workflowId });
101
184
 
102
- const ProcessLaunchForm = ({
185
+ const openRun = (runId: string) =>
186
+ navigate(AppPath.RecordShowPage, { objectNameSingular: 'workflowRun', objectRecordId: runId });
187
+
188
+ // The way of the process before it runs: every stage is still ahead
189
+ const ProcessStages = ({ process, members }: { process: DocflowProcess; members: WorkspaceMember[] }) => {
190
+ const [now] = useState(() => new Date());
191
+
192
+ if (process.stages.length === 0) {
193
+ return <Message tone="hint">Этапов пока нет: добавьте шаги в редакторе.</Message>;
194
+ }
195
+
196
+ return (
197
+ <Timeline
198
+ items={[
199
+ { key: 'start', title: 'Запуск', meta: process.launchFields.length > 0 ? `с параметрами: ${process.launchFields.length}` : 'без параметров', state: 'upcoming' },
200
+ ...process.stages.map((step) => {
201
+ const assigneeId = isFormStep(step)
202
+ ? getFormStepAssignment({ fields: getFormStepFields(step), now }).assigneeWorkspaceMemberId
203
+ : undefined;
204
+
205
+ return {
206
+ key: step.id,
207
+ title: step.name,
208
+ meta: [STEP_TYPE_LABELS[step.type] ?? step.type, formatMemberName(members.find(({ id }) => id === assigneeId))]
209
+ .filter(Boolean)
210
+ .join(' · '),
211
+ state: 'upcoming' as const,
212
+ };
213
+ }),
214
+ ]}
215
+ />
216
+ );
217
+ };
218
+
219
+ const RecentRuns = ({ workflowId }: { workflowId: string }) => {
220
+ const theme = useTheme();
221
+ const { data: runs, loading } = useAsync(
222
+ async () =>
223
+ (
224
+ await listRecords<DocflowWorkflowRun>('workflowRuns', {
225
+ filter: restEq('workflowId', workflowId),
226
+ order_by: 'createdAt[DescNullsLast]',
227
+ limit: RECENT_RUNS_LIMIT,
228
+ })
229
+ ).records,
230
+ [workflowId],
231
+ );
232
+
233
+ if (loading && runs === undefined) {
234
+ return <Message tone="hint">Загрузка…</Message>;
235
+ }
236
+
237
+ if ((runs ?? []).length === 0) {
238
+ return <Message tone="hint">Запусков ещё не было.</Message>;
239
+ }
240
+
241
+ return (
242
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
243
+ {(runs ?? []).map((run) => (
244
+ <button
245
+ key={run.id}
246
+ type="button"
247
+ onClick={() => void openRun(run.id)}
248
+ style={{
249
+ alignItems: 'center',
250
+ background: 'transparent',
251
+ border: 'none',
252
+ borderTop: `1px solid ${theme.border.color.light}`,
253
+ color: theme.font.color.secondary,
254
+ cursor: 'pointer',
255
+ display: 'flex',
256
+ fontFamily: 'inherit',
257
+ fontSize: theme.font.size.sm,
258
+ gap: theme.spacing[2],
259
+ justifyContent: 'space-between',
260
+ padding: `${theme.spacing[2]} 0`,
261
+ textAlign: 'left',
262
+ }}
263
+ >
264
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
265
+ {formatDateTime(run.startedAt ?? run.createdAt)}
266
+ {run.createdBy?.name ? ` · ${run.createdBy.name}` : ''}
267
+ </span>
268
+ <StatusChip color={RUN_STATUS_COLORS[run.status ?? ''] ?? 'gray'}>
269
+ {RUN_STATUS_LABELS[run.status ?? ''] ?? run.status}
270
+ </StatusChip>
271
+ </button>
272
+ ))}
273
+ </div>
274
+ );
275
+ };
276
+
277
+ const ProcessStatusTags = ({ process }: { process: DocflowProcess }) => (
278
+ <>
279
+ <StatusChip color={process.isActive ? 'green' : 'gray'}>{process.isActive ? 'Опубликован' : 'Черновик'}</StatusChip>
280
+ {process.hasUnpublishedChanges && <StatusChip color="orange">Есть неопубликованные изменения</StatusChip>}
281
+ </>
282
+ );
283
+
284
+ // The name of the process, edited in place: saved on Enter or on leaving
285
+ const ProcessName = ({ name, onSave }: { name: string; onSave: (name: string) => Promise<void> }) => {
286
+ const theme = useTheme();
287
+ const [value, setValue] = useState(name);
288
+ const [isFocused, setIsFocused] = useState(false);
289
+
290
+ const save = async () => {
291
+ const trimmed = value.trim();
292
+
293
+ if (trimmed.length === 0) {
294
+ setValue(name);
295
+
296
+ return;
297
+ }
298
+
299
+ if (trimmed !== name) {
300
+ await onSave(trimmed);
301
+ }
302
+ };
303
+
304
+ return (
305
+ <input
306
+ type="text"
307
+ value={value}
308
+ aria-label="Название процесса"
309
+ title="Нажмите, чтобы переименовать"
310
+ onChange={(event) => setValue(event.target.value)}
311
+ onFocus={() => setIsFocused(true)}
312
+ onBlur={() => {
313
+ setIsFocused(false);
314
+ void save();
315
+ }}
316
+ onKeyDown={(event) => {
317
+ if (event.key === 'Enter') {
318
+ void save();
319
+ }
320
+
321
+ if (event.key === 'Escape') {
322
+ setValue(name);
323
+ }
324
+ }}
325
+ style={{
326
+ background: isFocused ? theme.background.transparent.lighter : 'transparent',
327
+ border: `1px solid ${isFocused ? theme.border.color.medium : 'transparent'}`,
328
+ borderRadius: theme.border.radius.sm,
329
+ boxSizing: 'border-box',
330
+ color: theme.font.color.primary,
331
+ fontFamily: 'inherit',
332
+ fontSize: theme.font.size.lg,
333
+ fontWeight: theme.font.weight.semiBold,
334
+ margin: `0 -${theme.spacing[2]}`,
335
+ outline: 'none',
336
+ padding: `${theme.spacing[1]} ${theme.spacing[2]}`,
337
+ width: `calc(100% + ${theme.spacing[4]})`,
338
+ }}
339
+ />
340
+ );
341
+ };
342
+
343
+ const ProcessPanel = ({
103
344
  process,
345
+ members,
346
+ isBusy,
104
347
  onLaunched,
348
+ onRename,
349
+ onPublish,
105
350
  }: {
106
351
  process: DocflowProcess;
352
+ members: WorkspaceMember[];
353
+ isBusy: boolean;
107
354
  onLaunched: () => void;
355
+ onRename: (name: string) => Promise<void>;
356
+ onPublish: (publish: boolean) => void;
108
357
  }) => {
109
358
  const theme = useTheme();
110
359
  const [values, setValues] = useState<FormValues>({});
@@ -130,6 +379,7 @@ const ProcessLaunchForm = ({
130
379
  values: parameters,
131
380
  });
132
381
  await enqueueSnackbar({ message: 'Процесс запущен', variant: 'success' });
382
+ setValues({});
133
383
  onLaunched();
134
384
  } catch (error) {
135
385
  setErrorMessage(getErrorMessage(error, 'Не удалось запустить процесс'));
@@ -138,34 +388,58 @@ const ProcessLaunchForm = ({
138
388
  }
139
389
  };
140
390
 
141
- if (!process.isPublished) {
142
- return (
143
- <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
144
- <Message>Процесс ещё не опубликован: доделайте его в редакторе и нажмите «Активировать».</Message>
145
- <Button variant="solid" size="md" fullWidth icon={IconSettings} onClick={() => void openEditor(process.workflowId)}>
146
- Открыть редактор
147
- </Button>
148
- </div>
149
- );
150
- }
151
-
152
391
  return (
153
- <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
154
- {process.stageNames.length > 0 && (
155
- <Message>Этапы: {process.stageNames.join(' → ')}</Message>
156
- )}
157
- {process.launchFields.length > 0 ? (
158
- <FormFieldInputs fields={process.launchFields} values={values} onChange={setValues} disabled={isLaunching} />
392
+ <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[4] }}>
393
+ <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[2] }}>
394
+ <ProcessName key={process.name} name={process.name} onSave={onRename} />
395
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: theme.spacing[1] }}>
396
+ <ProcessStatusTags process={process} />
397
+ {process.updatedAt && <StatusChip color="gray">Изменён {formatDate(process.updatedAt)}</StatusChip>}
398
+ </div>
399
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: theme.spacing[1] }}>
400
+ {(!process.isActive || process.hasUnpublishedChanges) && (
401
+ <Button variant="solid" tone="accent" icon={IconRocket} loading={isBusy} onClick={() => onPublish(true)}>
402
+ {process.isActive ? 'Опубликовать изменения' : 'Опубликовать'}
403
+ </Button>
404
+ )}
405
+ {process.isActive && (
406
+ <Button icon={IconPlayerStop} disabled={isBusy} onClick={() => onPublish(false)}>
407
+ Снять с публикации
408
+ </Button>
409
+ )}
410
+ <Button icon={IconSettings} onClick={() => void openEditor(process.workflowId)}>
411
+ Открыть редактор
412
+ </Button>
413
+ </div>
414
+ </div>
415
+ {process.isPublished ? (
416
+ <section style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
417
+ <SectionTitle>Запуск</SectionTitle>
418
+ {process.launchFields.length > 0 ? (
419
+ <FormFieldInputs fields={process.launchFields} values={values} onChange={setValues} disabled={isLaunching} />
420
+ ) : (
421
+ <Message tone="hint">Процесс запускается без параметров.</Message>
422
+ )}
423
+ {errorMessage !== undefined && <Message tone="danger">{errorMessage}</Message>}
424
+ <Button variant="solid" tone="accent" size="md" fullWidth icon={IconPlayerPlay} loading={isLaunching} onClick={() => void handleLaunch()}>
425
+ Запустить
426
+ </Button>
427
+ </section>
159
428
  ) : (
160
- <Message>Процесс запускается без параметров.</Message>
429
+ <Message tone="hint">
430
+ {process.isActive
431
+ ? 'Процесс опубликован, но запускается не вручную: его запускает событие или расписание.'
432
+ : 'Процесс ещё не опубликован: проверьте этапы и нажмите «Опубликовать».'}
433
+ </Message>
161
434
  )}
162
- {errorMessage !== undefined && <Message tone="danger">{errorMessage}</Message>}
163
- <Button variant="solid" size="md" fullWidth loading={isLaunching} onClick={() => void handleLaunch()}>
164
- Запустить
165
- </Button>
166
- <Button variant="ghost" size="sm" icon={IconSettings} onClick={() => void openEditor(process.workflowId)}>
167
- Изменить процесс
168
- </Button>
435
+ <section style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
436
+ <SectionTitle>Этапы</SectionTitle>
437
+ <ProcessStages process={process} members={members} />
438
+ </section>
439
+ <section style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[1] }}>
440
+ <SectionTitle>Последние запуски</SectionTitle>
441
+ <RecentRuns workflowId={process.workflowId} />
442
+ </section>
169
443
  </div>
170
444
  );
171
445
  };
@@ -189,51 +463,83 @@ const waitForDraft = async (workflowId: string) => {
189
463
 
190
464
  const DocflowProcesses = () => {
191
465
  const theme = useTheme();
466
+ const { members } = useWorkspaceMembers();
192
467
  const [searchInput, setSearchInput] = useState('');
193
468
  const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
194
- const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
469
+ const [view, setView] = useState<ProcessView>('all');
470
+ const [parametersFilter, setParametersFilter] = useState<ParametersFilter>('any');
195
471
  const [sort, setSort] = useState<TableSort>({ key: 'name', direction: 'asc' });
196
472
  const [panel, setPanel] = useState<Panel>({ kind: 'closed' });
197
473
  const [renaming, setRenaming] = useState<DocflowProcess | undefined>();
198
474
  const [isCreating, setIsCreating] = useState(false);
475
+ const [hiddenColumns, setHiddenColumns] = useHiddenColumns(['updatedAt']);
199
476
  const { data: loaded, loading, error, reload } = useAsync(() => loadProcesses(searchQuery), [searchQuery]);
477
+ const workflowIdsKey = (loaded ?? []).map(({ workflowId }) => workflowId).join();
478
+ const { data: runCounts, reload: reloadRunCounts } = useAsync(
479
+ () => loadRunCounts(workflowIdsKey.length > 0 ? workflowIdsKey.split(',') : []),
480
+ [workflowIdsKey],
481
+ );
200
482
  const selectedWorkflowId = panel.kind === 'process' ? panel.workflowId : undefined;
201
483
  const selectedProcess = loaded?.find((process) => process.workflowId === selectedWorkflowId);
202
484
 
485
+ const byParameters = (loaded ?? []).filter((process) =>
486
+ parametersFilter === 'any' ? true : (process.launchFields.length > 0) === (parametersFilter === 'with'),
487
+ );
488
+ const isInView = (process: DocflowProcess, candidate: ProcessView) =>
489
+ candidate === 'all' ? true : candidate === 'active' ? process.isActive : !process.isActive;
490
+
203
491
  // The list is short: filtered and sorted here
204
- const processes = (loaded ?? [])
205
- .filter((process) =>
206
- statusFilter === 'all' ? true : statusFilter === 'active' ? process.isPublished : !process.isPublished,
207
- )
492
+ const processes = byParameters
493
+ .filter((process) => isInView(process, view))
208
494
  .sort((first, second) => {
209
495
  const direction = sort.direction === 'asc' ? 1 : -1;
496
+ const byKey: Record<string, (process: DocflowProcess) => number> = {
497
+ parameters: (process) => process.launchFields.length,
498
+ status: (process) => Number(process.isActive),
499
+ stageCount: (process) => process.stages.length,
500
+ runs: (process) => runCounts?.[process.workflowId]?.total ?? 0,
501
+ updatedAt: (process) => new Date(process.updatedAt ?? 0).getTime(),
502
+ };
503
+ const value = byKey[sort.key];
504
+
505
+ return value === undefined
506
+ ? first.name.localeCompare(second.name, 'ru') * direction
507
+ : (value(first) - value(second)) * direction;
508
+ });
210
509
 
211
- if (sort.key === 'parameters') {
212
- return (first.launchFields.length - second.launchFields.length) * direction;
213
- }
510
+ const selection = useRowSelection(processes.map(({ workflowId }) => workflowId));
511
+ const selectedIds = selection.selectedIds;
214
512
 
215
- if (sort.key === 'status') {
216
- return (Number(first.isPublished) - Number(second.isPublished)) * direction;
217
- }
513
+ const reloadAll = async () => {
514
+ await Promise.all([reload(), reloadRunCounts()]);
515
+ };
218
516
 
219
- return first.name.localeCompare(second.name, 'ru') * direction;
220
- });
517
+ const bulk = useBulkAction({
518
+ onDone: async () => {
519
+ selection.clear();
520
+ await reloadAll();
521
+ },
522
+ });
221
523
 
222
- // The process goes to the Twenty trash ("Рабочие процессы → Удалённые"),
223
- // its runs and tasks stay.
224
- const handleDeleteProcess = async (process: DocflowProcess) => {
225
- try {
226
- await deleteRecord('workflows', process.workflowId);
227
- setPanel((current) =>
228
- current.kind === 'process' && current.workflowId === process.workflowId ? { kind: 'closed' } : current,
229
- );
230
- await enqueueSnackbar({ message: `Процесс «${process.name}» удалён`, variant: 'success' });
231
- await reload();
232
- } catch (deleteError) {
233
- logDocflowError('process: delete failed', deleteError, { workflowId: process.workflowId });
234
- await enqueueSnackbar({ message: getErrorMessage(deleteError, 'Не удалось удалить процесс'), variant: 'error' });
235
- }
236
- };
524
+ // The processes go to the Twenty trash ("Рабочие процессы → Удалённые"),
525
+ // their runs and tasks stay.
526
+ const removeProcesses = (ids: string[]) =>
527
+ bulk.run(
528
+ async () => {
529
+ await deleteRecords('workflows', ids);
530
+ setPanel((current) => (current.kind === 'process' && ids.includes(current.workflowId) ? { kind: 'closed' } : current));
531
+ },
532
+ ids.length === 1 ? 'Процесс удалён' : `Удалено процессов: ${ids.length}`,
533
+ 'Не удалось удалить',
534
+ );
535
+
536
+ // Publishing goes through the app's route, one process at a time: the
537
+ // workflow engine takes a version per call.
538
+ const publishProcesses = (ids: string[], publish: boolean) =>
539
+ bulk.runEach(ids, (workflowId) => callAppRoute('/docflow/processes/publish', { workflowId, publish }), {
540
+ done: (count) => (publish ? `Опубликовано: ${count}` : `Снято с публикации: ${count}`),
541
+ failed: publish ? 'Не опубликованы' : 'Не сняты с публикации',
542
+ });
237
543
 
238
544
  const handleRenameProcess = async (process: DocflowProcess, name: string) => {
239
545
  try {
@@ -280,48 +586,103 @@ const DocflowProcesses = () => {
280
586
  }
281
587
  };
282
588
 
589
+ const rowActions = (workflowId: string): MenuAction[] => {
590
+ const process = processes.find((candidate) => candidate.workflowId === workflowId);
591
+
592
+ if (process === undefined) {
593
+ return [];
594
+ }
595
+
596
+ return [
597
+ ...(process.isPublished
598
+ ? [{ key: 'launch', label: 'Запустить', icon: IconPlayerPlay, onSelect: () => setPanel({ kind: 'process', workflowId }) }]
599
+ : []),
600
+ ...(!process.isActive || process.hasUnpublishedChanges
601
+ ? [
602
+ {
603
+ key: 'publish',
604
+ label: process.isActive ? 'Опубликовать изменения' : 'Опубликовать',
605
+ icon: IconRocket,
606
+ onSelect: () => void publishProcesses([workflowId], true),
607
+ },
608
+ ]
609
+ : []),
610
+ ...(process.isActive
611
+ ? [{ key: 'unpublish', label: 'Снять с публикации', icon: IconPlayerStop, onSelect: () => void publishProcesses([workflowId], false) }]
612
+ : []),
613
+ { key: 'edit', label: 'Открыть редактор', icon: IconSettings, onSelect: () => void openEditor(workflowId) },
614
+ { key: 'rename', label: 'Переименовать', icon: IconPencil, onSelect: () => setRenaming(process) },
615
+ {
616
+ key: 'delete',
617
+ label: 'Удалить',
618
+ confirmLabel: 'Точно удалить?',
619
+ icon: IconTrash,
620
+ tone: 'danger',
621
+ onSelect: () => void removeProcesses([workflowId]),
622
+ },
623
+ ];
624
+ };
625
+
283
626
  return (
284
627
  <DocflowRoot>
285
628
  <SplitView
286
- panelTitle={selectedProcess?.name}
629
+ panelTitle="Процесс"
287
630
  onClosePanel={() => setPanel({ kind: 'closed' })}
288
631
  panel={
289
632
  selectedProcess !== undefined ? (
290
- <ProcessLaunchForm
633
+ <ProcessPanel
291
634
  key={selectedProcess.workflowId}
292
635
  process={selectedProcess}
293
- onLaunched={() => setPanel({ kind: 'closed' })}
636
+ members={members}
637
+ isBusy={bulk.isBusy}
638
+ onLaunched={() => void reloadRunCounts()}
639
+ onRename={(name) => handleRenameProcess(selectedProcess, name)}
640
+ onPublish={(publish) => void publishProcesses([selectedProcess.workflowId], publish)}
294
641
  />
295
642
  ) : undefined
296
643
  }
297
644
  >
298
- <Toolbar>
299
- <SearchWithFilters
300
- value={searchInput}
301
- onChange={setSearchInput}
302
- placeholder="Поиск процессов"
303
- activeFilterCount={statusFilter === 'all' ? 0 : 1}
304
- onResetFilters={() => setStatusFilter('all')}
305
- filters={[
645
+ <ListToolbar
646
+ search={{
647
+ value: searchInput,
648
+ onChange: setSearchInput,
649
+ placeholder: 'Поиск процессов',
650
+ activeFilterCount: parametersFilter === 'any' ? 0 : 1,
651
+ onResetFilters: () => setParametersFilter('any'),
652
+ filters: [
306
653
  {
307
- key: 'status',
308
- title: 'Статус',
309
- value: statusFilter,
654
+ key: 'parameters',
655
+ title: 'Параметры запуска',
656
+ value: parametersFilter,
310
657
  options: [
311
- { value: 'all', label: 'Все' },
312
- { value: 'active', label: 'Опубликованные' },
313
- { value: 'draft', label: 'Черновики' },
658
+ { value: 'any', label: 'Любые' },
659
+ { value: 'with', label: 'С параметрами' },
660
+ { value: 'without', label: 'Без параметров' },
314
661
  ],
315
- onChange: (value) => setStatusFilter(value as StatusFilter),
662
+ onChange: (value) => setParametersFilter(value as ParametersFilter),
316
663
  },
317
- ]}
318
- />
319
- <div style={{ marginLeft: 'auto' }}>
320
- <Button variant="solid" size="sm" loading={isCreating} onClick={() => void handleCreate()}>
321
- Создать
322
- </Button>
323
- </div>
324
- </Toolbar>
664
+ ],
665
+ }}
666
+ views={
667
+ <ViewTabs
668
+ value={view}
669
+ onChange={(next) => {
670
+ setView(next);
671
+ selection.clear();
672
+ }}
673
+ options={VIEWS.map((option) => ({
674
+ ...option,
675
+ count: loaded === undefined ? undefined : byParameters.filter((process) => isInView(process, option.value)).length,
676
+ }))}
677
+ />
678
+ }
679
+ total={loaded === undefined ? undefined : formatTotal(processes.length, undefined, PROCESS_NOUN)}
680
+ columns={{ columns: COLUMNS, hiddenKeys: hiddenColumns, onChange: setHiddenColumns }}
681
+ onRefresh={() => void reloadAll()}
682
+ isRefreshing={loading}
683
+ onCreate={() => void handleCreate()}
684
+ isCreating={isCreating}
685
+ />
325
686
  {error !== undefined && (
326
687
  <div style={{ padding: theme.spacing[4] }}>
327
688
  <Message tone="danger">{getErrorMessage(error, 'Не удалось загрузить процессы')}</Message>
@@ -333,58 +694,77 @@ const DocflowProcesses = () => {
333
694
  )}
334
695
  {processes.length > 0 && (
335
696
  <DataTable
336
- columns={[
337
- { key: 'name', label: 'Процесс', width: '36%', sortKey: 'name' },
338
- { key: 'status', label: 'Статус', width: '18%', sortKey: 'status' },
339
- { key: 'parameters', label: 'Параметры', width: '14%', sortKey: 'parameters' },
340
- { key: 'stages', label: 'Этапы' },
341
- ]}
697
+ columns={COLUMNS}
698
+ hiddenColumnKeys={hiddenColumns}
342
699
  sort={sort}
343
700
  onSortChange={setSort}
344
701
  selectedRowId={selectedWorkflowId}
702
+ selectedIds={selectedIds}
703
+ onSelectionChange={selection.setSelectedIds}
345
704
  onRowClick={(workflowId) => setPanel({ kind: 'process', workflowId })}
346
- rowActions={(workflowId) => {
347
- const process = processes.find((candidate) => candidate.workflowId === workflowId);
348
-
349
- if (process === undefined) {
350
- return [];
351
- }
352
-
353
- return [
354
- ...(process.isPublished
355
- ? [{ key: 'launch', label: 'Запустить', icon: IconPlayerPlay, onSelect: () => setPanel({ kind: 'process', workflowId }) }]
356
- : []),
357
- { key: 'edit', label: 'Открыть редактор', icon: IconSettings, onSelect: () => void openEditor(workflowId) },
358
- { key: 'rename', label: 'Переименовать', icon: IconPencil, onSelect: () => setRenaming(process) },
359
- {
360
- key: 'delete',
361
- label: 'Удалить',
362
- confirmLabel: 'Точно удалить?',
363
- icon: IconTrash,
364
- tone: 'danger' as const,
365
- onSelect: () => void handleDeleteProcess(process),
366
- },
367
- ];
368
- }}
705
+ rowActions={rowActions}
369
706
  rows={processes.map((process) => {
370
707
  const parameterCount = process.launchFields.length;
708
+ const counts = runCounts?.[process.workflowId];
371
709
 
372
710
  return {
373
711
  id: process.workflowId,
374
712
  cells: {
375
713
  name: process.name,
376
714
  status: (
377
- <Tag color={process.isPublished ? 'green' : 'gray'}>
378
- {process.isPublished ? 'Опубликован' : 'Черновик'}
379
- </Tag>
715
+ <span style={{ display: 'inline-flex', gap: theme.spacing[1] }}>
716
+ <StatusChip color={process.isActive ? 'green' : 'gray'}>
717
+ {process.isActive ? 'Опубликован' : 'Черновик'}
718
+ </StatusChip>
719
+ {process.hasUnpublishedChanges && <StatusChip color="orange">Изменён</StatusChip>}
720
+ </span>
380
721
  ),
381
722
  parameters: parameterCount > 0 ? String(parameterCount) : 'нет',
382
- stages: process.stageNames.join(' → '),
723
+ stageCount: String(process.stages.length),
724
+ runs:
725
+ counts === undefined ? (
726
+ '…'
727
+ ) : (
728
+ <span>
729
+ {counts.total}
730
+ {counts.running > 0 && <span style={{ color: theme.color.blue }}> · идёт {counts.running}</span>}
731
+ </span>
732
+ ),
733
+ stages: process.stages.map(({ name }) => name).join(' → ') || '—',
734
+ updatedAt: formatDate(process.updatedAt),
383
735
  },
384
736
  };
385
737
  })}
386
738
  />
387
739
  )}
740
+ <SelectionBar count={selectedIds.length} noun={PROCESS_NOUN} onClear={selection.clear}>
741
+ {selectedIds.length === 1 && (
742
+ <Button icon={IconSettings} onClick={() => void openEditor(selectedIds[0])}>
743
+ Открыть редактор
744
+ </Button>
745
+ )}
746
+ <Button icon={IconRocket} loading={bulk.isBusy} onClick={() => void publishProcesses(selectedIds, true)}>
747
+ Опубликовать
748
+ </Button>
749
+ <Button icon={IconPlayerStop} disabled={bulk.isBusy} onClick={() => void publishProcesses(selectedIds, false)}>
750
+ Снять с публикации
751
+ </Button>
752
+ <MenuButton
753
+ label="Удалить"
754
+ icon={IconTrash}
755
+ disabled={bulk.isBusy}
756
+ actions={[
757
+ {
758
+ key: 'delete',
759
+ label: `Удалить выбранные (${selectedIds.length})`,
760
+ confirmLabel: 'Точно удалить? Запуски и задания останутся',
761
+ icon: IconTrash,
762
+ tone: 'danger',
763
+ onSelect: () => void removeProcesses(selectedIds),
764
+ },
765
+ ]}
766
+ />
767
+ </SelectionBar>
388
768
  </SplitView>
389
769
  {renaming !== undefined && (
390
770
  <PromptDialog