@haiiro/docuflow 0.0.11 → 0.0.12

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,23 +1,55 @@
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 { IconExternalLink, IconFolderSymlink, IconPencil, IconRestore, IconTrash } from 'twenty-ui/icon';
4
+ import {
5
+ IconExternalLink,
6
+ IconFileText,
7
+ IconFolderSymlink,
8
+ IconPencil,
9
+ IconRestore,
10
+ IconTrash,
11
+ } from 'twenty-ui/icon';
5
12
  import { useTheme } from 'twenty-ui/theme';
6
13
 
7
- import { createRecord, deleteRecord, getErrorMessage, listRecords, updateRecord } from 'src/front/api';
8
- import { DOCUMENT_TYPE_LABELS, formatDate } from 'src/front/format';
14
+ import {
15
+ countRecords,
16
+ createRecord,
17
+ deleteRecord,
18
+ deleteRecords,
19
+ getErrorMessage,
20
+ listRecords,
21
+ restoreRecords,
22
+ updateRecord,
23
+ updateRecords,
24
+ } from 'src/front/api';
25
+ import {
26
+ DOCUMENT_ORIGIN_LABELS,
27
+ DOCUMENT_STATUS_COLORS,
28
+ DOCUMENT_STATUS_LABELS,
29
+ DOCUMENT_TYPE_LABELS,
30
+ formatDate,
31
+ formatMemberName,
32
+ } from 'src/front/format';
9
33
  import { useAsync, useDebouncedValue } from 'src/front/hooks/use-async';
34
+ import { useBulkAction } from 'src/front/hooks/use-bulk-action';
10
35
  import { type DocflowDocument, type DocumentFolder } from 'src/front/types';
11
36
  import {
12
37
  DataTable,
13
38
  DocflowRoot,
39
+ formatTotal,
40
+ ListToolbar,
14
41
  Message,
42
+ pluralize,
43
+ SelectionBar,
44
+ ShowMore,
45
+ type TableColumn,
15
46
  type TableSort,
16
47
  toRestOrderBy,
17
- Toolbar,
48
+ useHiddenColumns,
49
+ useRowSelection,
18
50
  } 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';
51
+ import { type FolderAppearance, FolderList, useFolderColor } from 'src/front/ui/folders';
52
+ import { BulkChangeDialog, Button, type MenuAction, MenuButton, PromptDialog, StatusChip, ViewTabs } from 'src/front/ui/kit';
21
53
  import { restAnd, restEq, restIlike, restIn, restIsNotNull, restOr } from 'src/shared/api/rest-filter.util';
22
54
  import { DOCUMENT_OBJECT_NAME, PAGE_SIZE } from 'src/shared/docflow.constants';
23
55
  import { logDocflow, logDocflowError } from 'src/shared/log.util';
@@ -29,12 +61,40 @@ const SEARCH_DEBOUNCE_MS = 300;
29
61
  const FOLDERS_LIMIT = 500;
30
62
  const ALL_FOLDERS = '';
31
63
  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' };
64
+ const DOCUMENT_NOUN: [string, string, string] = ['документ', 'документа', 'документов'];
65
+
66
+ // The quick views: a status, all of them, or the trash
67
+ type DocumentView = 'all' | 'DRAFT' | 'IN_REVIEW' | 'APPROVED' | 'ARCHIVED' | 'deleted';
68
+ type DocumentFilters = { origin: string; type: string };
69
+
70
+ const VIEWS: { value: DocumentView; label: string }[] = [
71
+ { value: 'all', label: 'Все' },
72
+ { value: 'DRAFT', label: 'Черновики' },
73
+ { value: 'IN_REVIEW', label: 'На согласовании' },
74
+ { value: 'APPROVED', label: 'Согласованные' },
75
+ { value: 'ARCHIVED', label: 'В архиве' },
76
+ { value: 'deleted', label: 'Удалённые' },
77
+ ];
78
+
79
+ const DEFAULT_FILTERS: DocumentFilters = { origin: ANY, type: ANY };
36
80
  const DEFAULT_SORT: TableSort = { key: 'createdAt', direction: 'desc' };
