@baack-software/baack-ts-tasks 0.0.0-stage → 0.0.1

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.
Files changed (229) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +158 -2
  3. package/dist/activity.d.ts +20 -0
  4. package/dist/activity.d.ts.map +1 -0
  5. package/dist/activity.js +75 -0
  6. package/dist/activity.js.map +1 -0
  7. package/dist/blockers.d.ts +28 -0
  8. package/dist/blockers.d.ts.map +1 -0
  9. package/dist/blockers.js +134 -0
  10. package/dist/blockers.js.map +1 -0
  11. package/dist/conflicts.d.ts +50 -0
  12. package/dist/conflicts.d.ts.map +1 -0
  13. package/dist/conflicts.js +73 -0
  14. package/dist/conflicts.js.map +1 -0
  15. package/dist/draft.d.ts +45 -0
  16. package/dist/draft.d.ts.map +1 -0
  17. package/dist/draft.js +155 -0
  18. package/dist/draft.js.map +1 -0
  19. package/dist/errors.d.ts +30 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +37 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/index.d.ts +11 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +11 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/react/TaskActivity.d.ts +15 -0
  28. package/dist/react/TaskActivity.d.ts.map +1 -0
  29. package/dist/react/TaskActivity.js +60 -0
  30. package/dist/react/TaskActivity.js.map +1 -0
  31. package/dist/react/TaskBoard.d.ts +38 -0
  32. package/dist/react/TaskBoard.d.ts.map +1 -0
  33. package/dist/react/TaskBoard.js +85 -0
  34. package/dist/react/TaskBoard.js.map +1 -0
  35. package/dist/react/TaskConflicts.d.ts +26 -0
  36. package/dist/react/TaskConflicts.d.ts.map +1 -0
  37. package/dist/react/TaskConflicts.js +87 -0
  38. package/dist/react/TaskConflicts.js.map +1 -0
  39. package/dist/react/TaskCreateForm.d.ts +13 -0
  40. package/dist/react/TaskCreateForm.d.ts.map +1 -0
  41. package/dist/react/TaskCreateForm.js +68 -0
  42. package/dist/react/TaskCreateForm.js.map +1 -0
  43. package/dist/react/TaskEditor.d.ts +33 -0
  44. package/dist/react/TaskEditor.d.ts.map +1 -0
  45. package/dist/react/TaskEditor.js +172 -0
  46. package/dist/react/TaskEditor.js.map +1 -0
  47. package/dist/react/TaskFilters.d.ts +15 -0
  48. package/dist/react/TaskFilters.d.ts.map +1 -0
  49. package/dist/react/TaskFilters.js +48 -0
  50. package/dist/react/TaskFilters.js.map +1 -0
  51. package/dist/react/TaskView.d.ts +30 -0
  52. package/dist/react/TaskView.d.ts.map +1 -0
  53. package/dist/react/TaskView.js +108 -0
  54. package/dist/react/TaskView.js.map +1 -0
  55. package/dist/react/TasksAbout.d.ts +24 -0
  56. package/dist/react/TasksAbout.d.ts.map +1 -0
  57. package/dist/react/TasksAbout.js +82 -0
  58. package/dist/react/TasksAbout.js.map +1 -0
  59. package/dist/react/copy.d.ts +154 -0
  60. package/dist/react/copy.d.ts.map +1 -0
  61. package/dist/react/copy.js +173 -0
  62. package/dist/react/copy.js.map +1 -0
  63. package/dist/react/fields.d.ts +36 -0
  64. package/dist/react/fields.d.ts.map +1 -0
  65. package/dist/react/fields.js +21 -0
  66. package/dist/react/fields.js.map +1 -0
  67. package/dist/react/hooks.d.ts +9 -0
  68. package/dist/react/hooks.d.ts.map +1 -0
  69. package/dist/react/hooks.js +31 -0
  70. package/dist/react/hooks.js.map +1 -0
  71. package/dist/react/index.d.ts +13 -0
  72. package/dist/react/index.d.ts.map +1 -0
  73. package/dist/react/index.js +13 -0
  74. package/dist/react/index.js.map +1 -0
  75. package/dist/react/parts.d.ts +55 -0
  76. package/dist/react/parts.d.ts.map +1 -0
  77. package/dist/react/parts.js +92 -0
  78. package/dist/react/parts.js.map +1 -0
  79. package/dist/react/ui/alert.d.ts +11 -0
  80. package/dist/react/ui/alert.d.ts.map +1 -0
  81. package/dist/react/ui/alert.js +29 -0
  82. package/dist/react/ui/alert.js.map +1 -0
  83. package/dist/react/ui/avatar.d.ts +12 -0
  84. package/dist/react/ui/avatar.d.ts.map +1 -0
  85. package/dist/react/ui/avatar.js +24 -0
  86. package/dist/react/ui/avatar.js.map +1 -0
  87. package/dist/react/ui/badge.d.ts +10 -0
  88. package/dist/react/ui/badge.d.ts.map +1 -0
  89. package/dist/react/ui/badge.js +26 -0
  90. package/dist/react/ui/badge.js.map +1 -0
  91. package/dist/react/ui/button.d.ts +11 -0
  92. package/dist/react/ui/button.d.ts.map +1 -0
  93. package/dist/react/ui/button.js +37 -0
  94. package/dist/react/ui/button.js.map +1 -0
  95. package/dist/react/ui/card.d.ts +12 -0
  96. package/dist/react/ui/card.d.ts.map +1 -0
  97. package/dist/react/ui/card.js +26 -0
  98. package/dist/react/ui/card.js.map +1 -0
  99. package/dist/react/ui/input.d.ts +4 -0
  100. package/dist/react/ui/input.d.ts.map +1 -0
  101. package/dist/react/ui/input.js +8 -0
  102. package/dist/react/ui/input.js.map +1 -0
  103. package/dist/react/ui/label.d.ts +5 -0
  104. package/dist/react/ui/label.d.ts.map +1 -0
  105. package/dist/react/ui/label.js +10 -0
  106. package/dist/react/ui/label.js.map +1 -0
  107. package/dist/react/ui/radio-group.d.ts +6 -0
  108. package/dist/react/ui/radio-group.d.ts.map +1 -0
  109. package/dist/react/ui/radio-group.js +12 -0
  110. package/dist/react/ui/radio-group.js.map +1 -0
  111. package/dist/react/ui/select.d.ts +16 -0
  112. package/dist/react/ui/select.d.ts.map +1 -0
  113. package/dist/react/ui/select.js +37 -0
  114. package/dist/react/ui/select.js.map +1 -0
  115. package/dist/react/ui/separator.d.ts +5 -0
  116. package/dist/react/ui/separator.d.ts.map +1 -0
  117. package/dist/react/ui/separator.js +10 -0
  118. package/dist/react/ui/separator.js.map +1 -0
  119. package/dist/react/ui/sheet.d.ts +15 -0
  120. package/dist/react/ui/sheet.d.ts.map +1 -0
  121. package/dist/react/ui/sheet.js +38 -0
  122. package/dist/react/ui/sheet.js.map +1 -0
  123. package/dist/react/ui/skeleton.d.ts +3 -0
  124. package/dist/react/ui/skeleton.d.ts.map +1 -0
  125. package/dist/react/ui/skeleton.js +7 -0
  126. package/dist/react/ui/skeleton.js.map +1 -0
  127. package/dist/react/ui/table.d.ts +11 -0
  128. package/dist/react/ui/table.d.ts.map +1 -0
  129. package/dist/react/ui/table.js +29 -0
  130. package/dist/react/ui/table.js.map +1 -0
  131. package/dist/react/ui/tabs.d.ts +12 -0
  132. package/dist/react/ui/tabs.d.ts.map +1 -0
  133. package/dist/react/ui/tabs.js +31 -0
  134. package/dist/react/ui/tabs.js.map +1 -0
  135. package/dist/react/ui/textarea.d.ts +4 -0
  136. package/dist/react/ui/textarea.d.ts.map +1 -0
  137. package/dist/react/ui/textarea.js +8 -0
  138. package/dist/react/ui/textarea.js.map +1 -0
  139. package/dist/react/ui/toggle-group.d.ts +11 -0
  140. package/dist/react/ui/toggle-group.d.ts.map +1 -0
  141. package/dist/react/ui/toggle-group.js +25 -0
  142. package/dist/react/ui/toggle-group.js.map +1 -0
  143. package/dist/react/ui/toggle.d.ts +10 -0
  144. package/dist/react/ui/toggle.d.ts.map +1 -0
  145. package/dist/react/ui/toggle.js +27 -0
  146. package/dist/react/ui/toggle.js.map +1 -0
  147. package/dist/refs.d.ts +16 -0
  148. package/dist/refs.d.ts.map +1 -0
  149. package/dist/refs.js +43 -0
  150. package/dist/refs.js.map +1 -0
  151. package/dist/server/index.d.ts +4 -0
  152. package/dist/server/index.d.ts.map +1 -0
  153. package/dist/server/index.js +4 -0
  154. package/dist/server/index.js.map +1 -0
  155. package/dist/server/recipes.d.ts +39 -0
  156. package/dist/server/recipes.d.ts.map +1 -0
  157. package/dist/server/recipes.js +81 -0
  158. package/dist/server/recipes.js.map +1 -0
  159. package/dist/server/webhooks.d.ts +135 -0
  160. package/dist/server/webhooks.d.ts.map +1 -0
  161. package/dist/server/webhooks.js +243 -0
  162. package/dist/server/webhooks.js.map +1 -0
  163. package/dist/store.d.ts +124 -0
  164. package/dist/store.d.ts.map +1 -0
  165. package/dist/store.js +443 -0
  166. package/dist/store.js.map +1 -0
  167. package/dist/task-client.d.ts +137 -0
  168. package/dist/task-client.d.ts.map +1 -0
  169. package/dist/task-client.js +271 -0
  170. package/dist/task-client.js.map +1 -0
  171. package/dist/tasks.d.ts +84 -0
  172. package/dist/tasks.d.ts.map +1 -0
  173. package/dist/tasks.js +208 -0
  174. package/dist/tasks.js.map +1 -0
  175. package/dist/time.d.ts +15 -0
  176. package/dist/time.d.ts.map +1 -0
  177. package/dist/time.js +34 -0
  178. package/dist/time.js.map +1 -0
  179. package/dist/types.d.ts +74 -0
  180. package/dist/types.d.ts.map +1 -0
  181. package/dist/types.js +9 -0
  182. package/dist/types.js.map +1 -0
  183. package/package.json +101 -4
  184. package/src/activity.ts +86 -0
  185. package/src/blockers.ts +134 -0
  186. package/src/conflicts.ts +115 -0
  187. package/src/draft.ts +162 -0
  188. package/src/errors.ts +40 -0
  189. package/src/index.ts +10 -0
  190. package/src/react/TaskActivity.tsx +187 -0
  191. package/src/react/TaskBoard.tsx +264 -0
  192. package/src/react/TaskConflicts.tsx +217 -0
  193. package/src/react/TaskCreateForm.tsx +165 -0
  194. package/src/react/TaskEditor.tsx +485 -0
  195. package/src/react/TaskFilters.tsx +122 -0
  196. package/src/react/TaskView.tsx +291 -0
  197. package/src/react/TasksAbout.tsx +226 -0
  198. package/src/react/copy.tsx +253 -0
  199. package/src/react/fields.tsx +104 -0
  200. package/src/react/hooks.ts +41 -0
  201. package/src/react/index.ts +12 -0
  202. package/src/react/parts.tsx +176 -0
  203. package/src/react/ui/README.md +6 -0
  204. package/src/react/ui/alert.tsx +75 -0
  205. package/src/react/ui/avatar.tsx +109 -0
  206. package/src/react/ui/badge.tsx +48 -0
  207. package/src/react/ui/button.tsx +66 -0
  208. package/src/react/ui/card.tsx +102 -0
  209. package/src/react/ui/input.tsx +18 -0
  210. package/src/react/ui/label.tsx +23 -0
  211. package/src/react/ui/radio-group.tsx +41 -0
  212. package/src/react/ui/select.tsx +189 -0
  213. package/src/react/ui/separator.tsx +27 -0
  214. package/src/react/ui/sheet.tsx +145 -0
  215. package/src/react/ui/skeleton.tsx +13 -0
  216. package/src/react/ui/table.tsx +113 -0
  217. package/src/react/ui/tabs.tsx +89 -0
  218. package/src/react/ui/textarea.tsx +17 -0
  219. package/src/react/ui/toggle-group.tsx +89 -0
  220. package/src/react/ui/toggle.tsx +44 -0
  221. package/src/refs.ts +49 -0
  222. package/src/server/index.ts +3 -0
  223. package/src/server/recipes.ts +107 -0
  224. package/src/server/webhooks.ts +369 -0
  225. package/src/store.ts +538 -0
  226. package/src/task-client.ts +373 -0
  227. package/src/tasks.ts +263 -0
  228. package/src/time.ts +35 -0
  229. package/src/types.ts +101 -0
