@tuturuuu/ui 0.13.0 → 0.16.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 (96) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -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/task-settings.tsx +55 -0
  12. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  13. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  14. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  15. package/src/components/ui/custom/statistic-card.tsx +104 -0
  16. package/src/components/ui/custom/structure.test.tsx +29 -0
  17. package/src/components/ui/custom/structure.tsx +103 -88
  18. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  20. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  23. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  24. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  25. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  26. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  27. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  28. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  29. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  30. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  31. package/src/components/ui/finance/invoices/utils.ts +56 -134
  32. package/src/components/ui/resizable.test.ts +15 -0
  33. package/src/components/ui/resizable.tsx +28 -6
  34. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  36. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
  37. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
  38. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
  40. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
  42. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  43. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  44. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  45. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  46. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  47. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  48. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  49. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  50. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  51. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  52. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  53. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  54. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  55. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  56. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  57. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  58. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  59. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  60. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  61. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  62. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  63. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  64. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  65. package/src/components/ui/tu-do/projects/types.ts +4 -1
  66. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  67. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  68. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  69. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
  70. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  71. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  72. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  73. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  74. package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
  75. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  76. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  83. package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
  84. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  85. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  86. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  87. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  88. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  89. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  90. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  91. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  92. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  93. package/src/hooks/task-actions-personal-external.ts +12 -6
  94. package/src/hooks/use-notifications.ts +14 -2
  95. package/src/hooks/use-user-config.ts +10 -2
  96. package/vitest.config.ts +4 -0
@@ -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,
@@ -209,10 +209,16 @@ describe('BoardClient', () => {
209
209
  );
210
210
 
211
211
  expect(screen.getByTestId('task-board-loading-state')).toHaveClass(
212
- '-m-4',
212
+ '-mt-4',
213
+ '-mb-4',
214
+ '-ml-4',
213
215
  'h-[calc(100dvh+2rem)]',
214
216
  'w-[calc(100%+2rem)]'
215
217
  );
218
+ expect(screen.getByTestId('task-board-loading-state')).not.toHaveClass(
219
+ '-m-4',
220
+ '-mr-4'
221
+ );
216
222
  expect(
217
223
  screen.getByTestId('task-board-header-skeleton')
218
224
  ).toBeInTheDocument();
@@ -1,6 +1,7 @@
1
1
  import '@testing-library/jest-dom';
2
2
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
3
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { SidebarContext } from '@tuturuuu/ui/custom/sidebar-context';
4
5
  import type React from 'react';
5
6
  import { beforeEach, describe, expect, it, vi } from 'vitest';
6
7
  import { BoardHeader } from '../board-header';
@@ -180,7 +181,8 @@ const mockBoard = {
180
181
  } as const;
181
182
 
182
183
  function renderBoardHeader(
183
- overrides?: Partial<React.ComponentProps<typeof BoardHeader>>
184
+ overrides?: Partial<React.ComponentProps<typeof BoardHeader>>,
185
+ sidebarContext?: React.ContextType<typeof SidebarContext>
184
186
  ) {
185
187
  const queryClient = new QueryClient({
186
188
  defaultOptions: {
@@ -190,7 +192,7 @@ function renderBoardHeader(
190
192
  },
191
193
  });
192
194
 
193
- return render(
195
+ const content = (
194
196
  <QueryClientProvider client={queryClient}>
195
197
  <BoardHeader
196
198
  workspaceId="ws-1"
@@ -221,6 +223,16 @@ function renderBoardHeader(
221
223
  />
222
224
  </QueryClientProvider>
223
225
  );
226
+
227
+ return render(
228
+ sidebarContext ? (
229
+ <SidebarContext.Provider value={sidebarContext}>
230
+ {content}
231
+ </SidebarContext.Provider>
232
+ ) : (
233
+ content
234
+ )
235
+ );
224
236
  }
225
237
 
226
238
  describe('BoardHeader', () => {
@@ -247,6 +259,79 @@ describe('BoardHeader', () => {
247
259
  );
248
260
  });
249
261
 
262
+ it('renders a compact hide-sidebar button when sidebar context is available', () => {
263
+ const handleBehaviorChange = vi.fn();
264
+
265
+ renderBoardHeader(undefined, {
266
+ behavior: 'expanded',
267
+ handleBehaviorChange,
268
+ localOverride: false,
269
+ setBehavior: vi.fn(),
270
+ setLocalOverride: vi.fn(),
271
+ });
272
+
273
+ const hideButton = screen.getByRole('button', {
274
+ name: 'common.hide_sidebar',
275
+ });
276
+
277
+ expect(hideButton).toHaveClass('border', 'h-7', 'w-7', 'px-0');
278
+ fireEvent.click(hideButton);
279
+ expect(handleBehaviorChange).toHaveBeenCalledWith('hidden');
280
+ });
281
+
282
+ it('does not render the hide-sidebar button without context or when already hidden', () => {
283
+ const { rerender } = renderBoardHeader();
284
+
285
+ expect(
286
+ screen.queryByRole('button', { name: 'common.hide_sidebar' })
287
+ ).not.toBeInTheDocument();
288
+
289
+ rerender(
290
+ <SidebarContext.Provider
291
+ value={{
292
+ behavior: 'hidden',
293
+ handleBehaviorChange: vi.fn(),
294
+ localOverride: false,
295
+ setBehavior: vi.fn(),
296
+ setLocalOverride: vi.fn(),
297
+ }}
298
+ >
299
+ <QueryClientProvider client={new QueryClient()}>
300
+ <BoardHeader
301
+ workspaceId="ws-1"
302
+ board={mockBoard}
303
+ currentView="kanban"
304
+ filters={{
305
+ assignees: [],
306
+ dueDateRange: null,
307
+ estimationRange: null,
308
+ includeMyTasks: false,
309
+ includeUnassigned: false,
310
+ labels: [],
311
+ priorities: [],
312
+ projects: [],
313
+ sourceBoardIds: [],
314
+ sourceScope: 'all_visible',
315
+ sourceWorkspaceIds: [],
316
+ }}
317
+ isMultiSelectMode={false}
318
+ isPersonalWorkspace={false}
319
+ listStatusFilter="all"
320
+ onFiltersChange={vi.fn()}
321
+ onListStatusFilterChange={vi.fn()}
322
+ onUpdate={vi.fn()}
323
+ onViewChange={vi.fn()}
324
+ setIsMultiSelectMode={vi.fn()}
325
+ />
326
+ </QueryClientProvider>
327
+ </SidebarContext.Provider>
328
+ );
329
+
330
+ expect(
331
+ screen.queryByRole('button', { name: 'common.hide_sidebar' })
332
+ ).not.toBeInTheDocument();
333
+ });
334
+
250
335
  it('prefetches current board settings before opening the dialog', async () => {
251
336
  renderBoardHeader();
252
337