37
81
 
82
+ const COLUMNS: TableColumn[] = [
83
+ { key: 'name', label: 'Название', width: '24%', sortKey: 'name' },
84
+ { key: 'type', label: 'Тип', width: '10%', sortKey: 'documentType' },
85
+ { key: 'status', label: 'Статус', width: '13%', sortKey: 'status' },
86
+ { key: 'number', label: 'Номер', width: '9%', sortKey: 'number' },
87
+ { key: 'documentDate', label: 'Дата документа', width: '10%', sortKey: 'documentDate' },
88
+ { key: 'counterparty', label: 'Контрагент', width: '13%' },
89
+ { key: 'responsible', label: 'Ответственный', width: '12%' },
90
+ { key: 'folder', label: 'Папка', width: '11%' },
91
+ { key: 'source', label: 'Где загружен', width: '14%', sortKey: 'sourceRecordName' },
92
+ { key: 'file', label: 'Файл', width: '7%' },
93
+ { key: 'createdAt', label: 'Добавлен', sortKey: 'createdAt' },
94
+ ];
95
+
96
+ const STATUS_VALUES = ['DRAFT', 'IN_REVIEW', 'APPROVED', 'ARCHIVED'];
97
+
38
98
  // The sandbox of a front component gets only the name and size of a chosen
39
99
  // file, never its content, so the file goes in through the standard "File"
40
100
  // field of the document's own card, opened in the side panel.
@@ -56,39 +116,45 @@ const openDocumentCard = async (documentId: string) => {
56
116
  }
57
117
  };
58
118
 
119
+ const viewFilter = (view: DocumentView) =>
120
+ // Deleted = in the Twenty trash, restored from here or the document's card
121
+ view === 'deleted' ? restIsNotNull('deletedAt') : view !== 'all' ? restEq('status', view) : undefined;
122
+
123
+ const searchFilter = (searchQuery: string) =>
124
+ searchQuery.trim().length > 0
125
+ ? restOr(restIlike('name', searchQuery), restIlike('number', searchQuery), restIlike('sourceRecordName', searchQuery))
126
+ : undefined;
127
+
59
128
  const buildFilter = ({
60
129
  folderId,
130
+ view,
61
131
  filters,
62
132
  searchQuery,
63
133
  }: {
64
134
  folderId: string;
135
+ view: DocumentView;
65
136
  filters: DocumentFilters;
66
137
  searchQuery: string;
67
138
  }) =>
68
139
  restAnd(
69
140
  folderId !== ALL_FOLDERS ? restEq('folderId', folderId) : undefined,
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,
141
+ viewFilter(view),
142
+ filters.type !== ANY ? restEq('documentType', filters.type) : undefined,
73
143
  filters.origin === 'collected'
74
144
  ? restIn('origin', ['RECORD', 'PROCESS'])
75
145
  : filters.origin === 'UPLOAD'
76
146
  ? restEq('origin', 'UPLOAD')
77
147
  : undefined,
78
- searchQuery.trim().length > 0
79
- ? restOr(
80
- restIlike('name', searchQuery),
81
- restIlike('number', searchQuery),
82
- restIlike('sourceRecordName', searchQuery),
83
- )
84
- : undefined,
148
+ searchFilter(searchQuery),
85
149
  );
86
150
 
