@tuturuuu/ui 0.14.0 → 0.17.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 (101) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/biome.json +1 -1
  3. package/package.json +63 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  12. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  13. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  14. package/src/components/ui/custom/statistic-card.tsx +104 -0
  15. package/src/components/ui/custom/structure.test.tsx +29 -0
  16. package/src/components/ui/custom/structure.tsx +103 -88
  17. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  18. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  21. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  23. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  24. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  25. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  26. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  27. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  28. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  29. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  30. package/src/components/ui/finance/invoices/utils.ts +56 -134
  31. package/src/components/ui/resizable.test.ts +15 -0
  32. package/src/components/ui/resizable.tsx +28 -6
  33. package/src/components/ui/storefront/browse-panel.tsx +161 -0
  34. package/src/components/ui/storefront/bundle-selection-dialog.tsx +1 -1
  35. package/src/components/ui/storefront/cart-popover.tsx +1 -1
  36. package/src/components/ui/storefront/hero-panel.tsx +35 -30
  37. package/src/components/ui/storefront/listing-card.tsx +10 -5
  38. package/src/components/ui/storefront/merch-sections.tsx +7 -5
  39. package/src/components/ui/storefront/product-detail.tsx +79 -6
  40. package/src/components/ui/storefront/product-dialog.tsx +5 -1
  41. package/src/components/ui/storefront/storefront-surface.test.tsx +72 -0
  42. package/src/components/ui/storefront/storefront-surface.tsx +67 -63
  43. package/src/components/ui/storefront/types.ts +28 -0
  44. package/src/components/ui/storefront/utils.ts +4 -5
  45. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +59 -1
  46. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +37 -8
  47. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  48. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  49. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +32 -2
  50. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  51. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  52. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  53. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  54. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  55. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  56. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  57. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  58. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  59. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  60. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  61. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  62. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  63. package/src/components/ui/tu-do/progress/task-progress-page.tsx +60 -26
  64. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  65. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  66. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  67. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  68. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  69. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  70. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  71. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  72. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  73. package/src/components/ui/tu-do/projects/types.ts +4 -1
  74. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  75. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  76. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  77. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  78. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  79. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  80. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  81. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  82. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  88. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  89. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  90. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  91. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  92. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  93. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  94. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  95. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  96. package/src/globals.css +1 -0
  97. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  98. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  99. package/src/hooks/task-actions-personal-external.ts +12 -6
  100. package/src/hooks/use-notifications.ts +14 -2
  101. package/vitest.config.ts +4 -0
@@ -7,6 +7,7 @@ import {
7
7
  listWorkspaceTaskLists,
8
8
  removeWorkspaceTaskLabel,
9
9
  updateWorkspaceTask,
10
+ upsertCurrentUserTaskPersonalPlacement,
10
11
  } from '@tuturuuu/internal-api';
11
12
  import type { TaskWithRelations } from '@tuturuuu/types';
12
13
  import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
