@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.
- package/README.md +45 -9
- package/manifest.json +117 -16
- package/package.json +6 -3
- package/public/app/docflow-app.css +2 -0
- package/public/app/docflow-app.js +94 -0
- package/src/front-components/docflow-documents.front-component.mjs +42 -42
- package/src/front-components/docflow-documents.front-component.tsx +336 -128
- package/src/front-components/docflow-processes.front-component.mjs +35 -35
- package/src/front-components/docflow-processes.front-component.tsx +527 -131
- package/src/front-components/docflow-tasks.front-component.mjs +46 -41
- package/src/front-components/docflow-tasks.front-component.tsx +489 -132
- package/src/front-components/launch-process.front-component.mjs +75 -0
- package/src/front-components/launch-process.front-component.tsx +270 -0
- package/src/front-components/process-steps.front-component.mjs +25 -25
- package/src/front-components/process-steps.front-component.tsx +55 -10
- package/src/front-components/task-form.front-component.mjs +46 -41
- package/src/logic-functions/bitrix-app.logic-function.mjs +424 -198
- package/src/logic-functions/bitrix-app.logic-function.ts +54 -82
- package/src/logic-functions/bitrix-install.logic-function.mjs +0 -11
- package/src/logic-functions/bitrix-install.logic-function.ts +0 -3
- package/src/logic-functions/capture-file.logic-function.mjs +2 -1
- package/src/logic-functions/docflow-app-session.logic-function.mjs +60043 -0
- package/src/logic-functions/docflow-app-session.logic-function.ts +54 -0
- package/src/logic-functions/docflow-app.logic-function.mjs +60122 -0
- package/src/logic-functions/docflow-app.logic-function.ts +37 -0
- package/src/logic-functions/hand-out-form-step.logic-function.mjs +2 -1
- package/src/logic-functions/launch-process.logic-function.mjs +113 -29
- package/src/logic-functions/launch-process.logic-function.ts +83 -22
- package/src/logic-functions/on-workflow-run-updated.logic-function.mjs +2 -1
- package/src/logic-functions/publish-process.logic-function.mjs +119519 -0
- package/src/logic-functions/publish-process.logic-function.ts +91 -0
- package/src/logic-functions/save-launch-fields.logic-function.mjs +178 -10
- package/src/logic-functions/save-launch-fields.logic-function.ts +24 -6
|
@@ -1,34 +1,69 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
2
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
3
3
|
import { AppPath, enqueueSnackbar, openSidePanelPage } from 'twenty-sdk/front-component';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
IconCheck,
|
|
6
|
+
IconExternalLink,
|
|
7
|
+
IconEye,
|
|
8
|
+
IconPencil,
|
|
9
|
+
IconPlayerPlay,
|
|
10
|
+
IconRestore,
|
|
11
|
+
IconTrash,
|
|
12
|
+
} from 'twenty-ui/icon';
|
|
6
13
|
import { useTheme } from 'twenty-ui/theme';
|
|
7
14
|
|
|
8
|
-
import {
|
|
9
|
-
|
|
15
|
+
import {
|
|
16
|
+
callAppRoute,
|
|
17
|
+
countRecords,
|
|
18
|
+
createRecord,
|
|
19
|
+
deleteRecords,
|
|
20
|
+
getErrorMessage,
|
|
21
|
+
listRecords,
|
|
22
|
+
restoreRecords,
|
|
23
|
+
updateRecords,
|
|
24
|
+
} from 'src/front/api';
|
|
25
|
+
import {
|
|
26
|
+
describeDue,
|
|
27
|
+
formatDate,
|
|
28
|
+
formatMemberName,
|
|
29
|
+
TASK_STATUS_COLORS,
|
|
30
|
+
TASK_STATUS_LABELS,
|
|
31
|
+
} from 'src/front/format';
|
|
10
32
|
import { useAsync, useDebouncedValue } from 'src/front/hooks/use-async';
|
|
33
|
+
import { useBulkAction } from 'src/front/hooks/use-bulk-action';
|
|
11
34
|
import { useWorkspaceMembers } from 'src/front/hooks/use-workspace-members';
|
|
12
|
-
import {
|
|
35
|
+
import { getTaskFieldProgress, isProcessTask } from 'src/front/tasks';
|
|
36
|
+
import { type DocflowTask, type WorkspaceMember } from 'src/front/types';
|
|
13
37
|
import {
|
|
14
38
|
DataTable,
|
|
15
39
|
DocflowRoot,
|
|
40
|
+
formatTotal,
|
|
41
|
+
ListToolbar,
|
|
16
42
|
Message,
|
|
43
|
+
pluralize,
|
|
44
|
+
ProgressBar,
|
|
17
45
|
SectionTitle,
|
|
18
46
|
SelectField,
|
|
47
|
+
SelectionBar,
|
|
48
|
+
ShowMore,
|
|
19
49
|
SplitView,
|
|
20
50
|
TextAreaField,
|
|
21
51
|
TextField,
|
|
52
|
+
type TableColumn,
|
|
22
53
|
type TableSort,
|
|
23
54
|
toRestOrderBy,
|
|
24
|
-
|
|
55
|
+
useHiddenColumns,
|
|
56
|
+
useRowSelection,
|
|
25
57
|
} from 'src/front/ui/components';
|
|
26
58
|
import { FormFieldsBuilder } from 'src/front/ui/form-fields-builder';
|
|
27
|
-
import { Button,
|
|
59
|
+
import { BulkChangeDialog, Button, type MenuAction, MenuButton, StatusChip, ViewTabs } from 'src/front/ui/kit';
|
|
60
|
+
|
|
61
|
+
import { UrgentTaskCards } from 'src/front/ui/task-cards';
|
|
28
62
|
import { TaskWorkPanel } from 'src/front/ui/task-work-panel';
|
|
29
63
|
import {
|
|
30
64
|
restAnd,
|
|
31
65
|
restEq,
|
|
66
|
+
restGte,
|
|
32
67
|
restIlike,
|
|
33
68
|
restIsNotNull,
|
|
34
69
|
restIsNull,
|
|
@@ -41,64 +76,112 @@ import { type FormField } from 'src/shared/form/form-field.type';
|
|
|
41
76
|
import { getFormFieldsError } from 'src/shared/form/form-fields.util';
|
|
42
77
|
import { TASKS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
|
|
43
78
|
|
|
44
|
-
type
|
|
79
|
+
type TaskView = 'open' | 'mine' | 'done';
|
|
80
|
+
type TaskFilters = {
|
|
81
|
+
source: 'any' | 'process' | 'own';
|
|
82
|
+
due: 'any' | 'overdue' | 'week' | 'none';
|
|
83
|
+
assignee: string;
|
|
84
|
+
// Deleted = in the Twenty trash, restored from here
|
|
85
|
+
scope: 'active' | 'deleted';
|
|
86
|
+
};
|
|
45
87
|
type Panel = { kind: 'closed' } | { kind: 'create' } | { kind: 'task'; taskId: string };
|
|
88
|
+
type BulkDialog = 'change' | undefined;
|
|
46
89
|
|
|
47
90
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
48
91
|
const NO_ASSIGNEE = '';
|
|
92
|
+
const ANY = 'any';
|
|
93
|
+
const NOBODY = 'nobody';
|
|
94
|
+
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
95
|
+
// A task of mine due within this many days is a card above the table
|
|
96
|
+
const URGENT_DAYS = 3;
|
|
97
|
+
const URGENT_LIMIT = 30;
|
|
98
|
+
const TASK_IN_PROGRESS_STATUS = 'IN_PROGRESS';
|
|
99
|
+
const TASK_NOUN: [string, string, string] = ['задание', 'задания', 'заданий'];
|
|
49
100
|
|
|
50
|
-
const
|
|
101
|
+
const VIEWS: { value: TaskView; label: string }[] = [
|
|
51
102
|
{ value: 'open', label: 'Открытые' },
|
|
52
103
|
{ value: 'mine', label: 'Мои' },
|
|
53
|
-
{ value: 'overdue', label: 'Просроченные' },
|
|
54
104
|
{ value: 'done', label: 'Выполненные' },
|
|
55
|
-
{ value: 'archived', label: 'Архив (удалённые)' },
|
|
56
105
|
];
|
|
57
106
|
|
|
58
|
-
const
|
|
107
|
+
const DEFAULT_FILTERS: TaskFilters = { source: 'any', due: 'any', assignee: ANY, scope: 'active' };
|
|
108
|
+
const DEFAULT_SORT: TableSort = { key: 'dueAt', direction: 'asc' };
|
|
109
|
+
|
|
110
|
+
const COLUMNS: TableColumn[] = [
|
|
111
|
+
{ key: 'title', label: 'Задание', width: '30%', sortKey: 'title' },
|
|
112
|
+
{ key: 'assignee', label: 'Исполнитель', width: '15%', sortKey: 'assigneeId' },
|
|
113
|
+
{ key: 'dueAt', label: 'Срок', width: '15%', sortKey: 'dueAt' },
|
|
114
|
+
{ key: 'fields', label: 'Поля', width: '11%' },
|
|
115
|
+
{ key: 'status', label: 'Статус', width: '13%', sortKey: 'status' },
|
|
116
|
+
{ key: 'source', label: 'Откуда', width: '10%' },
|
|
117
|
+
{ key: 'createdAt', label: 'Создано', sortKey: 'createdAt' },
|
|
118
|
+
];
|
|
59
119
|
|
|
60
120
|
const OPEN_FILTER = restOr(restNeq('status', TASK_DONE_STATUS), restIsNull('status'));
|
|
61
121
|
|
|
62
|
-
// An archived task opens in its standard card: Twenty shows it as deleted
|
|
63
|
-
// there and offers to restore it.
|
|
64
122
|
// A task opens in its standard card too; a deleted one shows "Восстановить" there
|
|
65
|
-
const
|
|
123
|
+
const openTaskCard = (taskId: string) =>
|
|
66
124
|
openSidePanelPage({
|
|
67
125
|
to: AppPath.RecordShowPage,
|
|
68
126
|
params: { objectNameSingular: 'task', objectRecordId: taskId },
|
|
69
127
|
pageTitle: 'Задание',
|
|
70
128
|
});
|
|
71
129
|
|
|
130
|
+
// In the trash a task shows whatever its status was
|
|
131
|
+
const viewFilter = (view: TaskView, currentMemberId: string | undefined, isTrash = false) =>
|
|
132
|
+
restAnd(
|
|
133
|
+
isTrash ? restIsNotNull('deletedAt') : view === 'done' ? restEq('status', TASK_DONE_STATUS) : OPEN_FILTER,
|
|
134
|
+
view === 'mine'
|
|
135
|
+
? currentMemberId !== undefined
|
|
136
|
+
? restEq('assigneeId', currentMemberId)
|
|
137
|
+
: restIsNull('assigneeId')
|
|
138
|
+
: undefined,
|
|
139
|
+
);
|
|
140
|
+
|
|
72
141
|
// The list is filtered by the server, so a long history of tasks never
|
|
73
142
|
// travels to the browser.
|
|
74
143
|
const buildFilter = ({
|
|
75
|
-
|
|
144
|
+
view,
|
|
145
|
+
filters,
|
|
76
146
|
searchQuery,
|
|
77
147
|
currentMemberId,
|
|
78
148
|
now,
|
|
149
|
+
weekAhead,
|
|
79
150
|
}: {
|
|
80
|
-
|
|
151
|
+
view: TaskView;
|
|
152
|
+
filters: TaskFilters;
|
|
81
153
|
searchQuery: string;
|
|
82
154
|
currentMemberId: string | undefined;
|
|
83
155
|
now: string;
|
|
156
|
+
weekAhead: string;
|
|
84
157
|
}) =>
|
|
85
158
|
restAnd(
|
|
86
159
|
restEq('smbSection', DOCFLOW_SECTION),
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
?
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
taskFilter === 'overdue' ? restLt('dueAt', now) : undefined,
|
|
160
|
+
viewFilter(view, currentMemberId, filters.scope === 'deleted'),
|
|
161
|
+
filters.source === 'process' ? restIsNotNull('smbWorkflowThreadId') : undefined,
|
|
162
|
+
filters.source === 'own' ? restIsNull('smbWorkflowThreadId') : undefined,
|
|
163
|
+
filters.due === 'overdue' ? restLt('dueAt', now) : undefined,
|
|
164
|
+
filters.due === 'week' ? restAnd(restIsNotNull('dueAt'), restLt('dueAt', weekAhead)) : undefined,
|
|
165
|
+
filters.due === 'none' ? restIsNull('dueAt') : undefined,
|
|
166
|
+
filters.assignee === NOBODY
|
|
167
|
+
? restIsNull('assigneeId')
|
|
168
|
+
: filters.assignee !== ANY
|
|
169
|
+
? restEq('assigneeId', filters.assignee)
|
|
170
|
+
: undefined,
|
|
99
171
|
searchQuery.trim().length > 0 ? restIlike('title', searchQuery) : undefined,
|
|
100
172
|
);
|
|
101
173
|
|
|
174
|
+
// What the cards hold: mine, open, due before the horizon
|
|
175
|
+
const urgentFilter = (currentMemberId: string, horizon: string) =>
|
|
176
|
+
restAnd(restEq('assigneeId', currentMemberId), restIsNotNull('dueAt'), restLt('dueAt', horizon));
|
|
177
|
+
|
|
178
|
+
// The table without the cards' tasks, so a task shows in one place only
|
|
179
|
+
const notUrgentFilter = (currentMemberId: string, horizon: string) =>
|
|
180
|
+
restOr(restNeq('assigneeId', currentMemberId), restIsNull('assigneeId'), restIsNull('dueAt'), restGte('dueAt', horizon));
|
|
181
|
+
|
|
182
|
+
const memberOptions = (members: WorkspaceMember[]) =>
|
|
183
|
+
members.map((member) => ({ value: member.id, label: formatMemberName(member) || member.id }));
|
|
184
|
+
|
|
102
185
|
const TaskCreateForm = ({
|
|
103
186
|
currentMemberId,
|
|
104
187
|
onCreated,
|
|
@@ -161,10 +244,7 @@ const TaskCreateForm = ({
|
|
|
161
244
|
<SelectField
|
|
162
245
|
label="Исполнитель"
|
|
163
246
|
value={assigneeId}
|
|
164
|
-
options={[
|
|
165
|
-
{ value: NO_ASSIGNEE, label: 'Не назначен' },
|
|
166
|
-
...members.map((member) => ({ value: member.id, label: formatMemberName(member) || member.id })),
|
|
167
|
-
]}
|
|
247
|
+
options={[{ value: NO_ASSIGNEE, label: 'Не назначен' }, ...memberOptions(members)]}
|
|
168
248
|
onChange={setAssigneeId}
|
|
169
249
|
/>
|
|
170
250
|
<TextField label="Срок" type="date" value={dueAt} onChange={setDueAt} />
|
|
@@ -173,7 +253,7 @@ const TaskCreateForm = ({
|
|
|
173
253
|
<FormFieldsBuilder fields={fields} onChange={setFields} disabled={isSaving} />
|
|
174
254
|
{errorMessage !== undefined && <Message tone="danger">{errorMessage}</Message>}
|
|
175
255
|
<div style={{ display: 'flex', gap: theme.spacing[2] }}>
|
|
176
|
-
<Button variant="solid" size="sm" loading={isSaving} disabled={title.trim().length === 0} onClick={() => void handleCreate()}>
|
|
256
|
+
<Button variant="solid" tone="accent" size="sm" loading={isSaving} disabled={title.trim().length === 0} onClick={() => void handleCreate()}>
|
|
177
257
|
Создать
|
|
178
258
|
</Button>
|
|
179
259
|
<Button variant="ghost" size="sm" onClick={onCancel}>
|
|
@@ -189,34 +269,164 @@ const DocflowTasks = () => {
|
|
|
189
269
|
const { members, currentMember } = useWorkspaceMembers();
|
|
190
270
|
const [searchInput, setSearchInput] = useState('');
|
|
191
271
|
const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
|
192
|
-
const [
|
|
272
|
+
const [view, setView] = useState<TaskView>('open');
|
|
273
|
+
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_FILTERS);
|
|
193
274
|
const [sort, setSort] = useState<TableSort>(DEFAULT_SORT);
|
|
194
275
|
const [panel, setPanel] = useState<Panel>({ kind: 'closed' });
|
|
195
276
|
const [pages, setPages] = useState(1);
|
|
277
|
+
const [hiddenColumns, setHiddenColumns] = useHiddenColumns(['createdAt']);
|
|
278
|
+
const [bulkDialog, setBulkDialog] = useState<BulkDialog>();
|
|
196
279
|
// Fixed for the life of the list, so "overdue" does not refetch by itself.
|
|
197
|
-
const [
|
|
280
|
+
const [nowDate] = useState(() => new Date());
|
|
281
|
+
const now = nowDate.toISOString();
|
|
282
|
+
const weekAhead = new Date(nowDate.getTime() + 7 * DAY_IN_MS).toISOString();
|
|
283
|
+
// The cards take tasks due before the start of the day after the horizon
|
|
284
|
+
const horizonDate = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate() + URGENT_DAYS + 1);
|
|
285
|
+
const horizon = horizonDate.toISOString();
|
|
286
|
+
const currentMemberId = currentMember?.id;
|
|
287
|
+
const isTrash = filters.scope === 'deleted';
|
|
288
|
+
const hasCards = view !== 'done' && !isTrash && currentMemberId !== undefined;
|
|
289
|
+
const selectedTaskId = panel.kind === 'task' ? panel.taskId : undefined;
|
|
290
|
+
|
|
291
|
+
const baseFilter = useMemo(
|
|
292
|
+
() => buildFilter({ view, filters, searchQuery, currentMemberId, now, weekAhead }),
|
|
293
|
+
[view, filters, searchQuery, currentMemberId, now, weekAhead],
|
|
294
|
+
);
|
|
198
295
|
|
|
199
296
|
const { data, loading, error, reload } = useAsync(
|
|
200
297
|
() =>
|
|
201
298
|
listRecords<DocflowTask>('tasks', {
|
|
202
|
-
filter:
|
|
299
|
+
filter: hasCards ? restAnd(baseFilter, notUrgentFilter(currentMemberId, horizon)) : baseFilter,
|
|
203
300
|
order_by: toRestOrderBy(sort),
|
|
204
301
|
limit: PAGE_SIZE * pages,
|
|
205
302
|
}),
|
|
206
|
-
[
|
|
303
|
+
[baseFilter, hasCards, currentMemberId, horizon, sort, pages],
|
|
304
|
+
);
|
|
305
|
+
const { data: urgent, reload: reloadUrgent } = useAsync(
|
|
306
|
+
async () =>
|
|
307
|
+
hasCards
|
|
308
|
+
? (
|
|
309
|
+
await listRecords<DocflowTask>('tasks', {
|
|
310
|
+
filter: restAnd(baseFilter, urgentFilter(currentMemberId, horizon)),
|
|
311
|
+
order_by: 'dueAt[AscNullsLast]',
|
|
312
|
+
limit: URGENT_LIMIT,
|
|
313
|
+
})
|
|
314
|
+
).records
|
|
315
|
+
: [],
|
|
316
|
+
[baseFilter, hasCards, currentMemberId, horizon],
|
|
317
|
+
);
|
|
318
|
+
// The counts of the views follow the search, not the other filters
|
|
319
|
+
const { data: counts, reload: reloadCounts } = useAsync(
|
|
320
|
+
async () =>
|
|
321
|
+
Object.fromEntries(
|
|
322
|
+
await Promise.all(
|
|
323
|
+
VIEWS.map(async ({ value }) => [
|
|
324
|
+
value,
|
|
325
|
+
await countRecords(
|
|
326
|
+
'tasks',
|
|
327
|
+
restAnd(
|
|
328
|
+
restEq('smbSection', DOCFLOW_SECTION),
|
|
329
|
+
viewFilter(value, currentMemberId),
|
|
330
|
+
searchQuery.trim().length > 0 ? restIlike('title', searchQuery) : undefined,
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
]),
|
|
334
|
+
),
|
|
335
|
+
) as Record<TaskView, number>,
|
|
336
|
+
[searchQuery, currentMemberId],
|
|
207
337
|
);
|
|
208
338
|
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
339
|
+
const tasks = data?.records ?? [];
|
|
340
|
+
const urgentTasks = urgent ?? [];
|
|
341
|
+
const selection = useRowSelection(tasks.map(({ id }) => id));
|
|
342
|
+
const selectedTasks = tasks.filter(({ id }) => selection.selectedIds.includes(id));
|
|
343
|
+
const activeFilterCount =
|
|
344
|
+
(filters.source !== ANY ? 1 : 0) +
|
|
345
|
+
(filters.due !== ANY ? 1 : 0) +
|
|
346
|
+
(filters.assignee !== ANY ? 1 : 0) +
|
|
347
|
+
(isTrash ? 1 : 0);
|
|
348
|
+
|
|
349
|
+
const reloadAll = async () => {
|
|
350
|
+
await Promise.all([reload(), reloadUrgent(), reloadCounts()]);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const bulk = useBulkAction({
|
|
354
|
+
onDone: async () => {
|
|
355
|
+
selection.clear();
|
|
356
|
+
setBulkDialog(undefined);
|
|
357
|
+
await reloadAll();
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const changeView = (next: TaskView) => {
|
|
362
|
+
setView(next);
|
|
363
|
+
setPages(1);
|
|
364
|
+
selection.clear();
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const changeFilters = (patch: Partial<TaskFilters>) => {
|
|
368
|
+
setFilters((current) => ({ ...current, ...patch }));
|
|
369
|
+
setPages(1);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const completeTasks = (ids: string[]) =>
|
|
373
|
+
bulk.runEach(ids, (taskId) => callAppRoute('/docflow/tasks/complete', { taskId }), {
|
|
374
|
+
done: (count) => `Выполнено: ${count}`,
|
|
375
|
+
failed: 'Не выполнены',
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const setStatus = (ids: string[], status: string) =>
|
|
379
|
+
bulk.run(
|
|
380
|
+
() => updateRecords('tasks', ids, { status }),
|
|
381
|
+
`Статус изменён: ${TASK_STATUS_LABELS[status]}`,
|
|
382
|
+
'Не удалось изменить статус',
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const removeTasks = (ids: string[]) =>
|
|
386
|
+
bulk.run(
|
|
387
|
+
async () => {
|
|
388
|
+
await deleteRecords('tasks', ids);
|
|
389
|
+
setPanel((current) => (current.kind === 'task' && ids.includes(current.taskId) ? { kind: 'closed' } : current));
|
|
390
|
+
},
|
|
391
|
+
ids.length === 1 ? 'Задание удалено: оно в фильтре «Удалённые»' : `Удалено заданий: ${ids.length}, они в фильтре «Удалённые»`,
|
|
392
|
+
'Не удалось удалить',
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const restoreTasks = (ids: string[]) =>
|
|
396
|
+
bulk.run(() => restoreRecords('tasks', ids), `Восстановлено: ${ids.length}`, 'Не удалось восстановить');
|
|
397
|
+
|
|
398
|
+
const rowActions = (taskId: string): MenuAction[] => {
|
|
399
|
+
const task = tasks.find(({ id }) => id === taskId);
|
|
400
|
+
|
|
401
|
+
if (isTrash) {
|
|
402
|
+
return [
|
|
403
|
+
{ key: 'restore', label: 'Восстановить', icon: IconRestore, onSelect: () => void restoreTasks([taskId]) },
|
|
404
|
+
{ key: 'card', label: 'Открыть карточку', icon: IconExternalLink, onSelect: () => void openTaskCard(taskId) },
|
|
405
|
+
];
|
|
217
406
|
}
|
|
407
|
+
|
|
408
|
+
const isDone = task?.status === TASK_DONE_STATUS;
|
|
409
|
+
|
|
410
|
+
return [
|
|
411
|
+
{ key: 'open', label: 'Открыть', icon: IconEye, onSelect: () => setPanel({ kind: 'task', taskId }) },
|
|
412
|
+
{ key: 'card', label: 'Карточка задачи', icon: IconExternalLink, onSelect: () => void openTaskCard(taskId) },
|
|
413
|
+
...(task?.status === TASK_TODO_STATUS
|
|
414
|
+
? [{ key: 'start', label: 'Взять в работу', icon: IconPlayerPlay, onSelect: () => void setStatus([taskId], TASK_IN_PROGRESS_STATUS) }]
|
|
415
|
+
: []),
|
|
416
|
+
...(!isDone ? [{ key: 'complete', label: 'Выполнить', icon: IconCheck, onSelect: () => void completeTasks([taskId]) }] : []),
|
|
417
|
+
{
|
|
418
|
+
key: 'delete',
|
|
419
|
+
label: 'Удалить',
|
|
420
|
+
confirmLabel: 'Точно удалить?',
|
|
421
|
+
icon: IconTrash,
|
|
422
|
+
tone: 'danger',
|
|
423
|
+
onSelect: () => void removeTasks([taskId]),
|
|
424
|
+
},
|
|
425
|
+
];
|
|
218
426
|
};
|
|
219
|
-
|
|
427
|
+
|
|
428
|
+
const selectedIds = selection.selectedIds;
|
|
429
|
+
const openSelectedCount = selectedTasks.filter((task) => task.status !== TASK_DONE_STATUS).length;
|
|
220
430
|
|
|
221
431
|
return (
|
|
222
432
|
<DocflowRoot>
|
|
@@ -226,113 +436,260 @@ const DocflowTasks = () => {
|
|
|
226
436
|
panel={
|
|
227
437
|
panel.kind === 'create' ? (
|
|
228
438
|
<TaskCreateForm
|
|
229
|
-
currentMemberId={
|
|
439
|
+
currentMemberId={currentMemberId}
|
|
230
440
|
onCancel={() => setPanel({ kind: 'closed' })}
|
|
231
441
|
onCreated={(taskId) => {
|
|
232
442
|
setPanel({ kind: 'task', taskId });
|
|
233
|
-
void
|
|
443
|
+
void reloadAll();
|
|
234
444
|
}}
|
|
235
445
|
/>
|
|
236
446
|
) : panel.kind === 'task' ? (
|
|
237
|
-
<TaskWorkPanel key={panel.taskId} taskId={panel.taskId} onChanged={() => void
|
|
447
|
+
<TaskWorkPanel key={panel.taskId} taskId={panel.taskId} withChat onChanged={() => void reloadAll()} />
|
|
238
448
|
) : undefined
|
|
239
449
|
}
|
|
240
450
|
>
|
|
241
|
-
<
|
|
242
|
-
|
|
243
|
-
value
|
|
244
|
-
onChange
|
|
245
|
-
placeholder
|
|
246
|
-
activeFilterCount
|
|
247
|
-
onResetFilters
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
451
|
+
<ListToolbar
|
|
452
|
+
search={{
|
|
453
|
+
value: searchInput,
|
|
454
|
+
onChange: setSearchInput,
|
|
455
|
+
placeholder: 'Поиск заданий',
|
|
456
|
+
activeFilterCount,
|
|
457
|
+
onResetFilters: () => changeFilters(DEFAULT_FILTERS),
|
|
458
|
+
filters: [
|
|
459
|
+
{
|
|
460
|
+
key: 'source',
|
|
461
|
+
title: 'Откуда',
|
|
462
|
+
value: filters.source,
|
|
463
|
+
options: [
|
|
464
|
+
{ value: ANY, label: 'Все' },
|
|
465
|
+
{ value: 'process', label: 'Шаги процессов' },
|
|
466
|
+
{ value: 'own', label: 'Свои задания' },
|
|
467
|
+
],
|
|
468
|
+
onChange: (value) => changeFilters({ source: value as TaskFilters['source'] }),
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
key: 'due',
|
|
472
|
+
title: 'Срок',
|
|
473
|
+
value: filters.due,
|
|
474
|
+
options: [
|
|
475
|
+
{ value: ANY, label: 'Любой' },
|
|
476
|
+
{ value: 'overdue', label: 'Просроченные' },
|
|
477
|
+
{ value: 'week', label: 'В ближайшие 7 дней' },
|
|
478
|
+
{ value: 'none', label: 'Без срока' },
|
|
479
|
+
],
|
|
480
|
+
onChange: (value) => changeFilters({ due: value as TaskFilters['due'] }),
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
key: 'assignee',
|
|
484
|
+
title: 'Исполнитель',
|
|
485
|
+
value: filters.assignee,
|
|
486
|
+
options: [
|
|
487
|
+
{ value: ANY, label: 'Любой' },
|
|
488
|
+
{ value: NOBODY, label: 'Не назначен' },
|
|
489
|
+
...memberOptions(members),
|
|
490
|
+
],
|
|
491
|
+
onChange: (value) => changeFilters({ assignee: value }),
|
|
492
|
+
},
|
|
252
493
|
{
|
|
253
|
-
key: '
|
|
254
|
-
title: '
|
|
255
|
-
value:
|
|
256
|
-
options:
|
|
494
|
+
key: 'scope',
|
|
495
|
+
title: 'Корзина',
|
|
496
|
+
value: filters.scope,
|
|
497
|
+
options: [
|
|
498
|
+
{ value: 'active', label: 'Действующие' },
|
|
499
|
+
{ value: 'deleted', label: 'Удалённые' },
|
|
500
|
+
],
|
|
257
501
|
onChange: (value) => {
|
|
258
|
-
|
|
259
|
-
|
|
502
|
+
changeFilters({ scope: value as TaskFilters['scope'] });
|
|
503
|
+
selection.clear();
|
|
260
504
|
},
|
|
261
505
|
},
|
|
262
|
-
]
|
|
506
|
+
],
|
|
507
|
+
}}
|
|
508
|
+
views={
|
|
509
|
+
<ViewTabs
|
|
510
|
+
value={view}
|
|
511
|
+
onChange={changeView}
|
|
512
|
+
options={VIEWS.map((option) => ({ ...option, count: counts?.[option.value] }))}
|
|
513
|
+
/>
|
|
514
|
+
}
|
|
515
|
+
total={
|
|
516
|
+
data === undefined
|
|
517
|
+
? undefined
|
|
518
|
+
: formatTotal(tasks.length + urgentTasks.length, (data.totalCount ?? tasks.length) + urgentTasks.length, TASK_NOUN)
|
|
519
|
+
}
|
|
520
|
+
columns={{ columns: COLUMNS, hiddenKeys: hiddenColumns, onChange: setHiddenColumns }}
|
|
521
|
+
onRefresh={() => void reloadAll()}
|
|
522
|
+
isRefreshing={loading}
|
|
523
|
+
onCreate={() => setPanel({ kind: 'create' })}
|
|
524
|
+
/>
|
|
525
|
+
{hasCards && (
|
|
526
|
+
<UrgentTaskCards
|
|
527
|
+
tasks={urgentTasks}
|
|
528
|
+
members={members}
|
|
529
|
+
now={nowDate}
|
|
530
|
+
selectedTaskId={selectedTaskId}
|
|
531
|
+
onOpen={(taskId) => setPanel({ kind: 'task', taskId })}
|
|
263
532
|
/>
|
|
264
|
-
|
|
265
|
-
<Button variant="solid" size="sm" onClick={() => setPanel({ kind: 'create' })}>
|
|
266
|
-
Создать
|
|
267
|
-
</Button>
|
|
268
|
-
</div>
|
|
269
|
-
</Toolbar>
|
|
533
|
+
)}
|
|
270
534
|
{error !== undefined && (
|
|
271
535
|
<div style={{ padding: theme.spacing[4] }}>
|
|
272
536
|
<Message tone="danger">{getErrorMessage(error, 'Не удалось загрузить задания')}</Message>
|
|
273
537
|
</div>
|
|
274
538
|
)}
|
|
275
|
-
{loading &&
|
|
276
|
-
{!loading && error === undefined && tasks.length === 0 &&
|
|
539
|
+
{loading && data === undefined && <Message>Загрузка…</Message>}
|
|
540
|
+
{!loading && error === undefined && tasks.length === 0 && (
|
|
541
|
+
<Message>{urgentTasks.length > 0 ? 'Остальных заданий нет: все срочные — в карточках выше.' : 'Заданий нет.'}</Message>
|
|
542
|
+
)}
|
|
277
543
|
{tasks.length > 0 && (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
{
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
544
|
+
<>
|
|
545
|
+
{hasCards && urgentTasks.length > 0 && (
|
|
546
|
+
<div style={{ padding: `${theme.spacing[1]} ${theme.spacing[5]} 0` }}>
|
|
547
|
+
<SectionTitle>Остальные задания</SectionTitle>
|
|
548
|
+
</div>
|
|
549
|
+
)}
|
|
550
|
+
<DataTable
|
|
551
|
+
columns={COLUMNS}
|
|
552
|
+
hiddenColumnKeys={hiddenColumns}
|
|
553
|
+
sort={sort}
|
|
554
|
+
onSortChange={(next) => {
|
|
555
|
+
setSort(next);
|
|
556
|
+
setPages(1);
|
|
557
|
+
}}
|
|
558
|
+
selectedRowId={selectedTaskId}
|
|
559
|
+
selectedIds={selectedIds}
|
|
560
|
+
onSelectionChange={selection.setSelectedIds}
|
|
561
|
+
rowActions={rowActions}
|
|
562
|
+
onRowClick={(taskId) => (isTrash ? void openTaskCard(taskId) : setPanel({ kind: 'task', taskId }))}
|
|
563
|
+
rows={tasks.map((task) => {
|
|
564
|
+
const due = describeDue(task.dueAt, nowDate);
|
|
565
|
+
const isDone = task.status === TASK_DONE_STATUS;
|
|
566
|
+
const progress = getTaskFieldProgress(task);
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
id: task.id,
|
|
570
|
+
isMuted: isDone && view !== 'done',
|
|
571
|
+
cells: {
|
|
572
|
+
title: task.title?.trim() || 'Без названия',
|
|
573
|
+
assignee: formatMemberName(members.find((member) => member.id === task.assigneeId)) || '—',
|
|
574
|
+
dueAt: task.dueAt ? (
|
|
575
|
+
<span
|
|
576
|
+
title={formatDate(task.dueAt)}
|
|
577
|
+
style={{
|
|
578
|
+
color: isDone
|
|
579
|
+
? undefined
|
|
580
|
+
: due.tone === 'overdue'
|
|
581
|
+
? theme.font.color.danger
|
|
582
|
+
: due.tone === 'today'
|
|
583
|
+
? theme.color.orange
|
|
584
|
+
: undefined,
|
|
585
|
+
}}
|
|
586
|
+
>
|
|
587
|
+
{isDone ? formatDate(task.dueAt) : `${formatDate(task.dueAt)} · ${due.label.toLowerCase()}`}
|
|
588
|
+
</span>
|
|
589
|
+
) : (
|
|
590
|
+
'—'
|
|
591
|
+
),
|
|
592
|
+
fields:
|
|
593
|
+
progress.total > 0 ? (
|
|
594
|
+
<span style={{ alignItems: 'center', display: 'flex', gap: theme.spacing[2] }}>
|
|
595
|
+
<span style={{ flex: 1, maxWidth: '48px' }}>
|
|
596
|
+
<ProgressBar value={progress.filled} total={progress.total} tone={isDone ? 'green' : 'blue'} />
|
|
597
|
+
</span>
|
|
598
|
+
{progress.filled}/{progress.total}
|
|
599
|
+
</span>
|
|
600
|
+
) : (
|
|
601
|
+
'—'
|
|
602
|
+
),
|
|
603
|
+
status: (
|
|
604
|
+
<StatusChip color={TASK_STATUS_COLORS[task.status ?? ''] ?? 'gray'}>
|
|
605
|
+
{TASK_STATUS_LABELS[task.status ?? ''] ?? task.status ?? 'Без статуса'}
|
|
606
|
+
</StatusChip>
|
|
607
|
+
),
|
|
608
|
+
source: isProcessTask(task) ? 'Процесс' : 'Своё',
|
|
609
|
+
createdAt: formatDate(task.createdAt),
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
})}
|
|
613
|
+
/>
|
|
614
|
+
</>
|
|
327
615
|
)}
|
|
328
|
-
{data?.pageInfo.hasNextPage === true
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
616
|
+
<ShowMore isVisible={data?.pageInfo.hasNextPage === true} loading={loading} onClick={() => setPages((current) => current + 1)} />
|
|
617
|
+
<SelectionBar count={selectedIds.length} noun={TASK_NOUN} onClear={selection.clear}>
|
|
618
|
+
{isTrash ? (
|
|
619
|
+
<Button icon={IconRestore} loading={bulk.isBusy} onClick={() => void restoreTasks(selectedIds)}>
|
|
620
|
+
Восстановить
|
|
332
621
|
</Button>
|
|
333
|
-
|
|
334
|
-
|
|
622
|
+
) : (
|
|
623
|
+
<>
|
|
624
|
+
{openSelectedCount > 0 && (
|
|
625
|
+
<Button icon={IconCheck} loading={bulk.isBusy} onClick={() => void completeTasks(selectedIds)}>
|
|
626
|
+
Выполнить
|
|
627
|
+
</Button>
|
|
628
|
+
)}
|
|
629
|
+
<Button icon={IconPencil} disabled={bulk.isBusy} onClick={() => setBulkDialog('change')}>
|
|
630
|
+
Изменить
|
|
631
|
+
</Button>
|
|
632
|
+
<MenuButton
|
|
633
|
+
label="Удалить"
|
|
634
|
+
icon={IconTrash}
|
|
635
|
+
disabled={bulk.isBusy}
|
|
636
|
+
actions={[
|
|
637
|
+
{
|
|
638
|
+
key: 'delete',
|
|
639
|
+
label: `Удалить выбранные (${selectedIds.length})`,
|
|
640
|
+
confirmLabel: 'Точно удалить?',
|
|
641
|
+
icon: IconTrash,
|
|
642
|
+
tone: 'danger',
|
|
643
|
+
onSelect: () => void removeTasks(selectedIds),
|
|
644
|
+
},
|
|
645
|
+
]}
|
|
646
|
+
/>
|
|
647
|
+
</>
|
|
648
|
+
)}
|
|
649
|
+
</SelectionBar>
|
|
335
650
|
</SplitView>
|
|
651
|
+
{bulkDialog === 'change' && (
|
|
652
|
+
<BulkChangeDialog
|
|
653
|
+
title="Изменить поле"
|
|
654
|
+
hint={`Для ${selectedIds.length} ${pluralize(selectedIds.length, TASK_NOUN)}`}
|
|
655
|
+
fields={[
|
|
656
|
+
{
|
|
657
|
+
key: 'status',
|
|
658
|
+
label: 'Статус',
|
|
659
|
+
options: [TASK_TODO_STATUS, TASK_IN_PROGRESS_STATUS].map((status) => ({
|
|
660
|
+
value: status,
|
|
661
|
+
label: TASK_STATUS_LABELS[status],
|
|
662
|
+
})),
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
key: 'assignee',
|
|
666
|
+
label: 'Исполнитель',
|
|
667
|
+
options: [{ value: NO_ASSIGNEE, label: 'Не назначен' }, ...memberOptions(members)],
|
|
668
|
+
},
|
|
669
|
+
{ key: 'dueAt', label: 'Срок', inputType: 'date' },
|
|
670
|
+
]}
|
|
671
|
+
onClose={() => setBulkDialog(undefined)}
|
|
672
|
+
onSubmit={(fieldKey, value) => {
|
|
673
|
+
if (fieldKey === 'status') {
|
|
674
|
+
return setStatus(selectedIds, value);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (fieldKey === 'assignee') {
|
|
678
|
+
return bulk.run(
|
|
679
|
+
() => updateRecords('tasks', selectedIds, { assigneeId: value === NO_ASSIGNEE ? null : value }),
|
|
680
|
+
'Исполнитель назначен',
|
|
681
|
+
'Не удалось назначить исполнителя',
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return bulk.run(
|
|
686
|
+
() => updateRecords('tasks', selectedIds, { dueAt: /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null }),
|
|
687
|
+
'Срок изменён',
|
|
688
|
+
'Не удалось изменить срок',
|
|
689
|
+
);
|
|
690
|
+
}}
|
|
691
|
+
/>
|
|
692
|
+
)}
|
|
336
693
|
</DocflowRoot>
|
|
337
694
|
);
|
|
338
695
|
};
|