87
151
  const DocflowDocuments = () => {
88
152
  const theme = useTheme();
153
+ const folderColor = useFolderColor();
89
154
  const [searchInput, setSearchInput] = useState('');
90
155
  const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
91
156
  const [folderId, setFolderId] = useState(ALL_FOLDERS);
157
+ const [view, setView] = useState<DocumentView>('all');
92
158
  const [filters, setFilters] = useState<DocumentFilters>(DEFAULT_FILTERS);
93
159
  const [sort, setSort] = useState<TableSort>(DEFAULT_SORT);
94
160
  const [openedDocumentId, setOpenedDocumentId] = useState<string | undefined>();
@@ -96,6 +162,8 @@ const DocflowDocuments = () => {
96
162
  const [watchStartedAt, setWatchStartedAt] = useState<number | undefined>();
97
163
  const [pages, setPages] = useState(1);
98
164
  const [isCreating, setIsCreating] = useState(false);
165
+ const [bulkDialogOpen, setBulkDialogOpen] = useState(false);
166
+ const [hiddenColumns, setHiddenColumns] = useHiddenColumns(['documentDate', 'responsible', 'file']);
99
167
 
100
168
  const { data: folders, reload: reloadFolders } = useAsync(
101
169
  async () =>
@@ -109,20 +177,54 @@ const DocflowDocuments = () => {
109
177
  );
110
178
  const listQuery = useMemo(
111
179
  () => ({
112
- filter: buildFilter({ folderId, filters, searchQuery }),
180
+ filter: buildFilter({ folderId, view, filters, searchQuery }),
113
181
  order_by: toRestOrderBy(sort),
114
182
  limit: PAGE_SIZE * pages,
115
183
  depth: 1,
116
184
  }),
117
- [folderId, filters, searchQuery, sort, pages],
185
+ [folderId, view, filters, searchQuery, sort, pages],
118
186
  );
119
187
  const { data, setData, loading, error, reload } = useAsync(
120
188
  () => listRecords<DocflowDocument>('smbDocuments', listQuery),
121
189
  [listQuery],
122
190
  );
191
+ // The counts of the views follow the folder and the search
192
+ const { data: counts, reload: reloadCounts } = useAsync(
193
+ async () =>
194
+ Object.fromEntries(
195
+ await Promise.all(
196
+ VIEWS.map(async ({ value }) => [
197
+ value,
198
+ await countRecords(
199
+ 'smbDocuments',
200
+ restAnd(
201
+ folderId !== ALL_FOLDERS ? restEq('folderId', folderId) : undefined,
202
+ viewFilter(value),
203
+ searchFilter(searchQuery),
204
+ ),
205
+ ),
206
+ ]),
207
+ ),
208
+ ) as Record<DocumentView, number>,
209
+ [folderId, searchQuery],
210
+ );
123
211
  const documents = data?.records ?? [];
124
- const activeFilterCount =
125
- (filters.status !== ANY ? 1 : 0) + (filters.origin !== ANY ? 1 : 0) + (filters.scope !== 'active' ? 1 : 0);
212
+ const selection = useRowSelection(documents.map(({ id }) => id));
213
+ const selectedIds = selection.selectedIds;
214
+ const activeFilterCount = (filters.origin !== ANY ? 1 : 0) + (filters.type !== ANY ? 1 : 0);
215
+ const folderName = (id: string | null | undefined) => folders?.find((folder) => folder.id === id)?.name?.trim();
216
+
217
+ const reloadAll = async () => {
218
+ await Promise.all([reload(), reloadCounts()]);
219
+ };
220
+
221
+ const bulk = useBulkAction({
222
+ onDone: async () => {
223
+ selection.clear();
224
+ setBulkDialogOpen(false);
225
+ await reloadAll();
226
+ },
227
+ });
126
228
 
127
229
  const changeFilters = (patch: Partial<DocumentFilters>) => {
128
230
  setFilters((current) => ({ ...current, ...patch }));
@@ -183,7 +285,7 @@ const DocflowDocuments = () => {
183
285
  throw new Error('документ создан, но сервер его не вернул');
184
286
  }
185
287
 
186
- void reload();
288
+ void reloadAll();
187
289
  await openDocument(document.id);
188
290
  } catch (createError) {
189
291
  logDocflowError('documents: create failed', createError);
@@ -193,27 +295,20 @@ const DocflowDocuments = () => {
193
295
  }
194
296
  };
195
297
 
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
- };
298
+ // One change for the given documents: a row's menu passes one id, the
299
+ // selection bar all checked ones.
300
+ const changeDocuments = (ids: string[], patch: Record<string, unknown>, done: string) =>
301
+ bulk.run(() => updateRecords('smbDocuments', ids, patch), done, 'Не удалось изменить документы');
206
302
 
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, 'Не удалось удалить документ');
215
- }
216
- };
303
+ const removeDocuments = (ids: string[]) =>
304
+ bulk.run(
305
+ () => deleteRecords('smbDocuments', ids),
306
+ ids.length === 1 ? 'Документ удалён: он в «Удалённых»' : `Удалено документов: ${ids.length}, они в «Удалённых»`,
307
+ 'Не удалось удалить',
308
+ );
309
+
310
+ const restoreDocuments = (ids: string[]) =>
311
+ bulk.run(() => restoreRecords('smbDocuments', ids), `Восстановлено: ${ids.length}`, 'Не удалось восстановить');
217
312
 