@@ -221,23 +222,40 @@ export function useTaskContextActions({
221
222
  if (!taskBoardId || !taskWorkspaceId) return;
222
223
  setIsLoading(true);
223
224
  try {
224
- const { lists } = await listWorkspaceTaskLists(
225
- taskWorkspaceId,
226
- taskBoardId
227
- );
225
+ const personalPlacement = task.overrides as
226
+ | (NonNullable<typeof task.overrides> & {
227
+ personal_board_id?: string | null;
228
+ personal_list_id?: string | null;
229
+ })
230
+ | null;
231
+ const personalBoardId = personalPlacement?.personal_board_id ?? null;
228
232
 
229
- const doneList = lists?.find((l) => l.status === 'done');
230
- if (!doneList) {
231
- toast.error('No done list found');
232
- return;
233
- }
233
+ if (personalBoardId) {
234
+ await upsertCurrentUserTaskPersonalPlacement(task.id, {
235
+ personal_board_id: personalBoardId,
236
+ personal_list_id: personalPlacement?.personal_list_id ?? null,
237
+ terminal_status: 'done',
238
+ });
239
+ broadcastTaskUpsert({});
240
+ } else {
241
+ const { lists } = await listWorkspaceTaskLists(
242
+ taskWorkspaceId,
243
+ taskBoardId
244
+ );
234
245
 
235
- await updateWorkspaceTask(taskWorkspaceId, task.id, {
236
- list_id: doneList.id,
237
- });
238
- broadcastTaskUpsert({
239
- list_id: doneList.id,
240
- });
246
+ const doneList = lists?.find((l) => l.status === 'done');
247
+ if (!doneList) {
248
+ toast.error('No done list found');
249
+ return;
250
+ }
251
+
252
+ await updateWorkspaceTask(taskWorkspaceId, task.id, {
253
+ list_id: doneList.id,
254
+ });
255
+ broadcastTaskUpsert({
256
+ list_id: doneList.id,
257
+ });
258
+ }
241
259
 
242
260
  // Clear redundant personal overrides when task is actually done
243
261
  if (
@@ -19,7 +19,7 @@ function MetricSelect({
19
19
  }) {
20
20
  return (
21
21
  <select
22
- className="h-10 rounded-md border bg-background px-3 text-sm"
22
+ className="h-11 w-full rounded-xl border bg-background px-3 text-sm shadow-sm outline-none focus:border-dynamic-blue focus:ring-2 focus:ring-dynamic-blue/15"
23
23
  defaultValue={selectedMetric?.id}
24
24
  name="metric_id"
25
25
  required
@@ -55,8 +55,8 @@ export function LeaderboardsPanel(props: {
55
55
  } = props;
56
56
 
57
57
  return (
58
- <div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
59
- <Card>
58
+ <div className="grid gap-4 xl:grid-cols-[minmax(20rem,0.75fr)_minmax(0,1.25fr)]">
59
+ <Card className="h-fit xl:sticky xl:top-6">
60
60
  <CardHeader>
61
61
  <CardTitle>{t('leaderboards.create_leaderboard')}</CardTitle>
62
62
  </CardHeader>
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
- import { BarChart3, Flag, Target, TrendingUp, Trophy } from '@tuturuuu/icons';
4
+ import { BarChart3, Flag, Target, Trophy, Upload } from '@tuturuuu/icons';
5
5
  import {
6
6
  createTaskLeaderboard,
7
7
  createTaskLeaderboardTeam,
@@ -95,6 +95,7 @@ export function TaskProgressPage({
95
95
  const queryClient = useQueryClient();
96
96
  const [importText, setImportText] = useState('');
97
97
  const [importPreviewCount, setImportPreviewCount] = useState(0);
98
+ const [selectedMetricId, setSelectedMetricId] = useState<string | null>(null);
98
99
  const queryRoot = ['task-progress', wsId];
99
100
 
100
101
  const metricsQuery = useQuery({
@@ -103,7 +104,12 @@ export function TaskProgressPage({
103
104
  });
104
105
  const metrics =
105
106
  metricsQuery.data?.ok === true ? metricsQuery.data.metrics : [];
106
- const selectedMetric = useMemo(() => metricOption(metrics), [metrics]);
107
+ const selectedMetric = useMemo(
108
+ () =>
109
+ metrics.find((metric) => metric.id === selectedMetricId) ??
110
+ metricOption(metrics),
111
+ [metrics, selectedMetricId]
112
+ );
107
113
 
108
114
  const entriesQuery = useQuery({
109
115
  queryKey: [...queryRoot, 'entries', selectedMetric?.id],
@@ -236,42 +242,70 @@ export function TaskProgressPage({
236
242
  const leaderboards = leaderboardsQuery.data?.ok
237
243
  ? leaderboardsQuery.data.leaderboards
238
244
  : [];
245
+ const tabs = [
246
+ { icon: Flag, value: 'progress' },
247
+ { icon: Target, value: 'goals' },
248
+ { icon: BarChart3, value: 'stats' },
249
+ { icon: Trophy, value: 'leaderboards' },
250
+ { icon: Upload, value: 'import' },
251
+ ] as const;
239
252
 
240
253
  return (
241
- <div className="flex flex-col gap-6 p-4 md:p-6">
242
- <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
243
- <div className="space-y-2">
244
- <div className="flex items-center gap-3">
245
- <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-dynamic-blue/10 ring-1 ring-dynamic-blue/20">
246
- <TrendingUp className="h-5 w-5 text-dynamic-blue" />
247
- </div>
248
- <div>
249
- <h1 className="font-bold text-2xl tracking-tight">
250
- {t(`views.${view}.title`)}
251
- </h1>
252
- <p className="text-muted-foreground text-sm">
253
- {t(`views.${view}.description`)}
254
- </p>
255
- </div>
254
+ <div className="mx-auto flex w-full max-w-[1600px] flex-col gap-6 p-4 md:p-6 lg:p-8">
255
+ <section className="overflow-hidden rounded-2xl border bg-card/60 shadow-sm backdrop-blur">
256
+ <div className="flex flex-col gap-4 p-5 md:flex-row md:items-center md:justify-between md:p-6">
257
+ <div className="max-w-2xl">
258
+ <h1 className="font-bold text-2xl tracking-tight md:text-3xl">
259
+ {t('views.progress.title')}
260
+ </h1>
261
+ <p className="mt-1 text-muted-foreground text-sm md:text-base">
262
+ {t(`views.${view}.description`)}
263
+ </p>
264
+ </div>
265
+ <div className="shrink-0">
266
+ {metrics.length > 0 ? (
267
+ <label className="flex min-w-56 items-center justify-between gap-3 rounded-xl border bg-background px-3 py-2 text-sm shadow-sm">
268
+ <span className="text-muted-foreground">
269
+ {t('fields.metric_name')}
270
+ </span>
271
+ <select
272
+ className="min-w-28 bg-transparent text-right font-medium outline-none"
273
+ onChange={(event) => setSelectedMetricId(event.target.value)}
274
+ value={selectedMetric?.id ?? ''}
275
+ >
276
+ {metrics.map((metric) => (
277
+ <option key={metric.id} value={metric.id}>
278
+ {metric.name}
279
+ </option>
280
+ ))}
281
+ </select>
282
+ </label>
283
+ ) : null}
256
284
  </div>
257
285
  </div>
258
- <div className="flex flex-wrap gap-2">
259
- {(
260
- ['progress', 'goals', 'stats', 'leaderboards', 'import'] as const
261
- ).map((tab) => (
286
+ <nav className="flex max-w-full gap-1 overflow-x-auto border-t bg-muted/20 p-2">
287
+ {tabs.map(({ icon: TabIcon, value: tab }) => (
262
288
  <Button
263
289
  key={tab}
264
290
  asChild
291
+ className="shrink-0 gap-2 rounded-lg"
265
292
  size="sm"
266
- variant={tab === view ? 'default' : 'outline'}
293
+ variant={tab === view ? 'secondary' : 'ghost'}
267
294
  >
268
- <Link href={`/${routeWsId}/tasks/${tab}`}>
295
+ <Link
296
+ href={
297
+ tab === 'progress'
298
+ ? `/${routeWsId}/progress`
299
+ : `/${routeWsId}/progress/${tab}`
300
+ }
301
+ >
302
+ <TabIcon className="size-4" />
269
303
  {t(`tabs.${tab}`)}
270
304
  </Link>
271
305
  </Button>
272
306
  ))}
273
- </div>
274
- </div>
307
+ </nav>
308
+ </section>
275
309
 
276
310
  {hasPendingSchema ? (
277
311
  <Card>
@@ -281,7 +315,7 @@ export function TaskProgressPage({
281
315
  </Card>
282
316
  ) : null}
283
317
 
284
- <div className="grid gap-4 md:grid-cols-4">
318
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
285
319
  <SummaryCard
286
320
  icon={<BarChart3 className="h-4 w-4" />}
287
321
  label={t('summary.total')}
@@ -22,15 +22,17 @@ export function SummaryCard({
22
22
  value: number;
23
23
  }) {
24
24
  return (
25
- <Card>
25
+ <Card className="group overflow-hidden transition-all hover:-translate-y-0.5 hover:shadow-md">
26
26
  <CardHeader className="pb-2">
27
27
  <CardTitle className="flex items-center gap-2 text-muted-foreground text-sm">
28
- {icon}
28
+ <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-dynamic-blue/10 text-dynamic-blue transition-transform group-hover:scale-110">
29
+ {icon}
30
+ </span>
29
31
  {label}
30
32
  </CardTitle>
31
33
  </CardHeader>
32
34
  <CardContent>
33
- <div className="font-bold text-2xl">
35
+ <div className="font-bold text-3xl tracking-tight">
34
36
  {Number(value).toLocaleString()}
35
37
  </div>
36
38
  </CardContent>
@@ -49,7 +51,7 @@ function MetricSelect({
49
51
  }) {
50
52
  return (
51
53
  <select
52
- className="h-10 rounded-md border bg-background px-3 text-sm"
54
+ className="h-11 w-full rounded-xl border bg-background px-3 text-sm shadow-sm outline-none transition focus:border-dynamic-blue focus:ring-2 focus:ring-dynamic-blue/15"
53
55
  defaultValue={selectedMetric?.id}
54
56
  name={name}
55
57
  required
@@ -81,8 +83,8 @@ export function ProgressPanel(props: {
81
83
  } = props;
82
84
 
83
85
  return (
84
- <div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
85
- <Card>
86
+ <div className="grid gap-4 xl:grid-cols-[minmax(20rem,0.75fr)_minmax(0,1.25fr)]">
87
+ <Card className="h-fit xl:sticky xl:top-6">
86
88
  <CardHeader>
87
89
  <CardTitle>{t('progress.log_entry')}</CardTitle>
88
90
  </CardHeader>
@@ -138,7 +140,7 @@ export function ProgressPanel(props: {
138
140
  ) : (
139
141
  entries.map((entry) => (
140
142
  <div
141
- className="flex items-start justify-between gap-3 rounded-md border p-3"
143
+ className="flex items-start justify-between gap-3 rounded-xl border bg-background p-4 transition-colors hover:bg-muted/30"
142
144
  key={entry.id}
143
145
  >
144
146
  <div>
@@ -177,8 +179,8 @@ export function GoalsPanel(props: {
177
179
  const { createGoalMutation, goals, metrics, selectedMetric, t } = props;
178
180
 
179
181
  return (
180
- <div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
181
- <Card>
182
+ <div className="grid gap-4 xl:grid-cols-[minmax(20rem,0.75fr)_minmax(0,1.25fr)]">
183
+ <Card className="h-fit xl:sticky xl:top-6">
182
184
  <CardHeader>
183
185
  <CardTitle>{t('goals.create_goal')}</CardTitle>
184
186
  </CardHeader>
@@ -202,7 +204,7 @@ export function GoalsPanel(props: {
202
204
  <Input defaultValue={today()} name="period_start" type="date" />
203
205
  <Input name="period_end" type="date" />
204
206
  <select
205
- className="h-10 rounded-md border bg-background px-3 text-sm"
207
+ className="h-11 rounded-xl border bg-background px-3 text-sm shadow-sm outline-none focus:border-dynamic-blue focus:ring-2 focus:ring-dynamic-blue/15"
206
208
  name="goal_type"
207
209
  >
208
210
  <option value="target">{t('goal_types.target')}</option>
@@ -30,6 +30,7 @@ interface ProjectGridCardProps {
30
30
  onDelete: (projectId: string) => void;
31
31
  onManageTasks: (project: TaskProject) => void;
32
32
  onNavigate: (projectId: string) => void;
33
+ navigationMode?: 'dialog' | 'route';
33
34
  isUpdating: boolean;
34
35
  isDeleting: boolean;
35
36
  isLinking: boolean;
@@ -43,6 +44,7 @@ export function ProjectGridCard({
43
44
  onDelete,
44
45
  onManageTasks,
45
46
  onNavigate,
47
+ navigationMode = 'route',
46
48
  isUpdating,
47
49
  isDeleting,
48
50
  isLinking,
@@ -132,15 +134,29 @@ export function ProjectGridCard({
132
134
  <Link2 className="h-4 w-4" />
133
135
  {t('manage')}
134
136
  </Button>
135
- <NextLink
136
- href={`/${wsId}${tasksHref(`/projects/${project.id}`)}`}
137
- onClick={(event) => event.stopPropagation()}
138
- >
139
- <Button size="sm" className="w-full gap-2">
137
+ {navigationMode === 'route' ? (
138
+ <NextLink
139
+ href={`/${wsId}${tasksHref(`/projects/${project.id}`)}`}
140
+ onClick={(event) => event.stopPropagation()}
141
+ >
142
+ <Button size="sm" className="w-full gap-2">
143
+ <ExternalLink className="h-4 w-4" />
144
+ {t('open')}
145
+ </Button>
146
+ </NextLink>
147
+ ) : (
148
+ <Button
149
+ size="sm"
150
+ className="w-full gap-2"
151
+ onClick={(event) => {
152
+ event.stopPropagation();
153
+ onNavigate(project.id);
154
+ }}
155
+ >
140
156
  <ExternalLink className="h-4 w-4" />
141
157
  {t('open')}
142
158
  </Button>
143
- </NextLink>
159
+ )}
144
160
  </div>
145
161
  </Card>
146
162
  );
@@ -30,6 +30,7 @@ interface ProjectListItemProps {
30
30
  onDelete: (projectId: string) => void;
31
31
  onManageTasks: (project: TaskProject) => void;
32
32
  onNavigate: (projectId: string) => void;
33
+ navigationMode?: 'dialog' | 'route';
33
34
  isUpdating: boolean;
34
35
  isDeleting: boolean;
35
36
  isLinking: boolean;
@@ -43,6 +44,7 @@ export function ProjectListItem({
43
44
  onDelete,
44
45
  onManageTasks,
45
46
  onNavigate,
47
+ navigationMode = 'route',
46
48
  isUpdating,
47
49
  isDeleting,
48
50
  isLinking,
@@ -61,14 +63,28 @@ export function ProjectListItem({
61
63
  <div className="min-w-0 space-y-3">
62
64
  <div className="flex items-start justify-between gap-3">
63
65
  <div className="min-w-0">
64
- <NextLink
65
- href={`/${wsId}${tasksHref(`/projects/${project.id}`)}`}
66
- className="inline-flex max-w-full items-center gap-2 font-semibold text-lg hover:text-dynamic-blue"
67
- onClick={(event) => event.stopPropagation()}
68
- >
69
- <span className="truncate">{project.name}</span>
70
- <ExternalLink className="h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
71
- </NextLink>
66
+ {navigationMode === 'route' ? (
67
+ <NextLink
68
+ href={`/${wsId}${tasksHref(`/projects/${project.id}`)}`}
69
+ className="inline-flex max-w-full items-center gap-2 font-semibold text-lg hover:text-dynamic-blue"
70
+ onClick={(event) => event.stopPropagation()}
71
+ >
72
+ <span className="truncate">{project.name}</span>
73
+ <ExternalLink className="h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
74
+ </NextLink>
75
+ ) : (
76
+ <button
77
+ type="button"
78
+ className="inline-flex max-w-full items-center gap-2 text-left font-semibold text-lg hover:text-dynamic-blue"
79
+ onClick={(event) => {
80
+ event.stopPropagation();
81
+ onNavigate(project.id);
82
+ }}
83
+ >
84
+ <span className="truncate">{project.name}</span>
85
+ <ExternalLink className="h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
86
+ </button>
87
+ )}
72
88
  <p className="mt-1 line-clamp-2 text-muted-foreground text-sm">
73
89
  {project.description || t('no_description')}
74
90
  </p>
@@ -160,4 +160,10 @@ describe('TasksTab layout', () => {
160
160
  expect(captured.listViewProps.preserveTaskOrder).toBe(true);
161
161
  expect(captured.listViewProps.searchQuery).toBe('project');
162
162
  });
163
+
164
+ it('omits removed projects route back link in embedded mode', () => {
165
+ renderTasksTab({ embedded: true });
166
+
167
+ expect(captured.boardHeaderProps.backUrl).toBeUndefined();
168
+ });
163
169
  });
@@ -38,6 +38,7 @@ interface TasksTabProps {
38
38
  setListStatusFilter: (filter: ListStatusFilter) => void;
39
39
  setShowLinkTaskDialog: (show: boolean) => void;
40
40
  onTaskPartialUpdate: (taskId: string, partial: Partial<Task>) => void;
41
+ embedded?: boolean;
41
42
  isMultiSelectMode: boolean;
42
43
  setIsMultiSelectMode: (mode: boolean) => void;
43
44
  }
@@ -58,6 +59,7 @@ export function TasksTab({
58
59
  setListStatusFilter,
59
60
  setShowLinkTaskDialog,
60
61
  onTaskPartialUpdate,
62
+ embedded = false,
61
63
  isMultiSelectMode,
62
64
  setIsMultiSelectMode,
63
65
  }: TasksTabProps) {
@@ -207,7 +209,7 @@ export function TasksTab({
207
209
  listStatusFilter={listStatusFilter}
208
210
  onListStatusFilterChange={setListStatusFilter}
209
211
  isPersonalWorkspace={workspace.personal}
210
- backUrl={`/${wsId}${tasksHref('/projects')}`}
212
+ backUrl={embedded ? undefined : `/${wsId}${tasksHref('/projects')}`}
211
213
  hideActions={true}
212
214
  isMultiSelectMode={isMultiSelectMode}
213
215
  setIsMultiSelectMode={setIsMultiSelectMode}
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useQuery } from '@tanstack/react-query';
4
+ import { Loader2 } from '@tuturuuu/icons';
4
5
  import {
5
6
  getWorkspaceTaskProject,
6
7
  getWorkspaceTaskProjectTasks,
@@ -16,8 +17,9 @@ interface Props {
16
17
  projectId: string;
17
18
  currentUserId: string;
18
19
  workspace: Workspace;
19
- initialProject: TaskProjectWithRelations;
20
- initialProjectData: { tasks: Task[]; documents: Task[]; lists: TaskList[] };
20
+ embedded?: boolean;
21
+ initialProject?: TaskProjectWithRelations;
22
+ initialProjectData?: { tasks: Task[]; documents: Task[]; lists: TaskList[] };
21
23
  }
22
24
 
23
25
  export default function TaskProjectDetailPageClient({
@@ -25,6 +27,7 @@ export default function TaskProjectDetailPageClient({
25
27
  projectId,
26
28
  currentUserId,
27
29
  workspace,
30
+ embedded = false,
28
31
  initialProject,
29
32
  initialProjectData,
30
33
  }: Props) {
@@ -53,7 +56,11 @@ export default function TaskProjectDetailPageClient({
53
56
  }
54
57
 
55
58
  if (!project || !projectData) {
56
- return null;
59
+ return (
60
+ <div className="flex min-h-[320px] items-center justify-center">
61
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
62
+ </div>
63
+ );
57
64
  }
58
65
 
59
66
  const lists: TaskList[] = (projectData.lists ?? []).map((list) => ({
@@ -79,6 +86,7 @@ export default function TaskProjectDetailPageClient({
79
86
  documents={projectData.documents ?? []}
80
87
  lists={lists}
81
88
  currentUserId={currentUserId}
89
+ embedded={embedded}
82
90
  wsId={wsId}
83
91
  />
84
92
  );
@@ -38,6 +38,7 @@ export function TaskProjectDetail({
38
38
  documents,
39
39
  lists,
40
40
  currentUserId,
41
+ embedded = false,
41
42
  wsId,
42
43
  }: TaskProjectDetailProps) {
43
44
  const router = useRouter();
@@ -334,6 +335,7 @@ export function TaskProjectDetail({
334
335
  setListStatusFilter={setListStatusFilter}
335
336
  setShowLinkTaskDialog={taskLinking.setShowLinkTaskDialog}
336
337
  onTaskPartialUpdate={handleTaskPartialUpdate}
338
+ embedded={embedded}
337
339
  isMultiSelectMode={isMultiSelectMode}
338
340
  setIsMultiSelectMode={setIsMultiSelectMode}
339
341
  />
@@ -24,6 +24,7 @@ export interface TaskProjectDetailProps {
24
24
  documents: Task[];
25
25
  lists: TaskList[];
26
26
  currentUserId: string;
27
+ embedded?: boolean;
27
28
  wsId: string;
28
29
  }
29
30
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useRouter } from 'next/navigation';
4
4
  import { useCallback, useMemo, useState } from 'react';
5
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../dialog';
5
6
  import { useTasksHref } from '../tasks-route-context';
6
7
  import {
7
8
  ProjectGridCard,
@@ -16,11 +17,15 @@ import {
16
17
  ManageTasksDialog,
17
18
  } from './dialogs';
18
19
  import { useProjectFilters, useTaskProjects } from './hooks';
20
+ import TaskProjectDetailPageClient from './projectId/task-project-detail-page-client';
19
21
  import type { TaskProject, TaskProjectsClientProps } from './types';
20
22
 
21
23
  export function TaskProjectsClient({
24
+ currentUserId,
25
+ detailMode = 'route',
22
26
  wsId,
23
27
  initialProjects,
28
+ workspace,
24
29
  }: TaskProjectsClientProps) {
25
30
  const router = useRouter();
26
31
  const tasksHref = useTasksHref();
@@ -34,6 +39,9 @@ export function TaskProjectsClient({
34
39
  const [managingProject, setManagingProject] = useState<TaskProject | null>(
35
40
  null
36
41
  );
42
+ const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
43
+ null
44
+ );
37
45
 
38
46
  // Custom hooks
39
47
  const taskProjects = useTaskProjects({
@@ -74,6 +82,11 @@ export function TaskProjectsClient({
74
82
  ),
75
83
  [taskProjects.projects]
76
84
  );
85
+ const selectedProject = selectedProjectId
86
+ ? (taskProjects.projects.find(
87
+ (project) => project.id === selectedProjectId
88
+ ) ?? null)
89
+ : null;
77
90
 
78
91
  // Handlers
79
92
  const handleEditProject = (project: TaskProject) => {
@@ -120,9 +133,14 @@ export function TaskProjectsClient({
120
133
 
121
134
  const navigateToProject = useCallback(
122
135
  (projectId: string) => {
136
+ if (detailMode === 'dialog' && workspace && currentUserId) {
137
+ setSelectedProjectId(projectId);
138
+ return;
139
+ }
140
+
123
141
  router.push(`/${wsId}${tasksHref(`/projects/${projectId}`)}`);
124
142
  },
125
- [router, wsId, tasksHref]
143
+ [currentUserId, detailMode, router, tasksHref, workspace, wsId]
126
144
  );
127
145
 
128
146
  return (
@@ -168,6 +186,7 @@ export function TaskProjectsClient({
168
186
  onDelete={taskProjects.deleteProject}
169
187
  onManageTasks={handleOpenManageTasks}
170
188
  onNavigate={navigateToProject}
189
+ navigationMode={detailMode}
171
190
  isUpdating={taskProjects.isUpdating}
172
191
  isDeleting={taskProjects.isDeleting}
173
192
  isLinking={taskProjects.isLinking}
@@ -186,6 +205,7 @@ export function TaskProjectsClient({
186
205
  onDelete={taskProjects.deleteProject}
187
206
  onManageTasks={handleOpenManageTasks}
188
207
  onNavigate={navigateToProject}
208
+ navigationMode={detailMode}
189
209
  isUpdating={taskProjects.isUpdating}
190
210
  isDeleting={taskProjects.isDeleting}
191
211
  isLinking={taskProjects.isLinking}
@@ -221,6 +241,30 @@ export function TaskProjectsClient({
221
241
  isLinking={taskProjects.isLinking}
222
242
  isUnlinking={taskProjects.isUnlinking}
223
243
  />
244
+
245
+ <Dialog
246
+ open={Boolean(selectedProjectId)}
247
+ onOpenChange={(open) => {
248
+ if (!open) setSelectedProjectId(null);
249
+ }}
250
+ >
251
+ <DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
252
+ <DialogHeader>
253
+ <DialogTitle className="sr-only">
254
+ {selectedProject?.name ?? ''}
255
+ </DialogTitle>
256
+ </DialogHeader>
257
+ {selectedProjectId && workspace && currentUserId && (
258
+ <TaskProjectDetailPageClient
259
+ currentUserId={currentUserId}
260
+ embedded
261
+ projectId={selectedProjectId}
262
+ workspace={workspace}
263
+ wsId={wsId}
264
+ />
265
+ )}
266
+ </DialogContent>
267
+ </Dialog>
224
268
  </>
225
269
  );
226
270
  }
@@ -1,5 +1,5 @@
1
1
  import type { TaskProjectLinkedItem } from '@tuturuuu/internal-api/tasks';
2
- import type { TaskProjectWithRelations } from '@tuturuuu/types';
2
+ import type { TaskProjectWithRelations, Workspace } from '@tuturuuu/types';
3
3
 
4
4
  export type ProjectStatus =
5
5
  | 'backlog'
@@ -30,6 +30,9 @@ export interface TaskProject extends TaskProjectWithRelations {
30
30
  export interface TaskProjectsClientProps {
31
31
  wsId: string;
32
32
  initialProjects: TaskProject[];
33
+ currentUserId?: string;
34
+ detailMode?: 'dialog' | 'route';
35
+ workspace?: Workspace | null;
33
36
  }
34
37
 
35
38
  export interface TaskOption {
@@ -70,6 +70,10 @@ interface TaskDialogState {
70
70
  taskWorkspaceTier?: WorkspaceProductTier;
71
71
  /** Initial board/list context used for immediate partial-task rendering. */
72
72
  initialSharedContext?: SharedTaskContext;
73
+ /** Board that supplied the visible task card before source-task hydration. */
74
+ visibleBoardId?: string;
75
+ /** Visible task snapshot before hydration, used to preserve overlay metadata. */
76
+ visibleTaskSnapshot?: Partial<Task>;
73
77
  /** True while an existing task was opened from a partial snapshot and is hydrating. */
74
78
  isHydratingTask?: boolean;
75
79
  /** True when the latest hydration request failed after the dialog already opened. */
@@ -90,6 +94,8 @@ interface OpenTaskByIdOptions {
90
94
  canUseBoardAssignees?: boolean;
91
95
  assigneeMemberSource?: TaskAssigneeMemberSource;
92
96
  initialSharedContext?: SharedTaskContext;
97
+ visibleBoardId?: string;
98
+ visibleTaskSnapshot?: Partial<Task>;
93
99
  }
94
100
 
95
101
  interface TaskDialogContextValue {
@@ -486,6 +492,8 @@ export function TaskDialogProvider({
486
492
  assigneeMemberSource: options?.assigneeMemberSource,
487
493
  taskWorkspaceTier: options?.taskWorkspaceTier,
488
494
  initialSharedContext: options?.initialSharedContext,
495
+ visibleBoardId: options?.visibleBoardId,
496
+ visibleTaskSnapshot: options?.visibleTaskSnapshot,
489
497
  isHydratingTask: true,
490
498
  taskLoadError: false,
491
499
  taskHydrationVersion: 0,
@@ -567,6 +575,8 @@ export function TaskDialogProvider({
567
575
  options?.canUseBoardAssignees ?? !isTaskWorkspacePersonal,
568
576
  assigneeMemberSource: options?.assigneeMemberSource,
569
577
  taskWorkspaceTier,
578
+ visibleBoardId: options?.visibleBoardId,
579
+ visibleTaskSnapshot: options?.visibleTaskSnapshot,
570
580
  isHydratingTask: false,
571
581
  taskLoadError: false,
572
582
  taskHydrationVersion: 1,