@haiiro/docuflow 0.0.11 → 0.0.13

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