@@ -0,0 +1,291 @@
1
+ import { useEffect, useId, useMemo, useState } from 'react';
2
+ import type { Task } from '@baack-software/baack-ts-client';
3
+ import { Columns3, Plus, Rows3 } from 'lucide-react';
4
+ import { cn } from 'cn';
5
+ import { selectTasks, type TaskStore } from '../store.ts';
6
+ import { byPriorityThenDue, byUrgency, type TaskFilter, type TaskGrouping } from '../tasks.ts';
7
+ import { useCopy } from './copy.tsx';
8
+ import { ChoiceSelect } from './fields.tsx';
9
+ import { useMediaQuery, useTaskDirectory, useTaskStore } from './hooks.ts';
10
+ import { TaskActivity } from './TaskActivity.tsx';
11
+ import { TaskBoard, TaskTable } from './TaskBoard.tsx';
12
+ import { TaskCreateForm } from './TaskCreateForm.tsx';
13
+ import { TaskEditor } from './TaskEditor.tsx';
14
+ import { TaskFilters } from './TaskFilters.tsx';
15
+ import type { TaskDirectory } from './parts.tsx';
16
+ import { Alert, AlertDescription } from './ui/alert.tsx';
17
+ import { Button } from './ui/button.tsx';
18
+ import { Sheet, SheetContent, SheetTitle } from './ui/sheet.tsx';
19
+ import { Skeleton } from './ui/skeleton.tsx';
20
+ import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.tsx';
21
+
22
+ export type TaskLayout = 'board' | 'table';
23
+
24
+ export interface TaskViewProps {
25
+ store: TaskStore;
26
+ /** The signed-in person's identity URN, for "Assigned to me". */
27
+ me?: string | undefined;
28
+ /** Starting layout. Default board. */
29
+ layout?: TaskLayout;
30
+ /**
31
+ * Starting board columns. Default status. `workflow` has a column per workflow; `workflowStage` shows the first
32
+ * workflow's stages. Each column is sorted open first, then by priority, due date and title.
33
+ */
34
+ groupBy?: TaskGrouping;
35
+ /** Starting filter. */
36
+ filter?: TaskFilter;
37
+ /** Opens this task on load, e.g. from the URL. */
38
+ selected?: string | undefined;
39
+ /** Told when the open task changes, e.g. to put it in the URL. */
40
+ onSelect?: ((urn: string | undefined) => void) | undefined;
41
+ /** Companies and content to offer as related items, which the store doesn't load. */
42
+ related?: Pick<TaskDirectory, 'companies' | 'entities'> | undefined;
43
+ }
44
+
45
+ const GROUPINGS: readonly Exclude<TaskGrouping, 'workflow' | 'workflowStage'>[] = [
46
+ 'status',
47
+ 'priority',
48
+ 'assignee',
49
+ 'project',
50
+ ];
51
+ /** Prefixes a workflow URN in the grouping choice. */
52
+ const STAGE = 'stage:';
53
+
54
+ /**
55
+ * The full task view: a filterable board or table of a company's tasks, with the open task's editor and activity log
56
+ * beside it, and a form for new tasks. Loads the store when it hasn't been loaded.
57
+ */
58
+ export function TaskView({
59
+ store,
60
+ me,
61
+ layout: initialLayout = 'board',
62
+ groupBy: initialGroupBy = 'status',
63
+ filter: initialFilter = {},
64
+ selected: initialSelected,
65
+ onSelect,
66
+ related,
67
+ }: TaskViewProps) {
68
+ const copy = useCopy();
69
+ const ids = useId();
70
+ const state = useTaskStore(store);
71
+ const loaded = useTaskDirectory(state);
72
+ const directory = useMemo(
73
+ () => ({ ...loaded, companies: related?.companies, entities: related?.entities }),
74
+ [loaded, related?.companies, related?.entities],
75
+ );
76
+ const [layout, setLayout] = useState<TaskLayout>(initialLayout);
77
+ const [groupBy, setGroupBy] = useState<TaskGrouping>(initialGroupBy);
78
+ const [workflowUrn, setWorkflowUrn] = useState<string | undefined>();
79
+ const workflow = directory.workflows?.find((w) => w.urn === workflowUrn) ?? directory.workflows?.[0];
80
+ const [filter, setFilter] = useState<TaskFilter>(initialFilter);
81
+ const [selected, setSelected] = useState<string | undefined>(initialSelected);
82
+ const [creating, setCreating] = useState(false);
83
+ const [announcement, setAnnouncement] = useState('');
84
+
85
+ useEffect(() => {
86
+ if (store.getState().status === 'idle') store.load().catch(() => undefined);
87
+ }, [store]);
88
+
89
+ const task = selected ? state.byUrn.get(selected) : undefined;
90
+ const logs = selected ? state.logs.get(selected) : undefined;
91
+
92
+ useEffect(() => {
93
+ if (selected && store.getState().logs.get(selected) === undefined && store.get(selected)) {
94
+ store.loadLogs(selected).catch(() => undefined);
95
+ }
96
+ }, [store, selected, task !== undefined]);
97
+
98
+ const itemId = (urn: string) => `${ids}-task-${urn}`;
99
+ const select = (urn: string | undefined) => {
100
+ setSelected(urn);
101
+ setCreating(false);
102
+ onSelect?.(urn);
103
+ };
104
+ const close = () => {
105
+ const from = selected;
106
+ select(undefined);
107
+ if (from) requestAnimationFrame(() => document.getElementById(itemId(from))?.focus());
108
+ };
109
+
110
+ const wide = useMediaQuery('(min-width: 64rem)');
111
+ const visible = useMemo(() => selectTasks(state, filter, byUrgency), [state, filter]);
112
+ const list = {
113
+ tasks: visible,
114
+ directory,
115
+ selected,
116
+ itemId,
117
+ onOpen: (t: Task) => select(t.urn),
118
+ };
119
+
120
+ const panel = (
121
+ <>
122
+ {creating ? (
123
+ <TaskCreateForm
124
+ directory={directory}
125
+ onCancel={() => setCreating(false)}
126
+ onCreate={async (input) => {
127
+ const created = await store.create(input);
128
+ setAnnouncement(created.title);
129
+ select(created.urn);
130
+ }}
131
+ />
132
+ ) : (
133
+ task && (
134
+ <TaskEditor
135
+ task={task}
136
+ directory={directory}
137
+ saving={state.saving.has(task.urn)}
138
+ onChange={(changes) => store.update(task.urn, changes)}
139
+ onDelete={async () => {
140
+ await store.delete(task.urn);
141
+ select(undefined);
142
+ }}
143
+ onClose={close}
144
+ changedElsewhere={state.changedElsewhere.get(task.urn)}
145
+ onDismissChangedElsewhere={() => store.dismissChangedElsewhere(task.urn)}
146
+ activity={
147
+ <TaskActivity
148
+ logs={logs?.logs ?? []}
149
+ directory={directory}
150
+ loading={logs?.status === 'loading'}
151
+ onLoadMore={logs?.after ? () => store.loadMoreLogs(task.urn).catch(() => undefined) : undefined}
152
+ onComment={(text, replyTo) =>
153
+ store.comment(task.urn, text, replyTo ? { replyTo: replyTo.urn } : {})
154
+ }
155
+ />
156
+ }
157
+ />
158
+ )
159
+ )}
160
+ </>
161
+ );
162
+
163
+ return (
164
+ <div className="grid gap-4">
165
+ <div className="flex flex-wrap items-center gap-3">
166
+ <h2 className="mr-auto text-xl font-bold">
167
+ {copy('tasks_heading')}
168
+ {state.status === 'ready' && (
169
+ <span className="text-muted-foreground ml-2 text-sm font-normal tabular-nums">
170
+ {visible.length === 1 ? copy('tasks_count_one') : copy('tasks_count', { count: visible.length })}
171
+ </span>
172
+ )}
173
+ </h2>
174
+ <ToggleGroup
175
+ type="single"
176
+ variant="outline"
177
+ size="sm"
178
+ spacing={0}
179
+ value={layout}
180
+ onValueChange={(value) => value && setLayout(value as TaskLayout)}
181
+ aria-label={copy('tasks_view')}
182
+ >
183
+ <ToggleGroupItem value="board">
184
+ <Columns3 aria-hidden="true" />
185
+ {copy('tasks_view_board')}
186
+ </ToggleGroupItem>
187
+ <ToggleGroupItem value="table">
188
+ <Rows3 aria-hidden="true" />
189
+ {copy('tasks_view_table')}
190
+ </ToggleGroupItem>
191
+ </ToggleGroup>
192
+ {layout === 'board' && (
193
+ <div className="w-36">
194
+ <ChoiceSelect
195
+ value={groupBy === 'workflowStage' && workflow ? `${STAGE}${workflow.urn}` : groupBy}
196
+ label={copy('tasks_group_by')}
197
+ choices={[
198
+ ...GROUPINGS.map((grouping) => ({ value: grouping, label: copy(`tasks_group_${grouping}`) })),
199
+ // Workflows as columns, then one choice per workflow for its stages as columns.
200
+ ...(directory.workflows?.length ? [{ value: 'workflow', label: copy('tasks_group_workflow') }] : []),
201
+ ...(directory.workflows ?? []).map((w) => ({
202
+ value: `${STAGE}${w.urn}`,
203
+ label: copy('tasks_group_stages', { workflow: w.name }),
204
+ })),
205
+ ]}
206
+ onChange={(value) => {
207
+ if (value.startsWith(STAGE)) {
208
+ setGroupBy('workflowStage');
209
+ setWorkflowUrn(value.slice(STAGE.length));
210
+ } else setGroupBy(value as TaskGrouping);
211
+ }}
212
+ />
213
+ </div>
214
+ )}
215
+ <Button
216
+ size="sm"
217
+ onClick={() => {
218
+ setSelected(undefined);
219
+ setCreating(true);
220
+ }}
221
+ >
222
+ <Plus aria-hidden="true" />
223
+ {copy('tasks_new')}
224
+ </Button>
225
+ </div>
226
+
227
+ <TaskFilters value={filter} onChange={setFilter} directory={directory} me={me} />
228
+
229
+ <div
230
+ className={cn(
231
+ 'grid grid-cols-1 items-start gap-4',
232
+ (creating || task) && wide && 'grid-cols-[minmax(0,1fr)_minmax(22rem,28rem)]',
233
+ )}
234
+ >
235
+ <div className="min-w-0">
236
+ {state.status === 'error' && (
237
+ <Alert variant="destructive">
238
+ <AlertDescription className="flex flex-wrap items-center gap-2">
239
+ {copy('tasks_load_failed')}
240
+ <Button variant="outline" size="sm" onClick={() => store.load().catch(() => undefined)}>
241
+ {copy('tasks_retry')}
242
+ </Button>
243
+ </AlertDescription>
244
+ </Alert>
245
+ )}
246
+ {(state.status === 'loading' || state.status === 'idle') && state.tasks.length === 0 ? (
247
+ <div aria-busy="true" className="grid gap-2">
248
+ <span className="sr-only">{copy('tasks_loading')}</span>
249
+ {[0, 1, 2].map((row) => (
250
+ <Skeleton key={row} className="h-16 w-full" />
251
+ ))}
252
+ </div>
253
+ ) : state.status === 'ready' && visible.length === 0 ? (
254
+ <p className="text-muted-foreground rounded-xl border border-dashed p-8 text-center text-sm">
255
+ {state.tasks.length === 0 ? copy('tasks_none') : copy('tasks_empty')}
256
+ </p>
257
+ ) : layout === 'board' ? (
258
+ <TaskBoard {...list} groupBy={groupBy} workflow={workflow} sort={byPriorityThenDue} />
259
+ ) : (
260
+ <TaskTable {...list} />
261
+ )}
262
+ </div>
263
+
264
+ {(creating || task) && wide && (
265
+ <aside className="bg-card sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto rounded-xl border p-4">
266
+ {panel}
267
+ </aside>
268
+ )}
269
+ </div>
270
+ {!wide && (
271
+ <Sheet open={creating || task !== undefined} onOpenChange={(open) => !open && (creating ? setCreating(false) : close())}>
272
+ <SheetContent
273
+ side="right"
274
+ showCloseButton={false}
275
+ aria-describedby={undefined}
276
+ className="w-full overflow-y-auto p-4 sm:max-w-md"
277
+ // The editor and form focus their own heading or first field, and closing focuses the task's card.
278
+ onOpenAutoFocus={(event) => event.preventDefault()}
279
+ onCloseAutoFocus={(event) => event.preventDefault()}
280
+ >
281
+ <SheetTitle className="sr-only">{creating ? copy('tasks_new') : copy('editor_label')}</SheetTitle>
282
+ {panel}
283
+ </SheetContent>
284
+ </Sheet>
285
+ )}
286
+ <p aria-live="polite" className="sr-only">
287
+ {announcement}
288
+ </p>
289
+ </div>
290
+ );
291
+ }
@@ -0,0 +1,226 @@
1
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
2
+ import type { Task } from '@baack-software/baack-ts-client';
3
+ import { CircleCheck, Plus } from 'lucide-react';
4
+ import { selectTasks, type TaskStore } from '../store.ts';
5
+ import { byUrgency, isOpen, type TaskFilter } from '../tasks.ts';
6
+ import type { Ref } from '../types.ts';
7
+ import { useCopy } from './copy.tsx';
8
+ import { useTaskDirectory, useTaskStore } from './hooks.ts';
9
+ import { DueDate, nameOf, PersonAvatar, StatusBadge } from './parts.tsx';
10
+ import { Alert, AlertDescription } from './ui/alert.tsx';
11
+ import { Button } from './ui/button.tsx';
12
+ import { Input } from './ui/input.tsx';
13
+ import { Skeleton } from './ui/skeleton.tsx';
14
+
15
+ /** What a page is about, and so which tasks it shows. */
16
+ export type TaskSubjectKind = 'identity' | 'company' | 'entity' | 'task' | 'project';
17
+
18
+ export interface TaskSubject {
19
+ kind: TaskSubjectKind;
20
+ urn: string;
21
+ }
22
+
23
+ export interface TasksAboutProps {
24
+ store: TaskStore;
25
+ /** The person, company, content, task or project the page is about. */
26
+ about: TaskSubject;
27
+ /** Opens a task, e.g. in the full task view. Without it, titles aren't links. */
28
+ onOpen?: ((task: Task) => void) | undefined;
29
+ /** The heading's level, to fit the page's outline. Default 2. */
30
+ headingLevel?: 2 | 3 | 4;
31
+ }
32
+
33
+ /** The new task's fields that tie it to the subject. */
34
+ function subjectFields(about: TaskSubject): { project: Ref } | Record<string, Ref[]> {
35
+ const ref = { urn: about.urn };
36
+ switch (about.kind) {
37
+ case 'project':
38
+ return { project: ref };
39
+ case 'identity':
40
+ return { associatedIdentities: [ref] };
41
+ case 'company':
42
+ return { associatedCompanies: [ref] };
43
+ case 'entity':
44
+ return { associatedEntities: [ref] };
45
+ case 'task':
46
+ return { associatedTasks: [ref] };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * The tasks about one thing, for a console page about it: open tasks, most urgent first, then completed ones on
52
+ * request, and a quick-add whose task is related to it. The API can't search by association yet, so this filters the
53
+ * store's tasks; share one store across a page's panels. Loads the store when it hasn't been loaded.
54
+ */
55
+ export function TasksAbout({ store, about, onOpen, headingLevel = 2 }: TasksAboutProps) {
56
+ const copy = useCopy();
57
+ const ids = useId();
58
+ const state = useTaskStore(store);
59
+ const directory = useTaskDirectory(state);
60
+ const input = useRef<HTMLInputElement>(null);
61
+ const [title, setTitle] = useState('');
62
+ const [adding, setAdding] = useState(false);
63
+ const [failed, setFailed] = useState(false);
64
+ const [showDone, setShowDone] = useState(false);
65
+
66
+ useEffect(() => {
67
+ if (store.getState().status === 'idle') store.load().catch(() => undefined);
68
+ }, [store]);
69
+
70
+ const filter: TaskFilter = about.kind === 'project' ? { project: [about.urn] } : { relatedTo: about.urn };
71
+ const tasks = useMemo(() => selectTasks(state, filter, byUrgency), [state, about.kind, about.urn]);
72
+ const open = tasks.filter(isOpen);
73
+ const done = tasks.filter((task) => !isOpen(task));
74
+ const Heading = `h${headingLevel}` as const;
75
+
76
+ const add = async () => {
77
+ if (!title.trim()) return;
78
+ setAdding(true);
79
+ setFailed(false);
80
+ try {
81
+ await store.create({ title: title.trim(), ...subjectFields(about) });
82
+ setTitle('');
83
+ } catch {
84
+ setFailed(true);
85
+ } finally {
86
+ setAdding(false);
87
+ input.current?.focus();
88
+ }
89
+ };
90
+
91
+ return (
92
+ <section aria-labelledby={`${ids}-heading`} className="bg-card grid gap-3 rounded-xl border p-4">
93
+ <Heading id={`${ids}-heading`} className="flex items-baseline gap-2 text-base font-semibold">
94
+ {copy('about_heading')}{' '}
95
+ {state.status === 'ready' && (
96
+ <span className="text-muted-foreground text-sm font-normal tabular-nums">
97
+ {copy('about_open_count', { count: open.length })}
98
+ </span>
99
+ )}
100
+ </Heading>
101
+
102
+ <form
103
+ className="flex gap-2"
104
+ onSubmit={(event) => {
105
+ event.preventDefault();
106
+ void add();
107
+ }}
108
+ >
109
+ <Input
110
+ ref={input}
111
+ value={title}
112
+ onChange={(event) => setTitle(event.target.value)}
113
+ placeholder={copy('about_add_placeholder')}
114
+ aria-label={copy('about_add_label')}
115
+ disabled={adding}
116
+ className="h-8"
117
+ />
118
+ <Button type="submit" size="sm" disabled={adding || !title.trim()}>
119
+ <Plus aria-hidden="true" />
120
+ {copy('about_add')}
121
+ </Button>
122
+ </form>
123
+ {failed && (
124
+ <Alert variant="destructive">
125
+ <AlertDescription>{copy('about_add_failed')}</AlertDescription>
126
+ </Alert>
127
+ )}
128
+
129
+ {state.status === 'error' && (
130
+ <Alert variant="destructive">
131
+ <AlertDescription className="flex flex-wrap items-center gap-2">
132
+ {copy('tasks_load_failed')}
133
+ <Button variant="outline" size="sm" onClick={() => store.load().catch(() => undefined)}>
134
+ {copy('tasks_retry')}
135
+ </Button>
136
+ </AlertDescription>
137
+ </Alert>
138
+ )}
139
+ {(state.status === 'loading' || state.status === 'idle') && state.tasks.length === 0 ? (
140
+ <div aria-busy="true" className="grid gap-2">
141
+ <span className="sr-only">{copy('tasks_loading')}</span>
142
+ {[0, 1].map((row) => (
143
+ <Skeleton key={row} className="h-10 w-full" />
144
+ ))}
145
+ </div>
146
+ ) : (
147
+ state.status !== 'error' && (
148
+ <>
149
+ {open.length === 0 ? (
150
+ <p className="text-muted-foreground text-sm">{copy('about_none')}</p>
151
+ ) : (
152
+ <TaskRows tasks={open} store={store} onOpen={onOpen} directory={directory} />
153
+ )}
154
+ {done.length > 0 && (
155
+ <>
156
+ <Button
157
+ variant="ghost"
158
+ size="sm"
159
+ className="justify-self-start"
160
+ aria-expanded={showDone}
161
+ aria-controls={`${ids}-done`}
162
+ onClick={() => setShowDone(!showDone)}
163
+ >
164
+ {showDone ? copy('about_hide_done') : copy('about_show_done', { count: done.length })}
165
+ </Button>
166
+ <div id={`${ids}-done`} hidden={!showDone}>
167
+ {showDone && <TaskRows tasks={done} store={store} onOpen={onOpen} directory={directory} />}
168
+ </div>
169
+ </>
170
+ )}
171
+ </>
172
+ )
173
+ )}
174
+ </section>
175
+ );
176
+ }
177
+
178
+ function TaskRows({
179
+ tasks,
180
+ store,
181
+ onOpen,
182
+ directory,
183
+ }: {
184
+ tasks: readonly Task[];
185
+ store: TaskStore;
186
+ onOpen: ((task: Task) => void) | undefined;
187
+ directory: ReturnType<typeof useTaskDirectory>;
188
+ }) {
189
+ const copy = useCopy();
190
+ return (
191
+ <ul className="divide-border grid divide-y">
192
+ {tasks.map((task) => {
193
+ const assignee = task.assignee ? { urn: task.assignee.urn, name: nameOf(directory, task.assignee) } : null;
194
+ return (
195
+ <li key={task.urn} className="flex min-w-0 items-center gap-2 py-2 text-sm">
196
+ {isOpen(task) ? (
197
+ <Button
198
+ variant="ghost"
199
+ size="icon-xs"
200
+ aria-label={copy('about_complete', { name: task.title })}
201
+ onClick={() => store.setStatus(task.urn, 'COMPLETED').catch(() => undefined)}
202
+ >
203
+ <CircleCheck aria-hidden="true" />
204
+ </Button>
205
+ ) : (
206
+ <StatusBadge status={task.status} />
207
+ )}
208
+ {onOpen ? (
209
+ <button
210
+ type="button"
211
+ onClick={() => onOpen(task)}
212
+ className="hover:text-primary min-w-0 flex-1 truncate text-left font-medium hover:underline"
213
+ >
214
+ {task.title}
215
+ </button>
216
+ ) : (
217
+ <span className="min-w-0 flex-1 truncate font-medium">{task.title}</span>
218
+ )}
219
+ <DueDate task={task} />
220
+ <PersonAvatar person={assignee} />
221
+ </li>
222
+ );
223
+ })}
224
+ </ul>
225
+ );
226
+ }