@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,34 +1,69 @@
1
- import { useState } from 'react';
1
+ import { 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, IconEye, IconRestore, IconTrash } from 'twenty-ui/icon';
5
- import { Tag } from 'twenty-ui/primitives/data-display';
4
+ import {
5
+ IconCheck,
6
+ IconExternalLink,
7
+ IconEye,
8
+ IconPencil,
9
+ IconPlayerPlay,
10
+ IconRestore,
11
+ IconTrash,
12
+ } from 'twenty-ui/icon';
6
13
  import { useTheme } from 'twenty-ui/theme';
7
14
 
8
- import { createRecord, deleteRecord, getErrorMessage, listRecords } from 'src/front/api';
9
- import { formatDate, formatMemberName, isOverdue, TASK_STATUS_LABELS } from 'src/front/format';
15
+ import {
16
+ callAppRoute,
17
+ countRecords,
18
+ createRecord,
19
+ deleteRecords,
20
+ getErrorMessage,
21
+ listRecords,
22
+ restoreRecords,
23
+ updateRecords,
24
+ } from 'src/front/api';
25
+ import {
26
+ describeDue,
27
+ formatDate,
28
+ formatMemberName,
29
+ TASK_STATUS_COLORS,
30
+ TASK_STATUS_LABELS,
31
+ } from 'src/front/format';
10
32
  import { useAsync, useDebouncedValue } from 'src/front/hooks/use-async';
33
+ import { useBulkAction } from 'src/front/hooks/use-bulk-action';
11
34
  import { useWorkspaceMembers } from 'src/front/hooks/use-workspace-members';
12
- import { type DocflowTask } from 'src/front/types';
35
+ import { getTaskFieldProgress, isProcessTask } from 'src/front/tasks';
36
+ import { type DocflowTask, type WorkspaceMember } from 'src/front/types';
13
37
  import {
14
38
  DataTable,
15
39
  DocflowRoot,
40
+ formatTotal,
41
+ ListToolbar,
16
42
  Message,
43
+ pluralize,
44
+ ProgressBar,
17
45
  SectionTitle,
18
46
  SelectField,
47
+ SelectionBar,
48
+ ShowMore,
19
49
  SplitView,
20
50
  TextAreaField,
21
51
  TextField,
52
+ type TableColumn,
22
53
  type TableSort,
23
54
  toRestOrderBy,
24
- Toolbar,
55
+ useHiddenColumns,
56
+ useRowSelection,
25
57
  } from 'src/front/ui/components';
26
58
  import { FormFieldsBuilder } from 'src/front/ui/form-fields-builder';
27
- import { Button, SearchWithFilters } from 'src/front/ui/kit';
59
+ import { BulkChangeDialog, Button, type MenuAction, MenuButton, StatusChip, ViewTabs } from 'src/front/ui/kit';
60
+
61
+ import { UrgentTaskCards } from 'src/front/ui/task-cards';
28
62
  import { TaskWorkPanel } from 'src/front/ui/task-work-panel';
