@haiiro/docuflow 0.0.8 → 0.0.10

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,37 +1,39 @@
1
1
  import { useEffect, 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 { Button } from 'twenty-ui/primitives/input';
4
+ import { IconExternalLink, IconFolderSymlink, IconPencil, IconRestore, IconTrash } from 'twenty-ui/icon';
5
5
  import { useTheme } from 'twenty-ui/theme';
6
6
 
7
- import { createRecord, getErrorMessage, listRecords } from 'src/front/api';
8
- import {
9
- DOCUMENT_TYPE_LABELS,
10
- formatDate,
11
- } from 'src/front/format';
7
+ import { createRecord, deleteRecord, getErrorMessage, listRecords, updateRecord } from 'src/front/api';
8
+ import { DOCUMENT_TYPE_LABELS, formatDate } from 'src/front/format';
12
9
  import { useAsync, useDebouncedValue } from 'src/front/hooks/use-async';
13
10
  import { type DocflowDocument, type DocumentFolder } from 'src/front/types';
14
11
  import {
15
12
  DataTable,
16
13
  DocflowRoot,
17
14
  Message,
18
- SelectField,
19
- TextField,
15
+ type TableSort,
16
+ toRestOrderBy,
20
17
  Toolbar,
21
18
  } from 'src/front/ui/components';
22
- import { restAnd, restEq, restIlike, restIn, restOr } from 'src/shared/api/rest-filter.util';
19
+ import { type FolderAppearance, FolderList } from 'src/front/ui/folders';
20
+ import { Button, type MenuAction, PromptDialog, SearchWithFilters } from 'src/front/ui/kit';
21
+ import { restAnd, restEq, restIlike, restIn, restIsNotNull, restOr } from 'src/shared/api/rest-filter.util';
23
22
  import { DOCUMENT_OBJECT_NAME, PAGE_SIZE } from 'src/shared/docflow.constants';
24
23
  import { logDocflow, logDocflowError } from 'src/shared/log.util';
25
24
  import { DOCUMENTS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
26
25
 
27
26
  const LIST_POLL_INTERVAL_MS = 3000;
28
27
  const LIST_POLL_DURATION_MS = 10 * 60 * 1000;
29
-
30
- type DocumentFilter = 'all' | 'collected' | 'DRAFT' | 'IN_REVIEW' | 'APPROVED' | 'ARCHIVED';
31
-
32
28
  const SEARCH_DEBOUNCE_MS = 300;
33
29
  const FOLDERS_LIMIT = 500;
34
30
  const ALL_FOLDERS = '';
31
+ const ANY = 'any';
32
+
33
+ type DocumentFilters = { status: string; origin: string; scope: 'active' | 'deleted' };
34
+
35
+ const DEFAULT_FILTERS: DocumentFilters = { status: ANY, origin: ANY, scope: 'active' };
36
+ const DEFAULT_SORT: TableSort = { key: 'createdAt', direction: 'desc' };
35
37
 
36
38
  // The sandbox of a front component gets only the name and size of a chosen
37
39
  // file, never its content, so the file goes in through the standard "File"
@@ -56,19 +58,22 @@ const openDocumentCard = async (documentId: string) => {
56
58
 
57
59
  const buildFilter = ({
58
60
  folderId,
59
- documentFilter,
61
+ filters,
60
62
  searchQuery,
61
63
  }: {
62
64
  folderId: string;
63
- documentFilter: DocumentFilter;
65
+ filters: DocumentFilters;
64
66
  searchQuery: string;
65
67
  }) =>
66
68
  restAnd(
67
69
  folderId !== ALL_FOLDERS ? restEq('folderId', folderId) : undefined,
68
- documentFilter === 'collected'
70
+ // Deleted = in the Twenty trash, restored from the document's own card
71
+ filters.scope === 'deleted' ? restIsNotNull('deletedAt') : undefined,
72
+ filters.status !== ANY ? restEq('status', filters.status) : undefined,
73
+ filters.origin === 'collected'
69
74
  ? restIn('origin', ['RECORD', 'PROCESS'])
70
- : documentFilter !== 'all'
71
- ? restEq('status', documentFilter)
75
+ : filters.origin === 'UPLOAD'
76
+ ? restEq('origin', 'UPLOAD')
72
77
  : undefined,
73
78
  searchQuery.trim().length > 0
74
79
  ? restOr(
@@ -84,11 +89,12 @@ const DocflowDocuments = () => {
84
89
  const [searchInput, setSearchInput] = useState('');
85
90
  const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
86
91
  const [folderId, setFolderId] = useState(ALL_FOLDERS);
87
- const [documentFilter, setDocumentFilter] = useState<DocumentFilter>('all');
92
+ const [filters, setFilters] = useState<DocumentFilters>(DEFAULT_FILTERS);
93
+ const [sort, setSort] = useState<TableSort>(DEFAULT_SORT);
88
94
  const [openedDocumentId, setOpenedDocumentId] = useState<string | undefined>();
95
+ const [renamingDocument, setRenamingDocument] = useState<DocflowDocument | undefined>();
89
96
  const [watchStartedAt, setWatchStartedAt] = useState<number | undefined>();
90
97
  const [pages, setPages] = useState(1);
91
- const [newFolderName, setNewFolderName] = useState('');
92
98
  const [isCreating, setIsCreating] = useState(false);
93
99
 
94
100
  const { data: folders, reload: reloadFolders } = useAsync(
@@ -103,18 +109,25 @@ const DocflowDocuments = () => {
103
109
  );
104
110
  const listQuery = useMemo(
105
111
  () => ({
106
- filter: buildFilter({ folderId, documentFilter, searchQuery }),
107
- order_by: 'createdAt[DescNullsLast]',
112
+ filter: buildFilter({ folderId, filters, searchQuery }),
113
+ order_by: toRestOrderBy(sort),
108
114
  limit: PAGE_SIZE * pages,
109
115
  depth: 1,
110
116
  }),
111
- [folderId, documentFilter, searchQuery, pages],
117
+ [folderId, filters, searchQuery, sort, pages],
112
118
  );
113
119
  const { data, setData, loading, error, reload } = useAsync(
114
120
  () => listRecords<DocflowDocument>('smbDocuments', listQuery),
115
121
  [listQuery],
116
122
  );
117
123
  const documents = data?.records ?? [];
124
+ const activeFilterCount =
125
+ (filters.status !== ANY ? 1 : 0) + (filters.origin !== ANY ? 1 : 0) + (filters.scope !== 'active' ? 1 : 0);
126
+
127
+ const changeFilters = (patch: Partial<DocumentFilters>) => {
128
+ setFilters((current) => ({ ...current, ...patch }));
129
+ setPages(1);
130
+ };
118
131
 
119
132
  // A document opens in its standard card in the side panel; while people
120
133
  // edit there, the list re-reads itself quietly to show the changes.
@@ -149,6 +162,9 @@ const DocflowDocuments = () => {
149
162
  };
150
163
  }, [watchStartedAt, listQuery, data]);
151
164
 
165
+ const notifyError = (actionError: unknown, fallback: string) =>
166
+ enqueueSnackbar({ message: getErrorMessage(actionError, fallback), variant: 'error' });
167
+
152
168
  // As "Создать" of a standard list: an empty draft in the open folder, filled
153
169
  // in its own card in the side panel (the file goes in there too).
154
170
  const handleCreateDocument = async () => {
@@ -171,97 +187,191 @@ const DocflowDocuments = () => {
171
187
  await openDocument(document.id);
172
188
  } catch (createError) {
173
189
  logDocflowError('documents: create failed', createError);
174
- await enqueueSnackbar({
175
- message: getErrorMessage(createError, 'Не удалось создать документ'),
176
- variant: 'error',
177
- });
190
+ await notifyError(createError, 'Не удалось создать документ');
178
191
  } finally {
179
192
  setIsCreating(false);
180
193
  }
181
194
  };
182
195
 
183
- const handleCreateFolder = async () => {
184
- const folderName = newFolderName.trim();
196
+ const handleUpdateDocument = async (documentId: string, patch: Record<string, unknown>, done: string) => {
197
+ try {
198
+ await updateRecord('smbDocuments', documentId, patch);
199
+ await enqueueSnackbar({ message: done, variant: 'success' });
200
+ await reload();
201
+ } catch (updateError) {
202
+ logDocflowError('documents: update failed', updateError, { documentId });
203
+ await notifyError(updateError, 'Не удалось изменить документ');
204
+ }
205
+ };
185
206
 
186
- if (folderName.length === 0) {
187
- return;
207
+ const handleDeleteDocument = async (documentId: string) => {
208
+ try {
209
+ await deleteRecord('smbDocuments', documentId);
210
+ await enqueueSnackbar({ message: 'Документ удалён: он в фильтре «Удалённые»', variant: 'success' });
211
+ await reload();
212
+ } catch (deleteError) {
213
+ logDocflowError('documents: delete failed', deleteError, { documentId });
214
+ await notifyError(deleteError, 'Не удалось удалить документ');
188
215
  }
216
+ };
189
217
 
218
+ const handleCreateFolder = async (appearance: FolderAppearance) => {
190
219
  try {
191
- await createRecord('smbDocumentFolders', {
192
- name: folderName,
220
+ const folder = await createRecord<DocumentFolder>('smbDocumentFolders', {
221
+ ...appearance,
193
222
  parentFolderId: folderId === ALL_FOLDERS ? null : folderId,
194
223
  });
195
- setNewFolderName('');
224
+
196
225
  await reloadFolders();
226
+
227
+ if (folder !== undefined) {
228
+ setFolderId(folder.id);
229
+ }
197
230
  } catch (folderError) {
198
- await enqueueSnackbar({ message: getErrorMessage(folderError, 'Не удалось создать папку'), variant: 'error' });
231
+ await notifyError(folderError, 'Не удалось создать папку');
199
232
  }
200
233
  };
201
234
 
235
+ const handleUpdateFolder = async (id: string, appearance: FolderAppearance) => {
236
+ try {
237
+ await updateRecord('smbDocumentFolders', id, { ...appearance });
238
+ await reloadFolders();
239
+ } catch (folderError) {
240
+ await notifyError(folderError, 'Не удалось изменить папку');
241
+ }
242
+ };
243
+
244
+ // The documents stay: they show under "Все документы"
245
+ const handleDeleteFolder = async (id: string) => {
246
+ try {
247
+ await deleteRecord('smbDocumentFolders', id);
248
+
249
+ if (folderId === id) {
250
+ setFolderId(ALL_FOLDERS);
251
+ }
252
+
253
+ await reloadFolders();
254
+ await enqueueSnackbar({ message: 'Папка удалена, документы остались в «Все документы»', variant: 'success' });
255
+ } catch (folderError) {
256
+ await notifyError(folderError, 'Не удалось удалить папку');
257
+ }
258
+ };
259
+
260
+ const documentActions = (documentId: string): MenuAction[] => {
261
+ const document = documents.find(({ id }) => id === documentId);
262
+
263
+ if (document === undefined) {
264
+ return [];
265
+ }
266
+
267
+ if (filters.scope === 'deleted') {
268
+ return [
269
+ { key: 'restore', label: 'Открыть и восстановить', icon: IconRestore, onSelect: () => void openDocument(documentId) },
270
+ ];
271
+ }
272
+
273
+ return [
274
+ { key: 'open', label: 'Открыть карточку', icon: IconExternalLink, onSelect: () => void openDocument(documentId) },
275
+ { key: 'rename', label: 'Переименовать', icon: IconPencil, onSelect: () => setRenamingDocument(document) },
276
+ ...(folders ?? [])
277
+ .filter((folder) => folder.id !== document.folderId)
278
+ .map((folder) => ({
279
+ key: `move-${folder.id}`,
280
+ label: `В папку «${folder.name?.trim() || 'Без названия'}»`,
281
+ icon: IconFolderSymlink,
282
+ onSelect: () =>
283
+ void handleUpdateDocument(documentId, { folderId: folder.id }, `Документ перемещён в «${folder.name ?? ''}»`),
284
+ })),
285
+ ...(typeof document.folderId === 'string' && document.folderId.length > 0
286
+ ? [
287
+ {
288
+ key: 'unfile',
289
+ label: 'Убрать из папки',
290
+ icon: IconFolderSymlink,
291
+ onSelect: () => void handleUpdateDocument(documentId, { folderId: null }, 'Документ убран из папки'),
292
+ },
293
+ ]
294
+ : []),
295
+ {
296
+ key: 'delete',
297
+ label: 'Удалить',
298
+ confirmLabel: 'Точно удалить?',
299
+ icon: IconTrash,
300
+ tone: 'danger' as const,
301
+ onSelect: () => void handleDeleteDocument(documentId),
302
+ },
303
+ ];
304
+ };
305
+
202
306
  return (
203
307
  <DocflowRoot>
204
308
  <div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
205
309
  <div style={{ display: 'flex', flex: 1, flexWrap: 'wrap', minHeight: 0 }}>
206
- <nav
207
- aria-label="Папки"
310
+ <div
208
311
  style={{
209
312
  boxSizing: 'border-box',
210
- display: 'flex',
211
313
  flex: '1 1 200px',
212
- flexDirection: 'column',
213
- gap: theme.spacing[1],
214
314
  maxWidth: '240px',
215
315
  padding: `${theme.spacing[4]} ${theme.spacing[3]} ${theme.spacing[4]} ${theme.spacing[5]}`,
216
316
  }}
217
317
  >
218
- {[{ id: ALL_FOLDERS, name: 'Все документы' }, ...(folders ?? [])].map((folder) => (
219
- <Button
220
- key={folder.id}
221
- variant={folder.id === folderId ? 'solid' : 'ghost'}
222
- size="sm"
223
- fullWidth
224
- onClick={() => {
225
- setFolderId(folder.id);
226
- setPages(1);
227
- }}
228
- >
229
- {folder.name?.trim() || 'Без названия'}
230
- </Button>
231
- ))}
232
- <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[1], marginTop: theme.spacing[3] }}>
233
- <TextField placeholder="Новая папка" value={newFolderName} onChange={setNewFolderName} />
234
- <Button variant="outline" size="sm" disabled={newFolderName.trim().length === 0} onClick={() => void handleCreateFolder()}>
235
- Создать папку
236
- </Button>
237
- </div>
238
- </nav>
318
+ <FolderList
319
+ folders={folders ?? []}
320
+ selectedFolderId={folderId}
321
+ allFoldersId={ALL_FOLDERS}
322
+ onSelect={(id) => {
323
+ setFolderId(id);
324
+ setPages(1);
325
+ }}
326
+ onCreate={handleCreateFolder}
327
+ onUpdate={handleUpdateFolder}
328
+ onDelete={handleDeleteFolder}
329
+ />
330
+ </div>
239
331
  <div style={{ display: 'flex', flex: '999 1 420px', flexDirection: 'column', minWidth: 0 }}>
240
332
  <Toolbar>
241
- <div style={{ flex: '0 1 240px' }}>
242
- <TextField
243
- type="search"
244
- variant="bare"
245
- placeholder="Название или номер"
246
- value={searchInput}
247
- onChange={setSearchInput}
248
- />
249
- </div>
250
- <SelectField<DocumentFilter>
251
- variant="bare"
252
- value={documentFilter}
253
- options={[
254
- { value: 'all', label: 'Все документы' },
255
- { value: 'collected', label: 'Из записей и процессов' },
256
- { value: 'DRAFT', label: 'Черновики' },
257
- { value: 'IN_REVIEW', label: 'На согласовании' },
258
- { value: 'APPROVED', label: 'Согласованные' },
259
- { value: 'ARCHIVED', label: 'В архиве' },
333
+ <SearchWithFilters
334
+ value={searchInput}
335
+ onChange={setSearchInput}
336
+ placeholder="Название, номер, где загружен"
337
+ activeFilterCount={activeFilterCount}
338
+ onResetFilters={() => changeFilters(DEFAULT_FILTERS)}
339
+ filters={[
340
+ {
341
+ key: 'status',
342
+ title: 'Статус',
343
+ value: filters.status,
344
+ options: [
345
+ { value: ANY, label: 'Любой' },
346
+ { value: 'DRAFT', label: 'Черновики' },
347
+ { value: 'IN_REVIEW', label: 'На согласовании' },
348
+ { value: 'APPROVED', label: 'Согласованные' },
349
+ { value: 'ARCHIVED', label: 'В архиве' },
350
+ ],
351
+ onChange: (value) => changeFilters({ status: value }),
352
+ },
353
+ {
354
+ key: 'origin',
355
+ title: 'Откуда',
356
+ value: filters.origin,
357
+ options: [
358
+ { value: ANY, label: 'Отовсюду' },
359
+ { value: 'UPLOAD', label: 'Загружены в документы' },
360
+ { value: 'collected', label: 'Из записей и процессов' },
361
+ ],
362
+ onChange: (value) => changeFilters({ origin: value }),
363
+ },
364
+ {
365
+ key: 'scope',
366
+ title: 'Корзина',
367
+ value: filters.scope,
368
+ options: [
369
+ { value: 'active', label: 'Действующие' },
370
+ { value: 'deleted', label: 'Удалённые' },
371
+ ],
372
+ onChange: (value) => changeFilters({ scope: value as DocumentFilters['scope'] }),
373
+ },
260
374
  ]}
261
- onChange={(value) => {
262
- setDocumentFilter(value);
263
- setPages(1);
264
- }}
265
375
  />
266
376
  <div style={{ marginLeft: 'auto' }}>
267
377
  <Button variant="solid" size="sm" loading={isCreating} onClick={() => void handleCreateDocument()}>
@@ -279,14 +389,20 @@ const DocflowDocuments = () => {
279
389
  {documents.length > 0 && (
280
390
  <DataTable
281
391
  columns={[
282
- { key: 'name', label: 'Название', width: '34%' },
283
- { key: 'type', label: 'Тип', width: '14%' },
284
- { key: 'number', label: 'Номер', width: '12%' },
285
- { key: 'source', label: 'Где загружен', width: '24%' },
286
- { key: 'createdAt', label: 'Добавлен' },
392
+ { key: 'name', label: 'Название', width: '30%', sortKey: 'name' },
393
+ { key: 'type', label: 'Тип', width: '14%', sortKey: 'documentType' },
394
+ { key: 'number', label: 'Номер', width: '12%', sortKey: 'number' },
395
+ { key: 'source', label: 'Где загружен', width: '24%', sortKey: 'sourceRecordName' },
396
+ { key: 'createdAt', label: 'Добавлен', sortKey: 'createdAt' },
287
397
  ]}
398
+ sort={sort}
399
+ onSortChange={(next) => {
400
+ setSort(next);
401
+ setPages(1);
402
+ }}
288
403
  selectedRowId={openedDocumentId}
289
404
  onRowClick={(documentId) => void openDocument(documentId)}
405
+ rowActions={documentActions}
290
406
  rows={documents.map((document) => ({
291
407
  id: document.id,
292
408
  cells: {
@@ -309,6 +425,18 @@ const DocflowDocuments = () => {
309
425
  </div>
310
426
  </div>
311
427
  </div>
428
+ {renamingDocument !== undefined && (
429
+ <PromptDialog
430
+ title="Переименовать документ"
431
+ label="Название"
432
+ initialValue={renamingDocument.name ?? ''}
433
+ onClose={() => setRenamingDocument(undefined)}
434
+ onSubmit={async (name) => {
435
+ await handleUpdateDocument(renamingDocument.id, { name }, 'Документ переименован');
436
+ setRenamingDocument(undefined);
437
+ }}
438
+ />
439
+ )}
312
440
  </DocflowRoot>
313
441
  );
314
442
  };