@tuturuuu/ui 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/package.json +79 -67
- package/src/components/ui/__tests__/avatar.test.tsx +8 -5
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
- package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
- package/src/components/ui/chart.test.tsx +29 -0
- package/src/components/ui/chart.tsx +12 -3
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
- package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
- package/src/components/ui/custom/common-footer.tsx +16 -1
- package/src/components/ui/custom/production-indicator.tsx +1 -1
- package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
- package/src/components/ui/custom/settings/task-settings.tsx +18 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
- package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
- package/src/components/ui/custom/sidebar-context.tsx +61 -61
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
- package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
- package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
- package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
- package/src/components/ui/custom/workspace-select.tsx +33 -12
- package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
- package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
- package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
- package/src/components/ui/finance/invoices/hooks.ts +75 -20
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
- package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
- package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
- package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
- package/src/components/ui/finance/invoices/utils.test.ts +50 -0
- package/src/components/ui/finance/invoices/utils.ts +75 -17
- package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
- package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
- package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
- package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
- package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
- package/src/components/ui/finance/transactions/form-types.ts +2 -0
- package/src/components/ui/finance/transactions/form.test.tsx +43 -0
- package/src/components/ui/finance/transactions/form.tsx +60 -0
- package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
- package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
- package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
- package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
- package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
- package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
- package/src/components/ui/legacy/meet/page.test.ts +180 -0
- package/src/components/ui/legacy/meet/page.tsx +87 -39
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
- package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
- package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
- package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
- package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
- package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
- package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
- package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
- package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
- package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
- package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
- package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
- package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
- package/src/hooks/use-calendar-sync.tsx +22 -277
- package/src/hooks/use-calendar.tsx +95 -525
- package/src/hooks/use-task-actions.ts +43 -117
- package/src/hooks/use-user-config.ts +1 -1
- package/src/hooks/use-workspace-config.ts +6 -2
- package/src/hooks/use-workspace-presence.ts +1 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import {
|
|
3
|
+
type CalendarSourceOption,
|
|
4
|
+
getWorkspaceCalendarDefaultSource,
|
|
5
|
+
updateWorkspaceCalendarDefaultSource,
|
|
6
|
+
} from '@tuturuuu/internal-api';
|
|
7
|
+
import { createClient } from '@tuturuuu/supabase/next/client';
|
|
8
|
+
import { useRouter } from 'next/navigation';
|
|
9
|
+
import { useTranslations } from 'next-intl';
|
|
10
|
+
import { useMemo, useState } from 'react';
|
|
11
|
+
import { useCalendarSync } from '../../../../hooks/use-calendar-sync';
|
|
12
|
+
import { toast } from '../../sonner';
|
|
13
|
+
import type {
|
|
14
|
+
AuthResponse,
|
|
15
|
+
CalendarSyncHealth,
|
|
16
|
+
ConnectedAccount,
|
|
17
|
+
ProviderCalendar,
|
|
18
|
+
} from './calendar-types';
|
|
19
|
+
|
|
20
|
+
interface AccountsResponse {
|
|
21
|
+
accounts: ConnectedAccount[];
|
|
22
|
+
grouped: {
|
|
23
|
+
google: ConnectedAccount[];
|
|
24
|
+
microsoft: ConnectedAccount[];
|
|
25
|
+
};
|
|
26
|
+
total: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface WorkspaceCalendar {
|
|
30
|
+
id: string;
|
|
31
|
+
ws_id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
description: string | null;
|
|
34
|
+
color: string | null;
|
|
35
|
+
calendar_type: 'primary' | 'tasks' | 'habits' | 'custom';
|
|
36
|
+
is_system: boolean;
|
|
37
|
+
is_enabled: boolean;
|
|
38
|
+
position: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface WorkspaceCalendarsResponse {
|
|
42
|
+
calendars: WorkspaceCalendar[];
|
|
43
|
+
grouped: {
|
|
44
|
+
system: WorkspaceCalendar[];
|
|
45
|
+
custom: WorkspaceCalendar[];
|
|
46
|
+
};
|
|
47
|
+
total: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ManualSyncResponse {
|
|
51
|
+
ok: boolean;
|
|
52
|
+
alreadyRunning?: boolean;
|
|
53
|
+
code?: string;
|
|
54
|
+
error?: string;
|
|
55
|
+
retryAfterSeconds?: number | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type CalendarConnectionsUnifiedVariant = 'compact' | 'settings';
|
|
59
|
+
|
|
60
|
+
function sourceInputFromOption(option: CalendarSourceOption) {
|
|
61
|
+
if (option.provider === 'tuturuuu') {
|
|
62
|
+
return {
|
|
63
|
+
provider: 'tuturuuu' as const,
|
|
64
|
+
workspaceCalendarId: option.workspaceCalendarId,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
provider: option.provider,
|
|
70
|
+
connectionId: option.connectionId,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useCalendarConnectionsManager(wsId: string) {
|
|
75
|
+
const t = useTranslations('calendar');
|
|
76
|
+
const router = useRouter();
|
|
77
|
+
const queryClient = useQueryClient();
|
|
78
|
+
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
|
|
79
|
+
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set());
|
|
80
|
+
const [togglingTuturuuuIds, setTogglingTuturuuuIds] = useState<Set<string>>(
|
|
81
|
+
new Set()
|
|
82
|
+
);
|
|
83
|
+
const [expandedAccounts, setExpandedAccounts] = useState<Set<string>>(
|
|
84
|
+
new Set()
|
|
85
|
+
);
|
|
86
|
+
const [expandedPopoverAccounts, setExpandedPopoverAccounts] = useState<
|
|
87
|
+
Set<string>
|
|
88
|
+
>(
|
|
89
|
+
new Set(['tuturuuu']) // Tuturuuu expanded by default
|
|
90
|
+
);
|
|
91
|
+
const [showCreateCalendarDialog, setShowCreateCalendarDialog] =
|
|
92
|
+
useState(false);
|
|
93
|
+
const [newCalendarName, setNewCalendarName] = useState('');
|
|
94
|
+
|
|
95
|
+
const {
|
|
96
|
+
calendarConnections,
|
|
97
|
+
updateCalendarConnection,
|
|
98
|
+
setCalendarConnections,
|
|
99
|
+
syncToTuturuuu,
|
|
100
|
+
isSyncing,
|
|
101
|
+
} = useCalendarSync();
|
|
102
|
+
|
|
103
|
+
const syncMutation = useMutation({
|
|
104
|
+
mutationFn: async () => {
|
|
105
|
+
const response = await fetch(`/api/v1/workspaces/${wsId}/calendar/sync`, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
108
|
+
body: JSON.stringify({ direction: 'inbound', source: 'manual' }),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const result = (await response.json()) as ManualSyncResponse &
|
|
112
|
+
Record<string, unknown>;
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
typeof result.error === 'string'
|
|
116
|
+
? result.error
|
|
117
|
+
: 'Failed to sync calendars'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return result;
|
|
122
|
+
},
|
|
123
|
+
onSuccess: async (result) => {
|
|
124
|
+
await queryClient.invalidateQueries({
|
|
125
|
+
queryKey: ['calendar-sync-status', wsId],
|
|
126
|
+
});
|
|
127
|
+
await queryClient.invalidateQueries({
|
|
128
|
+
queryKey: ['databaseCalendarEvents', wsId],
|
|
129
|
+
exact: false,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (!result.alreadyRunning) {
|
|
133
|
+
toast.success(t('calendar_sync_started') || 'Calendar sync started');
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
onError: (error: Error) => {
|
|
137
|
+
toast.error(error.message);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Fetch connected accounts
|
|
142
|
+
const { data: accountsData, isLoading: isLoadingAccounts } = useQuery({
|
|
143
|
+
queryKey: ['calendar-accounts', wsId],
|
|
144
|
+
queryFn: async () => {
|
|
145
|
+
const response = await fetch(
|
|
146
|
+
`/api/v1/calendar/auth/accounts?wsId=${wsId}`,
|
|
147
|
+
{ cache: 'no-store' }
|
|
148
|
+
);
|
|
149
|
+
if (!response.ok)
|
|
150
|
+
return {
|
|
151
|
+
accounts: [],
|
|
152
|
+
grouped: { google: [], microsoft: [] },
|
|
153
|
+
total: 0,
|
|
154
|
+
};
|
|
155
|
+
return response.json() as Promise<AccountsResponse>;
|
|
156
|
+
},
|
|
157
|
+
staleTime: 30_000,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const accounts = accountsData?.accounts || [];
|
|
161
|
+
const hasConnectedAccounts = accounts.length > 0;
|
|
162
|
+
|
|
163
|
+
const { data: syncStatusData } = useQuery({
|
|
164
|
+
queryKey: ['calendar-sync-status', wsId],
|
|
165
|
+
queryFn: async () => {
|
|
166
|
+
const response = await fetch(
|
|
167
|
+
`/api/v1/workspaces/${wsId}/calendar/sync-status`,
|
|
168
|
+
{ cache: 'no-store' }
|
|
169
|
+
);
|
|
170
|
+
if (!response.ok) return null;
|
|
171
|
+
return response.json() as Promise<{
|
|
172
|
+
health: CalendarSyncHealth;
|
|
173
|
+
accountsSummary: { total: number; google: number; microsoft: number };
|
|
174
|
+
connectionsSummary: { total: number; enabled: number };
|
|
175
|
+
}>;
|
|
176
|
+
},
|
|
177
|
+
staleTime: 15_000,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const { data: defaultSourceData } = useQuery({
|
|
181
|
+
queryKey: ['calendar-default-source', wsId],
|
|
182
|
+
queryFn: () => getWorkspaceCalendarDefaultSource(wsId),
|
|
183
|
+
staleTime: 30_000,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const defaultSourceMutation = useMutation({
|
|
187
|
+
mutationFn: async (sourceId: string) => {
|
|
188
|
+
const option = defaultSourceData?.options.find(
|
|
189
|
+
(candidate) => candidate.id === sourceId
|
|
190
|
+
);
|
|
191
|
+
if (!option) throw new Error('Calendar source is unavailable');
|
|
192
|
+
|
|
193
|
+
return updateWorkspaceCalendarDefaultSource(
|
|
194
|
+
wsId,
|
|
195
|
+
sourceInputFromOption(option)
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
onSuccess: () => {
|
|
199
|
+
queryClient.invalidateQueries({
|
|
200
|
+
queryKey: ['calendar-default-source', wsId],
|
|
201
|
+
});
|
|
202
|
+
toast.success(
|
|
203
|
+
t('default_calendar_updated') || 'Default calendar updated'
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
onError: (error: Error) => {
|
|
207
|
+
toast.error(error.message || 'Failed to update default calendar');
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Fetch current user's email
|
|
212
|
+
const { data: userEmail } = useQuery({
|
|
213
|
+
queryKey: ['current-user-email'],
|
|
214
|
+
queryFn: async () => {
|
|
215
|
+
const supabase = createClient();
|
|
216
|
+
const {
|
|
217
|
+
data: { user },
|
|
218
|
+
} = await supabase.auth.getUser();
|
|
219
|
+
return user?.email || null;
|
|
220
|
+
},
|
|
221
|
+
staleTime: Infinity, // User email rarely changes
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Fetch workspace calendars (Tuturuuu native calendars)
|
|
225
|
+
const {
|
|
226
|
+
data: workspaceCalendarsData,
|
|
227
|
+
isLoading: isLoadingWorkspaceCalendars,
|
|
228
|
+
} = useQuery({
|
|
229
|
+
queryKey: ['workspace-calendars', wsId],
|
|
230
|
+
queryFn: async () => {
|
|
231
|
+
const response = await fetch(`/api/v1/workspaces/${wsId}/calendars`, {
|
|
232
|
+
cache: 'no-store',
|
|
233
|
+
});
|
|
234
|
+
if (!response.ok)
|
|
235
|
+
return { calendars: [], grouped: { system: [], custom: [] }, total: 0 };
|
|
236
|
+
return response.json() as Promise<WorkspaceCalendarsResponse>;
|
|
237
|
+
},
|
|
238
|
+
staleTime: 30_000,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const isLoadingCalendars = isLoadingAccounts || isLoadingWorkspaceCalendars;
|
|
242
|
+
|
|
243
|
+
const workspaceCalendars = workspaceCalendarsData?.calendars || [];
|
|
244
|
+
const systemCalendars = workspaceCalendarsData?.grouped?.system || [];
|
|
245
|
+
const customCalendars = workspaceCalendarsData?.grouped?.custom || [];
|
|
246
|
+
|
|
247
|
+
// Calendar color mapping helper
|
|
248
|
+
const getCalendarColor = (color: string): string => {
|
|
249
|
+
const colorMap: Record<string, string> = {
|
|
250
|
+
BLUE: '#3b82f6',
|
|
251
|
+
RED: '#ef4444',
|
|
252
|
+
GREEN: '#22c55e',
|
|
253
|
+
YELLOW: '#eab308',
|
|
254
|
+
ORANGE: '#f97316',
|
|
255
|
+
PURPLE: '#a855f7',
|
|
256
|
+
PINK: '#ec4899',
|
|
257
|
+
CYAN: '#06b6d4',
|
|
258
|
+
GRAY: '#6b7280',
|
|
259
|
+
};
|
|
260
|
+
return colorMap[color.toUpperCase()] || color;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Calculate total enabled calendars count
|
|
264
|
+
const tuturuuuEnabledCount = workspaceCalendars.filter(
|
|
265
|
+
(c) => c.is_enabled
|
|
266
|
+
).length;
|
|
267
|
+
|
|
268
|
+
// Toggle workspace calendar visibility
|
|
269
|
+
const toggleWorkspaceCalendarMutation = useMutation({
|
|
270
|
+
mutationFn: async ({
|
|
271
|
+
id,
|
|
272
|
+
is_enabled,
|
|
273
|
+
}: {
|
|
274
|
+
id: string;
|
|
275
|
+
is_enabled: boolean;
|
|
276
|
+
}) => {
|
|
277
|
+
const response = await fetch(`/api/v1/workspaces/${wsId}/calendars`, {
|
|
278
|
+
method: 'PATCH',
|
|
279
|
+
headers: { 'Content-Type': 'application/json' },
|
|
280
|
+
body: JSON.stringify({ id, is_enabled }),
|
|
281
|
+
});
|
|
282
|
+
if (!response.ok) throw new Error('Failed to toggle calendar');
|
|
283
|
+
return response.json();
|
|
284
|
+
},
|
|
285
|
+
onMutate: ({ id }) => {
|
|
286
|
+
setTogglingTuturuuuIds((prev) => new Set(prev).add(id));
|
|
287
|
+
},
|
|
288
|
+
onSuccess: () => {
|
|
289
|
+
queryClient.invalidateQueries({
|
|
290
|
+
queryKey: ['workspace-calendars', wsId],
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
onError: () =>
|
|
294
|
+
toast.error(t('calendar_toggle_failed') || 'Failed to toggle calendar'),
|
|
295
|
+
onSettled: (_, __, { id }) => {
|
|
296
|
+
setTogglingTuturuuuIds((prev) => {
|
|
297
|
+
const next = new Set(prev);
|
|
298
|
+
next.delete(id);
|
|
299
|
+
return next;
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Create custom calendar
|
|
305
|
+
const createCalendarMutation = useMutation({
|
|
306
|
+
mutationFn: async (data: { name: string; color?: string }) => {
|
|
307
|
+
const response = await fetch(`/api/v1/workspaces/${wsId}/calendars`, {
|
|
308
|
+
method: 'POST',
|
|
309
|
+
headers: { 'Content-Type': 'application/json' },
|
|
310
|
+
body: JSON.stringify(data),
|
|
311
|
+
});
|
|
312
|
+
if (!response.ok) throw new Error('Failed to create calendar');
|
|
313
|
+
return response.json();
|
|
314
|
+
},
|
|
315
|
+
onSuccess: () => {
|
|
316
|
+
toast.success(t('calendar_created') || 'Calendar created');
|
|
317
|
+
queryClient.invalidateQueries({
|
|
318
|
+
queryKey: ['workspace-calendars', wsId],
|
|
319
|
+
});
|
|
320
|
+
},
|
|
321
|
+
onError: () =>
|
|
322
|
+
toast.error(t('calendar_creation_failed') || 'Failed to create calendar'),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Delete custom calendar
|
|
326
|
+
const deleteCalendarMutation = useMutation({
|
|
327
|
+
mutationFn: async (id: string) => {
|
|
328
|
+
const response = await fetch(
|
|
329
|
+
`/api/v1/workspaces/${wsId}/calendars?id=${id}`,
|
|
330
|
+
{
|
|
331
|
+
method: 'DELETE',
|
|
332
|
+
}
|
|
333
|
+
);
|
|
334
|
+
if (!response.ok) throw new Error('Failed to delete calendar');
|
|
335
|
+
return response.json();
|
|
336
|
+
},
|
|
337
|
+
onSuccess: () => {
|
|
338
|
+
toast.success(t('calendar_deleted') || 'Calendar deleted');
|
|
339
|
+
queryClient.invalidateQueries({
|
|
340
|
+
queryKey: ['workspace-calendars', wsId],
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
onError: () =>
|
|
344
|
+
toast.error(t('calendar_deletion_failed') || 'Failed to delete calendar'),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Reset all calendar data
|
|
348
|
+
const resetCalendarDataMutation = useMutation({
|
|
349
|
+
mutationFn: async () => {
|
|
350
|
+
const response = await fetch(
|
|
351
|
+
`/api/v1/workspaces/${wsId}/calendars/reset`,
|
|
352
|
+
{ method: 'POST' }
|
|
353
|
+
);
|
|
354
|
+
if (!response.ok) {
|
|
355
|
+
const error = await response.json();
|
|
356
|
+
throw new Error(error.error || 'Failed to reset calendar data');
|
|
357
|
+
}
|
|
358
|
+
return response.json();
|
|
359
|
+
},
|
|
360
|
+
onSuccess: () => {
|
|
361
|
+
toast.success(
|
|
362
|
+
t('calendar_data_reset') || 'Calendar data reset successfully'
|
|
363
|
+
);
|
|
364
|
+
// Clear the calendar connections immediately
|
|
365
|
+
setCalendarConnections([]);
|
|
366
|
+
// Refetch all calendar-related queries to ensure UI updates
|
|
367
|
+
queryClient.refetchQueries({
|
|
368
|
+
queryKey: ['workspace-calendars', wsId],
|
|
369
|
+
});
|
|
370
|
+
queryClient.refetchQueries({ queryKey: ['calendar-accounts', wsId] });
|
|
371
|
+
queryClient.refetchQueries({
|
|
372
|
+
queryKey: ['databaseCalendarEvents', wsId],
|
|
373
|
+
});
|
|
374
|
+
queryClient.refetchQueries({
|
|
375
|
+
queryKey: ['googleCalendarEvents', wsId],
|
|
376
|
+
});
|
|
377
|
+
queryClient.refetchQueries({
|
|
378
|
+
queryKey: ['calendar-connections'],
|
|
379
|
+
});
|
|
380
|
+
// Refresh the page to reload server data
|
|
381
|
+
router.refresh();
|
|
382
|
+
},
|
|
383
|
+
onError: (error: Error) => {
|
|
384
|
+
toast.error(
|
|
385
|
+
error.message ||
|
|
386
|
+
t('calendar_reset_failed') ||
|
|
387
|
+
'Failed to reset calendar data'
|
|
388
|
+
);
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Google auth mutation
|
|
393
|
+
const googleAuthMutation = useMutation<AuthResponse, Error, void>({
|
|
394
|
+
mutationKey: ['calendar', 'google-auth', wsId],
|
|
395
|
+
mutationFn: async () => {
|
|
396
|
+
const response = await fetch(`/api/v1/calendar/auth?wsId=${wsId}`);
|
|
397
|
+
if (!response.ok) throw new Error('Failed to get auth URL');
|
|
398
|
+
return response.json();
|
|
399
|
+
},
|
|
400
|
+
onSuccess: (data) => {
|
|
401
|
+
if (data.authUrl) window.location.href = data.authUrl;
|
|
402
|
+
else toast.error(t('auth_url_invalid'));
|
|
403
|
+
},
|
|
404
|
+
onError: () => toast.error(t('google_auth_failed')),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Microsoft auth mutation
|
|
408
|
+
const microsoftAuthMutation = useMutation<AuthResponse, Error, void>({
|
|
409
|
+
mutationKey: ['calendar', 'microsoft-auth', wsId],
|
|
410
|
+
mutationFn: async () => {
|
|
411
|
+
const response = await fetch(
|
|
412
|
+
`/api/v1/calendar/auth/microsoft?wsId=${wsId}`
|
|
413
|
+
);
|
|
414
|
+
if (!response.ok) throw new Error('Failed to get auth URL');
|
|
415
|
+
return response.json();
|
|
416
|
+
},
|
|
417
|
+
onSuccess: (data) => {
|
|
418
|
+
if (data.authUrl) window.location.href = data.authUrl;
|
|
419
|
+
else toast.error(t('auth_url_invalid'));
|
|
420
|
+
},
|
|
421
|
+
onError: () => toast.error(t('microsoft_auth_failed')),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Disconnect account mutation
|
|
425
|
+
const disconnectMutation = useMutation({
|
|
426
|
+
mutationFn: async (accountId: string) => {
|
|
427
|
+
const response = await fetch(
|
|
428
|
+
`/api/v1/calendar/auth/accounts?accountId=${accountId}&wsId=${wsId}`,
|
|
429
|
+
{ method: 'DELETE' }
|
|
430
|
+
);
|
|
431
|
+
if (!response.ok) throw new Error('Failed to disconnect');
|
|
432
|
+
return response.json();
|
|
433
|
+
},
|
|
434
|
+
onMutate: (accountId) => {
|
|
435
|
+
setDisconnectingId(accountId);
|
|
436
|
+
},
|
|
437
|
+
onSuccess: () => {
|
|
438
|
+
toast.success(t('account_disconnected'));
|
|
439
|
+
queryClient.invalidateQueries({ queryKey: ['calendar-accounts', wsId] });
|
|
440
|
+
},
|
|
441
|
+
onError: () => toast.error(t('failed_to_disconnect')),
|
|
442
|
+
onSettled: () => setDisconnectingId(null),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Toggle calendar visibility - handles both existing connections and new calendars
|
|
446
|
+
const handleToggle = async (
|
|
447
|
+
calendarId: string,
|
|
448
|
+
currentState: boolean,
|
|
449
|
+
calendarData?: {
|
|
450
|
+
calendar_id: string;
|
|
451
|
+
calendar_name: string;
|
|
452
|
+
color: string | null;
|
|
453
|
+
connectionExists: boolean;
|
|
454
|
+
accountId?: string;
|
|
455
|
+
accessRole?: string;
|
|
456
|
+
}
|
|
457
|
+
) => {
|
|
458
|
+
const newState = !currentState;
|
|
459
|
+
setTogglingIds((prev) => new Set(prev).add(calendarId));
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
// If connection doesn't exist in database and we're enabling, create it
|
|
463
|
+
if (calendarData && !calendarData.connectionExists && newState) {
|
|
464
|
+
const response = await fetch('/api/v1/calendar/connections', {
|
|
465
|
+
method: 'POST',
|
|
466
|
+
headers: { 'Content-Type': 'application/json' },
|
|
467
|
+
body: JSON.stringify({
|
|
468
|
+
wsId,
|
|
469
|
+
calendarId: calendarData.calendar_id,
|
|
470
|
+
calendarName: calendarData.calendar_name,
|
|
471
|
+
color: calendarData.color,
|
|
472
|
+
isEnabled: true,
|
|
473
|
+
authTokenId: calendarData.accountId,
|
|
474
|
+
accessRole: calendarData.accessRole,
|
|
475
|
+
}),
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if (!response.ok) {
|
|
479
|
+
// Handle 409 Conflict - calendar already exists, update it instead
|
|
480
|
+
if (response.status === 409) {
|
|
481
|
+
// Calendar already exists, make a PATCH request to enable it
|
|
482
|
+
const patchResponse = await fetch('/api/v1/calendar/connections', {
|
|
483
|
+
method: 'PATCH',
|
|
484
|
+
headers: { 'Content-Type': 'application/json' },
|
|
485
|
+
body: JSON.stringify({
|
|
486
|
+
// Use calendar_id to find the connection since we don't have the connection ID
|
|
487
|
+
calendarId: calendarData.calendar_id,
|
|
488
|
+
wsId,
|
|
489
|
+
authTokenId: calendarData.accountId,
|
|
490
|
+
isEnabled: true,
|
|
491
|
+
accessRole: calendarData.accessRole,
|
|
492
|
+
}),
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
if (patchResponse.ok) {
|
|
496
|
+
// Update local state immediately
|
|
497
|
+
updateCalendarConnection(calendarId, true);
|
|
498
|
+
queryClient.invalidateQueries({
|
|
499
|
+
queryKey: ['provider-calendar-list', wsId],
|
|
500
|
+
});
|
|
501
|
+
queryClient.invalidateQueries({
|
|
502
|
+
queryKey: ['calendar-connections', wsId],
|
|
503
|
+
});
|
|
504
|
+
toast.success(t('calendar_enabled'));
|
|
505
|
+
} else {
|
|
506
|
+
toast.error(t('toggle_failed'));
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
toast.error(t('toggle_failed'));
|
|
510
|
+
}
|
|
511
|
+
} else {
|
|
512
|
+
// Update local state immediately
|
|
513
|
+
updateCalendarConnection(calendarId, true);
|
|
514
|
+
// Invalidate queries to refresh the list
|
|
515
|
+
queryClient.invalidateQueries({
|
|
516
|
+
queryKey: ['provider-calendar-list', wsId],
|
|
517
|
+
});
|
|
518
|
+
queryClient.invalidateQueries({
|
|
519
|
+
queryKey: ['calendar-connections', wsId],
|
|
520
|
+
});
|
|
521
|
+
toast.success(t('calendar_enabled'));
|
|
522
|
+
}
|
|
523
|
+
} else {
|
|
524
|
+
// Update existing connection
|
|
525
|
+
updateCalendarConnection(calendarId, newState);
|
|
526
|
+
|
|
527
|
+
const response = await fetch('/api/v1/calendar/connections', {
|
|
528
|
+
method: 'PATCH',
|
|
529
|
+
headers: { 'Content-Type': 'application/json' },
|
|
530
|
+
body: JSON.stringify({ id: calendarId, isEnabled: newState }),
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
if (!response.ok) {
|
|
534
|
+
updateCalendarConnection(calendarId, currentState);
|
|
535
|
+
toast.error(t('toggle_failed'));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
} catch {
|
|
539
|
+
if (calendarData?.connectionExists) {
|
|
540
|
+
updateCalendarConnection(calendarId, currentState);
|
|
541
|
+
}
|
|
542
|
+
toast.error(t('toggle_failed'));
|
|
543
|
+
} finally {
|
|
544
|
+
setTogglingIds((prev) => {
|
|
545
|
+
const next = new Set(prev);
|
|
546
|
+
next.delete(calendarId);
|
|
547
|
+
return next;
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const toggleAccountExpanded = (accountId: string) => {
|
|
553
|
+
setExpandedAccounts((prev) => {
|
|
554
|
+
const next = new Set(prev);
|
|
555
|
+
if (next.has(accountId)) next.delete(accountId);
|
|
556
|
+
else next.add(accountId);
|
|
557
|
+
return next;
|
|
558
|
+
});
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const enabledCount =
|
|
562
|
+
calendarConnections.filter((c) => c.is_enabled).length +
|
|
563
|
+
tuturuuuEnabledCount;
|
|
564
|
+
const syncHealth = syncStatusData?.health;
|
|
565
|
+
const manualSyncDisabled = useMemo(() => {
|
|
566
|
+
if (syncMutation.isPending || isSyncing) {
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (syncHealth?.currentlyRunning) {
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if ((syncHealth?.retryAfterSeconds ?? 0) > 0) {
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return false;
|
|
579
|
+
}, [
|
|
580
|
+
isSyncing,
|
|
581
|
+
syncHealth?.currentlyRunning,
|
|
582
|
+
syncHealth?.retryAfterSeconds,
|
|
583
|
+
syncMutation.isPending,
|
|
584
|
+
]);
|
|
585
|
+
const syncStatusStyles =
|
|
586
|
+
syncHealth?.state === 'syncing'
|
|
587
|
+
? 'bg-dynamic-blue/10 text-dynamic-blue'
|
|
588
|
+
: syncHealth?.state === 'healthy'
|
|
589
|
+
? 'bg-dynamic-green/10 text-dynamic-green'
|
|
590
|
+
: syncHealth?.state === 'disconnected'
|
|
591
|
+
? 'bg-muted text-muted-foreground'
|
|
592
|
+
: syncHealth?.state === 'degraded'
|
|
593
|
+
? 'bg-dynamic-orange/10 text-dynamic-orange'
|
|
594
|
+
: 'bg-dynamic-red/10 text-dynamic-red';
|
|
595
|
+
|
|
596
|
+
// Fetch Google calendars from API (must be before any conditional returns)
|
|
597
|
+
const { data: googleCalendarsData } = useQuery({
|
|
598
|
+
queryKey: ['provider-calendar-list', wsId],
|
|
599
|
+
enabled: hasConnectedAccounts,
|
|
600
|
+
queryFn: async () => {
|
|
601
|
+
const response = await fetch(
|
|
602
|
+
`/api/v1/calendar/auth/provider-calendars?wsId=${wsId}`,
|
|
603
|
+
{ cache: 'no-store' }
|
|
604
|
+
);
|
|
605
|
+
if (!response.ok) return { calendars: [], byAccount: {} };
|
|
606
|
+
return response.json() as Promise<{
|
|
607
|
+
calendars: ProviderCalendar[];
|
|
608
|
+
byAccount: Record<string, ProviderCalendar[]>;
|
|
609
|
+
}>;
|
|
610
|
+
},
|
|
611
|
+
staleTime: 30_000,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const calendarsByAccountFromAPI = googleCalendarsData?.byAccount || {};
|
|
615
|
+
|
|
616
|
+
// Merge API calendars with existing connections for visibility state
|
|
617
|
+
const calendarsByAccount = accounts.reduce(
|
|
618
|
+
(acc, account) => {
|
|
619
|
+
const apiCalendars = calendarsByAccountFromAPI[account.id] || [];
|
|
620
|
+
|
|
621
|
+
// Map API calendars to connection-like objects, merging with existing connections
|
|
622
|
+
acc[account.id] = apiCalendars.map((apiCal) => {
|
|
623
|
+
// Find existing connection for this calendar
|
|
624
|
+
const existingConnection = calendarConnections.find(
|
|
625
|
+
(conn) =>
|
|
626
|
+
conn.calendar_id === apiCal.id && conn.auth_token_id === account.id
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
id: existingConnection?.id || apiCal.id,
|
|
631
|
+
ws_id: wsId,
|
|
632
|
+
calendar_id: apiCal.id,
|
|
633
|
+
calendar_name: apiCal.name,
|
|
634
|
+
is_enabled: existingConnection?.is_enabled ?? false, // Default to hidden if no connection exists
|
|
635
|
+
color: existingConnection?.color || apiCal.backgroundColor,
|
|
636
|
+
isFromAPI: true,
|
|
637
|
+
connectionExists: !!existingConnection,
|
|
638
|
+
accountId: account.id,
|
|
639
|
+
accessRole: apiCal.accessRole,
|
|
640
|
+
};
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
return acc;
|
|
644
|
+
},
|
|
645
|
+
{} as Record<
|
|
646
|
+
string,
|
|
647
|
+
Array<{
|
|
648
|
+
id: string;
|
|
649
|
+
ws_id: string;
|
|
650
|
+
calendar_id: string;
|
|
651
|
+
calendar_name: string;
|
|
652
|
+
is_enabled: boolean;
|
|
653
|
+
color: string | null;
|
|
654
|
+
isFromAPI: boolean;
|
|
655
|
+
connectionExists: boolean;
|
|
656
|
+
accountId: string;
|
|
657
|
+
accessRole: string;
|
|
658
|
+
}>
|
|
659
|
+
>
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
// Note: Removed early return - we always show the full UI now so Tuturuuu calendars are visible
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
accounts,
|
|
666
|
+
calendarConnections,
|
|
667
|
+
calendarsByAccount,
|
|
668
|
+
createCalendarMutation,
|
|
669
|
+
customCalendars,
|
|
670
|
+
defaultSourceData,
|
|
671
|
+
defaultSourceMutation,
|
|
672
|
+
deleteCalendarMutation,
|
|
673
|
+
disconnectingId,
|
|
674
|
+
disconnectMutation,
|
|
675
|
+
enabledCount,
|
|
676
|
+
expandedAccounts,
|
|
677
|
+
expandedPopoverAccounts,
|
|
678
|
+
getCalendarColor,
|
|
679
|
+
googleAuthMutation,
|
|
680
|
+
handleToggle,
|
|
681
|
+
hasConnectedAccounts,
|
|
682
|
+
isLoadingCalendars,
|
|
683
|
+
isSyncing,
|
|
684
|
+
manualSyncDisabled,
|
|
685
|
+
microsoftAuthMutation,
|
|
686
|
+
newCalendarName,
|
|
687
|
+
resetCalendarDataMutation,
|
|
688
|
+
setExpandedPopoverAccounts,
|
|
689
|
+
setNewCalendarName,
|
|
690
|
+
setShowCreateCalendarDialog,
|
|
691
|
+
showCreateCalendarDialog,
|
|
692
|
+
syncHealth,
|
|
693
|
+
syncMutation,
|
|
694
|
+
syncStatusStyles,
|
|
695
|
+
syncToTuturuuu,
|
|
696
|
+
systemCalendars,
|
|
697
|
+
t,
|
|
698
|
+
togglingIds,
|
|
699
|
+
togglingTuturuuuIds,
|
|
700
|
+
toggleAccountExpanded,
|
|
701
|
+
toggleWorkspaceCalendarMutation,
|
|
702
|
+
tuturuuuEnabledCount,
|
|
703
|
+
userEmail,
|
|
704
|
+
workspaceCalendars,
|
|
705
|
+
wsId,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export type CalendarConnectionsManagerState = ReturnType<
|
|
710
|
+
typeof useCalendarConnectionsManager
|
|
711
|
+
>;
|