@haiiro/docuflow 0.0.9 → 0.0.11

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,24 +1,23 @@
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, deleteRecord, 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';
19
+ import { type FolderAppearance, FolderList } from 'src/front/ui/folders';
20
+ import { Button, type MenuAction, PromptDialog, SearchWithFilters } from 'src/front/ui/kit';
22
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';
@@ -26,12 +25,15 @@ import { DOCUMENTS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-id
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' | 'deleted';
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,22 +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
70
  // Deleted = in the Twenty trash, restored from the document's own card
69
- documentFilter === 'deleted'
70
- ? restIsNotNull('deletedAt')
71
- : documentFilter === 'collected'
71
+ filters.scope === 'deleted' ? restIsNotNull('deletedAt') : undefined,
72
+ filters.status !== ANY ? restEq('status', filters.status) : undefined,
73
+ filters.origin === 'collected'
72
74
  ? restIn('origin', ['RECORD', 'PROCESS'])
73
- : documentFilter !== 'all'
74
- ? restEq('status', documentFilter)
75
+ : filters.origin === 'UPLOAD'
76
+ ? restEq('origin', 'UPLOAD')
75
77
  : undefined,
76
78
  searchQuery.trim().length > 0
77
79
  ? restOr(
@@ -87,11 +89,12 @@ const DocflowDocuments = () => {
87
89
  const [searchInput, setSearchInput] = useState('');
88
90
  const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
89
91
  const [folderId, setFolderId] = useState(ALL_FOLDERS);
90
- const [documentFilter, setDocumentFilter] = useState<DocumentFilter>('all');
92
+ const [filters, setFilters] = useState<DocumentFilters>(DEFAULT_FILTERS);
93
+ const [sort, setSort] = useState<TableSort>(DEFAULT_SORT);
91
94
  const [openedDocumentId, setOpenedDocumentId] = useState<string | undefined>();
95
+ const [renamingDocument, setRenamingDocument] = useState<DocflowDocument | undefined>();
92
96
  const [watchStartedAt, setWatchStartedAt] = useState<number | undefined>();
93
97
  const [pages, setPages] = useState(1);
94
- const [newFolderName, setNewFolderName] = useState('');
95
98
  const [isCreating, setIsCreating] = useState(false);
96
99
 
97
100
  const { data: folders, reload: reloadFolders } = useAsync(
@@ -106,18 +109,25 @@ const DocflowDocuments = () => {
106
109
  );
107
110
  const listQuery = useMemo(
108
111
  () => ({
109
- filter: buildFilter({ folderId, documentFilter, searchQuery }),
110
- order_by: 'createdAt[DescNullsLast]',
112
+ filter: buildFilter({ folderId, filters, searchQuery }),
113
+ order_by: toRestOrderBy(sort),
111
114
  limit: PAGE_SIZE * pages,
112
115
  depth: 1,
113
116
  }),
114
- [folderId, documentFilter, searchQuery, pages],
117
+ [folderId, filters, searchQuery, sort, pages],
115
118
  );
116
119
  const { data, setData, loading, error, reload } = useAsync(
117
120
  () => listRecords<DocflowDocument>('smbDocuments', listQuery),
118
121
  [listQuery],
119
122
  );
120
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
+ };
121
131
 
122
132
  // A document opens in its standard card in the side panel; while people
123
133
  // edit there, the list re-reads itself quietly to show the changes.
@@ -152,6 +162,9 @@ const DocflowDocuments = () => {
152
162
  };
153
163
  }, [watchStartedAt, listQuery, data]);
154
164
 
165
+ const notifyError = (actionError: unknown, fallback: string) =>
166
+ enqueueSnackbar({ message: getErrorMessage(actionError, fallback), variant: 'error' });
167
+
155
168
  // As "Создать" of a standard list: an empty draft in the open folder, filled
156
169
  // in its own card in the side panel (the file goes in there too).
157
170
  const handleCreateDocument = async () => {
@@ -174,15 +187,23 @@ const DocflowDocuments = () => {
174
187
  await openDocument(document.id);
175
188
  } catch (createError) {
176
189
  logDocflowError('documents: create failed', createError);
177
- await enqueueSnackbar({
178
- message: getErrorMessage(createError, 'Не удалось создать документ'),
179
- variant: 'error',
180
- });
190
+ await notifyError(createError, 'Не удалось создать документ');
181
191
  } finally {
182
192
  setIsCreating(false);
183
193
  }
184
194
  };
185
195
 
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
+ };
206
+
186
207
  const handleDeleteDocument = async (documentId: string) => {
187
208
  try {
188
209
  await deleteRecord('smbDocuments', documentId);
@@ -190,93 +211,167 @@ const DocflowDocuments = () => {
190
211
  await reload();
191
212
  } catch (deleteError) {
192
213
  logDocflowError('documents: delete failed', deleteError, { documentId });
193
- await enqueueSnackbar({ message: getErrorMessage(deleteError, 'Не удалось удалить документ'), variant: 'error' });
214
+ await notifyError(deleteError, 'Не удалось удалить документ');
194
215
  }
195
216
  };
196
217
 
197
- const handleCreateFolder = async () => {
198
- const folderName = newFolderName.trim();
218
+ const handleCreateFolder = async (appearance: FolderAppearance) => {
219
+ try {
220
+ const folder = await createRecord<DocumentFolder>('smbDocumentFolders', {
221
+ ...appearance,
222
+ parentFolderId: folderId === ALL_FOLDERS ? null : folderId,
223
+ });
199
224
 
200
- if (folderName.length === 0) {
201
- return;
225
+ await reloadFolders();
226
+
227
+ if (folder !== undefined) {
228
+ setFolderId(folder.id);
229
+ }
230
+ } catch (folderError) {
231
+ await notifyError(folderError, 'Не удалось создать папку');
202
232
  }
233
+ };
203
234
 
235
+ const handleUpdateFolder = async (id: string, appearance: FolderAppearance) => {
204
236
  try {
205
- await createRecord('smbDocumentFolders', {
206
- name: folderName,
207
- parentFolderId: folderId === ALL_FOLDERS ? null : folderId,
208
- });
209
- setNewFolderName('');
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
+
210
253
  await reloadFolders();
254
+ await enqueueSnackbar({ message: 'Папка удалена, документы остались в «Все документы»', variant: 'success' });
211
255
  } catch (folderError) {
212
- await enqueueSnackbar({ message: getErrorMessage(folderError, 'Не удалось создать папку'), variant: 'error' });
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
+ ];
213
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
+ ];
214
304
  };
215
305
 
216
306
  return (
217
307
  <DocflowRoot>
218
308
  <div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
219
309
  <div style={{ display: 'flex', flex: 1, flexWrap: 'wrap', minHeight: 0 }}>
220
- <nav
221
- aria-label="Папки"
310
+ <div
222
311
  style={{
223
312
  boxSizing: 'border-box',
224
- display: 'flex',
225
313
  flex: '1 1 200px',
226
- flexDirection: 'column',
227
- gap: theme.spacing[1],
228
314
  maxWidth: '240px',
229
315
  padding: `${theme.spacing[4]} ${theme.spacing[3]} ${theme.spacing[4]} ${theme.spacing[5]}`,
230
316
  }}
231
317
  >
232
- {[{ id: ALL_FOLDERS, name: 'Все документы' }, ...(folders ?? [])].map((folder) => (
233
- <Button
234
- key={folder.id}
235
- variant={folder.id === folderId ? 'solid' : 'ghost'}
236
- size="sm"
237
- fullWidth
238
- onClick={() => {
239
- setFolderId(folder.id);
240
- setPages(1);
241
- }}
242
- >
243
- {folder.name?.trim() || 'Без названия'}
244
- </Button>
245
- ))}
246
- <div style={{ display: 'flex', flexDirection: 'column', gap: theme.spacing[1], marginTop: theme.spacing[3] }}>
247
- <TextField placeholder="Новая папка" value={newFolderName} onChange={setNewFolderName} />
248
- <Button variant="outline" size="sm" disabled={newFolderName.trim().length === 0} onClick={() => void handleCreateFolder()}>
249
- Создать папку
250
- </Button>
251
- </div>
252
- </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>
253
331
  <div style={{ display: 'flex', flex: '999 1 420px', flexDirection: 'column', minWidth: 0 }}>
254
332
  <Toolbar>
255
- <div style={{ flex: '0 1 240px' }}>
256
- <TextField
257
- type="search"
258
- variant="bare"
259
- placeholder="Название или номер"
260
- value={searchInput}
261
- onChange={setSearchInput}
262
- />
263
- </div>
264
- <SelectField<DocumentFilter>
265
- variant="bare"
266
- value={documentFilter}
267
- options={[
268
- { value: 'all', label: 'Все документы' },
269
- { value: 'collected', label: 'Из записей и процессов' },
270
- { value: 'DRAFT', label: 'Черновики' },
271
- { value: 'IN_REVIEW', label: 'На согласовании' },
272
- { value: 'APPROVED', label: 'Согласованные' },
273
- { value: 'ARCHIVED', label: 'В архиве' },
274
- { value: 'deleted', 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
+ },
275
374
  ]}
276
- onChange={(value) => {
277
- setDocumentFilter(value);
278
- setPages(1);
279
- }}
280
375
  />
281
376
  <div style={{ marginLeft: 'auto' }}>
282
377
  <Button variant="solid" size="sm" loading={isCreating} onClick={() => void handleCreateDocument()}>
@@ -294,15 +389,20 @@ const DocflowDocuments = () => {
294
389
  {documents.length > 0 && (
295
390
  <DataTable
296
391
  columns={[
297
- { key: 'name', label: 'Название', width: '30%' },
298
- { key: 'type', label: 'Тип', width: '12%' },
299
- { key: 'number', label: 'Номер', width: '10%' },
300
- { key: 'source', label: 'Где загружен', width: '22%' },
301
- { 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' },
302
397
  ]}
398
+ sort={sort}
399
+ onSortChange={(next) => {
400
+ setSort(next);
401
+ setPages(1);
402
+ }}
303
403
  selectedRowId={openedDocumentId}
304
404
  onRowClick={(documentId) => void openDocument(documentId)}
305
- onDeleteRow={documentFilter === 'deleted' ? undefined : handleDeleteDocument}
405
+ rowActions={documentActions}
306
406
  rows={documents.map((document) => ({
307
407
  id: document.id,
308
408
  cells: {
@@ -325,6 +425,18 @@ const DocflowDocuments = () => {
325
425
  </div>
326
426
  </div>
327
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
+ )}
328
440
  </DocflowRoot>
329
441
  );
330
442
  };