@tuturuuu/ui 0.14.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.
- package/CHANGELOG.md +78 -0
- package/biome.json +1 -1
- package/package.json +62 -62
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
- package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
- package/src/components/ui/custom/notification-popover-client.tsx +5 -1
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
- package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
- package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
- package/src/components/ui/custom/sidebar-context.tsx +58 -3
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
- package/src/components/ui/custom/statistic-card.tsx +104 -0
- package/src/components/ui/custom/structure.test.tsx +29 -0
- package/src/components/ui/custom/structure.tsx +103 -88
- package/src/components/ui/finance/invoices/civil-month.ts +224 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
- package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
- package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
- package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
- package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
- package/src/components/ui/finance/invoices/utils.test.ts +84 -0
- package/src/components/ui/finance/invoices/utils.ts +56 -134
- package/src/components/ui/resizable.test.ts +15 -0
- package/src/components/ui/resizable.tsx +28 -6
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +32 -2
- package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
- package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
- package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
- package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
- package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
- package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
- package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
- package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
- package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
- package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
- package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
- package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
- package/src/components/ui/tu-do/projects/types.ts +4 -1
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
- package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
- package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
- package/src/components/ui/tu-do/templates/client.tsx +97 -78
- package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
- package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
- package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
- package/src/hooks/task-actions-personal-external.ts +12 -6
- package/src/hooks/use-notifications.ts +14 -2
- package/vitest.config.ts +4 -0
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
mockT,
|
|
16
16
|
mockToastError,
|
|
17
17
|
mockUpdateWorkspaceTask,
|
|
18
|
+
mockUpsertCurrentUserTaskPersonalPlacement,
|
|
18
19
|
} = vi.hoisted(() => ({
|
|
19
20
|
mockAddWorkspaceTaskLabel: vi.fn(),
|
|
20
21
|
mockDeleteWorkspaceTask: vi.fn(),
|
|
@@ -26,6 +27,7 @@ const {
|
|
|
26
27
|
mockT: vi.fn((key: string) => key),
|
|
27
28
|
mockToastError: vi.fn(),
|
|
28
29
|
mockUpdateWorkspaceTask: vi.fn(),
|
|
30
|
+
mockUpsertCurrentUserTaskPersonalPlacement: vi.fn(),
|
|
29
31
|
}));
|
|
30
32
|
|
|
31
33
|
vi.mock('@tanstack/react-query', () => ({
|
|
@@ -45,6 +47,8 @@ vi.mock('@tuturuuu/internal-api', () => ({
|
|
|
45
47
|
listWorkspaceTaskLists: mockListWorkspaceTaskLists,
|
|
46
48
|
removeWorkspaceTaskLabel: mockRemoveWorkspaceTaskLabel,
|
|
47
49
|
updateWorkspaceTask: mockUpdateWorkspaceTask,
|
|
50
|
+
upsertCurrentUserTaskPersonalPlacement:
|
|
51
|
+
mockUpsertCurrentUserTaskPersonalPlacement,
|
|
48
52
|
}));
|
|
49
53
|
|
|
50
54
|
vi.mock('next-intl', () => ({
|
|
@@ -106,6 +110,9 @@ describe('useTaskContextActions', () => {
|
|
|
106
110
|
});
|
|
107
111
|
mockRemoveWorkspaceTaskLabel.mockResolvedValue({ success: true });
|
|
108
112
|
mockUpdateWorkspaceTask.mockResolvedValue({ task: null });
|
|
113
|
+
mockUpsertCurrentUserTaskPersonalPlacement.mockResolvedValue({
|
|
114
|
+
task: { id: 'task-123' },
|
|
115
|
+
});
|
|
109
116
|
});
|
|
110
117
|
|
|
111
118
|
it('handleDoneWithMyPart sends PUT with personally_unassigned=true', async () => {
|
|
@@ -230,6 +237,53 @@ describe('useTaskContextActions', () => {
|
|
|
230
237
|
);
|
|
231
238
|
});
|
|
232
239
|
|
|
240
|
+
it('handleComplete uses terminal personal placement for personal external tasks', async () => {
|
|
241
|
+
const taskWithPersonalPlacement = {
|
|
242
|
+
...mockTask,
|
|
243
|
+
overrides: {
|
|
244
|
+
task_id: 'task-123',
|
|
245
|
+
user_id: 'user-1',
|
|
246
|
+
self_managed: false,
|
|
247
|
+
completed_at: null,
|
|
248
|
+
priority_override: null,
|
|
249
|
+
due_date_override: null,
|
|
250
|
+
estimation_override: null,
|
|
251
|
+
personally_unassigned: false,
|
|
252
|
+
notes: null,
|
|
253
|
+
personal_board_id: 'personal-board',
|
|
254
|
+
personal_list_id: 'personal-list',
|
|
255
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
256
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
257
|
+
},
|
|
258
|
+
} as TaskWithRelations;
|
|
259
|
+
|
|
260
|
+
const { result } = renderHook(() =>
|
|
261
|
+
useTaskContextActions({
|
|
262
|
+
task: taskWithPersonalPlacement,
|
|
263
|
+
userId,
|
|
264
|
+
onTaskUpdate,
|
|
265
|
+
onClose,
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await act(async () => {
|
|
270
|
+
await result.current.handleComplete();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(mockListWorkspaceTaskLists).not.toHaveBeenCalled();
|
|
274
|
+
expect(mockUpdateWorkspaceTask).not.toHaveBeenCalled();
|
|
275
|
+
expect(mockUpsertCurrentUserTaskPersonalPlacement).toHaveBeenCalledWith(
|
|
276
|
+
'task-123',
|
|
277
|
+
{
|
|
278
|
+
personal_board_id: 'personal-board',
|
|
279
|
+
personal_list_id: 'personal-list',
|
|
280
|
+
terminal_status: 'done',
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
expect(onTaskUpdate).toHaveBeenCalled();
|
|
284
|
+
expect(onClose).toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
|
|
233
287
|
it('handleComplete clears overrides when task has personally_unassigned', async () => {
|
|
234
288
|
const taskWithPersonallyUnassigned = {
|
|
235
289
|
...mockTask,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
listWorkspaceTaskLists,
|
|
16
16
|
updateWorkspaceTask,
|
|
17
|
+
upsertCurrentUserTaskPersonalPlacement,
|
|
17
18
|
} from '@tuturuuu/internal-api';
|
|
18
19
|
import type { TaskWithRelations } from '@tuturuuu/types';
|
|
19
20
|
import type { Task as PrimitiveTask } from '@tuturuuu/types/primitives/Task';
|
|
@@ -200,8 +201,50 @@ export default function TaskListWithCompletion({
|
|
|
200
201
|
|
|
201
202
|
setCompletingTasks((prev) => new Set(prev).add(task.id));
|
|
202
203
|
const snapshot = snapshotCache();
|
|
204
|
+
const isCompleted = task.list?.status === 'done';
|
|
205
|
+
const personalPlacement = task.overrides as
|
|
206
|
+
| (NonNullable<typeof task.overrides> & {
|
|
207
|
+
personal_board_id?: string | null;
|
|
208
|
+
personal_list_id?: string | null;
|
|
209
|
+
})
|
|
210
|
+
| null;
|
|
211
|
+
const personalBoardId = personalPlacement?.personal_board_id ?? null;
|
|
203
212
|
|
|
204
213
|
try {
|
|
214
|
+
if (personalBoardId && !isCompleted) {
|
|
215
|
+
removeTaskFromCache(task.id);
|
|
216
|
+
|
|
217
|
+
await upsertCurrentUserTaskPersonalPlacement(task.id, {
|
|
218
|
+
personal_board_id: personalBoardId,
|
|
219
|
+
personal_list_id: personalPlacement?.personal_list_id ?? null,
|
|
220
|
+
terminal_status: 'done',
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
task.overrides?.completed_at ||
|
|
225
|
+
task.overrides?.personally_unassigned
|
|
226
|
+
) {
|
|
227
|
+
await fetch(
|
|
228
|
+
getTaskApiUrl(`/api/v1/users/me/tasks/${task.id}/overrides`),
|
|
229
|
+
{
|
|
230
|
+
method: 'PUT',
|
|
231
|
+
credentials: 'include',
|
|
232
|
+
headers: { 'Content-Type': 'application/json' },
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
completed_at: null,
|
|
235
|
+
personally_unassigned: false,
|
|
236
|
+
}),
|
|
237
|
+
}
|
|
238
|
+
).catch(() => {
|
|
239
|
+
// Non-critical cleanup — don't block the completion flow
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
toast.success(t('task_completed_toast'));
|
|
244
|
+
onTaskUpdate?.();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
205
248
|
// Find the board's done list
|
|
206
249
|
const { lists } = await listWorkspaceTaskLists(
|
|
207
250
|
task.list.board.ws_id,
|
|
@@ -221,7 +264,6 @@ export default function TaskListWithCompletion({
|
|
|
221
264
|
return;
|
|
222
265
|
}
|
|
223
266
|
|
|
224
|
-
const isCompleted = task.list?.status === 'done';
|
|
225
267
|
const targetListId = isCompleted ? notStartedList.id : doneList.id;
|
|
226
268
|
|
|
227
269
|
// Optimistic: remove from view (completion hides task)
|
|
@@ -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
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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-
|
|
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
|
|
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,
|
|
4
|
+
import { BarChart3, Flag, Sparkles, Target, Trophy } from '@tuturuuu/icons';
|
|
5
5
|
import {
|
|
6
6
|
createTaskLeaderboard,
|
|
7
7
|
createTaskLeaderboardTeam,
|
|
@@ -40,6 +40,7 @@ export type TaskProgressView =
|
|
|
40
40
|
| 'import';
|
|
41
41
|
|
|
42
42
|
interface TaskProgressPageProps {
|
|
43
|
+
onViewChange?: (view: TaskProgressView) => void;
|
|
43
44
|
routeWsId: string;
|
|
44
45
|
view: TaskProgressView;
|
|
45
46
|
wsId: string;
|
|
@@ -87,6 +88,7 @@ function parseImportRows(text: string, metrics: TaskProgressMetric[]) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
export function TaskProgressPage({
|
|
91
|
+
onViewChange,
|
|
90
92
|
routeWsId,
|
|
91
93
|
view,
|
|
92
94
|
wsId,
|
|
@@ -95,6 +97,7 @@ export function TaskProgressPage({
|
|
|
95
97
|
const queryClient = useQueryClient();
|
|
96
98
|
const [importText, setImportText] = useState('');
|
|
97
99
|
const [importPreviewCount, setImportPreviewCount] = useState(0);
|
|
100
|
+
const [selectedMetricId, setSelectedMetricId] = useState<string | null>(null);
|
|
98
101
|
const queryRoot = ['task-progress', wsId];
|
|
99
102
|
|
|
100
103
|
const metricsQuery = useQuery({
|
|
@@ -103,7 +106,12 @@ export function TaskProgressPage({
|
|
|
103
106
|
});
|
|
104
107
|
const metrics =
|
|
105
108
|
metricsQuery.data?.ok === true ? metricsQuery.data.metrics : [];
|
|
106
|
-
const selectedMetric = useMemo(
|
|
109
|
+
const selectedMetric = useMemo(
|
|
110
|
+
() =>
|
|
111
|
+
metrics.find((metric) => metric.id === selectedMetricId) ??
|
|
112
|
+
metricOption(metrics),
|
|
113
|
+
[metrics, selectedMetricId]
|
|
114
|
+
);
|
|
107
115
|
|
|
108
116
|
const entriesQuery = useQuery({
|
|
109
117
|
queryKey: [...queryRoot, 'entries', selectedMetric?.id],
|
|
@@ -238,40 +246,78 @@ export function TaskProgressPage({
|
|
|
238
246
|
: [];
|
|
239
247
|
|
|
240
248
|
return (
|
|
241
|
-
<div className="flex flex-col gap-6 p-4 md:p-6">
|
|
242
|
-
<
|
|
243
|
-
<div className="
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
249
|
+
<div className="mx-auto flex w-full max-w-[1600px] flex-col gap-6 p-4 md:p-6 lg:p-8">
|
|
250
|
+
<section className="relative overflow-hidden rounded-3xl border bg-gradient-to-br from-background via-background to-dynamic-blue/10 p-5 shadow-sm md:p-7">
|
|
251
|
+
<div className="pointer-events-none absolute -top-24 -right-20 h-64 w-64 rounded-full bg-dynamic-blue/10 blur-3xl" />
|
|
252
|
+
<div className="relative flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
|
253
|
+
<div className="max-w-2xl space-y-3">
|
|
254
|
+
<div className="flex items-center gap-2 font-semibold text-dynamic-blue text-xs uppercase tracking-[0.18em]">
|
|
255
|
+
<Sparkles className="h-4 w-4" />
|
|
256
|
+
{t('views.progress.title')}
|
|
247
257
|
</div>
|
|
248
258
|
<div>
|
|
249
|
-
<h1 className="font-bold text-
|
|
259
|
+
<h1 className="font-bold text-3xl tracking-tight md:text-4xl">
|
|
250
260
|
{t(`views.${view}.title`)}
|
|
251
261
|
</h1>
|
|
252
|
-
<p className="text-muted-foreground
|
|
262
|
+
<p className="mt-2 text-muted-foreground">
|
|
253
263
|
{t(`views.${view}.description`)}
|
|
254
264
|
</p>
|
|
255
265
|
</div>
|
|
266
|
+
{metrics.length > 0 ? (
|
|
267
|
+
<label className="inline-flex items-center gap-3 rounded-xl border bg-background/80 px-3 py-2 text-sm shadow-sm backdrop-blur">
|
|
268
|
+
<span className="text-muted-foreground">
|
|
269
|
+
{t('fields.metric_name')}
|
|
270
|
+
</span>
|
|
271
|
+
<select
|
|
272
|
+
className="min-w-32 bg-transparent 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>
|
|
285
|
+
<nav className="flex max-w-full gap-1 overflow-x-auto rounded-2xl border bg-background/80 p-1.5 shadow-sm backdrop-blur">
|
|
286
|
+
{(
|
|
287
|
+
['progress', 'goals', 'stats', 'leaderboards', 'import'] as const
|
|
288
|
+
).map((tab) =>
|
|
289
|
+
onViewChange ? (
|
|
290
|
+
<Button
|
|
291
|
+
key={tab}
|
|
292
|
+
onClick={() => onViewChange(tab)}
|
|
293
|
+
size="sm"
|
|
294
|
+
type="button"
|
|
295
|
+
variant={tab === view ? 'default' : 'ghost'}
|
|
296
|
+
>
|
|
297
|
+
{t(`tabs.${tab}`)}
|
|
298
|
+
</Button>
|
|
299
|
+
) : (
|
|
300
|
+
<Button
|
|
301
|
+
key={tab}
|
|
302
|
+
asChild
|
|
303
|
+
size="sm"
|
|
304
|
+
variant={tab === view ? 'default' : 'ghost'}
|
|
305
|
+
>
|
|
306
|
+
<Link
|
|
307
|
+
href={
|
|
308
|
+
tab === 'progress'
|
|
309
|
+
? `/${routeWsId}/progress`
|
|
310
|
+
: `/${routeWsId}/progress/${tab}`
|
|
311
|
+
}
|
|
312
|
+
>
|
|
313
|
+
{t(`tabs.${tab}`)}
|
|
314
|
+
</Link>
|
|
315
|
+
</Button>
|
|
316
|
+
)
|
|
317
|
+
)}
|
|
318
|
+
</nav>
|
|
257
319
|
</div>
|
|
258
|
-
|
|
259
|
-
{(
|
|
260
|
-
['progress', 'goals', 'stats', 'leaderboards', 'import'] as const
|
|
261
|
-
).map((tab) => (
|
|
262
|
-
<Button
|
|
263
|
-
key={tab}
|
|
264
|
-
asChild
|
|
265
|
-
size="sm"
|
|
266
|
-
variant={tab === view ? 'default' : 'outline'}
|
|
267
|
-
>
|
|
268
|
-
<Link href={`/${routeWsId}/tasks/${tab}`}>
|
|
269
|
-
{t(`tabs.${tab}`)}
|
|
270
|
-
</Link>
|
|
271
|
-
</Button>
|
|
272
|
-
))}
|
|
273
|
-
</div>
|
|
274
|
-
</div>
|
|
320
|
+
</section>
|
|
275
321
|
|
|
276
322
|
{hasPendingSchema ? (
|
|
277
323
|
<Card>
|
|
@@ -281,7 +327,7 @@ export function TaskProgressPage({
|
|
|
281
327
|
</Card>
|
|
282
328
|
) : null}
|
|
283
329
|
|
|
284
|
-
<div className="grid gap-
|
|
330
|
+
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
285
331
|
<SummaryCard
|
|
286
332
|
icon={<BarChart3 className="h-4 w-4" />}
|
|
287
333
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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
|
|
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-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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>
|
package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
|
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
|
/>
|