@haiiro/docuflow 0.0.0-stage → 0.0.2
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/.nvmrc +1 -0
- package/.oxlintrc.json +37 -0
- package/.yarnrc.yml +1 -0
- package/README.md +127 -2
- package/package.json +60 -4
- package/public/cover.svg +15 -0
- package/public/logo.svg +9 -0
- package/src/application-config.ts +58 -0
- package/src/fields/company-documents.field.ts +29 -0
- package/src/fields/document-author.field.ts +32 -0
- package/src/fields/document-counterparty.field.ts +32 -0
- package/src/fields/document-folder-child-folders.field.ts +23 -0
- package/src/fields/document-folder-documents.field.ts +24 -0
- package/src/fields/document-folder-parent-folder.field.ts +31 -0
- package/src/fields/document-folder.field.ts +32 -0
- package/src/fields/document-responsible.field.ts +32 -0
- package/src/fields/member-authored-documents.field.ts +29 -0
- package/src/fields/member-responsible-documents.field.ts +29 -0
- package/src/fields/task-form-schema.field.ts +21 -0
- package/src/fields/task-form-values.field.ts +21 -0
- package/src/fields/task-instruction.field.ts +21 -0
- package/src/fields/task-section.field.ts +36 -0
- package/src/fields/task-workflow-run-id.field.ts +21 -0
- package/src/fields/task-workflow-step-id.field.ts +21 -0
- package/src/fields/task-workflow-thread-id.field.ts +21 -0
- package/src/fields/workflow-launch-fields.field.ts +21 -0
- package/src/fields/workflow-section.field.ts +36 -0
- package/src/front/api.ts +56 -0
- package/src/front/format.ts +60 -0
- package/src/front/hooks/use-async.ts +54 -0
- package/src/front/hooks/use-workspace-members.ts +22 -0
- package/src/front/types.ts +48 -0
- package/src/front/ui/components.tsx +436 -0
- package/src/front/ui/form-field-inputs.tsx +187 -0
- package/src/front/ui/form-fields-builder.tsx +183 -0
- package/src/front/ui/task-work-panel.tsx +218 -0
- package/src/front-components/docflow-documents.front-component.tsx +516 -0
- package/src/front-components/docflow-processes.front-component.tsx +266 -0
- package/src/front-components/docflow-tasks.front-component.tsx +303 -0
- package/src/front-components/process-steps.front-component.tsx +286 -0
- package/src/front-components/task-form.front-component.tsx +29 -0
- package/src/logic-functions/bitrix-app.logic-function.ts +143 -0
- package/src/logic-functions/bitrix-event.logic-function.ts +54 -0
- package/src/logic-functions/bitrix-install.logic-function.ts +81 -0
- package/src/logic-functions/capture-file.logic-function.ts +32 -0
- package/src/logic-functions/complete-task.logic-function.ts +46 -0
- package/src/logic-functions/hand-out-form-step.logic-function.ts +30 -0
- package/src/logic-functions/health-check.ts +31 -0
- package/src/logic-functions/launch-parameters-step.logic-function.ts +33 -0
- package/src/logic-functions/launch-process.logic-function.ts +96 -0
- package/src/logic-functions/notify-bitrix-assignee.logic-function.ts +85 -0
- package/src/logic-functions/on-attachment-created.logic-function.ts +67 -0
- package/src/logic-functions/on-task-updated.logic-function.ts +71 -0
- package/src/logic-functions/on-workflow-run-updated.logic-function.ts +70 -0
- package/src/logic-functions/save-launch-fields.logic-function.ts +73 -0
- package/src/logic-functions/save-step-settings.logic-function.ts +90 -0
- package/src/logic-functions/save-task-form.logic-function.ts +62 -0
- package/src/navigation-menu-items/docflow.navigation-menu-item.ts +15 -0
- package/src/objects/document-folder.object.ts +80 -0
- package/src/objects/document.object.ts +312 -0
- package/src/page-layouts/docflow.page-layout.ts +86 -0
- package/src/page-layouts/task-docflow.page-layout-tab.ts +36 -0
- package/src/page-layouts/workflow-steps.page-layout-tab.ts +37 -0
- package/src/roles/default-role.ts +21 -0
- package/src/roles/docflow-member.role.ts +23 -0
- package/src/server/bitrix-page.ts +52 -0
- package/src/server/bitrix-portal.ts +67 -0
- package/src/server/document-capture.ts +102 -0
- package/src/server/http-response.ts +18 -0
- package/src/server/launch-step.ts +166 -0
- package/src/server/process-tasks.ts +116 -0
- package/src/server/records.ts +132 -0
- package/src/server/task-completion.ts +100 -0
- package/src/server/workflow-engine.ts +105 -0
- package/src/shared/api/core-graphql.util.ts +98 -0
- package/src/shared/api/object-summaries.ts +79 -0
- package/src/shared/api/rest-filter.util.ts +37 -0
- package/src/shared/api/rest-response.util.ts +43 -0
- package/src/shared/bitrix/bitrix-client.ts +114 -0
- package/src/shared/bitrix/bitrix.type.ts +25 -0
- package/src/shared/bitrix/bitrix.util.ts +139 -0
- package/src/shared/docflow.constants.ts +31 -0
- package/src/shared/documents/attachment-target.util.ts +64 -0
- package/src/shared/engine.ts +11 -0
- package/src/shared/form/form-field-builder.util.ts +122 -0
- package/src/shared/form/form-field.type.ts +23 -0
- package/src/shared/form/form-fields.util.ts +138 -0
- package/src/shared/form/step-settings.util.ts +104 -0
- package/src/shared/workflow/launch-step.util.ts +56 -0
- package/src/shared/workflow/run-target-record.util.ts +70 -0
- package/src/shared/workflow/workflow-run.type.ts +35 -0
- package/src/shared/workflow/workflow-run.util.ts +174 -0
- package/src/universal-identifiers.ts +199 -0
- package/src/views/all-document-folders.view.ts +50 -0
- package/src/views/all-documents.view.ts +91 -0
- package/src/views/documents-by-status.view.ts +76 -0
- package/tsconfig.json +37 -0
- package/tsconfig.spec.json +9 -0
- package/vitest.unit.config.ts +14 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
3
|
+
import { AppPath, enqueueSnackbar, navigate } from 'twenty-sdk/front-component';
|
|
4
|
+
import { Button } from 'twenty-ui/primitives/input';
|
|
5
|
+
import { useTheme } from 'twenty-ui/theme';
|
|
6
|
+
|
|
7
|
+
import { callAppRoute, createRecord, getErrorMessage, listRecords } from 'src/front/api';
|
|
8
|
+
import { useAsync, useDebouncedValue } from 'src/front/hooks/use-async';
|
|
9
|
+
import { DataTable, DocflowRoot, Message, SplitView, TextField, Toolbar } from 'src/front/ui/components';
|
|
10
|
+
import { FormFieldInputs } from 'src/front/ui/form-field-inputs';
|
|
11
|
+
import { restAnd, restEq, restIlike, restIn } from 'src/shared/api/rest-filter.util';
|
|
12
|
+
import { DOCFLOW_SECTION } from 'src/shared/docflow.constants';
|
|
13
|
+
import { type FormField, type FormValues } from 'src/shared/form/form-field.type';
|
|
14
|
+
import { findMissingRequiredFields } from 'src/shared/form/form-fields.util';
|
|
15
|
+
import {
|
|
16
|
+
buildLaunchParameters,
|
|
17
|
+
isLaunchParametersStep,
|
|
18
|
+
parseLaunchFields,
|
|
19
|
+
} from 'src/shared/workflow/launch-step.util';
|
|
20
|
+
import { type WorkflowStep, type WorkflowTrigger } from 'src/shared/workflow/workflow-run.type';
|
|
21
|
+
import { PROCESSES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
|
|
22
|
+
|
|
23
|
+
const PROCESSES_LIMIT = 200;
|
|
24
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
25
|
+
|
|
26
|
+
type WorkflowRecord = { id: string; name?: string | null; smbLaunchFields?: unknown };
|
|
27
|
+
type WorkflowVersionRecord = {
|
|
28
|
+
id: string;
|
|
29
|
+
workflowId: string;
|
|
30
|
+
trigger?: WorkflowTrigger | null;
|
|
31
|
+
steps?: WorkflowStep[] | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type DocflowProcess = {
|
|
35
|
+
workflowId: string;
|
|
36
|
+
workflowVersionId: string;
|
|
37
|
+
name: string;
|
|
38
|
+
launchFields: FormField[];
|
|
39
|
+
stageNames: string[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Docflow processes are the standard workflows with the docflow section,
|
|
43
|
+
// published, started by hand. Both lists are filtered by the server.
|
|
44
|
+
const loadProcesses = async (searchQuery: string): Promise<DocflowProcess[]> => {
|
|
45
|
+
const { records: workflows } = await listRecords<WorkflowRecord>('workflows', {
|
|
46
|
+
filter: restAnd(
|
|
47
|
+
restEq('smbSection', DOCFLOW_SECTION),
|
|
48
|
+
searchQuery.trim().length > 0 ? restIlike('name', searchQuery) : undefined,
|
|
49
|
+
),
|
|
50
|
+
order_by: 'name[AscNullsLast]',
|
|
51
|
+
limit: PROCESSES_LIMIT,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (workflows.length === 0) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { records: versions } = await listRecords<WorkflowVersionRecord>('workflowVersions', {
|
|
59
|
+
filter: restAnd(
|
|
60
|
+
restEq('status', 'ACTIVE'),
|
|
61
|
+
restIn('workflowId', workflows.map(({ id }) => id)),
|
|
62
|
+
),
|
|
63
|
+
limit: PROCESSES_LIMIT,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return workflows.flatMap((workflow) => {
|
|
67
|
+
const version = versions.find((candidate) => candidate.workflowId === workflow.id);
|
|
68
|
+
|
|
69
|
+
if (version === undefined || version.trigger?.type !== 'MANUAL') {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
workflowId: workflow.id,
|
|
76
|
+
workflowVersionId: version.id,
|
|
77
|
+
name: workflow.name?.trim() || 'Без названия',
|
|
78
|
+
launchFields: parseLaunchFields(workflow.smbLaunchFields),
|
|
79
|
+
stageNames: (version.steps ?? [])
|
|
80
|
+
.filter((step) => !isLaunchParametersStep(step))
|
|
81
|
+
.map((step) => step.name),
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const ProcessLaunchForm = ({
|
|
88
|
+
process,
|
|
89
|
+
onLaunched,
|
|
90
|
+
}: {
|
|
91
|
+
process: DocflowProcess;
|
|
92
|
+
onLaunched: () => void;
|
|
93
|
+
}) => {
|
|
94
|
+
const theme = useTheme();
|
|
95
|
+
const [values, setValues] = useState<FormValues>({});
|
|
96
|
+
const [isLaunching, setIsLaunching] = useState(false);
|
|
97
|
+
const [errorMessage, setErrorMessage] = useState<string | undefined>();
|
|
98
|
+
|
|
99
|
+
const handleLaunch = async () => {
|
|
100
|
+
const parameters = buildLaunchParameters({ fields: process.launchFields, values });
|
|
101
|
+
const missingFields = findMissingRequiredFields({ fields: process.launchFields, values: parameters });
|
|
102
|
+
|
|
103
|
+
if (missingFields.length > 0) {
|
|
104
|
+
setErrorMessage(`Заполните: ${missingFields.map(({ label }) => label).join(', ')}`);
|
|
105
|
+
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setIsLaunching(true);
|
|
110
|
+
setErrorMessage(undefined);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await callAppRoute('/docflow/processes/launch', {
|
|
114
|
+
workflowVersionId: process.workflowVersionId,
|
|
115
|
+
values: parameters,
|
|
116
|
+
});
|
|
117
|
+
await enqueueSnackbar({ message: 'Процесс запущен', variant: 'success' });
|
|
118
|
+
onLaunched();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
setErrorMessage(getErrorMessage(error, 'Не удалось запустить процесс'));
|
|
121
|
+
} finally {
|
|
122
|
+
setIsLaunching(false);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
|
|
128
|
+
{process.stageNames.length > 0 && (
|
|
129
|
+
<Message>Этапы: {process.stageNames.join(' → ')}</Message>
|
|
130
|
+
)}
|
|
131
|
+
{process.launchFields.length > 0 ? (
|
|
132
|
+
<FormFieldInputs fields={process.launchFields} values={values} onChange={setValues} disabled={isLaunching} />
|
|
133
|
+
) : (
|
|
134
|
+
<Message>Процесс запускается без параметров.</Message>
|
|
135
|
+
)}
|
|
136
|
+
{errorMessage !== undefined && <Message tone="danger">{errorMessage}</Message>}
|
|
137
|
+
<Button variant="solid" size="md" fullWidth loading={isLaunching} onClick={() => void handleLaunch()}>
|
|
138
|
+
Запустить
|
|
139
|
+
</Button>
|
|
140
|
+
<Button
|
|
141
|
+
variant="ghost"
|
|
142
|
+
size="sm"
|
|
143
|
+
onClick={() =>
|
|
144
|
+
void navigate(AppPath.RecordShowPage, {
|
|
145
|
+
objectNameSingular: 'workflow',
|
|
146
|
+
objectRecordId: process.workflowId,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
>
|
|
150
|
+
Изменить процесс
|
|
151
|
+
</Button>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const DocflowProcesses = () => {
|
|
157
|
+
const theme = useTheme();
|
|
158
|
+
const [searchInput, setSearchInput] = useState('');
|
|
159
|
+
const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
|
160
|
+
const [selectedVersionId, setSelectedVersionId] = useState<string | undefined>();
|
|
161
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
162
|
+
const { data: processes, loading, error } = useAsync(() => loadProcesses(searchQuery), [searchQuery]);
|
|
163
|
+
const selectedProcess = processes?.find((process) => process.workflowVersionId === selectedVersionId);
|
|
164
|
+
|
|
165
|
+
// Created in the docflow section, then opened in the standard editor.
|
|
166
|
+
const handleCreate = async () => {
|
|
167
|
+
setIsCreating(true);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const workflow = await createRecord<WorkflowRecord>('workflows', {
|
|
171
|
+
name: 'Новый процесс',
|
|
172
|
+
smbSection: DOCFLOW_SECTION,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (workflow !== undefined) {
|
|
176
|
+
await navigate(AppPath.RecordShowPage, {
|
|
177
|
+
objectNameSingular: 'workflow',
|
|
178
|
+
objectRecordId: workflow.id,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} catch (createError) {
|
|
182
|
+
await enqueueSnackbar({
|
|
183
|
+
message: getErrorMessage(createError, 'Не удалось создать процесс'),
|
|
184
|
+
variant: 'error',
|
|
185
|
+
});
|
|
186
|
+
} finally {
|
|
187
|
+
setIsCreating(false);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<DocflowRoot>
|
|
193
|
+
<SplitView
|
|
194
|
+
panelTitle={selectedProcess?.name}
|
|
195
|
+
onClosePanel={() => setSelectedVersionId(undefined)}
|
|
196
|
+
panel={
|
|
197
|
+
selectedProcess !== undefined ? (
|
|
198
|
+
<ProcessLaunchForm
|
|
199
|
+
key={selectedProcess.workflowVersionId}
|
|
200
|
+
process={selectedProcess}
|
|
201
|
+
onLaunched={() => setSelectedVersionId(undefined)}
|
|
202
|
+
/>
|
|
203
|
+
) : undefined
|
|
204
|
+
}
|
|
205
|
+
>
|
|
206
|
+
<Toolbar>
|
|
207
|
+
<div style={{ flex: '0 1 240px' }}>
|
|
208
|
+
<TextField
|
|
209
|
+
type="search"
|
|
210
|
+
variant="bare"
|
|
211
|
+
placeholder="Поиск процессов"
|
|
212
|
+
value={searchInput}
|
|
213
|
+
onChange={setSearchInput}
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
216
|
+
<div style={{ marginLeft: 'auto' }}>
|
|
217
|
+
<Button variant="outline" size="sm" loading={isCreating} onClick={() => void handleCreate()}>
|
|
218
|
+
Создать
|
|
219
|
+
</Button>
|
|
220
|
+
</div>
|
|
221
|
+
</Toolbar>
|
|
222
|
+
{error !== undefined && (
|
|
223
|
+
<div style={{ padding: theme.spacing[4] }}>
|
|
224
|
+
<Message tone="danger">{getErrorMessage(error, 'Не удалось загрузить процессы')}</Message>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
{loading && processes === undefined && <Message>Загрузка…</Message>}
|
|
228
|
+
{!loading && error === undefined && processes?.length === 0 && (
|
|
229
|
+
<Message>
|
|
230
|
+
Процессов для запуска нет. Здесь видны опубликованные процессы с ручным запуском и разделом «Документооборот». Параметры запуска задаются на вкладке процесса «Запуск и задания».
|
|
231
|
+
</Message>
|
|
232
|
+
)}
|
|
233
|
+
{processes !== undefined && processes.length > 0 && (
|
|
234
|
+
<DataTable
|
|
235
|
+
columns={[
|
|
236
|
+
{ key: 'name', label: 'Процесс', width: '40%' },
|
|
237
|
+
{ key: 'parameters', label: 'Параметры', width: '18%' },
|
|
238
|
+
{ key: 'stages', label: 'Этапы' },
|
|
239
|
+
]}
|
|
240
|
+
selectedRowId={selectedVersionId}
|
|
241
|
+
onRowClick={setSelectedVersionId}
|
|
242
|
+
rows={processes.map((process) => {
|
|
243
|
+
const parameterCount = process.launchFields.length;
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
id: process.workflowVersionId,
|
|
247
|
+
cells: {
|
|
248
|
+
name: process.name,
|
|
249
|
+
parameters: parameterCount > 0 ? String(parameterCount) : 'нет',
|
|
250
|
+
stages: process.stageNames.join(' → '),
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
})}
|
|
254
|
+
/>
|
|
255
|
+
)}
|
|
256
|
+
</SplitView>
|
|
257
|
+
</DocflowRoot>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export default defineFrontComponent({
|
|
262
|
+
universalIdentifier: PROCESSES_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
|
263
|
+
name: 'docflow-processes',
|
|
264
|
+
description: 'Процессы документооборота: запуск с параметрами',
|
|
265
|
+
component: DocflowProcesses,
|
|
266
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
3
|
+
import { enqueueSnackbar } from 'twenty-sdk/front-component';
|
|
4
|
+
import { Button } from 'twenty-ui/primitives/input';
|
|
5
|
+
import { Tag } from 'twenty-ui/primitives/data-display';
|
|
6
|
+
import { useTheme } from 'twenty-ui/theme';
|
|
7
|
+
|
|
8
|
+
import { createRecord, getErrorMessage, listRecords } from 'src/front/api';
|
|
9
|
+
import { formatDate, formatMemberName, isOverdue, TASK_STATUS_LABELS } from 'src/front/format';
|
|
10
|
+
import { useAsync, useDebouncedValue } from 'src/front/hooks/use-async';
|
|
11
|
+
import { useWorkspaceMembers } from 'src/front/hooks/use-workspace-members';
|
|
12
|
+
import { type DocflowTask } from 'src/front/types';
|
|
13
|
+
import {
|
|
14
|
+
DataTable,
|
|
15
|
+
DocflowRoot,
|
|
16
|
+
Message,
|
|
17
|
+
SectionTitle,
|
|
18
|
+
SelectField,
|
|
19
|
+
SplitView,
|
|
20
|
+
TextAreaField,
|
|
21
|
+
TextField,
|
|
22
|
+
Toolbar,
|
|
23
|
+
} from 'src/front/ui/components';
|
|
24
|
+
import { FormFieldsBuilder } from 'src/front/ui/form-fields-builder';
|
|
25
|
+
import { TaskWorkPanel } from 'src/front/ui/task-work-panel';
|
|
26
|
+
import {
|
|
27
|
+
restAnd,
|
|
28
|
+
restEq,
|
|
29
|
+
restIlike,
|
|
30
|
+
restIsNull,
|
|
31
|
+
restLt,
|
|
32
|
+
restNeq,
|
|
33
|
+
restOr,
|
|
34
|
+
} from 'src/shared/api/rest-filter.util';
|
|
35
|
+
import { DOCFLOW_SECTION, PAGE_SIZE, TASK_DONE_STATUS, TASK_TODO_STATUS } from 'src/shared/docflow.constants';
|
|
36
|
+
import { type FormField } from 'src/shared/form/form-field.type';
|
|
37
|
+
import { getFormFieldsError } from 'src/shared/form/form-fields.util';
|
|
38
|
+
import { TASKS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
|
|
39
|
+
|
|
40
|
+
type TaskFilter = 'open' | 'mine' | 'overdue' | 'done';
|
|
41
|
+
type TaskSort = 'newest' | 'oldest' | 'dueSoon' | 'nameAsc';
|
|
42
|
+
type Panel = { kind: 'closed' } | { kind: 'create' } | { kind: 'task'; taskId: string };
|
|
43
|
+
|
|
44
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
45
|
+
const NO_ASSIGNEE = '';
|
|
46
|
+
|
|
47
|
+
const ORDER_BY: Record<TaskSort, string> = {
|
|
48
|
+
newest: 'createdAt[DescNullsLast]',
|
|
49
|
+
oldest: 'createdAt[AscNullsLast]',
|
|
50
|
+
dueSoon: 'dueAt[AscNullsLast]',
|
|
51
|
+
nameAsc: 'title[AscNullsLast]',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const OPEN_FILTER = restOr(restNeq('status', TASK_DONE_STATUS), restIsNull('status'));
|
|
55
|
+
|
|
56
|
+
// The list is filtered by the server, so a long history of tasks never
|
|
57
|
+
// travels to the browser.
|
|
58
|
+
const buildFilter = ({
|
|
59
|
+
taskFilter,
|
|
60
|
+
searchQuery,
|
|
61
|
+
currentMemberId,
|
|
62
|
+
now,
|
|
63
|
+
}: {
|
|
64
|
+
taskFilter: TaskFilter;
|
|
65
|
+
searchQuery: string;
|
|
66
|
+
currentMemberId: string | undefined;
|
|
67
|
+
now: string;
|
|
68
|
+
}) =>
|
|
69
|
+
restAnd(
|
|
70
|
+
restEq('smbSection', DOCFLOW_SECTION),
|
|
71
|
+
taskFilter === 'done' ? restEq('status', TASK_DONE_STATUS) : OPEN_FILTER,
|
|
72
|
+
taskFilter === 'mine'
|
|
73
|
+
? currentMemberId !== undefined
|
|
74
|
+
? restEq('assigneeId', currentMemberId)
|
|
75
|
+
: restIsNull('assigneeId')
|
|
76
|
+
: undefined,
|
|
77
|
+
taskFilter === 'overdue' ? restLt('dueAt', now) : undefined,
|
|
78
|
+
searchQuery.trim().length > 0 ? restIlike('title', searchQuery) : undefined,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const TaskCreateForm = ({
|
|
82
|
+
currentMemberId,
|
|
83
|
+
onCreated,
|
|
84
|
+
onCancel,
|
|
85
|
+
}: {
|
|
86
|
+
currentMemberId: string | undefined;
|
|
87
|
+
onCreated: (taskId: string) => void;
|
|
88
|
+
onCancel: () => void;
|
|
89
|
+
}) => {
|
|
90
|
+
const theme = useTheme();
|
|
91
|
+
const { members } = useWorkspaceMembers();
|
|
92
|
+
const [title, setTitle] = useState('');
|
|
93
|
+
const [assigneeId, setAssigneeId] = useState(currentMemberId ?? NO_ASSIGNEE);
|
|
94
|
+
const [dueAt, setDueAt] = useState('');
|
|
95
|
+
const [instruction, setInstruction] = useState('');
|
|
96
|
+
const [fields, setFields] = useState<FormField[]>([]);
|
|
97
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
98
|
+
const [errorMessage, setErrorMessage] = useState<string | undefined>();
|
|
99
|
+
|
|
100
|
+
const handleCreate = async () => {
|
|
101
|
+
const fieldsError = getFormFieldsError(fields);
|
|
102
|
+
|
|
103
|
+
if (fieldsError !== undefined) {
|
|
104
|
+
setErrorMessage(fieldsError);
|
|
105
|
+
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setIsSaving(true);
|
|
110
|
+
setErrorMessage(undefined);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const task = await createRecord<DocflowTask>('tasks', {
|
|
114
|
+
title: title.trim(),
|
|
115
|
+
assigneeId: assigneeId === NO_ASSIGNEE ? null : assigneeId,
|
|
116
|
+
dueAt: /^\d{4}-\d{2}-\d{2}$/.test(dueAt) ? dueAt : null,
|
|
117
|
+
status: TASK_TODO_STATUS,
|
|
118
|
+
smbSection: DOCFLOW_SECTION,
|
|
119
|
+
smbInstruction: instruction.trim().length > 0 ? instruction.trim() : null,
|
|
120
|
+
smbFormSchema: fields.length > 0 ? fields : null,
|
|
121
|
+
smbFormValues: fields.length > 0 ? {} : null,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (task === undefined) {
|
|
125
|
+
throw new Error('The created task was not returned');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await enqueueSnackbar({ message: 'Задание создано', variant: 'success' });
|
|
129
|
+
onCreated(task.id);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
setErrorMessage(getErrorMessage(error, 'Не удалось создать задание'));
|
|
132
|
+
} finally {
|
|
133
|
+
setIsSaving(false);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
|
|
139
|
+
<TextField label="Название" value={title} onChange={setTitle} />
|
|
140
|
+
<SelectField
|
|
141
|
+
label="Исполнитель"
|
|
142
|
+
value={assigneeId}
|
|
143
|
+
options={[
|
|
144
|
+
{ value: NO_ASSIGNEE, label: 'Не назначен' },
|
|
145
|
+
...members.map((member) => ({ value: member.id, label: formatMemberName(member) || member.id })),
|
|
146
|
+
]}
|
|
147
|
+
onChange={setAssigneeId}
|
|
148
|
+
/>
|
|
149
|
+
<TextField label="Срок" type="date" value={dueAt} onChange={setDueAt} />
|
|
150
|
+
<TextAreaField label="Инструкция" value={instruction} onChange={setInstruction} rows={4} />
|
|
151
|
+
<SectionTitle>Поля для заполнения</SectionTitle>
|
|
152
|
+
<FormFieldsBuilder fields={fields} onChange={setFields} disabled={isSaving} />
|
|
153
|
+
{errorMessage !== undefined && <Message tone="danger">{errorMessage}</Message>}
|
|
154
|
+
<div style={{ display: 'flex', gap: theme.spacing[2] }}>
|
|
155
|
+
<Button variant="solid" size="sm" loading={isSaving} disabled={title.trim().length === 0} onClick={() => void handleCreate()}>
|
|
156
|
+
Создать
|
|
157
|
+
</Button>
|
|
158
|
+
<Button variant="ghost" size="sm" onClick={onCancel}>
|
|
159
|
+
Отмена
|
|
160
|
+
</Button>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const DocflowTasks = () => {
|
|
167
|
+
const theme = useTheme();
|
|
168
|
+
const { members, currentMember } = useWorkspaceMembers();
|
|
169
|
+
const [searchInput, setSearchInput] = useState('');
|
|
170
|
+
const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
|
171
|
+
const [taskFilter, setTaskFilter] = useState<TaskFilter>('open');
|
|
172
|
+
const [sort, setSort] = useState<TaskSort>('newest');
|
|
173
|
+
const [panel, setPanel] = useState<Panel>({ kind: 'closed' });
|
|
174
|
+
const [pages, setPages] = useState(1);
|
|
175
|
+
// Fixed for the life of the list, so "overdue" does not refetch by itself.
|
|
176
|
+
const [now] = useState(() => new Date().toISOString());
|
|
177
|
+
|
|
178
|
+
const { data, loading, error, reload } = useAsync(
|
|
179
|
+
() =>
|
|
180
|
+
listRecords<DocflowTask>('tasks', {
|
|
181
|
+
filter: buildFilter({ taskFilter, searchQuery, currentMemberId: currentMember?.id, now }),
|
|
182
|
+
order_by: ORDER_BY[sort],
|
|
183
|
+
limit: PAGE_SIZE * pages,
|
|
184
|
+
}),
|
|
185
|
+
[taskFilter, searchQuery, currentMember?.id, sort, pages, now],
|
|
186
|
+
);
|
|
187
|
+
const tasks = data?.records ?? [];
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<DocflowRoot>
|
|
191
|
+
<SplitView
|
|
192
|
+
panelTitle={panel.kind === 'create' ? 'Новое задание' : 'Задание'}
|
|
193
|
+
onClosePanel={() => setPanel({ kind: 'closed' })}
|
|
194
|
+
panel={
|
|
195
|
+
panel.kind === 'create' ? (
|
|
196
|
+
<TaskCreateForm
|
|
197
|
+
currentMemberId={currentMember?.id}
|
|
198
|
+
onCancel={() => setPanel({ kind: 'closed' })}
|
|
199
|
+
onCreated={(taskId) => {
|
|
200
|
+
setPanel({ kind: 'task', taskId });
|
|
201
|
+
void reload();
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
) : panel.kind === 'task' ? (
|
|
205
|
+
<TaskWorkPanel key={panel.taskId} taskId={panel.taskId} onChanged={() => void reload()} />
|
|
206
|
+
) : undefined
|
|
207
|
+
}
|
|
208
|
+
>
|
|
209
|
+
<Toolbar>
|
|
210
|
+
<div style={{ flex: '0 1 240px' }}>
|
|
211
|
+
<TextField
|
|
212
|
+
type="search"
|
|
213
|
+
variant="bare"
|
|
214
|
+
placeholder="Поиск заданий"
|
|
215
|
+
value={searchInput}
|
|
216
|
+
onChange={setSearchInput}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
<SelectField<TaskFilter>
|
|
220
|
+
variant="bare"
|
|
221
|
+
value={taskFilter}
|
|
222
|
+
options={[
|
|
223
|
+
{ value: 'open', label: 'Открытые' },
|
|
224
|
+
{ value: 'mine', label: 'Мои' },
|
|
225
|
+
{ value: 'overdue', label: 'Просроченные' },
|
|
226
|
+
{ value: 'done', label: 'Выполненные' },
|
|
227
|
+
]}
|
|
228
|
+
onChange={(value) => {
|
|
229
|
+
setTaskFilter(value);
|
|
230
|
+
setPages(1);
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
<SelectField<TaskSort>
|
|
234
|
+
variant="bare"
|
|
235
|
+
value={sort}
|
|
236
|
+
options={[
|
|
237
|
+
{ value: 'newest', label: 'Сначала новые' },
|
|
238
|
+
{ value: 'oldest', label: 'Сначала старые' },
|
|
239
|
+
{ value: 'dueSoon', label: 'По сроку' },
|
|
240
|
+
{ value: 'nameAsc', label: 'По названию' },
|
|
241
|
+
]}
|
|
242
|
+
onChange={setSort}
|
|
243
|
+
/>
|
|
244
|
+
<div style={{ marginLeft: 'auto' }}>
|
|
245
|
+
<Button variant="outline" size="sm" onClick={() => setPanel({ kind: 'create' })}>
|
|
246
|
+
Создать
|
|
247
|
+
</Button>
|
|
248
|
+
</div>
|
|
249
|
+
</Toolbar>
|
|
250
|
+
{error !== undefined && (
|
|
251
|
+
<div style={{ padding: theme.spacing[4] }}>
|
|
252
|
+
<Message tone="danger">{getErrorMessage(error, 'Не удалось загрузить задания')}</Message>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
{loading && tasks.length === 0 && <Message>Загрузка…</Message>}
|
|
256
|
+
{!loading && error === undefined && tasks.length === 0 && <Message>Заданий нет.</Message>}
|
|
257
|
+
{tasks.length > 0 && (
|
|
258
|
+
<DataTable
|
|
259
|
+
columns={[
|
|
260
|
+
{ key: 'title', label: 'Задание', width: '40%' },
|
|
261
|
+
{ key: 'assignee', label: 'Исполнитель', width: '24%' },
|
|
262
|
+
{ key: 'dueAt', label: 'Срок', width: '16%' },
|
|
263
|
+
{ key: 'status', label: 'Статус' },
|
|
264
|
+
]}
|
|
265
|
+
selectedRowId={panel.kind === 'task' ? panel.taskId : undefined}
|
|
266
|
+
onRowClick={(taskId) => setPanel({ kind: 'task', taskId })}
|
|
267
|
+
rows={tasks.map((task) => ({
|
|
268
|
+
id: task.id,
|
|
269
|
+
cells: {
|
|
270
|
+
title: task.title?.trim() || 'Без названия',
|
|
271
|
+
assignee: formatMemberName(members.find((member) => member.id === task.assigneeId)),
|
|
272
|
+
dueAt: (
|
|
273
|
+
<span style={{ color: isOverdue(task.dueAt, task.status, new Date(now)) ? theme.font.color.danger : undefined }}>
|
|
274
|
+
{formatDate(task.dueAt)}
|
|
275
|
+
</span>
|
|
276
|
+
),
|
|
277
|
+
status: (
|
|
278
|
+
<Tag color={task.status === TASK_DONE_STATUS ? 'green' : 'gray'}>
|
|
279
|
+
{TASK_STATUS_LABELS[task.status ?? ''] ?? task.status ?? 'Без статуса'}
|
|
280
|
+
</Tag>
|
|
281
|
+
),
|
|
282
|
+
},
|
|
283
|
+
}))}
|
|
284
|
+
/>
|
|
285
|
+
)}
|
|
286
|
+
{data?.pageInfo.hasNextPage === true && (
|
|
287
|
+
<div style={{ display: 'flex', justifyContent: 'center', padding: theme.spacing[3] }}>
|
|
288
|
+
<Button variant="ghost" size="sm" loading={loading} onClick={() => setPages((current) => current + 1)}>
|
|
289
|
+
Показать ещё
|
|
290
|
+
</Button>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</SplitView>
|
|
294
|
+
</DocflowRoot>
|
|
295
|
+
);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export default defineFrontComponent({
|
|
299
|
+
universalIdentifier: TASKS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
|
300
|
+
name: 'docflow-tasks',
|
|
301
|
+
description: 'Задания документооборота: штатные задачи с разделом «Документооборот»',
|
|
302
|
+
component: DocflowTasks,
|
|
303
|
+
});
|