29
63
  import {
30
64
  restAnd,
31
65
  restEq,
66
+ restGte,
32
67
  restIlike,
33
68
  restIsNotNull,
34
69
  restIsNull,
@@ -41,64 +76,112 @@ import { type FormField } from 'src/shared/form/form-field.type';
41
76
  import { getFormFieldsError } from 'src/shared/form/form-fields.util';
42
77
  import { TASKS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/universal-identifiers';
43
78
 
44
- type TaskFilter = 'open' | 'mine' | 'overdue' | 'done' | 'archived';
79
+ type TaskView = 'open' | 'mine' | 'done';
80
+ type TaskFilters = {
81
+ source: 'any' | 'process' | 'own';
82
+ due: 'any' | 'overdue' | 'week' | 'none';
83
+ assignee: string;
84
+ // Deleted = in the Twenty trash, restored from here
85
+ scope: 'active' | 'deleted';
86
+ };
45
87
  type Panel = { kind: 'closed' } | { kind: 'create' } | { kind: 'task'; taskId: string };
88
+ type BulkDialog = 'change' | undefined;
46
89
 
47
90
  const SEARCH_DEBOUNCE_MS = 300;
48
91
  const NO_ASSIGNEE = '';
92
+ const ANY = 'any';
93
+ const NOBODY = 'nobody';
94
+ const DAY_IN_MS = 24 * 60 * 60 * 1000;
95
+ // A task of mine due within this many days is a card above the table
96
+ const URGENT_DAYS = 3;
97
+ const URGENT_LIMIT = 30;
98
+ const TASK_IN_PROGRESS_STATUS = 'IN_PROGRESS';
99
+ const TASK_NOUN: [string, string, string] = ['задание', 'задания', 'заданий'];
49
100
 
50
- const TASK_FILTER_OPTIONS: { value: TaskFilter; label: string }[] = [
101
+ const VIEWS: { value: TaskView; label: string }[] = [
51
102
  { value: 'open', label: 'Открытые' },
52
103
  { value: 'mine', label: 'Мои' },
53
- { value: 'overdue', label: 'Просроченные' },
54
104
  { value: 'done', label: 'Выполненные' },
55
- { value: 'archived', label: 'Архив (удалённые)' },
56
105
  ];
57
106
 
58
- const DEFAULT_SORT: TableSort = { key: 'createdAt', direction: 'desc' };
107
+ const DEFAULT_FILTERS: TaskFilters = { source: 'any', due: 'any', assignee: ANY, scope: 'active' };
108
+ const DEFAULT_SORT: TableSort = { key: 'dueAt', direction: 'asc' };
109
+
110
+ const COLUMNS: TableColumn[] = [
111
+ { key: 'title', label: 'Задание', width: '30%', sortKey: 'title' },
112
+ { key: 'assignee', label: 'Исполнитель', width: '15%', sortKey: 'assigneeId' },
113
+ { key: 'dueAt', label: 'Срок', width: '15%', sortKey: 'dueAt' },
114
+ { key: 'fields', label: 'Поля', width: '11%' },
115
+ { key: 'status', label: 'Статус', width: '13%', sortKey: 'status' },
116
+ { key: 'source', label: 'Откуда', width: '10%' },
117
+ { key: 'createdAt', label: 'Создано', sortKey: 'createdAt' },
118
+ ];
59
119
 
60
120
  const OPEN_FILTER = restOr(restNeq('status', TASK_DONE_STATUS), restIsNull('status'));
61
121
 
62
- // An archived task opens in its standard card: Twenty shows it as deleted
63
- // there and offers to restore it.
64
122
  // A task opens in its standard card too; a deleted one shows "Восстановить" there
65
- const openArchivedTask = (taskId: string) =>
123
+ const openTaskCard = (taskId: string) =>
66
124
  openSidePanelPage({
67
125
  to: AppPath.RecordShowPage,
68
126
  params: { objectNameSingular: 'task', objectRecordId: taskId },
69
127
  pageTitle: 'Задание',
70
128
  });
71
129
 
130
+ // In the trash a task shows whatever its status was
131
+ const viewFilter = (view: TaskView, currentMemberId: string | undefined, isTrash = false) =>
132
+ restAnd(
133
+ isTrash ? restIsNotNull('deletedAt') : view === 'done' ? restEq('status', TASK_DONE_STATUS) : OPEN_FILTER,
134
+ view === 'mine'
135
+ ? currentMemberId !== undefined
136
+ ? restEq('assigneeId', currentMemberId)
137
+ : restIsNull('assigneeId')
138
+ : undefined,
139
+ );
140
+
72
141
  // The list is filtered by the server, so a long history of tasks never
73
142
  // travels to the browser.
74
143
  const buildFilter = ({
75
- taskFilter,
144
+ view,
145
+ filters,
76
146
  searchQuery,
77
147
  currentMemberId,
78
148
  now,
149
+ weekAhead,
79
150
  }: {
80
- taskFilter: TaskFilter;
151
+ view: TaskView;
152
+ filters: TaskFilters;
81
153
  searchQuery: string;
82
154
  currentMemberId: string | undefined;
83
155
  now: string;
156
+ weekAhead: string;
84
157
  }) =>
85
158
  restAnd(
86
159
  restEq('smbSection', DOCFLOW_SECTION),
87
- // Archived = deleted in Twenty (kept in its trash): any status
88
- taskFilter === 'archived'
89
- ? restIsNotNull('deletedAt')
90
- : taskFilter === 'done'
91
- ? restEq('status', TASK_DONE_STATUS)
92
- : OPEN_FILTER,
93
- taskFilter === 'mine'
94
- ? currentMemberId !== undefined
95
- ? restEq('assigneeId', currentMemberId)
96
- : restIsNull('assigneeId')
97
- : undefined,
98
- taskFilter === 'overdue' ? restLt('dueAt', now) : undefined,
160
+ viewFilter(view, currentMemberId, filters.scope === 'deleted'),
161
+ filters.source === 'process' ? restIsNotNull('smbWorkflowThreadId') : undefined,
162
+ filters.source === 'own' ? restIsNull('smbWorkflowThreadId') : undefined,
163
+ filters.due === 'overdue' ? restLt('dueAt', now) : undefined,
164
+ filters.due === 'week' ? restAnd(restIsNotNull('dueAt'), restLt('dueAt', weekAhead)) : undefined,
165
+ filters.due === 'none' ? restIsNull('dueAt') : undefined,
166
+ filters.assignee === NOBODY
167
+ ? restIsNull('assigneeId')
168
+ : filters.assignee !== ANY
169
+ ? restEq('assigneeId', filters.assignee)
170
+ : undefined,
99
171
  searchQuery.trim().length > 0 ? restIlike('title', searchQuery) : undefined,
100
172
  );
101
173
 
174
+ // What the cards hold: mine, open, due before the horizon
175
+ const urgentFilter = (currentMemberId: string, horizon: string) =>
176
+ restAnd(restEq('assigneeId', currentMemberId), restIsNotNull('dueAt'), restLt('dueAt', horizon));
177
+
178
+ // The table without the cards' tasks, so a task shows in one place only
179
+ const notUrgentFilter = (currentMemberId: string, horizon: string) =>
180
+ restOr(restNeq('assigneeId', currentMemberId), restIsNull('assigneeId'), restIsNull('dueAt'), restGte('dueAt', horizon));
181
+
182
+ const memberOptions = (members: WorkspaceMember[]) =>
183
+ members.map((member) => ({ value: member.id, label: formatMemberName(member) || member.id }));
184
+
102
185
  const TaskCreateForm = ({
103
186
  currentMemberId,
104
187
  onCreated,
@@ -161,10 +244,7 @@ const TaskCreateForm = ({
161
244
  <SelectField
162
245
  label="Исполнитель"
163
246
  value={assigneeId}
164
- options={[
165
- { value: NO_ASSIGNEE, label: 'Не назначен' },
166
- ...members.map((member) => ({ value: member.id, label: formatMemberName(member) || member.id })),
167
- ]}
247
+ options={[{ value: NO_ASSIGNEE, label: 'Не назначен' }, ...memberOptions(members)]}
168
248
  onChange={setAssigneeId}
169
249
  />
170
250
  <TextField label="Срок" type="date" value={dueAt} onChange={setDueAt} />
@@ -173,7 +253,7 @@ const TaskCreateForm = ({
173
253
  <FormFieldsBuilder fields={fields} onChange={setFields} disabled={isSaving} />
174
254
  {errorMessage !== undefined && <Message tone="danger">{errorMessage}</Message>}
175
255
  <div style={{ display: 'flex', gap: theme.spacing[2] }}>
176
- <Button variant="solid" size="sm" loading={isSaving} disabled={title.trim().length === 0} onClick={() => void handleCreate()}>
256
+ <Button variant="solid" tone="accent" size="sm" loading={isSaving} disabled={title.trim().length === 0} onClick={() => void handleCreate()}>
177
257
  Создать
178
258
  </Button>
179
259
  <Button variant="ghost" size="sm" onClick={onCancel}>
@@ -189,34 +269,164 @@ const DocflowTasks = () => {
189
269
  const { members, currentMember } = useWorkspaceMembers();
190
270
  const [searchInput, setSearchInput] = useState('');
191
271
  const searchQuery = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
192
- const [taskFilter, setTaskFilter] = useState<TaskFilter>('open');
272
+ const [view, setView] = useState<TaskView>('open');
273
+ const [filters, setFilters] = useState<TaskFilters>(DEFAULT_FILTERS);
193
274
  const [sort, setSort] = useState<TableSort>(DEFAULT_SORT);
194
275
  const [panel, setPanel] = useState<Panel>({ kind: 'closed' });
195
276
  const [pages, setPages] = useState(1);
277
+ const [hiddenColumns, setHiddenColumns] = useHiddenColumns(['createdAt']);
278
+ const [bulkDialog, setBulkDialog] = useState<BulkDialog>();
196
279
  // Fixed for the life of the list, so "overdue" does not refetch by itself.
197
- const [now] = useState(() => new Date().toISOString());
280
+ const [nowDate] = useState(() => new Date());
281
+ const now = nowDate.toISOString();
282
+ const weekAhead = new Date(nowDate.getTime() + 7 * DAY_IN_MS).toISOString();
283
+ // The cards take tasks due before the start of the day after the horizon
284
+ const horizonDate = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate() + URGENT_DAYS + 1);
285
+ const horizon = horizonDate.toISOString();
286
+ const currentMemberId = currentMember?.id;
287
+ const isTrash = filters.scope === 'deleted';
288
+ const hasCards = view !== 'done' && !isTrash && currentMemberId !== undefined;
289
+ const selectedTaskId = panel.kind === 'task' ? panel.taskId : undefined;
290
+
291
+ const baseFilter = useMemo(
292
+ () => buildFilter({ view, filters, searchQuery, currentMemberId, now, weekAhead }),
293
+ [view, filters, searchQuery, currentMemberId, now, weekAhead],
294
+ );
198
295
 
199
296
  const { data, loading, error, reload } = useAsync(
200
297
  () =>
201
298
  listRecords<DocflowTask>('tasks', {
202
- filter: buildFilter({ taskFilter, searchQuery, currentMemberId: currentMember?.id, now }),
299
+ filter: hasCards ? restAnd(baseFilter, notUrgentFilter(currentMemberId, horizon)) : baseFilter,
203
300
  order_by: toRestOrderBy(sort),
204
301
  limit: PAGE_SIZE * pages,
205
302
  }),
206
- [taskFilter, searchQuery, currentMember?.id, sort, pages, now],
303
+ [baseFilter, hasCards, currentMemberId, horizon, sort, pages],
304
+ );
305
+ const { data: urgent, reload: reloadUrgent } = useAsync(
306
+ async () =>
307
+ hasCards
308
+ ? (
309
+ await listRecords<DocflowTask>('tasks', {
310
+ filter: restAnd(baseFilter, urgentFilter(currentMemberId, horizon)),
311
+ order_by: 'dueAt[AscNullsLast]',
312
+ limit: URGENT_LIMIT,
313
+ })
314
+ ).records
315
+ : [],
316
+ [baseFilter, hasCards, currentMemberId, horizon],
317
+ );
318
+ // The counts of the views follow the search, not the other filters
319
+ const { data: counts, reload: reloadCounts } = useAsync(
320
+ async () =>
321
+ Object.fromEntries(
322
+ await Promise.all(
323
+ VIEWS.map(async ({ value }) => [
324
+ value,
325
+ await countRecords(
326
+ 'tasks',
327
+ restAnd(
328
+ restEq('smbSection', DOCFLOW_SECTION),
329
+ viewFilter(value, currentMemberId),
330
+ searchQuery.trim().length > 0 ? restIlike('title', searchQuery) : undefined,
331
+ ),
332
+ ),
333
+ ]),
334
+ ),
335
+ ) as Record<TaskView, number>,
336
+ [searchQuery, currentMemberId],
207
337
  );
208
338
 
209
- const handleDeleteTask = async (taskId: string) => {
210
- try {
211
- await deleteRecord('tasks', taskId);
212
- setPanel((current) => (current.kind === 'task' && current.taskId === taskId ? { kind: 'closed' } : current));
213
- await enqueueSnackbar({ message: 'Задание удалено: оно в фильтре «Архив»', variant: 'success' });
214
- await reload();
215
- } catch (deleteError) {
216
- await enqueueSnackbar({ message: getErrorMessage(deleteError, 'Не удалось удалить задание'), variant: 'error' });
339
+ const tasks = data?.records ?? [];
340
+ const urgentTasks = urgent ?? [];
341
+ const selection = useRowSelection(tasks.map(({ id }) => id));
342
+ const selectedTasks = tasks.filter(({ id }) => selection.selectedIds.includes(id));
343
+ const activeFilterCount =
344
+ (filters.source !== ANY ? 1 : 0) +
345
+ (filters.due !== ANY ? 1 : 0) +
346
+ (filters.assignee !== ANY ? 1 : 0) +
347
+ (isTrash ? 1 : 0);
348
+
349
+ const reloadAll = async () => {
350
+ await Promise.all([reload(), reloadUrgent(), reloadCounts()]);
351
+ };
352
+
353
+ const bulk = useBulkAction({
354
+ onDone: async () => {
355
+ selection.clear();
356
+ setBulkDialog(undefined);
357
+ await reloadAll();
358
+ },
359
+ });
360
+
361
+ const changeView = (next: TaskView) => {
362
+ setView(next);
363
+ setPages(1);
364
+ selection.clear();
365
+ };
366
+
367
+ const changeFilters = (patch: Partial<TaskFilters>) => {
368
+ setFilters((current) => ({ ...current, ...patch }));
369
+ setPages(1);
370
+ };
371
+
372
+ const completeTasks = (ids: string[]) =>
373
+ bulk.runEach(ids, (taskId) => callAppRoute('/docflow/tasks/complete', { taskId }), {
374
+ done: (count) => `Выполнено: ${count}`,
375
+ failed: 'Не выполнены',
376
+ });
377
+
378
+ const setStatus = (ids: string[], status: string) =>
379
+ bulk.run(
380
+ () => updateRecords('tasks', ids, { status }),
381
+ `Статус изменён: ${TASK_STATUS_LABELS[status]}`,
382
+ 'Не удалось изменить статус',
383
+ );
384
+
385
+ const removeTasks = (ids: string[]) =>
386
+ bulk.run(
387
+ async () => {
388
+ await deleteRecords('tasks', ids);
389
+ setPanel((current) => (current.kind === 'task' && ids.includes(current.taskId) ? { kind: 'closed' } : current));
390
+ },
391
+ ids.length === 1 ? 'Задание удалено: оно в фильтре «Удалённые»' : `Удалено заданий: ${ids.length}, они в фильтре «Удалённые»`,
392
+ 'Не удалось удалить',
393
+ );
394
+
395
+ const restoreTasks = (ids: string[]) =>
396
+ bulk.run(() => restoreRecords('tasks', ids), `Восстановлено: ${ids.length}`, 'Не удалось восстановить');
397
+
398
+ const rowActions = (taskId: string): MenuAction[] => {
399
+ const task = tasks.find(({ id }) => id === taskId);
400
+
401
+ if (isTrash) {
402
+ return [
403
+ { key: 'restore', label: 'Восстановить', icon: IconRestore, onSelect: () => void restoreTasks([taskId]) },
404
+ { key: 'card', label: 'Открыть карточку', icon: IconExternalLink, onSelect: () => void openTaskCard(taskId) },
405
+ ];
217
406
  }
407
+
408
+ const isDone = task?.status === TASK_DONE_STATUS;
409
+
410
+ return [
411
+ { key: 'open', label: 'Открыть', icon: IconEye, onSelect: () => setPanel({ kind: 'task', taskId }) },
412
+ { key: 'card', label: 'Карточка задачи', icon: IconExternalLink, onSelect: () => void openTaskCard(taskId) },
413
+ ...(task?.status === TASK_TODO_STATUS
414
+ ? [{ key: 'start', label: 'Взять в работу', icon: IconPlayerPlay, onSelect: () => void setStatus([taskId], TASK_IN_PROGRESS_STATUS) }]
415
+ : []),
416
+ ...(!isDone ? [{ key: 'complete', label: 'Выполнить', icon: IconCheck, onSelect: () => void completeTasks([taskId]) }] : []),
417
+ {
418
+ key: 'delete',
419
+ label: 'Удалить',
420
+ confirmLabel: 'Точно удалить?',
421
+ icon: IconTrash,
422
+ tone: 'danger',
423
+ onSelect: () => void removeTasks([taskId]),
424
+ },
425
+ ];
218
426
  };
219
- const tasks = data?.records ?? [];
427
+
428
+ const selectedIds = selection.selectedIds;
429
+ const openSelectedCount = selectedTasks.filter((task) => task.status !== TASK_DONE_STATUS).length;
220
430
 
221
431
  return (
222
432
  <DocflowRoot>
@@ -226,113 +436,260 @@ const DocflowTasks = () => {
226
436
  panel={
227
437
  panel.kind === 'create' ? (
228
438
  <TaskCreateForm
229
- currentMemberId={currentMember?.id}
439
+ currentMemberId={currentMemberId}
230
440
  onCancel={() => setPanel({ kind: 'closed' })}
231
441
  onCreated={(taskId) => {
232
442
  setPanel({ kind: 'task', taskId });
233
- void reload();
443
+ void reloadAll();
234
444
  }}
235
445
  />
236
446
  ) : panel.kind === 'task' ? (
237
- <TaskWorkPanel key={panel.taskId} taskId={panel.taskId} onChanged={() => void reload()} />
447
+ <TaskWorkPanel key={panel.taskId} taskId={panel.taskId} withChat onChanged={() => void reloadAll()} />
238
448
  ) : undefined
239
449
  }
240
450
  >
241
- <Toolbar>
242
- <SearchWithFilters
243
- value={searchInput}
244
- onChange={setSearchInput}
245
- placeholder="Поиск заданий"
246
- activeFilterCount={taskFilter === 'open' ? 0 : 1}
247
- onResetFilters={() => {
248
- setTaskFilter('open');
249
- setPages(1);
250
- }}
251
- filters={[
451
+ <ListToolbar
452
+ search={{
453
+ value: searchInput,
454
+ onChange: setSearchInput,
455
+ placeholder: 'Поиск заданий',
456
+ activeFilterCount,
457
+ onResetFilters: () => changeFilters(DEFAULT_FILTERS),
458
+ filters: [
459
+ {
460
+ key: 'source',
461
+ title: 'Откуда',
462
+ value: filters.source,
463
+ options: [
464
+ { value: ANY, label: 'Все' },
465
+ { value: 'process', label: 'Шаги процессов' },
466
+ { value: 'own', label: 'Свои задания' },
467
+ ],
468
+ onChange: (value) => changeFilters({ source: value as TaskFilters['source'] }),
469
+ },
470
+ {
471
+ key: 'due',
472
+ title: 'Срок',
473
+ value: filters.due,
474
+ options: [
475
+ { value: ANY, label: 'Любой' },
476
+ { value: 'overdue', label: 'Просроченные' },
477
+ { value: 'week', label: 'В ближайшие 7 дней' },
478
+ { value: 'none', label: 'Без срока' },
479
+ ],
480
+ onChange: (value) => changeFilters({ due: value as TaskFilters['due'] }),
481
+ },
482
+ {
483
+ key: 'assignee',
484
+ title: 'Исполнитель',
485
+ value: filters.assignee,
486
+ options: [
487
+ { value: ANY, label: 'Любой' },
488
+ { value: NOBODY, label: 'Не назначен' },
489
+ ...memberOptions(members),
490
+ ],
491
+ onChange: (value) => changeFilters({ assignee: value }),
492
+ },
252
493
  {
253
- key: 'tasks',
254
- title: 'Показать',
255
- value: taskFilter,
256
- options: TASK_FILTER_OPTIONS,
494
+ key: 'scope',
495
+ title: 'Корзина',
496
+ value: filters.scope,
497
+ options: [
498
+ { value: 'active', label: 'Действующие' },
499
+ { value: 'deleted', label: 'Удалённые' },
500
+ ],
257
501
  onChange: (value) => {
258
- setTaskFilter(value as TaskFilter);
259
- setPages(1);
502
+ changeFilters({ scope: value as TaskFilters['scope'] });
503
+ selection.clear();
260
504
  },
261
505
  },
262
- ]}
506
+ ],
507
+ }}
508
+ views={
509
+ <ViewTabs
510
+ value={view}
511
+ onChange={changeView}
512
+ options={VIEWS.map((option) => ({ ...option, count: counts?.[option.value] }))}
513
+ />
514
+ }
515
+ total={
516
+ data === undefined
517
+ ? undefined
518
+ : formatTotal(tasks.length + urgentTasks.length, (data.totalCount ?? tasks.length) + urgentTasks.length, TASK_NOUN)
519
+ }
520
+ columns={{ columns: COLUMNS, hiddenKeys: hiddenColumns, onChange: setHiddenColumns }}
521
+ onRefresh={() => void reloadAll()}
522
+ isRefreshing={loading}
523
+ onCreate={() => setPanel({ kind: 'create' })}
524
+ />
525
+ {hasCards && (
526
+ <UrgentTaskCards
527
+ tasks={urgentTasks}
528
+ members={members}
529
+ now={nowDate}
530
+ selectedTaskId={selectedTaskId}
531
+ onOpen={(taskId) => setPanel({ kind: 'task', taskId })}
263
532
  />
264
- <div style={{ marginLeft: 'auto' }}>
265
- <Button variant="solid" size="sm" onClick={() => setPanel({ kind: 'create' })}>
266
- Создать
267
- </Button>
268
- </div>
269
- </Toolbar>
533
+ )}
270
534
  {error !== undefined && (
271
535
  <div style={{ padding: theme.spacing[4] }}>
272
536
  <Message tone="danger">{getErrorMessage(error, 'Не удалось загрузить задания')}</Message>
273
537
  </div>
274
538
  )}
275
- {loading && tasks.length === 0 && <Message>Загрузка…</Message>}
276
- {!loading && error === undefined && tasks.length === 0 && <Message>Заданий нет.</Message>}
539
+ {loading && data === undefined && <Message>Загрузка…</Message>}
540
+ {!loading && error === undefined && tasks.length === 0 && (
541
+ <Message>{urgentTasks.length > 0 ? 'Остальных заданий нет: все срочные — в карточках выше.' : 'Заданий нет.'}</Message>
542
+ )}
277
543
  {tasks.length > 0 && (
278
- <DataTable
279
- columns={[
280
- { key: 'title', label: 'Задание', width: '38%', sortKey: 'title' },
281
- { key: 'assignee', label: 'Исполнитель', width: '22%' },
282
- { key: 'dueAt', label: 'Срок', width: '16%', sortKey: 'dueAt' },
283
- { key: 'status', label: 'Статус', sortKey: 'status' },
284
- ]}
285
- sort={sort}
286
- onSortChange={setSort}
287
- selectedRowId={panel.kind === 'task' ? panel.taskId : undefined}
288
- rowActions={(taskId) =>
289
- taskFilter === 'archived'
290
- ? [{ key: 'open', label: 'Открыть и восстановить', icon: IconRestore, onSelect: () => void openArchivedTask(taskId) }]
291
- : [
292
- { key: 'open', label: 'Открыть', icon: IconEye, onSelect: () => setPanel({ kind: 'task', taskId }) },
293
- { key: 'card', label: 'Карточка задачи', icon: IconExternalLink, onSelect: () => void openArchivedTask(taskId) },
294
- {
295
- key: 'delete',
296
- label: 'Удалить',
297
- confirmLabel: 'Точно удалить?',
298
- icon: IconTrash,
299
- tone: 'danger',
300
- onSelect: () => void handleDeleteTask(taskId),
301
- },
302
- ]
303
- }
304
- onRowClick={(taskId) =>
305
- taskFilter === 'archived'
306
- ? void openArchivedTask(taskId)
307
- : setPanel({ kind: 'task', taskId })
308
- }
309
- rows={tasks.map((task) => ({
310
- id: task.id,
311
- cells: {
312
- title: task.title?.trim() || 'Без названия',
313
- assignee: formatMemberName(members.find((member) => member.id === task.assigneeId)),
314
- dueAt: (
315
- <span style={{ color: isOverdue(task.dueAt, task.status, new Date(now)) ? theme.font.color.danger : undefined }}>
316
- {formatDate(task.dueAt)}
317
- </span>
318
- ),
319
- status: (
320
- <Tag color={task.status === TASK_DONE_STATUS ? 'green' : 'gray'}>
321
- {TASK_STATUS_LABELS[task.status ?? ''] ?? task.status ?? 'Без статуса'}
322
- </Tag>
323
- ),
324
- },
325
- }))}
326
- />
544
+ <>
545
+ {hasCards && urgentTasks.length > 0 && (
546
+ <div style={{ padding: `${theme.spacing[1]} ${theme.spacing[5]} 0` }}>
547
+ <SectionTitle>Остальные задания</SectionTitle>
548
+ </div>
549
+ )}
550
+ <DataTable
551
+ columns={COLUMNS}
552
+ hiddenColumnKeys={hiddenColumns}
553
+ sort={sort}
554
+ onSortChange={(next) => {
555
+ setSort(next);
556
+ setPages(1);
557
+ }}
558
+ selectedRowId={selectedTaskId}
559
+ selectedIds={selectedIds}
560
+ onSelectionChange={selection.setSelectedIds}
561
+ rowActions={rowActions}
562
+ onRowClick={(taskId) => (isTrash ? void openTaskCard(taskId) : setPanel({ kind: 'task', taskId }))}
563
+ rows={tasks.map((task) => {
564
+ const due = describeDue(task.dueAt, nowDate);
565
+ const isDone = task.status === TASK_DONE_STATUS;
566
+ const progress = getTaskFieldProgress(task);
567
+
568
+ return {
569
+ id: task.id,
570
+ isMuted: isDone && view !== 'done',
571
+ cells: {
572
+ title: task.title?.trim() || 'Без названия',
573
+ assignee: formatMemberName(members.find((member) => member.id === task.assigneeId)) || '—',
574
+ dueAt: task.dueAt ? (
575
+ <span
576
+ title={formatDate(task.dueAt)}
577
+ style={{
578
+ color: isDone
579
+ ? undefined
580
+ : due.tone === 'overdue'
581
+ ? theme.font.color.danger
582
+ : due.tone === 'today'
583
+ ? theme.color.orange
584
+ : undefined,
585
+ }}
586
+ >
587
+ {isDone ? formatDate(task.dueAt) : `${formatDate(task.dueAt)} · ${due.label.toLowerCase()}`}
588
+ </span>
589
+ ) : (
590
+ '—'
591
+ ),
592
+ fields:
593
+ progress.total > 0 ? (
594
+ <span style={{ alignItems: 'center', display: 'flex', gap: theme.spacing[2] }}>
595
+ <span style={{ flex: 1, maxWidth: '48px' }}>
596
+ <ProgressBar value={progress.filled} total={progress.total} tone={isDone ? 'green' : 'blue'} />
597
+ </span>
598
+ {progress.filled}/{progress.total}
599
+ </span>
600
+ ) : (
601
+ '—'
602
+ ),
603
+ status: (
604
+ <StatusChip color={TASK_STATUS_COLORS[task.status ?? ''] ?? 'gray'}>
605
+ {TASK_STATUS_LABELS[task.status ?? ''] ?? task.status ?? 'Без статуса'}
606
+ </StatusChip>
607
+ ),
608
+ source: isProcessTask(task) ? 'Процесс' : 'Своё',
609
+ createdAt: formatDate(task.createdAt),
610
+ },
611
+ };
612
+ })}
613
+ />
614
+ </>
327
615
  )}
328
- {data?.pageInfo.hasNextPage === true && (
329
- <div style={{ display: 'flex', justifyContent: 'center', padding: theme.spacing[3] }}>
330
- <Button variant="ghost" size="sm" loading={loading} onClick={() => setPages((current) => current + 1)}>
331
- Показать ещё
616
+ <ShowMore isVisible={data?.pageInfo.hasNextPage === true} loading={loading} onClick={() => setPages((current) => current + 1)} />
617
+ <SelectionBar count={selectedIds.length} noun={TASK_NOUN} onClear={selection.clear}>
618
+ {isTrash ? (
619
+ <Button icon={IconRestore} loading={bulk.isBusy} onClick={() => void restoreTasks(selectedIds)}>
620
+ Восстановить
332
621
  </Button>
333
- </div>
334
- )}
622
+ ) : (
623
+ <>
624
+ {openSelectedCount > 0 && (
625
+ <Button icon={IconCheck} loading={bulk.isBusy} onClick={() => void completeTasks(selectedIds)}>
626
+ Выполнить
627
+ </Button>
628
+ )}
629
+ <Button icon={IconPencil} disabled={bulk.isBusy} onClick={() => setBulkDialog('change')}>
630
+ Изменить
631
+ </Button>
632
+ <MenuButton
633
+ label="Удалить"
634
+ icon={IconTrash}
635
+ disabled={bulk.isBusy}
636
+ actions={[
637
+ {
638
+ key: 'delete',
639
+ label: `Удалить выбранные (${selectedIds.length})`,
640
+ confirmLabel: 'Точно удалить?',
641
+ icon: IconTrash,
642
+ tone: 'danger',
643
+ onSelect: () => void removeTasks(selectedIds),
644
+ },
645
+ ]}
646
+ />
647
+ </>
648
+ )}
649
+ </SelectionBar>
335
650
  </SplitView>
651
+ {bulkDialog === 'change' && (
652
+ <BulkChangeDialog
653
+ title="Изменить поле"
654
+ hint={`Для ${selectedIds.length} ${pluralize(selectedIds.length, TASK_NOUN)}`}
655
+ fields={[
656
+ {
657
+ key: 'status',
658
+ label: 'Статус',
659
+ options: [TASK_TODO_STATUS, TASK_IN_PROGRESS_STATUS].map((status) => ({
660
+ value: status,
661
+ label: TASK_STATUS_LABELS[status],
662
+ })),
663
+ },
664
+ {
665
+ key: 'assignee',
666
+ label: 'Исполнитель',
667
+ options: [{ value: NO_ASSIGNEE, label: 'Не назначен' }, ...memberOptions(members)],
668
+ },
669
+ { key: 'dueAt', label: 'Срок', inputType: 'date' },
670
+ ]}
671
+ onClose={() => setBulkDialog(undefined)}
672
+ onSubmit={(fieldKey, value) => {
673
+ if (fieldKey === 'status') {
674
+ return setStatus(selectedIds, value);
675
+ }
676
+
677
+ if (fieldKey === 'assignee') {
678
+ return bulk.run(
679
+ () => updateRecords('tasks', selectedIds, { assigneeId: value === NO_ASSIGNEE ? null : value }),
680
+ 'Исполнитель назначен',
681
+ 'Не удалось назначить исполнителя',
682
+ );
683
+ }
684
+
685
+ return bulk.run(
686
+ () => updateRecords('tasks', selectedIds, { dueAt: /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null }),
687
+ 'Срок изменён',
688
+ 'Не удалось изменить срок',
689
+ );
690
+ }}
691
+ />
692
+ )}
336
693
  </DocflowRoot>
337
694
  );
338
695
  };