@haiiro/docuflow 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,45 +1,37 @@
1
- import { useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import { defineFrontComponent } from 'twenty-sdk/define';
3
- import { AppPath, enqueueSnackbar, navigate, openSidePanelPage } from 'twenty-sdk/front-component';
3
+ import { AppPath, enqueueSnackbar, openSidePanelPage } from 'twenty-sdk/front-component';
4
4
  import { Button } from 'twenty-ui/primitives/input';
5
- import { Tag } from 'twenty-ui/primitives/data-display';
6
5
  import { useTheme } from 'twenty-ui/theme';
7
6
 
8
- import { createRecord, getErrorMessage, getRecord, listRecords } from 'src/front/api';
7
+ import { createRecord, getErrorMessage, listRecords } from 'src/front/api';
9
8
  import {
10
- DOCUMENT_ORIGIN_LABELS,
11
- DOCUMENT_STATUS_LABELS,
12
9
  DOCUMENT_TYPE_LABELS,
13
10
  formatDate,
14
- formatMemberName,
15
11
  } from 'src/front/format';
16
12
  import { useAsync, useDebouncedValue } from 'src/front/hooks/use-async';
17
13
  import { type DocflowDocument, type DocumentFolder } from 'src/front/types';
18
14
  import {
19
15
  DataTable,
20
- Details,
21
16
  DocflowRoot,
22
17
  Message,
23
18
  SelectField,
24
- SplitView,
25
- TextAreaField,
26
19
  TextField,
27
20
  Toolbar,
28
21
  } from 'src/front/ui/components';
29
- import { getObjectSummaries } from 'src/shared/api/object-summaries';
30
22
  import { restAnd, restEq, restIlike, restIn, restOr } from 'src/shared/api/rest-filter.util';
31
23
  import { DOCUMENT_OBJECT_NAME, PAGE_SIZE } from 'src/shared/docflow.constants';
32
24
  import { logDocflow, logDocflowError } from 'src/shared/log.util';
33
25
  import { DOCUMENTS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
34
26
 
27
+ const LIST_POLL_INTERVAL_MS = 3000;
28
+ const LIST_POLL_DURATION_MS = 10 * 60 * 1000;
29
+
35
30
  type DocumentFilter = 'all' | 'collected' | 'DRAFT' | 'IN_REVIEW' | 'APPROVED' | 'ARCHIVED';
36
- type Panel = { kind: 'closed' } | { kind: 'upload' } | { kind: 'document'; documentId: string };
37
31
 
38
32
  const SEARCH_DEBOUNCE_MS = 300;
39
33
  const FOLDERS_LIMIT = 500;
40
- const COMPANY_OPTIONS_LIMIT = 8;
41
34
  const ALL_FOLDERS = '';
42
- const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
43
35
 
44
36
  // The sandbox of a front component gets only the name and size of a chosen
45
37
  // file, never its content, so the file goes in through the standard "File"
@@ -56,7 +48,7 @@ const openDocumentCard = async (documentId: string) => {
56
48
  } catch (error) {
57
49
  logDocflowError('documents: side panel did not open', error, { documentId });
58
50
  await enqueueSnackbar({
59
- message: 'Карточка документа не открылась: откройте её кнопкой «Прикрепить файл»',
51
+ message: 'Карточка документа не открылась, попробуйте ещё раз',
60
52
  variant: 'error',
61
53
  });
62
54
  }
@@ -87,292 +79,107 @@ const buildFilter = ({
87
79
  : undefined,
88
80
  );
89
81
 
90
- const CompanyPicker = ({
91
- value,
92
- onChange,
93
- }: {
94
- value: { id: string; name: string } | null;
95
- onChange: (company: { id: string; name: string } | null) => void;
96
- }) => {
82
+ const DocflowDocuments = () => {
97
83
  const theme = useTheme();
98
84
  const [searchInput, setSearchInput] = useState('');
99
85
  const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
100
- const { data } = useAsync(
86
+ const [folderId, setFolderId] = useState(ALL_FOLDERS);
87
+ const [documentFilter, setDocumentFilter] = useState<DocumentFilter>('all');
88
+ const [openedDocumentId, setOpenedDocumentId] = useState<string | undefined>();
89
+ const [watchStartedAt, setWatchStartedAt] = useState<number | undefined>();
90
+ const [pages, setPages] = useState(1);
91
+ const [newFolderName, setNewFolderName] = useState('');
92
+ const [isCreating, setIsCreating] = useState(false);
93
+
94
+ const { data: folders, reload: reloadFolders } = useAsync(
101
95
  async () =>
102
- searchQuery.trim().length === 0
103
- ? []
104
- : (
105
- await listRecords<{ id: string; name?: string | null }>('companies', {
106
- filter: restIlike('name', searchQuery),
107
- limit: COMPANY_OPTIONS_LIMIT,
108
- })
109
- ).records,
110
- [searchQuery],
96
+ (
97
+ await listRecords<DocumentFolder>('smbDocumentFolders', {
98
+ order_by: 'name[AscNullsLast]',
99
+ limit: FOLDERS_LIMIT,
100
+ })
101
+ ).records,
102
+ [],
111
103
  );
112
-
113
- if (value !== null) {
114
- return (
115
- <div style={{ alignItems: 'center', display: 'flex', gap: theme.spacing[2], justifyContent: 'space-between' }}>
116
- <span>Контрагент: {value.name}</span>
117
- <Button variant="ghost" size="sm" onClick={() => onChange(null)}>
118
- Убрать
119
- </Button>
120
- </div>
121
- );
122
- }
123
-
124
- return (
125
- <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[1] }}>
126
- <TextField label="Контрагент" placeholder="Начните вводить название" value={searchInput} onChange={setSearchInput} />
127
- {(data ?? []).map((company) => (
128
- <Button
129
- key={company.id}
130
- variant="ghost"
131
- size="sm"
132
- onClick={() => {
133
- onChange({ id: company.id, name: company.name?.trim() || 'Без названия' });
134
- setSearchInput('');
135
- }}
136
- >
137
- {company.name?.trim() || 'Без названия'}
138
- </Button>
139
- ))}
140
- </div>
104
+ const listQuery = useMemo(
105
+ () => ({
106
+ filter: buildFilter({ folderId, documentFilter, searchQuery }),
107
+ order_by: 'createdAt[DescNullsLast]',
108
+ limit: PAGE_SIZE * pages,
109
+ depth: 1,
110
+ }),
111
+ [folderId, documentFilter, searchQuery, pages],
141
112
  );
142
- };
113
+ const { data, setData, loading, error, reload } = useAsync(
114
+ () => listRecords<DocflowDocument>('smbDocuments', listQuery),
115
+ [listQuery],
116
+ );
117
+ const documents = data?.records ?? [];
143
118
 
144
- const DocumentUploadForm = ({
145
- folderId,
146
- onUploaded,
147
- onCancel,
148
- }: {
149
- folderId: string;
150
- onUploaded: (documentId: string) => void;
151
- onCancel: () => void;
152
- }) => {
153
- const theme = useTheme();
154
- const [name, setName] = useState('');
155
- const [documentType, setDocumentType] = useState('OTHER');
156
- const [status, setStatus] = useState('DRAFT');
157
- const [number, setNumber] = useState('');
158
- const [documentDate, setDocumentDate] = useState('');
159
- const [validUntil, setValidUntil] = useState('');
160
- const [description, setDescription] = useState('');
161
- const [counterparty, setCounterparty] = useState<{ id: string; name: string } | null>(null);
162
- const [isSaving, setIsSaving] = useState(false);
163
- const [errorMessage, setErrorMessage] = useState<string | undefined>();
119
+ // A document opens in its standard card in the side panel; while people
120
+ // edit there, the list re-reads itself quietly to show the changes.
121
+ const openDocument = async (documentId: string) => {
122
+ setOpenedDocumentId(documentId);
123
+ setWatchStartedAt(Date.now());
124
+ await openDocumentCard(documentId);
125
+ };
164
126
 
165
- const handleCreate = async () => {
166
- if (name.trim().length === 0) {
127
+ useEffect(() => {
128
+ if (watchStartedAt === undefined) {
167
129
  return;
168
130
  }
169
131
 
170
- setIsSaving(true);
171
- setErrorMessage(undefined);
172
- logDocflow('documents: create', { name: name.trim(), documentType, status, folderId });
132
+ const interval = setInterval(() => {
133
+ void listRecords<DocflowDocument>('smbDocuments', listQuery)
134
+ .then((latest) => {
135
+ const signature = (list: DocflowDocument[]) =>
136
+ list.map(({ id, updatedAt }) => `${id}:${updatedAt ?? ''}`).join();
137
+
138
+ if (signature(latest.records) !== signature(data?.records ?? [])) {
139
+ setData(latest);
140
+ }
141
+ })
142
+ .catch(() => undefined);
143
+ }, LIST_POLL_INTERVAL_MS);
144
+ const stop = setTimeout(() => clearInterval(interval), LIST_POLL_DURATION_MS);
145
+
146
+ return () => {
147
+ clearInterval(interval);
148
+ clearTimeout(stop);
149
+ };
150
+ }, [watchStartedAt, listQuery, data]);
151
+
152
+ // As "Создать" of a standard list: an empty draft in the open folder, filled
153
+ // in its own card in the side panel (the file goes in there too).
154
+ const handleCreateDocument = async () => {
155
+ setIsCreating(true);
156
+ logDocflow('documents: create', { folderId });
173
157
 
174
158
  try {
175
159
  const document = await createRecord<DocflowDocument>('smbDocuments', {
176
- name: name.trim(),
177
- documentType,
178
- status,
179
- number: number.trim() || null,
180
- documentDate: DATE_PATTERN.test(documentDate) ? documentDate : null,
181
- validUntil: DATE_PATTERN.test(validUntil) ? validUntil : null,
182
- description: description.trim() || null,
183
- counterpartyId: counterparty?.id ?? null,
184
- folderId: folderId === ALL_FOLDERS ? null : folderId,
160
+ name: '',
161
+ status: 'DRAFT',
185
162
  origin: 'UPLOAD',
163
+ folderId: folderId === ALL_FOLDERS ? null : folderId,
186
164
  });
187
165
 
188
166
  if (document === undefined) {
189
167
  throw new Error('документ создан, но сервер его не вернул');
190
168
  }
191
169
 
192
- logDocflow('documents: created', { documentId: document.id });
193
- await enqueueSnackbar({ message: 'Документ создан: прикрепите файл в поле «Файл»', variant: 'success' });
194
- onUploaded(document.id);
195
- await openDocumentCard(document.id);
196
- } catch (error) {
197
- logDocflowError('documents: create failed', error);
198
- setErrorMessage(getErrorMessage(error, 'Не удалось создать документ'));
170
+ void reload();
171
+ await openDocument(document.id);
172
+ } catch (createError) {
173
+ logDocflowError('documents: create failed', createError);
174
+ await enqueueSnackbar({
175
+ message: getErrorMessage(createError, 'Не удалось создать документ'),
176
+ variant: 'error',
177
+ });
199
178
  } finally {
200
- setIsSaving(false);
179
+ setIsCreating(false);
201
180
  }
202
181
  };
203
182
 
204
- return (
205
- <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
206
- <TextField label="Название" value={name} onChange={setName} />
207
- <SelectField
208
- label="Тип"
209
- value={documentType}
210
- options={Object.entries(DOCUMENT_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
211
- onChange={setDocumentType}
212
- />
213
- <SelectField
214
- label="Статус"
215
- value={status}
216
- options={Object.entries(DOCUMENT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
217
- onChange={setStatus}
218
- />
219
- <TextField label="Номер" value={number} onChange={setNumber} />
220
- <TextField label="Дата документа" type="date" value={documentDate} onChange={setDocumentDate} />
221
- <TextField label="Действует до" type="date" value={validUntil} onChange={setValidUntil} />
222
- <CompanyPicker value={counterparty} onChange={setCounterparty} />
223
- <TextAreaField label="Описание" value={description} onChange={setDescription} />
224
- <Message tone="hint">Файл прикрепляется в карточке документа: она откроется справа после создания.</Message>
225
- {errorMessage !== undefined && <Message tone="danger">{errorMessage}</Message>}
226
- <div style={{ display: 'flex', gap: theme.spacing[2] }}>
227
- <Button
228
- variant="solid"
229
- size="sm"
230
- loading={isSaving}
231
- disabled={name.trim().length === 0}
232
- onClick={() => void handleCreate()}
233
- >
234
- Создать и прикрепить файл
235
- </Button>
236
- <Button variant="ghost" size="sm" onClick={onCancel}>
237
- Отмена
238
- </Button>
239
- </div>
240
- </div>
241
- );
242
- };
243
-
244
- const DocumentCard = ({ documentId }: { documentId: string }) => {
245
- const theme = useTheme();
246
- const { data: document, loading, error, reload } = useAsync(
247
- () => getRecord<DocflowDocument>('smbDocuments', documentId),
248
- [documentId],
249
- );
250
- const { data: objectSummaries } = useAsync(() => getObjectSummaries(), []);
251
-
252
- if (document === undefined) {
253
- return loading ? (
254
- <Message>Загрузка…</Message>
255
- ) : (
256
- <Message tone="danger">{getErrorMessage(error, 'Документ не найден или нет доступа.')}</Message>
257
- );
258
- }
259
-
260
- const file = document.file?.[0];
261
- const sourceSummary =
262
- typeof document.sourceObjectName === 'string'
263
- ? objectSummaries?.get(document.sourceObjectName)
264
- : undefined;
265
- const linkStyle = { color: theme.color.blue, cursor: 'pointer' };
266
-
267
- return (
268
- <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[3] }}>
269
- <strong style={{ fontSize: theme.font.size.lg }}>{document.name?.trim() || 'Без названия'}</strong>
270
- <Details
271
- rows={[
272
- { label: 'Тип', value: DOCUMENT_TYPE_LABELS[document.documentType ?? ''] },
273
- { label: 'Номер', value: document.number },
274
- { label: 'Дата', value: formatDate(document.documentDate) },
275
- { label: 'Действует до', value: formatDate(document.validUntil) },
276
- { label: 'Контрагент', value: document.counterparty?.name },
277
- {
278
- label: 'Статус',
279
- value: document.status && <Tag color="gray">{DOCUMENT_STATUS_LABELS[document.status] ?? document.status}</Tag>,
280
- },
281
- { label: 'Автор', value: formatMemberName(document.author ?? undefined) },
282
- { label: 'Добавлен', value: formatDate(document.createdAt) },
283
- { label: 'Источник', value: DOCUMENT_ORIGIN_LABELS[document.origin ?? ''] },
284
- {
285
- label: 'Где загружен',
286
- value: sourceSummary !== undefined && typeof document.sourceRecordId === 'string' && (
287
- <span
288
- role="link"
289
- tabIndex={0}
290
- style={linkStyle}
291
- onClick={() =>
292
- void navigate(AppPath.RecordShowPage, {
293
- objectNameSingular: sourceSummary.nameSingular,
294
- objectRecordId: document.sourceRecordId as string,
295
- })
296
- }
297
- >
298
- {document.sourceRecordName ?? sourceSummary.labelSingular}
299
- </span>
300
- ),
301
- },
302
- {
303
- label: 'Процесс',
304
- value: typeof document.workflowRunId === 'string' && (
305
- <span
306
- role="link"
307
- tabIndex={0}
308
- style={linkStyle}
309
- onClick={() =>
310
- void navigate(AppPath.RecordShowPage, {
311
- objectNameSingular: 'workflowRun',
312
- objectRecordId: document.workflowRunId as string,
313
- })
314
- }
315
- >
316
- Открыть запуск
317
- </span>
318
- ),
319
- },
320
- ]}
321
- />
322
- {typeof document.description === 'string' && document.description.length > 0 && (
323
- <div style={{ whiteSpace: 'pre-wrap' }}>{document.description}</div>
324
- )}
325
- {file?.url !== undefined ? (
326
- <a href={file.url} target="_blank" rel="noopener noreferrer" download={file.label} style={linkStyle}>
327
- Скачать {file.label}
328
- </a>
329
- ) : (
330
- <Message tone="hint">Файл не прикреплён.</Message>
331
- )}
332
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: theme.spacing[2] }}>
333
- <Button variant="outline" size="sm" onClick={() => void openDocumentCard(documentId)}>
334
- {file === undefined ? 'Прикрепить файл' : 'Открыть карточку'}
335
- </Button>
336
- <Button variant="ghost" size="sm" loading={loading} onClick={() => void reload()}>
337
- Обновить
338
- </Button>
339
- </div>
340
- </div>
341
- );
342
- };
343
-
344
- const DocflowDocuments = () => {
345
- const theme = useTheme();
346
- const [searchInput, setSearchInput] = useState('');
347
- const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
348
- const [folderId, setFolderId] = useState(ALL_FOLDERS);
349
- const [documentFilter, setDocumentFilter] = useState<DocumentFilter>('all');
350
- const [panel, setPanel] = useState<Panel>({ kind: 'closed' });
351
- const [pages, setPages] = useState(1);
352
- const [newFolderName, setNewFolderName] = useState('');
353
-
354
- const { data: folders, reload: reloadFolders } = useAsync(
355
- async () =>
356
- (
357
- await listRecords<DocumentFolder>('smbDocumentFolders', {
358
- order_by: 'name[AscNullsLast]',
359
- limit: FOLDERS_LIMIT,
360
- })
361
- ).records,
362
- [],
363
- );
364
- const { data, loading, error, reload } = useAsync(
365
- () =>
366
- listRecords<DocflowDocument>('smbDocuments', {
367
- filter: buildFilter({ folderId, documentFilter, searchQuery }),
368
- order_by: 'createdAt[DescNullsLast]',
369
- limit: PAGE_SIZE * pages,
370
- depth: 1,
371
- }),
372
- [folderId, documentFilter, searchQuery, pages],
373
- );
374
- const documents = data?.records ?? [];
375
-
376
183
  const handleCreateFolder = async () => {
377
184
  const folderName = newFolderName.trim();
378
185
 
@@ -394,24 +201,7 @@ const DocflowDocuments = () => {
394
201
 
395
202
  return (
396
203
  <DocflowRoot>
397
- <SplitView
398
- panelTitle={panel.kind === 'upload' ? 'Новый документ' : 'Документ'}
399
- onClosePanel={() => setPanel({ kind: 'closed' })}
400
- panel={
401
- panel.kind === 'upload' ? (
402
- <DocumentUploadForm
403
- folderId={folderId}
404
- onCancel={() => setPanel({ kind: 'closed' })}
405
- onUploaded={(documentId) => {
406
- setPanel({ kind: 'document', documentId });
407
- void reload();
408
- }}
409
- />
410
- ) : panel.kind === 'document' ? (
411
- <DocumentCard key={panel.documentId} documentId={panel.documentId} />
412
- ) : undefined
413
- }
414
- >
204
+ <div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
415
205
  <div style={{ display: 'flex', flex: 1, flexWrap: 'wrap', minHeight: 0 }}>
416
206
  <nav
417
207
  aria-label="Папки"
@@ -422,7 +212,7 @@ const DocflowDocuments = () => {
422
212
  flexDirection: 'column',
423
213
  gap: theme.spacing[1],
424
214
  maxWidth: '240px',
425
- padding: theme.spacing[2],
215
+ padding: `${theme.spacing[4]} ${theme.spacing[3]} ${theme.spacing[4]} ${theme.spacing[5]}`,
426
216
  }}
427
217
  >
428
218
  {[{ id: ALL_FOLDERS, name: 'Все документы' }, ...(folders ?? [])].map((folder) => (
@@ -474,8 +264,8 @@ const DocflowDocuments = () => {
474
264
  }}
475
265
  />
476
266
  <div style={{ marginLeft: 'auto' }}>
477
- <Button variant="solid" size="sm" onClick={() => setPanel({ kind: 'upload' })}>
478
- Добавить документ
267
+ <Button variant="solid" size="sm" loading={isCreating} onClick={() => void handleCreateDocument()}>
268
+ Создать
479
269
  </Button>
480
270
  </div>
481
271
  </Toolbar>
@@ -495,8 +285,8 @@ const DocflowDocuments = () => {
495
285
  { key: 'source', label: 'Где загружен', width: '24%' },
496
286
  { key: 'createdAt', label: 'Добавлен' },
497
287
  ]}
498
- selectedRowId={panel.kind === 'document' ? panel.documentId : undefined}
499
- onRowClick={(documentId) => setPanel({ kind: 'document', documentId })}
288
+ selectedRowId={openedDocumentId}
289
+ onRowClick={(documentId) => void openDocument(documentId)}
500
290
  rows={documents.map((document) => ({
501
291
  id: document.id,
502
292
  cells: {
@@ -518,7 +308,7 @@ const DocflowDocuments = () => {
518
308
  )}
519
309
  </div>
520
310
  </div>
521
- </SplitView>
311
+ </div>
522
312
  </DocflowRoot>
523
313
  );
524
314
  };