@tuturuuu/ui 0.9.0 → 0.11.0

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 (123) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +6 -5
  3. package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
  5. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
  6. package/src/components/ui/custom/nav-link.test.tsx +165 -0
  7. package/src/components/ui/custom/nav-link.tsx +69 -11
  8. package/src/components/ui/custom/navigation.tsx +1 -0
  9. package/src/components/ui/custom/settings/task-settings.tsx +104 -0
  10. package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
  11. package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
  12. package/src/components/ui/custom/settings-dialog-search.ts +75 -0
  13. package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
  14. package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
  15. package/src/components/ui/custom/workspace-select.tsx +17 -16
  16. package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
  17. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
  18. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
  21. package/src/components/ui/finance/invoices/hooks.ts +11 -2
  22. package/src/components/ui/finance/invoices/internal-api.ts +5 -0
  23. package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
  24. package/src/components/ui/finance/invoices/utils.test.ts +82 -0
  25. package/src/components/ui/finance/invoices/utils.ts +240 -7
  26. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  27. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  28. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  29. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  30. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  31. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  33. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  35. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  36. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  37. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  38. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  39. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  40. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  41. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  46. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  50. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  51. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  52. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  53. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  54. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  55. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  56. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  57. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  58. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  59. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  60. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  61. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  62. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  63. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +123 -1
  64. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  65. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  66. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  67. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +58 -0
  68. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  69. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  70. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  71. package/src/components/ui/tu-do/shared/board-client.tsx +16 -4
  72. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  73. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  74. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  75. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  76. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  77. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +58 -4
  78. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  79. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  102. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  103. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  104. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  105. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  106. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  107. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  108. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +390 -34
  109. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  110. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  111. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  112. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  113. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  114. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  115. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  116. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  117. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  118. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
  119. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  120. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  121. package/src/hooks/useBoardPresence.ts +364 -0
  122. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  123. package/src/lib/workspace-actions.ts +2 -6