218
313
  const handleCreateFolder = async (appearance: FolderAppearance) => {
219
314
  try {
@@ -257,6 +352,35 @@ const DocflowDocuments = () => {
257
352
  }
258
353
  };
259
354
 
355
+ // The menus that change several documents at once: status, type, folder
356
+ const statusActions = (ids: string[], current?: string | null): MenuAction[] =>
357
+ STATUS_VALUES.map((status) => ({
358
+ key: `status-${status}`,
359
+ section: 'Статус',
360
+ label: DOCUMENT_STATUS_LABELS[status],
361
+ isSelected: status === current,
362
+ onSelect: () => void changeDocuments(ids, { status }, `Статус: ${DOCUMENT_STATUS_LABELS[status]}`),
363
+ }));
364
+
365
+ const folderActions = (ids: string[], current?: string | null): MenuAction[] => [
366
+ ...(folders ?? []).map((folder) => ({
367
+ key: `folder-${folder.id}`,
368
+ section: 'В папку',
369
+ label: folder.name?.trim() || 'Без названия',
370
+ icon: IconFolderSymlink,
371
+ isSelected: folder.id === current,
372
+ onSelect: () =>
373
+ void changeDocuments(ids, { folderId: folder.id }, `Перемещено в «${folder.name?.trim() || 'Без названия'}»`),
374
+ })),
375
+ {
376
+ key: 'unfile',
377
+ section: 'В папку',
378
+ label: 'Без папки',
379
+ icon: IconFolderSymlink,
380
+ onSelect: () => void changeDocuments(ids, { folderId: null }, 'Убрано из папки'),
381
+ },
382
+ ];
383
+
260
384
  const documentActions = (documentId: string): MenuAction[] => {
261
385
  const document = documents.find(({ id }) => id === documentId);
262
386
 
@@ -264,54 +388,39 @@ const DocflowDocuments = () => {
264
388
  return [];
265
389
  }
266
390
 
267
- if (filters.scope === 'deleted') {
391
+ if (view === 'deleted') {
268
392
  return [
269
- { key: 'restore', label: 'Открыть и восстановить', icon: IconRestore, onSelect: () => void openDocument(documentId) },
393
+ { key: 'restore', label: 'Восстановить', icon: IconRestore, onSelect: () => void restoreDocuments([documentId]) },
394
+ { key: 'open', label: 'Открыть карточку', icon: IconExternalLink, onSelect: () => void openDocument(documentId) },
270
395
  ];
271
396
  }
272
397
 
273
398
  return [
274
399
  { key: 'open', label: 'Открыть карточку', icon: IconExternalLink, onSelect: () => void openDocument(documentId) },
275
400
  { 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
401
  {
296
402
  key: 'delete',
297
403
  label: 'Удалить',
298
404
  confirmLabel: 'Точно удалить?',
299
405
  icon: IconTrash,
300
- tone: 'danger' as const,
301
- onSelect: () => void handleDeleteDocument(documentId),
406
+ tone: 'danger',
407
+ onSelect: () => void removeDocuments([documentId]),
302
408
  },
409
+ ...statusActions([documentId], document.status),
410
+ ...folderActions([documentId], document.folderId),
303
411
  ];
304
412
  };
305
413
 
306
414
  return (
307
415
  <DocflowRoot>
308
416
  <div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
309
- <div style={{ display: 'flex', flex: 1, flexWrap: 'wrap', minHeight: 0 }}>
417
+ <div style={{ alignItems: 'flex-start', display: 'flex', flex: 1, flexWrap: 'wrap', minHeight: 0 }}>
310
418
  <div
311
419
  style={{
312
420
  boxSizing: 'border-box',
313
421
  flex: '1 1 200px',
314
422
  maxWidth: '240px',
423
+ minWidth: 0,
315
424
  padding: `${theme.spacing[4]} ${theme.spacing[3]} ${theme.spacing[4]} ${theme.spacing[5]}`,
316
425
  }}
317
426
  >
@@ -322,6 +431,7 @@ const DocflowDocuments = () => {
322
431
  onSelect={(id) => {
323
432
  setFolderId(id);
324
433
  setPages(1);
434
+ selection.clear();
325
435
  }}
326
436
  onCreate={handleCreateFolder}
327
437
  onUpdate={handleUpdateFolder}
@@ -329,26 +439,23 @@ const DocflowDocuments = () => {
329
439
  />
330
440
  </div>
331
441
  <div style={{ display: 'flex', flex: '999 1 420px', flexDirection: 'column', minWidth: 0 }}>
332
- <Toolbar>
333
- <SearchWithFilters
334
- value={searchInput}
335
- onChange={setSearchInput}
336
- placeholder="Название, номер, где загружен"
337
- activeFilterCount={activeFilterCount}
338
- onResetFilters={() => changeFilters(DEFAULT_FILTERS)}
339
- filters={[
442
+ <ListToolbar
443
+ search={{
444
+ value: searchInput,
445
+ onChange: setSearchInput,
446
+ placeholder: 'Название, номер, где загружен',
447
+ activeFilterCount,
448
+ onResetFilters: () => changeFilters(DEFAULT_FILTERS),
449
+ filters: [
340
450
  {
341
- key: 'status',
342
- title: 'Статус',
343
- value: filters.status,
451
+ key: 'type',
452
+ title: 'Тип',
453
+ value: filters.type,
344
454
  options: [
345
455
  { value: ANY, label: 'Любой' },
346
- { value: 'DRAFT', label: 'Черновики' },
347
- { value: 'IN_REVIEW', label: 'На согласовании' },
348
- { value: 'APPROVED', label: 'Согласованные' },
349
- { value: 'ARCHIVED', label: 'В архиве' },
456
+ ...Object.entries(DOCUMENT_TYPE_LABELS).map(([value, label]) => ({ value, label })),
350
457
  ],
351
- onChange: (value) => changeFilters({ status: value }),
458
+ onChange: (value) => changeFilters({ type: value }),
352
459
  },
353
460
  {
354
461
  key: 'origin',
@@ -361,70 +468,171 @@ const DocflowDocuments = () => {
361
468
  ],
362
469
  onChange: (value) => changeFilters({ origin: value }),
363
470
  },
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
- },
374
- ]}
375
- />
376
- <div style={{ marginLeft: 'auto' }}>
377
- <Button variant="solid" size="sm" loading={isCreating} onClick={() => void handleCreateDocument()}>
378
- Создать
379
- </Button>
380
- </div>
381
- </Toolbar>
471
+ ],
472
+ }}
473
+ views={
474
+ <ViewTabs
475
+ value={view}
476
+ onChange={(next) => {
477
+ setView(next);
478
+ setPages(1);
479
+ selection.clear();
480
+ }}
481
+ options={VIEWS.map((option) => ({ ...option, count: counts?.[option.value] }))}
482
+ />
483
+ }
484
+ total={data === undefined ? undefined : formatTotal(documents.length, data.totalCount, DOCUMENT_NOUN)}
485
+ columns={{ columns: COLUMNS, hiddenKeys: hiddenColumns, onChange: setHiddenColumns }}
486
+ onRefresh={() => void reloadAll()}
487
+ isRefreshing={loading}
488
+ onCreate={() => void handleCreateDocument()}
489
+ isCreating={isCreating}
490
+ />
382
491
  {error !== undefined && (
383
492
  <div style={{ padding: theme.spacing[4] }}>
384
493
  <Message tone="danger">{getErrorMessage(error, 'Не удалось загрузить документы')}</Message>
385
494
  </div>
386
495
  )}
387
- {loading && documents.length === 0 && <Message>Загрузка…</Message>}
496
+ {loading && data === undefined && <Message>Загрузка…</Message>}
388
497
  {!loading && error === undefined && documents.length === 0 && <Message>Документов нет.</Message>}
389
498
  {documents.length > 0 && (
390
499
  <DataTable
391
- columns={[
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' },
397
- ]}
500
+ columns={COLUMNS}
501
+ hiddenColumnKeys={hiddenColumns}
398
502
  sort={sort}
399
503
  onSortChange={(next) => {
400
504
  setSort(next);
401
505
  setPages(1);
402
506
  }}
403
507
  selectedRowId={openedDocumentId}
508
+ selectedIds={selectedIds}
509
+ onSelectionChange={selection.setSelectedIds}
404
510
  onRowClick={(documentId) => void openDocument(documentId)}
405
511
  rowActions={documentActions}
406
- rows={documents.map((document) => ({
407
- id: document.id,
408
- cells: {
409
- name: document.name?.trim() || 'Без названия',
410
- type: DOCUMENT_TYPE_LABELS[document.documentType ?? ''],
411
- number: document.number,
412
- source: document.sourceRecordName,
413
- createdAt: formatDate(document.createdAt),
414
- },
415
- }))}
512
+ rows={documents.map((document) => {
513
+ const fileCount = document.file?.length ?? 0;
514
+ const folder = folders?.find(({ id }) => id === document.folderId);
515
+
516
+ return {
517
+ id: document.id,
518
+ cells: {
519
+ name: (
520
+ <span style={{ alignItems: 'center', display: 'inline-flex', gap: theme.spacing[1], maxWidth: '100%' }}>
521
+ <IconFileText size={theme.icon.size.md} stroke={theme.icon.stroke.sm} color={theme.font.color.tertiary} />
522
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{document.name?.trim() || 'Без названия'}</span>
523
+ </span>
524
+ ),
525
+ type: DOCUMENT_TYPE_LABELS[document.documentType ?? ''] ?? '—',
526
+ status: document.status ? (
527
+ <StatusChip color={DOCUMENT_STATUS_COLORS[document.status] ?? 'gray'}>
528
+ {DOCUMENT_STATUS_LABELS[document.status] ?? document.status}
529
+ </StatusChip>
530
+ ) : (
531
+ '—'
532
+ ),
533
+ number: document.number || '—',
534
+ documentDate: formatDate(document.documentDate) || '—',
535
+ counterparty: document.counterparty?.name || '—',
536
+ responsible: formatMemberName(document.responsible ?? undefined) || '—',
537
+ folder: folder ? (
538
+ <span style={{ alignItems: 'center', display: 'inline-flex', gap: theme.spacing[1] }}>
539
+ <span style={{ background: folderColor(folder.color), flexShrink: 0, height: '8px', width: '8px' }} />
540
+ {folderName(folder.id) || 'Без названия'}
541
+ </span>
542
+ ) : (
543
+ '—'
544
+ ),
545
+ source: document.sourceRecordName || DOCUMENT_ORIGIN_LABELS[document.origin ?? ''] || '—',
546
+ file: fileCount > 0 ? `${fileCount} шт.` : 'нет',
547
+ createdAt: formatDate(document.createdAt),
548
+ },
549
+ };
550
+ })}
416
551
  />
417
552
  )}
418
- {data?.pageInfo.hasNextPage === true && (
419
- <div style={{ display: 'flex', justifyContent: 'center', padding: theme.spacing[3] }}>
420
- <Button variant="ghost" size="sm" loading={loading} onClick={() => setPages((current) => current + 1)}>
421
- Показать ещё
553
+ <ShowMore isVisible={data?.pageInfo.hasNextPage === true} loading={loading} onClick={() => setPages((current) => current + 1)} />
554
+ <SelectionBar count={selectedIds.length} noun={DOCUMENT_NOUN} onClear={selection.clear}>
555
+ {view === 'deleted' ? (
556
+ <Button icon={IconRestore} loading={bulk.isBusy} onClick={() => void restoreDocuments(selectedIds)}>
557
+ Восстановить
422
558
  </Button>
423
- </div>
424
- )}
559
+ ) : (
560
+ <>
561
+ <Button icon={IconPencil} disabled={bulk.isBusy} onClick={() => setBulkDialogOpen(true)}>
562
+ Изменить
563
+ </Button>
564
+ <MenuButton
565
+ label="Удалить"
566
+ icon={IconTrash}
567
+ disabled={bulk.isBusy}
568
+ actions={[
569
+ {
570
+ key: 'delete',
571
+ label: `Удалить выбранные (${selectedIds.length})`,
572
+ confirmLabel: 'Точно удалить?',
573
+ icon: IconTrash,
574
+ tone: 'danger',
575
+ onSelect: () => void removeDocuments(selectedIds),
576
+ },
577
+ ]}
578
+ />
579
+ </>
580
+ )}
581
+ </SelectionBar>
425
582
  </div>
426
583
  </div>
427
584
  </div>
585
+ {bulkDialogOpen && (
586
+ <BulkChangeDialog
587
+ title="Изменить поле"
588
+ hint={`Для ${selectedIds.length} ${pluralize(selectedIds.length, DOCUMENT_NOUN)}`}
589
+ fields={[
590
+ {
591
+ key: 'status',
592
+ label: 'Статус',
593
+ options: STATUS_VALUES.map((status) => ({ value: status, label: DOCUMENT_STATUS_LABELS[status] })),
594
+ },
595
+ {
596
+ key: 'documentType',
597
+ label: 'Тип',
598
+ options: Object.entries(DOCUMENT_TYPE_LABELS).map(([type, label]) => ({ value: type, label })),
599
+ },
600
+ {
601
+ key: 'folderId',
602
+ label: 'Папка',
603
+ options: [
604
+ ...(folders ?? []).map((folder) => ({
605
+ value: folder.id,
606
+ label: folder.name?.trim() || 'Без названия',
607
+ })),
608
+ { value: '', label: 'Без папки' },
609
+ ],
610
+ },
611
+ ]}
612
+ onClose={() => setBulkDialogOpen(false)}
613
+ onSubmit={(fieldKey, value) => {
614
+ if (fieldKey === 'status') {
615
+ return changeDocuments(selectedIds, { status: value }, `Статус: ${DOCUMENT_STATUS_LABELS[value]}`);
616
+ }
617
+
618
+ if (fieldKey === 'documentType') {
619
+ return changeDocuments(
620
+ selectedIds,
621
+ { documentType: value },
622
+ `Тип: ${DOCUMENT_TYPE_LABELS[value] ?? value}`,
623
+ );
624
+ }
625
+
626
+ return changeDocuments(
627
+ selectedIds,
628
+ { folderId: value.length > 0 ? value : null },
629
+ value.length > 0
630
+ ? `Перемещено в «${(folders ?? []).find((folder) => folder.id === value)?.name?.trim() || 'Без названия'}»`
631
+ : 'Убрано из папки',
632
+ );
633
+ }}
634
+ />
635
+ )}
428
636
  {renamingDocument !== undefined && (
429
637
  <PromptDialog
430
638
  title="Переименовать документ"
@@ -432,7 +640,7 @@ const DocflowDocuments = () => {
432
640
  initialValue={renamingDocument.name ?? ''}
433
641
  onClose={() => setRenamingDocument(undefined)}
434
642
  onSubmit={async (name) => {
435
- await handleUpdateDocument(renamingDocument.id, { name }, 'Документ переименован');
643
+ await changeDocuments([renamingDocument.id], { name }, 'Документ переименован');
436
644
  setRenamingDocument(undefined);
437
645
  }}
438
646
  />