@@ -0,0 +1,376 @@
1
+ 'use client';
2
+
3
+ import type {
4
+ WorkspaceTaskBoardListItem,
5
+ WorkspaceTaskListSummary,
6
+ } from '@tuturuuu/internal-api/tasks';
7
+ import { Button } from '@tuturuuu/ui/button';
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from '@tuturuuu/ui/dialog';
16
+ import { Input } from '@tuturuuu/ui/input';
17
+ import { Label } from '@tuturuuu/ui/label';
18
+ import {
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from '@tuturuuu/ui/select';
25
+ import { Textarea } from '@tuturuuu/ui/textarea';
26
+ import { useTranslations } from 'next-intl';
27
+ import { useEffect, useId, useState } from 'react';
28
+ import type {
29
+ WorkspaceTaskTemplate,
30
+ WorkspaceTaskTemplatePayload,
31
+ } from './task-template-api';
32
+
33
+ type Visibility = 'private' | 'workspace';
34
+
35
+ interface CreateTaskTemplateDialogProps {
36
+ onCreate: (payload: WorkspaceTaskTemplatePayload) => void;
37
+ onOpenChange: (open: boolean) => void;
38
+ open: boolean;
39
+ pending: boolean;
40
+ }
41
+
42
+ export function CreateTaskTemplateDialog({
43
+ onCreate,
44
+ onOpenChange,
45
+ open,
46
+ pending,
47
+ }: CreateTaskTemplateDialogProps) {
48
+ const t = useTranslations('ws-task-templates');
49
+ const nameId = useId();
50
+ const keyId = useId();
51
+ const taskNameId = useId();
52
+ const descriptionId = useId();
53
+ const [name, setName] = useState('');
54
+ const [key, setKey] = useState('');
55
+ const [taskName, setTaskName] = useState('');
56
+ const [description, setDescription] = useState('');
57
+ const [priority, setPriority] = useState<string>('none');
58
+ const [visibility, setVisibility] = useState<Visibility>('private');
59
+
60
+ useEffect(() => {
61
+ if (!open) return;
62
+ setName('');
63
+ setKey('');
64
+ setTaskName('');
65
+ setDescription('');
66
+ setPriority('none');
67
+ setVisibility('private');
68
+ }, [open]);
69
+
70
+ const canSubmit = name.trim().length > 0 || taskName.trim().length > 0;
71
+
72
+ return (
73
+ <Dialog open={open} onOpenChange={onOpenChange}>
74
+ <DialogContent>
75
+ <DialogHeader>
76
+ <DialogTitle>{t('create.title')}</DialogTitle>
77
+ <DialogDescription>{t('create.description')}</DialogDescription>
78
+ </DialogHeader>
79
+ <div className="space-y-4">
80
+ <div className="space-y-2">
81
+ <Label htmlFor={nameId}>{t('fields.template_name')}</Label>
82
+ <Input
83
+ id={nameId}
84
+ value={name}
85
+ onChange={(event) => setName(event.target.value)}
86
+ placeholder={t('fields.template_name_placeholder')}
87
+ />
88
+ </div>
89
+ <div className="space-y-2">
90
+ <Label htmlFor={keyId}>{t('fields.key')}</Label>
91
+ <Input
92
+ id={keyId}
93
+ value={key}
94
+ onChange={(event) => setKey(event.target.value)}
95
+ placeholder={t('fields.key_placeholder')}
96
+ />
97
+ </div>
98
+ <div className="space-y-2">
99
+ <Label htmlFor={taskNameId}>{t('fields.task_name')}</Label>
100
+ <Input
101
+ id={taskNameId}
102
+ value={taskName}
103
+ onChange={(event) => setTaskName(event.target.value)}
104
+ placeholder={t('fields.task_name_placeholder')}
105
+ />
106
+ </div>
107
+ <div className="space-y-2">
108
+ <Label htmlFor={descriptionId}>{t('fields.description')}</Label>
109
+ <Textarea
110
+ id={descriptionId}
111
+ value={description}
112
+ onChange={(event) => setDescription(event.target.value)}
113
+ placeholder={t('fields.description_placeholder')}
114
+ />
115
+ </div>
116
+ <div className="grid gap-3 sm:grid-cols-2">
117
+ <Select value={priority} onValueChange={setPriority}>
118
+ <SelectTrigger>
119
+ <SelectValue placeholder={t('fields.priority')} />
120
+ </SelectTrigger>
121
+ <SelectContent>
122
+ <SelectItem value="none">{t('priority.none')}</SelectItem>
123
+ <SelectItem value="low">{t('priority.low')}</SelectItem>
124
+ <SelectItem value="normal">{t('priority.normal')}</SelectItem>
125
+ <SelectItem value="high">{t('priority.high')}</SelectItem>
126
+ <SelectItem value="critical">
127
+ {t('priority.critical')}
128
+ </SelectItem>
129
+ </SelectContent>
130
+ </Select>
131
+ <Select
132
+ value={visibility}
133
+ onValueChange={(value) => setVisibility(value as Visibility)}
134
+ >
135
+ <SelectTrigger>
136
+ <SelectValue placeholder={t('fields.visibility')} />
137
+ </SelectTrigger>
138
+ <SelectContent>
139
+ <SelectItem value="private">
140
+ {t('visibility.private')}
141
+ </SelectItem>
142
+ <SelectItem value="workspace">
143
+ {t('visibility.workspace')}
144
+ </SelectItem>
145
+ </SelectContent>
146
+ </Select>
147
+ </div>
148
+ </div>
149
+ <DialogFooter>
150
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
151
+ {t('actions.cancel')}
152
+ </Button>
153
+ <Button
154
+ disabled={!canSubmit || pending}
155
+ onClick={() =>
156
+ onCreate({
157
+ description: description.trim() || null,
158
+ key: key.trim() || undefined,
159
+ name: name.trim() || taskName.trim(),
160
+ priority: priority === 'none' ? null : (priority as never),
161
+ task_name: taskName.trim() || name.trim(),
162
+ visibility,
163
+ })
164
+ }
165
+ >
166
+ {t('actions.create')}
167
+ </Button>
168
+ </DialogFooter>
169
+ </DialogContent>
170
+ </Dialog>
171
+ );
172
+ }
173
+
174
+ interface UseTaskTemplateDialogProps {
175
+ boards: WorkspaceTaskBoardListItem[];
176
+ lists: WorkspaceTaskListSummary[];
177
+ loadingLists: boolean;
178
+ onBoardChange: (boardId: string) => void;
179
+ onListChange: (listId: string) => void;
180
+ onOpenChange: (open: boolean) => void;
181
+ onUse: (payload: { listId: string; name?: string }) => void;
182
+ open: boolean;
183
+ pending: boolean;
184
+ selectedBoardId: string;
185
+ selectedListId: string;
186
+ template: WorkspaceTaskTemplate | null;
187
+ }
188
+
189
+ export function UseTaskTemplateDialog({
190
+ boards,
191
+ lists,
192
+ loadingLists,
193
+ onBoardChange,
194
+ onListChange,
195
+ onOpenChange,
196
+ onUse,
197
+ open,
198
+ pending,
199
+ selectedBoardId,
200
+ selectedListId,
201
+ template,
202
+ }: UseTaskTemplateDialogProps) {
203
+ const t = useTranslations('ws-task-templates');
204
+ const overrideNameId = useId();
205
+ const [name, setName] = useState('');
206
+
207
+ useEffect(() => {
208
+ if (open) setName('');
209
+ }, [open]);
210
+
211
+ return (
212
+ <Dialog open={open} onOpenChange={onOpenChange}>
213
+ <DialogContent>
214
+ <DialogHeader>
215
+ <DialogTitle>{t('use.title')}</DialogTitle>
216
+ <DialogDescription>
217
+ {template
218
+ ? t('use.description', { name: template.name })
219
+ : t('use.description_empty')}
220
+ </DialogDescription>
221
+ </DialogHeader>
222
+ <div className="space-y-4">
223
+ <Select value={selectedBoardId} onValueChange={onBoardChange}>
224
+ <SelectTrigger>
225
+ <SelectValue placeholder={t('fields.board')} />
226
+ </SelectTrigger>
227
+ <SelectContent>
228
+ {boards.map((board) => (
229
+ <SelectItem key={board.id} value={board.id}>
230
+ {board.name || t('fields.unnamed_board')}
231
+ </SelectItem>
232
+ ))}
233
+ </SelectContent>
234
+ </Select>
235
+ <Select
236
+ disabled={!selectedBoardId || loadingLists}
237
+ onValueChange={onListChange}
238
+ value={selectedListId}
239
+ >
240
+ <SelectTrigger>
241
+ <SelectValue placeholder={t('fields.list')} />
242
+ </SelectTrigger>
243
+ <SelectContent>
244
+ {lists.map((list) => (
245
+ <SelectItem key={list.id} value={list.id}>
246
+ {list.name || t('fields.unnamed_list')}
247
+ </SelectItem>
248
+ ))}
249
+ </SelectContent>
250
+ </Select>
251
+ <div className="space-y-2">
252
+ <Label htmlFor={overrideNameId}>{t('fields.override_name')}</Label>
253
+ <Input
254
+ id={overrideNameId}
255
+ value={name}
256
+ onChange={(event) => setName(event.target.value)}
257
+ placeholder={template?.task_name ?? t('fields.task_name')}
258
+ />
259
+ </div>
260
+ </div>
261
+ <DialogFooter>
262
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
263
+ {t('actions.cancel')}
264
+ </Button>
265
+ <Button
266
+ disabled={!template || !selectedListId || pending}
267
+ onClick={() =>
268
+ onUse({
269
+ listId: selectedListId,
270
+ name: name.trim() || undefined,
271
+ })
272
+ }
273
+ >
274
+ {t('actions.create_task')}
275
+ </Button>
276
+ </DialogFooter>
277
+ </DialogContent>
278
+ </Dialog>
279
+ );
280
+ }
281
+
282
+ interface SaveTaskTemplateFromTaskDialogProps {
283
+ onOpenChange: (open: boolean) => void;
284
+ onSave: (payload: {
285
+ name?: string;
286
+ taskId: string;
287
+ visibility: Visibility;
288
+ }) => void;
289
+ open: boolean;
290
+ pending: boolean;
291
+ }
292
+
293
+ export function SaveTaskTemplateFromTaskDialog({
294
+ onOpenChange,
295
+ onSave,
296
+ open,
297
+ pending,
298
+ }: SaveTaskTemplateFromTaskDialogProps) {
299
+ const t = useTranslations('ws-task-templates');
300
+ const taskIdInput = useId();
301
+ const nameInput = useId();
302
+ const [taskId, setTaskId] = useState('');
303
+ const [name, setName] = useState('');
304
+ const [visibility, setVisibility] = useState<Visibility>('private');
305
+
306
+ useEffect(() => {
307
+ if (!open) return;
308
+ setTaskId('');
309
+ setName('');
310
+ setVisibility('private');
311
+ }, [open]);
312
+
313
+ return (
314
+ <Dialog open={open} onOpenChange={onOpenChange}>
315
+ <DialogContent>
316
+ <DialogHeader>
317
+ <DialogTitle>{t('save_from_task.title')}</DialogTitle>
318
+ <DialogDescription>
319
+ {t('save_from_task.description')}
320
+ </DialogDescription>
321
+ </DialogHeader>
322
+ <div className="space-y-4">
323
+ <div className="space-y-2">
324
+ <Label htmlFor={taskIdInput}>{t('fields.task_id')}</Label>
325
+ <Input
326
+ id={taskIdInput}
327
+ value={taskId}
328
+ onChange={(event) => setTaskId(event.target.value)}
329
+ placeholder={t('fields.task_id_placeholder')}
330
+ />
331
+ </div>
332
+ <div className="space-y-2">
333
+ <Label htmlFor={nameInput}>{t('fields.template_name')}</Label>
334
+ <Input
335
+ id={nameInput}
336
+ value={name}
337
+ onChange={(event) => setName(event.target.value)}
338
+ placeholder={t('fields.template_name_placeholder')}
339
+ />
340
+ </div>
341
+ <Select
342
+ value={visibility}
343
+ onValueChange={(value) => setVisibility(value as Visibility)}
344
+ >
345
+ <SelectTrigger>
346
+ <SelectValue placeholder={t('fields.visibility')} />
347
+ </SelectTrigger>
348
+ <SelectContent>
349
+ <SelectItem value="private">{t('visibility.private')}</SelectItem>
350
+ <SelectItem value="workspace">
351
+ {t('visibility.workspace')}
352
+ </SelectItem>
353
+ </SelectContent>
354
+ </Select>
355
+ </div>
356
+ <DialogFooter>
357
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
358
+ {t('actions.cancel')}
359
+ </Button>
360
+ <Button
361
+ disabled={!taskId.trim() || pending}
362
+ onClick={() =>
363
+ onSave({
364
+ name: name.trim() || undefined,
365
+ taskId: taskId.trim(),
366
+ visibility,
367
+ })
368
+ }
369
+ >
370
+ {t('actions.save')}
371
+ </Button>
372
+ </DialogFooter>
373
+ </DialogContent>
374
+ </Dialog>
375
+ );
376
+ }
@@ -0,0 +1,114 @@
1
+ import '@testing-library/jest-dom';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { TaskTemplatesHub } from './task-templates-hub';
5
+
6
+ vi.mock('next-intl', () => ({
7
+ useTranslations: () => (key: string) => key,
8
+ }));
9
+
10
+ vi.mock('./task-template-client', () => ({
11
+ TaskTemplateClient: ({
12
+ initialTemplates,
13
+ wsId,
14
+ }: {
15
+ initialTemplates: Array<{ name: string }>;
16
+ wsId: string;
17
+ }) => (
18
+ <div data-testid="task-template-tab">
19
+ task templates for {wsId}:{' '}
20
+ {initialTemplates.map((item) => item.name).join(', ')}
21
+ </div>
22
+ ),
23
+ }));
24
+
25
+ vi.mock('./client', () => ({
26
+ default: ({
27
+ initialTemplates,
28
+ templatesBasePath,
29
+ wsId,
30
+ }: {
31
+ initialTemplates: Array<{ name: string }>;
32
+ templatesBasePath: string;
33
+ wsId: string;
34
+ }) => (
35
+ <div data-testid="board-template-tab">
36
+ board templates for {wsId}/{templatesBasePath}:{' '}
37
+ {initialTemplates.map((item) => item.name).join(', ')}
38
+ </div>
39
+ ),
40
+ }));
41
+
42
+ describe('TaskTemplatesHub', () => {
43
+ it('defaults to task templates and keeps board templates available', () => {
44
+ render(
45
+ <TaskTemplatesHub
46
+ boardTemplates={[
47
+ {
48
+ createdAt: '2026-06-29T00:00:00.000Z',
49
+ createdBy: 'user-1',
50
+ description: null,
51
+ id: 'board-template-1',
52
+ isOwner: true,
53
+ name: 'Sprint board',
54
+ sourceBoardId: 'board-1',
55
+ stats: { labels: 0, lists: 1, tasks: 0 },
56
+ updatedAt: '2026-06-29T00:00:00.000Z',
57
+ visibility: 'workspace',
58
+ wsId: 'ws-1',
59
+ },
60
+ ]}
61
+ taskTemplates={[
62
+ {
63
+ archived_at: null,
64
+ assignee_ids: [],
65
+ created_at: '2026-06-29T00:00:00.000Z',
66
+ created_by: 'user-1',
67
+ default_board_id: null,
68
+ default_list_id: null,
69
+ description: null,
70
+ description_yjs_state: null,
71
+ end_date: null,
72
+ estimation_points: null,
73
+ id: 'task-template-1',
74
+ isOwner: true,
75
+ label_ids: [],
76
+ name: 'Bug report',
77
+ priority: null,
78
+ project_ids: [],
79
+ slug: 'bug-report',
80
+ source_task_id: null,
81
+ start_date: null,
82
+ task_name: 'Investigate bug',
83
+ updated_at: '2026-06-29T00:00:00.000Z',
84
+ visibility: 'private',
85
+ ws_id: 'ws-1',
86
+ },
87
+ ]}
88
+ templatesBasePath="tasks/templates"
89
+ wsId="ws-1"
90
+ />
91
+ );
92
+
93
+ expect(screen.getByRole('tab', { name: /tabs.tasks/i })).toHaveAttribute(
94
+ 'data-state',
95
+ 'active'
96
+ );
97
+ expect(screen.getByTestId('task-template-tab')).toHaveTextContent(
98
+ 'Bug report'
99
+ );
100
+
101
+ const boardTab = screen.getByRole('tab', { name: /tabs.boards/i });
102
+ fireEvent.pointerDown(boardTab);
103
+ fireEvent.mouseDown(boardTab);
104
+ fireEvent.click(boardTab);
105
+
106
+ expect(boardTab).toHaveAttribute('data-state', 'active');
107
+ expect(screen.getByTestId('board-template-tab')).toHaveTextContent(
108
+ 'Sprint board'
109
+ );
110
+ expect(screen.getByTestId('board-template-tab')).toHaveTextContent(
111
+ 'tasks/templates'
112
+ );
113
+ });
114
+ });
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+
3
+ import { KanbanSquare, ListTodo } from '@tuturuuu/icons';
4
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
5
+ import { useTranslations } from 'next-intl';
6
+ import TemplatesClient from './client';
7
+ import type { WorkspaceTaskTemplate } from './task-template-api';
8
+ import { TaskTemplateClient } from './task-template-client';
9
+ import type { BoardTemplate } from './types';
10
+
11
+ interface TaskTemplatesHubProps {
12
+ boardTemplates: BoardTemplate[];
13
+ taskTemplates?: WorkspaceTaskTemplate[];
14
+ templatesBasePath?: string;
15
+ wsId: string;
16
+ }
17
+
18
+ export function TaskTemplatesHub({
19
+ boardTemplates,
20
+ taskTemplates = [],
21
+ templatesBasePath = 'templates',
22
+ wsId,
23
+ }: TaskTemplatesHubProps) {
24
+ const t = useTranslations('ws-task-templates');
25
+
26
+ return (
27
+ <Tabs defaultValue="tasks" className="space-y-4">
28
+ <TabsList>
29
+ <TabsTrigger value="tasks">
30
+ <ListTodo className="h-4 w-4" />
31
+ {t('tabs.tasks')}
32
+ </TabsTrigger>
33
+ <TabsTrigger value="boards">
34
+ <KanbanSquare className="h-4 w-4" />
35
+ {t('tabs.boards')}
36
+ </TabsTrigger>
37
+ </TabsList>
38
+ <TabsContent value="tasks">
39
+ <TaskTemplateClient initialTemplates={taskTemplates} wsId={wsId} />
40
+ </TabsContent>
41
+ <TabsContent value="boards">
42
+ <TemplatesClient
43
+ initialTemplates={boardTemplates}
44
+ templatesBasePath={templatesBasePath}
45
+ wsId={wsId}
46
+ />
47
+ </TabsContent>
48
+ </Tabs>
49
+ );
50
+ }
@@ -2,10 +2,10 @@ import { Store } from '@tuturuuu/icons';
2
2
  import { createAdminClient } from '@tuturuuu/supabase/next/server';
3
3
  import { Button } from '@tuturuuu/ui/button';
4
4
  import FeatureSummary from '@tuturuuu/ui/custom/feature-summary';
5
- import TemplatesClient from '@tuturuuu/ui/tu-do/templates/client';
5
+ import { TaskTemplatesHub } from '@tuturuuu/ui/tu-do/templates/task-templates-hub';
6
6
  import type { BoardTemplate } from '@tuturuuu/ui/tu-do/templates/types';
7
7
  import { getCurrentUser } from '@tuturuuu/utils/user-helper';
8
- import { getPermissions, getWorkspace } from '@tuturuuu/utils/workspace-helper';
8
+ import { getWorkspace } from '@tuturuuu/utils/workspace-helper';
9
9
  import Link from 'next/link';
10
10
  import { notFound, redirect } from 'next/navigation';
11
11
  import { getTranslations } from 'next-intl/server';
@@ -121,13 +121,8 @@ export default async function TaskTemplatesPage({
121
121
 
122
122
  const wsId = workspace.id;
123
123
 
124
- const permissions = await getPermissions({ wsId });
125
- if (!permissions) notFound();
126
- const { withoutPermission } = permissions;
127
- if (withoutPermission('manage_projects')) redirect(`/${wsId}`);
128
-
129
124
  const { templates } = await getTemplates(wsId, user.id);
130
- const t = await getTranslations('ws-board-templates');
125
+ const t = await getTranslations('ws-task-templates');
131
126
 
132
127
  const marketplaceUrl = `/${wsId}/${templatesBasePath}/marketplace`;
133
128
 
@@ -144,14 +139,14 @@ export default async function TaskTemplatesPage({
144
139
  <Link href={marketplaceUrl}>
145
140
  <Button variant="outline" size="sm" className="gap-2">
146
141
  <Store className="h-4 w-4" />
147
- {t('gallery.marketplace')}
142
+ {t('gallery.board_marketplace')}
148
143
  </Button>
149
144
  </Link>
150
145
  }
151
146
  />
152
- <TemplatesClient
147
+ <TaskTemplatesHub
148
+ boardTemplates={templates}
153
149
  wsId={wsId}
154
- initialTemplates={templates}
155
150
  templatesBasePath={templatesBasePath}
156
151
  />
157
152
  </div>