@tuturuuu/ui 0.2.0 → 0.3.2

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 (129) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/package.json +79 -67
  3. package/src/components/ui/__tests__/avatar.test.tsx +8 -5
  4. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
  8. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
  9. package/src/components/ui/chart.test.tsx +29 -0
  10. package/src/components/ui/chart.tsx +12 -3
  11. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +396 -2
  12. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +36 -8
  13. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +14 -0
  14. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +5 -0
  15. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +21 -7
  16. package/src/components/ui/chat/chat-agent-details-utils.test.ts +73 -0
  17. package/src/components/ui/chat/chat-agent-details-utils.tsx +100 -26
  18. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +517 -0
  19. package/src/components/ui/chat/chat-workspace.tsx +31 -1
  20. package/src/components/ui/chat/hooks-messages.test.tsx +45 -1
  21. package/src/components/ui/chat/hooks-messages.ts +1 -1
  22. package/src/components/ui/chat/hooks-realtime.ts +13 -16
  23. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
  24. package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
  25. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
  26. package/src/components/ui/custom/common-footer.tsx +16 -1
  27. package/src/components/ui/custom/production-indicator.tsx +1 -1
  28. package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
  29. package/src/components/ui/custom/settings/task-settings.tsx +18 -0
  30. package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
  31. package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
  32. package/src/components/ui/custom/sidebar-context.tsx +61 -61
  33. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
  34. package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
  35. package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
  36. package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
  37. package/src/components/ui/custom/workspace-select.tsx +33 -12
  38. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
  39. package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
  40. package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
  41. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
  42. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
  43. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
  44. package/src/components/ui/finance/invoices/hooks.ts +75 -20
  45. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
  46. package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
  47. package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
  48. package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
  49. package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
  50. package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
  51. package/src/components/ui/finance/invoices/utils.test.ts +50 -0
  52. package/src/components/ui/finance/invoices/utils.ts +75 -17
  53. package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
  54. package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
  55. package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
  56. package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
  57. package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
  58. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  59. package/src/components/ui/finance/transactions/form.test.tsx +43 -0
  60. package/src/components/ui/finance/transactions/form.tsx +60 -0
  61. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
  62. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
  63. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
  64. package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
  65. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  66. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
  67. package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
  68. package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
  69. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
  70. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
  71. package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
  72. package/src/components/ui/legacy/meet/page.test.ts +180 -0
  73. package/src/components/ui/legacy/meet/page.tsx +87 -39
  74. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
  75. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
  76. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
  77. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
  78. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
  79. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
  80. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
  81. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
  82. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
  83. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
  84. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
  85. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
  86. package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
  87. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
  88. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
  89. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
  90. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
  91. package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
  92. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
  93. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
  94. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
  95. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
  96. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
  97. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
  98. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
  99. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
  100. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
  101. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
  102. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
  103. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
  104. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
  105. package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
  106. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
  107. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
  108. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
  109. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
  110. package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
  111. package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
  112. package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
  113. package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
  114. package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
  115. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
  116. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
  117. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
  118. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
  119. package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
  120. package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
  121. package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
  122. package/src/hooks/use-calendar-sync.tsx +22 -277
  123. package/src/hooks/use-calendar.tsx +95 -525
  124. package/src/hooks/use-semantic-task-search.ts +10 -33
  125. package/src/hooks/use-task-actions.ts +43 -117
  126. package/src/hooks/use-user-config.ts +1 -1
  127. package/src/hooks/use-workspace-config.ts +6 -2
  128. package/src/hooks/use-workspace-presence.ts +1 -1
  129. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
@@ -1,1433 +1,30 @@
1
1
  'use client';
2
2
 
3
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { CalendarConnectionsCompact } from './calendar-connections-compact';
4
+ import { CalendarConnectionsSettingsContent } from './calendar-connections-settings-content';
4
5
  import {
5
- Calendar,
6
- ChevronDown,
7
- ExternalLink,
8
- Eye,
9
- EyeOff,
10
- Loader2,
11
- Lock,
12
- Plus,
13
- RefreshCw,
14
- RotateCcw,
15
- Settings,
16
- Sparkles,
17
- Target,
18
- Trash2,
19
- } from '@tuturuuu/icons';
20
- import { createClient } from '@tuturuuu/supabase/next/client';
21
- import Image from 'next/image';
22
- import { useRouter } from 'next/navigation';
23
- import { useTranslations } from 'next-intl';
24
- import { useMemo, useState } from 'react';
25
- import { useCalendarSync } from '../../../../hooks/use-calendar-sync';
26
- import {
27
- AlertDialog,
28
- AlertDialogAction,
29
- AlertDialogCancel,
30
- AlertDialogContent,
31
- AlertDialogDescription,
32
- AlertDialogFooter,
33
- AlertDialogHeader,
34
- AlertDialogTitle,
35
- AlertDialogTrigger,
36
- } from '../../alert-dialog';
37
- import { Badge } from '../../badge';
38
- import { Button } from '../../button';
39
- import {
40
- Collapsible,
41
- CollapsibleContent,
42
- CollapsibleTrigger,
43
- } from '../../collapsible';
44
- import {
45
- Dialog,
46
- DialogContent,
47
- DialogDescription,
48
- DialogFooter,
49
- DialogHeader,
50
- DialogTitle,
51
- DialogTrigger,
52
- } from '../../dialog';
53
- import { Input } from '../../input';
54
- import { Popover, PopoverContent, PopoverTrigger } from '../../popover';
55
- import { Separator } from '../../separator';
56
- import { toast } from '../../sonner';
57
- import { Switch } from '../../switch';
58
- import type {
59
- AuthResponse,
60
- CalendarSyncHealth,
61
- ConnectedAccount,
62
- ProviderCalendar,
63
- } from './calendar-types';
64
-
65
- interface AccountsResponse {
66
- accounts: ConnectedAccount[];
67
- grouped: {
68
- google: ConnectedAccount[];
69
- microsoft: ConnectedAccount[];
70
- };
71
- total: number;
72
- }
73
-
74
- interface WorkspaceCalendar {
75
- id: string;
76
- ws_id: string;
77
- name: string;
78
- description: string | null;
79
- color: string | null;
80
- calendar_type: 'primary' | 'tasks' | 'habits' | 'custom';
81
- is_system: boolean;
82
- is_enabled: boolean;
83
- position: number;
84
- }
85
-
86
- interface WorkspaceCalendarsResponse {
87
- calendars: WorkspaceCalendar[];
88
- grouped: {
89
- system: WorkspaceCalendar[];
90
- custom: WorkspaceCalendar[];
91
- };
92
- total: number;
93
- }
94
-
95
- interface ManualSyncResponse {
96
- ok: boolean;
97
- alreadyRunning?: boolean;
98
- code?: string;
99
- error?: string;
100
- retryAfterSeconds?: number | null;
101
- }
102
-
103
- export default function CalendarConnectionsUnified({ wsId }: { wsId: string }) {
104
- const t = useTranslations('calendar');
105
- const router = useRouter();
106
- const queryClient = useQueryClient();
107
- const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
108
- const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set());
109
- const [togglingTuturuuuIds, setTogglingTuturuuuIds] = useState<Set<string>>(
110
- new Set()
111
- );
112
- const [expandedAccounts, setExpandedAccounts] = useState<Set<string>>(
113
- new Set()
114
- );
115
- const [expandedPopoverAccounts, setExpandedPopoverAccounts] = useState<
116
- Set<string>
117
- >(
118
- new Set(['tuturuuu']) // Tuturuuu expanded by default
119
- );
120
- const [showCreateCalendarDialog, setShowCreateCalendarDialog] =
121
- useState(false);
122
- const [newCalendarName, setNewCalendarName] = useState('');
123
-
124
- const {
125
- calendarConnections,
126
- updateCalendarConnection,
127
- setCalendarConnections,
128
- syncToTuturuuu,
129
- isSyncing,
130
- } = useCalendarSync();
131
-
132
- const syncMutation = useMutation({
133
- mutationFn: async () => {
134
- const response = await fetch(`/api/v1/workspaces/${wsId}/calendar/sync`, {
135
- method: 'POST',
136
- headers: { 'Content-Type': 'application/json' },
137
- body: JSON.stringify({ direction: 'inbound', source: 'manual' }),
138
- });
139
-
140
- const result = (await response.json()) as ManualSyncResponse &
141
- Record<string, unknown>;
142
- if (!response.ok) {
143
- throw new Error(
144
- typeof result.error === 'string'
145
- ? result.error
146
- : 'Failed to sync calendars'
147
- );
148
- }
149
-
150
- return result;
151
- },
152
- onSuccess: async (result) => {
153
- await queryClient.invalidateQueries({
154
- queryKey: ['calendar-sync-status', wsId],
155
- });
156
- await queryClient.invalidateQueries({
157
- queryKey: ['databaseCalendarEvents', wsId],
158
- exact: false,
159
- });
160
-
161
- if (!result.alreadyRunning) {
162
- toast.success(t('calendar_sync_started') || 'Calendar sync started');
163
- }
164
- },
165
- onError: (error: Error) => {
166
- toast.error(error.message);
167
- },
168
- });
169
-
170
- // Fetch connected accounts
171
- const { data: accountsData, isLoading: isLoadingAccounts } = useQuery({
172
- queryKey: ['calendar-accounts', wsId],
173
- queryFn: async () => {
174
- const response = await fetch(
175
- `/api/v1/calendar/auth/accounts?wsId=${wsId}`,
176
- { cache: 'no-store' }
177
- );
178
- if (!response.ok)
179
- return {
180
- accounts: [],
181
- grouped: { google: [], microsoft: [] },
182
- total: 0,
183
- };
184
- return response.json() as Promise<AccountsResponse>;
185
- },
186
- staleTime: 30_000,
187
- });
188
-
189
- const accounts = accountsData?.accounts || [];
190
- const hasConnectedAccounts = accounts.length > 0;
191
-
192
- const { data: syncStatusData } = useQuery({
193
- queryKey: ['calendar-sync-status', wsId],
194
- queryFn: async () => {
195
- const response = await fetch(
196
- `/api/v1/workspaces/${wsId}/calendar/sync-status`,
197
- { cache: 'no-store' }
198
- );
199
- if (!response.ok) return null;
200
- return response.json() as Promise<{
201
- health: CalendarSyncHealth;
202
- accountsSummary: { total: number; google: number; microsoft: number };
203
- connectionsSummary: { total: number; enabled: number };
204
- }>;
205
- },
206
- staleTime: 15_000,
207
- });
208
-
209
- // Fetch current user's email
210
- const { data: userEmail } = useQuery({
211
- queryKey: ['current-user-email'],
212
- queryFn: async () => {
213
- const supabase = createClient();
214
- const {
215
- data: { user },
216
- } = await supabase.auth.getUser();
217
- return user?.email || null;
218
- },
219
- staleTime: Infinity, // User email rarely changes
220
- });
221
-
222
- // Fetch workspace calendars (Tuturuuu native calendars)
223
- const {
224
- data: workspaceCalendarsData,
225
- isLoading: isLoadingWorkspaceCalendars,
226
- } = useQuery({
227
- queryKey: ['workspace-calendars', wsId],
228
- queryFn: async () => {
229
- const response = await fetch(`/api/v1/workspaces/${wsId}/calendars`, {
230
- cache: 'no-store',
231
- });
232
- if (!response.ok)
233
- return { calendars: [], grouped: { system: [], custom: [] }, total: 0 };
234
- return response.json() as Promise<WorkspaceCalendarsResponse>;
235
- },
236
- staleTime: 30_000,
237
- });
238
-
239
- const isLoadingCalendars = isLoadingAccounts || isLoadingWorkspaceCalendars;
240
-
241
- const workspaceCalendars = workspaceCalendarsData?.calendars || [];
242
- const systemCalendars = workspaceCalendarsData?.grouped?.system || [];
243
- const customCalendars = workspaceCalendarsData?.grouped?.custom || [];
244
-
245
- // Calendar color mapping helper
246
- const getCalendarColor = (color: string): string => {
247
- const colorMap: Record<string, string> = {
248
- BLUE: '#3b82f6',
249
- RED: '#ef4444',
250
- GREEN: '#22c55e',
251
- YELLOW: '#eab308',
252
- ORANGE: '#f97316',
253
- PURPLE: '#a855f7',
254
- PINK: '#ec4899',
255
- CYAN: '#06b6d4',
256
- GRAY: '#6b7280',
257
- };
258
- return colorMap[color.toUpperCase()] || color;
259
- };
260
-
261
- // Calculate total enabled calendars count
262
- const tuturuuuEnabledCount = workspaceCalendars.filter(
263
- (c) => c.is_enabled
264
- ).length;
265
-
266
- // Toggle workspace calendar visibility
267
- const toggleWorkspaceCalendarMutation = useMutation({
268
- mutationFn: async ({
269
- id,
270
- is_enabled,
271
- }: {
272
- id: string;
273
- is_enabled: boolean;
274
- }) => {
275
- const response = await fetch(`/api/v1/workspaces/${wsId}/calendars`, {
276
- method: 'PATCH',
277
- headers: { 'Content-Type': 'application/json' },
278
- body: JSON.stringify({ id, is_enabled }),
279
- });
280
- if (!response.ok) throw new Error('Failed to toggle calendar');
281
- return response.json();
282
- },
283
- onMutate: ({ id }) => {
284
- setTogglingTuturuuuIds((prev) => new Set(prev).add(id));
285
- },
286
- onSuccess: () => {
287
- queryClient.invalidateQueries({
288
- queryKey: ['workspace-calendars', wsId],
289
- });
290
- },
291
- onError: () =>
292
- toast.error(t('calendar_toggle_failed') || 'Failed to toggle calendar'),
293
- onSettled: (_, __, { id }) => {
294
- setTogglingTuturuuuIds((prev) => {
295
- const next = new Set(prev);
296
- next.delete(id);
297
- return next;
298
- });
299
- },
300
- });
301
-
302
- // Create custom calendar
303
- const createCalendarMutation = useMutation({
304
- mutationFn: async (data: { name: string; color?: string }) => {
305
- const response = await fetch(`/api/v1/workspaces/${wsId}/calendars`, {
306
- method: 'POST',
307
- headers: { 'Content-Type': 'application/json' },
308
- body: JSON.stringify(data),
309
- });
310
- if (!response.ok) throw new Error('Failed to create calendar');
311
- return response.json();
312
- },
313
- onSuccess: () => {
314
- toast.success(t('calendar_created') || 'Calendar created');
315
- queryClient.invalidateQueries({
316
- queryKey: ['workspace-calendars', wsId],
317
- });
318
- },
319
- onError: () =>
320
- toast.error(t('calendar_creation_failed') || 'Failed to create calendar'),
321
- });
322
-
323
- // Delete custom calendar
324
- const deleteCalendarMutation = useMutation({
325
- mutationFn: async (id: string) => {
326
- const response = await fetch(
327
- `/api/v1/workspaces/${wsId}/calendars?id=${id}`,
328
- {
329
- method: 'DELETE',
330
- }
331
- );
332
- if (!response.ok) throw new Error('Failed to delete calendar');
333
- return response.json();
334
- },
335
- onSuccess: () => {
336
- toast.success(t('calendar_deleted') || 'Calendar deleted');
337
- queryClient.invalidateQueries({
338
- queryKey: ['workspace-calendars', wsId],
339
- });
340
- },
341
- onError: () =>
342
- toast.error(t('calendar_deletion_failed') || 'Failed to delete calendar'),
343
- });
344
-
345
- // Reset all calendar data
346
- const resetCalendarDataMutation = useMutation({
347
- mutationFn: async () => {
348
- const response = await fetch(
349
- `/api/v1/workspaces/${wsId}/calendars/reset`,
350
- { method: 'POST' }
351
- );
352
- if (!response.ok) {
353
- const error = await response.json();
354
- throw new Error(error.error || 'Failed to reset calendar data');
355
- }
356
- return response.json();
357
- },
358
- onSuccess: (data) => {
359
- toast.success(
360
- t('calendar_data_reset') || 'Calendar data reset successfully'
361
- );
362
- // Clear the calendar connections immediately
363
- setCalendarConnections([]);
364
- // Refetch all calendar-related queries to ensure UI updates
365
- queryClient.refetchQueries({
366
- queryKey: ['workspace-calendars', wsId],
367
- });
368
- queryClient.refetchQueries({ queryKey: ['calendar-accounts', wsId] });
369
- queryClient.refetchQueries({
370
- queryKey: ['databaseCalendarEvents', wsId],
371
- });
372
- queryClient.refetchQueries({
373
- queryKey: ['googleCalendarEvents', wsId],
374
- });
375
- queryClient.refetchQueries({
376
- queryKey: ['calendar-connections'],
377
- });
378
- // Refresh the page to reload server data
379
- router.refresh();
380
- console.log('Reset results:', data);
381
- },
382
- onError: (error: Error) => {
383
- toast.error(
384
- error.message ||
385
- t('calendar_reset_failed') ||
386
- 'Failed to reset calendar data'
387
- );
388
- },
389
- });
390
-
391
- // Google auth mutation
392
- const googleAuthMutation = useMutation<AuthResponse, Error, void>({
393
- mutationKey: ['calendar', 'google-auth', wsId],
394
- mutationFn: async () => {
395
- const response = await fetch(`/api/v1/calendar/auth?wsId=${wsId}`);
396
- if (!response.ok) throw new Error('Failed to get auth URL');
397
- return response.json();
398
- },
399
- onSuccess: (data) => {
400
- if (data.authUrl) window.location.href = data.authUrl;
401
- else toast.error(t('auth_url_invalid'));
402
- },
403
- onError: () => toast.error(t('google_auth_failed')),
404
- });
405
-
406
- // Microsoft auth mutation
407
- const microsoftAuthMutation = useMutation<AuthResponse, Error, void>({
408
- mutationKey: ['calendar', 'microsoft-auth', wsId],
409
- mutationFn: async () => {
410
- const response = await fetch(
411
- `/api/v1/calendar/auth/microsoft?wsId=${wsId}`
412
- );
413
- if (!response.ok) throw new Error('Failed to get auth URL');
414
- return response.json();
415
- },
416
- onSuccess: (data) => {
417
- if (data.authUrl) window.location.href = data.authUrl;
418
- else toast.error(t('auth_url_invalid'));
419
- },
420
- onError: () => toast.error(t('microsoft_auth_failed')),
421
- });
422
-
423
- // Disconnect account mutation
424
- const disconnectMutation = useMutation({
425
- mutationFn: async (accountId: string) => {
426
- const response = await fetch(
427
- `/api/v1/calendar/auth/accounts?accountId=${accountId}&wsId=${wsId}`,
428
- { method: 'DELETE' }
429
- );
430
- if (!response.ok) throw new Error('Failed to disconnect');
431
- return response.json();
432
- },
433
- onMutate: (accountId) => {
434
- setDisconnectingId(accountId);
435
- },
436
- onSuccess: () => {
437
- toast.success(t('account_disconnected'));
438
- queryClient.invalidateQueries({ queryKey: ['calendar-accounts', wsId] });
439
- },
440
- onError: () => toast.error(t('failed_to_disconnect')),
441
- onSettled: () => setDisconnectingId(null),
442
- });
443
-
444
- // Toggle calendar visibility - handles both existing connections and new calendars
445
- const handleToggle = async (
446
- calendarId: string,
447
- currentState: boolean,
448
- calendarData?: {
449
- calendar_id: string;
450
- calendar_name: string;
451
- color: string | null;
452
- connectionExists: boolean;
453
- accountId?: string;
454
- }
455
- ) => {
456
- const newState = !currentState;
457
- setTogglingIds((prev) => new Set(prev).add(calendarId));
458
-
459
- try {
460
- // If connection doesn't exist in database and we're enabling, create it
461
- if (calendarData && !calendarData.connectionExists && newState) {
462
- const response = await fetch('/api/v1/calendar/connections', {
463
- method: 'POST',
464
- headers: { 'Content-Type': 'application/json' },
465
- body: JSON.stringify({
466
- wsId,
467
- calendarId: calendarData.calendar_id,
468
- calendarName: calendarData.calendar_name,
469
- color: calendarData.color,
470
- isEnabled: true,
471
- authTokenId: calendarData.accountId,
472
- }),
473
- });
474
-
475
- if (!response.ok) {
476
- // Handle 409 Conflict - calendar already exists, update it instead
477
- if (response.status === 409) {
478
- // Calendar already exists, make a PATCH request to enable it
479
- const patchResponse = await fetch('/api/v1/calendar/connections', {
480
- method: 'PATCH',
481
- headers: { 'Content-Type': 'application/json' },
482
- body: JSON.stringify({
483
- // Use calendar_id to find the connection since we don't have the connection ID
484
- calendarId: calendarData.calendar_id,
485
- wsId,
486
- isEnabled: true,
487
- }),
488
- });
489
-
490
- if (patchResponse.ok) {
491
- // Update local state immediately
492
- updateCalendarConnection(calendarId, true);
493
- queryClient.invalidateQueries({
494
- queryKey: ['provider-calendar-list', wsId],
495
- });
496
- queryClient.invalidateQueries({
497
- queryKey: ['calendar-connections', wsId],
498
- });
499
- toast.success(t('calendar_enabled'));
500
- } else {
501
- toast.error(t('toggle_failed'));
502
- }
503
- } else {
504
- const errorText = await response.text().catch(() => '');
505
- console.error('Failed to create connection:', {
506
- status: response.status,
507
- statusText: response.statusText,
508
- rawText: errorText,
509
- });
510
- toast.error(t('toggle_failed'));
511
- }
512
- } else {
513
- // Update local state immediately
514
- updateCalendarConnection(calendarId, true);
515
- // Invalidate queries to refresh the list
516
- queryClient.invalidateQueries({
517
- queryKey: ['provider-calendar-list', wsId],
518
- });
519
- queryClient.invalidateQueries({
520
- queryKey: ['calendar-connections', wsId],
521
- });
522
- toast.success(t('calendar_enabled'));
523
- }
524
- } else {
525
- // Update existing connection
526
- updateCalendarConnection(calendarId, newState);
527
-
528
- const response = await fetch('/api/v1/calendar/connections', {
529
- method: 'PATCH',
530
- headers: { 'Content-Type': 'application/json' },
531
- body: JSON.stringify({ id: calendarId, isEnabled: newState }),
532
- });
533
-
534
- if (!response.ok) {
535
- updateCalendarConnection(calendarId, currentState);
536
- toast.error(t('toggle_failed'));
537
- }
538
- }
539
- } catch {
540
- if (calendarData?.connectionExists) {
541
- updateCalendarConnection(calendarId, currentState);
542
- }
543
- toast.error(t('toggle_failed'));
544
- } finally {
545
- setTogglingIds((prev) => {
546
- const next = new Set(prev);
547
- next.delete(calendarId);
548
- return next;
549
- });
550
- }
551
- };
552
-
553
- const toggleAccountExpanded = (accountId: string) => {
554
- setExpandedAccounts((prev) => {
555
- const next = new Set(prev);
556
- if (next.has(accountId)) next.delete(accountId);
557
- else next.add(accountId);
558
- return next;
559
- });
560
- };
561
-
562
- const enabledCount =
563
- calendarConnections.filter((c) => c.is_enabled).length +
564
- tuturuuuEnabledCount;
565
- const syncHealth = syncStatusData?.health;
566
- const manualSyncDisabled = useMemo(() => {
567
- if (syncMutation.isPending || isSyncing) {
568
- return true;
569
- }
570
-
571
- if (syncHealth?.currentlyRunning) {
572
- return true;
573
- }
574
-
575
- if ((syncHealth?.retryAfterSeconds ?? 0) > 0) {
576
- return true;
577
- }
578
-
579
- return false;
580
- }, [
581
- isSyncing,
582
- syncHealth?.currentlyRunning,
583
- syncHealth?.retryAfterSeconds,
584
- syncMutation.isPending,
585
- ]);
586
- const syncStatusStyles =
587
- syncHealth?.state === 'syncing'
588
- ? 'bg-dynamic-blue/10 text-dynamic-blue'
589
- : syncHealth?.state === 'healthy'
590
- ? 'bg-dynamic-green/10 text-dynamic-green'
591
- : syncHealth?.state === 'disconnected'
592
- ? 'bg-muted text-muted-foreground'
593
- : syncHealth?.state === 'degraded'
594
- ? 'bg-dynamic-orange/10 text-dynamic-orange'
595
- : 'bg-dynamic-red/10 text-dynamic-red';
596
-
597
- // Fetch Google calendars from API (must be before any conditional returns)
598
- const { data: googleCalendarsData } = useQuery({
599
- queryKey: ['provider-calendar-list', wsId],
600
- enabled: hasConnectedAccounts,
601
- queryFn: async () => {
602
- const response = await fetch(
603
- `/api/v1/calendar/auth/provider-calendars?wsId=${wsId}`,
604
- { cache: 'no-store' }
605
- );
606
- if (!response.ok) return { calendars: [], byAccount: {} };
607
- return response.json() as Promise<{
608
- calendars: ProviderCalendar[];
609
- byAccount: Record<string, ProviderCalendar[]>;
610
- }>;
611
- },
612
- staleTime: 30_000,
613
- });
614
-
615
- const calendarsByAccountFromAPI = googleCalendarsData?.byAccount || {};
616
-
617
- // Merge API calendars with existing connections for visibility state
618
- const calendarsByAccount = accounts.reduce(
619
- (acc, account) => {
620
- const apiCalendars = calendarsByAccountFromAPI[account.id] || [];
621
-
622
- // Map API calendars to connection-like objects, merging with existing connections
623
- acc[account.id] = apiCalendars.map((apiCal) => {
624
- // Find existing connection for this calendar
625
- const existingConnection = calendarConnections.find(
626
- (conn) => conn.calendar_id === apiCal.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
- };
640
- });
641
-
642
- return acc;
643
- },
644
- {} as Record<
645
- string,
646
- Array<{
647
- id: string;
648
- ws_id: string;
649
- calendar_id: string;
650
- calendar_name: string;
651
- is_enabled: boolean;
652
- color: string | null;
653
- isFromAPI: boolean;
654
- connectionExists: boolean;
655
- accountId: string;
656
- }>
657
- >
658
- );
659
-
660
- // Note: Removed early return - we always show the full UI now so Tuturuuu calendars are visible
661
-
662
- return (
663
- <div className="flex items-center gap-2">
664
- {/* Quick calendar visibility toggle */}
665
- <Popover>
666
- <PopoverTrigger asChild>
667
- <Button variant="outline" size="sm" className="gap-2">
668
- <Calendar className="h-4 w-4" />
669
- <span className="hidden sm:inline">{t('calendars')}</span>
670
- {enabledCount > 0 && (
671
- <Badge variant="secondary" className="ml-1">
672
- {enabledCount}
673
- </Badge>
674
- )}
675
- </Button>
676
- </PopoverTrigger>
677
- <PopoverContent className="w-80" align="end">
678
- <div className="space-y-4">
679
- <div className="flex items-center justify-between">
680
- <div>
681
- <h4 className="font-medium text-sm">
682
- {t('visible_calendars')}
683
- </h4>
684
- <p className="text-muted-foreground text-xs">
685
- {enabledCount} {t('calendars_selected') || 'selected'}
686
- </p>
687
- </div>
688
- <Dialog>
689
- <DialogTrigger asChild>
690
- <Button variant="ghost" size="icon" className="h-8 w-8">
691
- <Settings className="h-4 w-4" />
692
- </Button>
693
- </DialogTrigger>
694
- <DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
695
- <DialogHeader>
696
- <DialogTitle>{t('manage_calendar_accounts')}</DialogTitle>
697
- <DialogDescription>
698
- {t('manage_calendar_accounts_desc')}
699
- </DialogDescription>
700
- </DialogHeader>
701
-
702
- <div className="space-y-4 py-4">
703
- {/* Tuturuuu Calendars Section */}
704
- <div className="space-y-3">
705
- <div className="flex items-center justify-between">
706
- <div className="flex items-center gap-2">
707
- <Image
708
- src="/icon-512x512.png"
709
- alt="Tuturuuu"
710
- width={24}
711
- height={24}
712
- />
713
- <span className="font-medium text-sm">
714
- {t('tuturuuu_calendars') || 'Tuturuuu Calendars'}
715
- </span>
716
- </div>
717
- <Button
718
- variant="ghost"
719
- size="sm"
720
- className="h-7 gap-1 text-xs"
721
- onClick={() => setShowCreateCalendarDialog(true)}
722
- disabled={createCalendarMutation.isPending}
723
- >
724
- {createCalendarMutation.isPending ? (
725
- <Loader2 className="h-3 w-3 animate-spin" />
726
- ) : (
727
- <Plus className="h-3 w-3" />
728
- )}
729
- {t('new') || 'New'}
730
- </Button>
731
- <Dialog
732
- open={showCreateCalendarDialog}
733
- onOpenChange={(open) => {
734
- setShowCreateCalendarDialog(open);
735
- if (!open) setNewCalendarName('');
736
- }}
737
- >
738
- <DialogContent className="sm:max-w-md">
739
- <DialogHeader>
740
- <DialogTitle>{t('create_calendar')}</DialogTitle>
741
- </DialogHeader>
742
- <div className="py-4">
743
- <Input
744
- value={newCalendarName}
745
- onChange={(
746
- e: React.ChangeEvent<HTMLInputElement>
747
- ) => setNewCalendarName(e.target.value)}
748
- placeholder={
749
- t('enter_calendar_name') ||
750
- 'Enter calendar name'
751
- }
752
- autoFocus
753
- onKeyDown={(
754
- e: React.KeyboardEvent<HTMLInputElement>
755
- ) => {
756
- if (
757
- e.key === 'Enter' &&
758
- newCalendarName.trim()
759
- ) {
760
- createCalendarMutation.mutate({
761
- name: newCalendarName.trim(),
762
- });
763
- setShowCreateCalendarDialog(false);
764
- setNewCalendarName('');
765
- }
766
- }}
767
- />
768
- </div>
769
- <DialogFooter>
770
- <Button
771
- onClick={() => {
772
- if (newCalendarName.trim()) {
773
- createCalendarMutation.mutate({
774
- name: newCalendarName.trim(),
775
- });
776
- setShowCreateCalendarDialog(false);
777
- setNewCalendarName('');
778
- }
779
- }}
780
- disabled={
781
- !newCalendarName.trim() ||
782
- createCalendarMutation.isPending
783
- }
784
- >
785
- {createCalendarMutation.isPending && (
786
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
787
- )}
788
- {t('create')}
789
- </Button>
790
- </DialogFooter>
791
- </DialogContent>
792
- </Dialog>
793
- </div>
794
-
795
- {/* System Calendars */}
796
- <div className="space-y-1">
797
- {systemCalendars.map((cal) => {
798
- const Icon =
799
- cal.calendar_type === 'primary'
800
- ? Calendar
801
- : cal.calendar_type === 'tasks'
802
- ? Target
803
- : cal.calendar_type === 'habits'
804
- ? Sparkles
805
- : Calendar;
806
- return (
807
- <div
808
- key={cal.id}
809
- className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
810
- >
811
- <div className="flex min-w-0 flex-1 items-center gap-2">
812
- <div
813
- className="flex h-5 w-5 shrink-0 items-center justify-center rounded"
814
- style={{
815
- backgroundColor: `${getCalendarColor(cal.color || 'BLUE')}20`,
816
- }}
817
- >
818
- <Icon
819
- className="h-3 w-3"
820
- style={{
821
- color: getCalendarColor(
822
- cal.color || 'BLUE'
823
- ),
824
- }}
825
- />
826
- </div>
827
- <span
828
- className="line-clamp-1 break-all text-sm"
829
- title={cal.name}
830
- >
831
- {cal.name}
832
- </span>
833
- <Lock className="h-3 w-3 shrink-0 text-muted-foreground" />
834
- </div>
835
- <Switch
836
- checked={cal.is_enabled}
837
- onCheckedChange={() =>
838
- toggleWorkspaceCalendarMutation.mutate({
839
- id: cal.id,
840
- is_enabled: !cal.is_enabled,
841
- })
842
- }
843
- disabled={togglingTuturuuuIds.has(cal.id)}
844
- />
845
- </div>
846
- );
847
- })}
848
- </div>
849
-
850
- {/* Custom Calendars */}
851
- {customCalendars.length > 0 && (
852
- <div className="space-y-1">
853
- <p className="px-2 text-muted-foreground text-xs">
854
- {t('custom_calendars') || 'Custom'}
855
- </p>
856
- {customCalendars.map((cal) => (
857
- <div
858
- key={cal.id}
859
- className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
860
- >
861
- <div className="flex min-w-0 flex-1 items-center gap-2">
862
- <div
863
- className="h-3 w-3 shrink-0 rounded-full"
864
- style={{
865
- backgroundColor: getCalendarColor(
866
- cal.color || 'BLUE'
867
- ),
868
- }}
869
- />
870
- <span
871
- className="line-clamp-1 break-all text-sm"
872
- title={cal.name}
873
- >
874
- {cal.name}
875
- </span>
876
- </div>
877
- <div className="flex items-center gap-1">
878
- <Button
879
- variant="ghost"
880
- size="icon"
881
- className="h-6 w-6 text-destructive hover:bg-destructive/10"
882
- onClick={() =>
883
- deleteCalendarMutation.mutate(cal.id)
884
- }
885
- disabled={deleteCalendarMutation.isPending}
886
- >
887
- <Trash2 className="h-3 w-3" />
888
- </Button>
889
- <Switch
890
- checked={cal.is_enabled}
891
- onCheckedChange={() =>
892
- toggleWorkspaceCalendarMutation.mutate({
893
- id: cal.id,
894
- is_enabled: !cal.is_enabled,
895
- })
896
- }
897
- disabled={togglingTuturuuuIds.has(cal.id)}
898
- />
899
- </div>
900
- </div>
901
- ))}
902
- </div>
903
- )}
904
- </div>
905
-
906
- <Separator />
907
-
908
- {/* Connected accounts with calendars */}
909
- {accounts.map((account) => (
910
- <Collapsible
911
- key={account.id}
912
- open={expandedAccounts.has(account.id)}
913
- onOpenChange={() => toggleAccountExpanded(account.id)}
914
- >
915
- <div className="rounded-lg border">
916
- <CollapsibleTrigger className="flex w-full items-center justify-between p-3 transition-colors hover:bg-muted/50">
917
- <div className="flex items-center gap-3">
918
- <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
919
- <Image
920
- src={
921
- account.provider === 'google'
922
- ? '/media/logos/google.svg'
923
- : '/media/logos/microsoft.svg'
924
- }
925
- alt={account.provider}
926
- width={18}
927
- height={18}
928
- />
929
- </div>
930
- <div className="text-left">
931
- <p className="font-medium text-sm">
932
- {account.account_name ||
933
- account.account_email ||
934
- t('unknown_account')}
935
- </p>
936
- {account.account_email &&
937
- account.account_name && (
938
- <p className="text-muted-foreground text-xs">
939
- {account.account_email}
940
- </p>
941
- )}
942
- </div>
943
- </div>
944
- <div className="flex items-center gap-2">
945
- <Badge
946
- variant="outline"
947
- className="text-xs capitalize"
948
- >
949
- {account.provider}
950
- </Badge>
951
- <ChevronDown
952
- className={`h-4 w-4 transition-transform ${expandedAccounts.has(account.id) ? 'rotate-180' : ''}`}
953
- />
954
- </div>
955
- </CollapsibleTrigger>
956
-
957
- <CollapsibleContent>
958
- <Separator />
959
- <div className="space-y-2 p-3">
960
- {(calendarsByAccount[account.id] || []).length >
961
- 0 ? (
962
- (calendarsByAccount[account.id] || []).map(
963
- (cal) => (
964
- <div
965
- key={cal.id}
966
- className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
967
- >
968
- <div className="flex min-w-0 flex-1 items-center gap-2">
969
- <div
970
- className="h-3 w-3 shrink-0 rounded-full"
971
- style={{
972
- backgroundColor:
973
- cal.color || '#4285f4',
974
- }}
975
- />
976
- <span
977
- className="line-clamp-1 break-all text-sm"
978
- title={cal.calendar_name}
979
- >
980
- {cal.calendar_name}
981
- </span>
982
- </div>
983
- <Switch
984
- checked={cal.is_enabled}
985
- onCheckedChange={() =>
986
- handleToggle(cal.id, cal.is_enabled, {
987
- calendar_id: cal.calendar_id,
988
- calendar_name: cal.calendar_name,
989
- color: cal.color,
990
- connectionExists:
991
- cal.connectionExists,
992
- accountId: cal.accountId,
993
- })
994
- }
995
- disabled={togglingIds.has(cal.id)}
996
- />
997
- </div>
998
- )
999
- )
1000
- ) : (
1001
- <p className="py-2 text-center text-muted-foreground text-xs">
1002
- {t('no_calendars_found')}
1003
- </p>
1004
- )}
1005
-
1006
- <Separator className="my-2" />
1007
- <Button
1008
- variant="ghost"
1009
- size="sm"
1010
- className="w-full text-destructive hover:bg-destructive/10 hover:text-destructive"
1011
- onClick={() =>
1012
- disconnectMutation.mutate(account.id)
1013
- }
1014
- disabled={disconnectingId === account.id}
1015
- >
1016
- {disconnectingId === account.id ? (
1017
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1018
- ) : (
1019
- <Trash2 className="mr-2 h-4 w-4" />
1020
- )}
1021
- {t('disconnect')}
1022
- </Button>
1023
- </div>
1024
- </CollapsibleContent>
1025
- </div>
1026
- </Collapsible>
1027
- ))}
1028
-
1029
- {/* Add new account buttons */}
1030
- <div className="space-y-2 pt-2">
1031
- <p className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
1032
- {t('add_account')}
1033
- </p>
1034
- <div className="grid grid-cols-2 gap-2">
1035
- <Button
1036
- variant="outline"
1037
- size="sm"
1038
- className="gap-2 text-center"
1039
- onClick={() => googleAuthMutation.mutate()}
1040
- disabled={googleAuthMutation.isPending}
1041
- >
1042
- {googleAuthMutation.isPending ? (
1043
- <Loader2 className="h-4 w-4 animate-spin" />
1044
- ) : (
1045
- <Image
1046
- src="/media/logos/google.svg"
1047
- alt="Google"
1048
- width={16}
1049
- height={16}
1050
- />
1051
- )}
1052
- {t('google')}
1053
- </Button>
1054
- <Button
1055
- variant="outline"
1056
- size="sm"
1057
- className="gap-2 text-center"
1058
- onClick={() => microsoftAuthMutation.mutate()}
1059
- disabled={microsoftAuthMutation.isPending}
1060
- >
1061
- {microsoftAuthMutation.isPending ? (
1062
- <Loader2 className="h-4 w-4 animate-spin" />
1063
- ) : (
1064
- <Image
1065
- src="/media/logos/microsoft.svg"
1066
- alt="Microsoft"
1067
- width={16}
1068
- height={16}
1069
- />
1070
- )}
1071
- {t('outlook')}
1072
- </Button>
1073
- </div>
1074
- </div>
1075
-
1076
- {/* Danger Zone */}
1077
- <Separator />
1078
- <div className="space-y-2">
1079
- <p className="font-medium text-destructive text-xs uppercase tracking-wider">
1080
- {t('danger_zone') || 'Danger Zone'}
1081
- </p>
1082
- <AlertDialog>
1083
- <AlertDialogTrigger asChild>
1084
- <Button
1085
- variant="destructive"
1086
- size="sm"
1087
- className="w-full gap-2"
1088
- disabled={resetCalendarDataMutation.isPending}
1089
- >
1090
- {resetCalendarDataMutation.isPending ? (
1091
- <Loader2 className="h-4 w-4 animate-spin" />
1092
- ) : (
1093
- <RotateCcw className="h-4 w-4" />
1094
- )}
1095
- {t('reset_calendar_data') || 'Reset Calendar Data'}
1096
- </Button>
1097
- </AlertDialogTrigger>
1098
- <AlertDialogContent>
1099
- <AlertDialogHeader>
1100
- <AlertDialogTitle>
1101
- {t('reset_calendar_confirm_title') ||
1102
- 'Reset all calendar data?'}
1103
- </AlertDialogTitle>
1104
- <AlertDialogDescription>
1105
- {t('reset_calendar_confirm_desc') ||
1106
- 'This will permanently delete all calendar events, disconnect all linked accounts (Google, Microsoft), and remove custom calendars. System calendars will be preserved but emptied. This action cannot be undone.'}
1107
- </AlertDialogDescription>
1108
- </AlertDialogHeader>
1109
- <AlertDialogFooter>
1110
- <AlertDialogCancel>
1111
- {t('cancel') || 'Cancel'}
1112
- </AlertDialogCancel>
1113
- <AlertDialogAction
1114
- onClick={() => resetCalendarDataMutation.mutate()}
1115
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
1116
- >
1117
- {t('reset_all_data') || 'Reset All Data'}
1118
- </AlertDialogAction>
1119
- </AlertDialogFooter>
1120
- </AlertDialogContent>
1121
- </AlertDialog>
1122
- </div>
1123
- </div>
1124
- </DialogContent>
1125
- </Dialog>
1126
- </div>
1127
-
1128
- {!hasConnectedAccounts && (
1129
- <div className="rounded-xl border border-border/60 bg-muted/20 p-3">
1130
- <div className="space-y-1">
1131
- <p className="font-medium text-sm">
1132
- {t('connect_calendar_accounts') ||
1133
- 'Connect calendar accounts'}
1134
- </p>
1135
- <p className="text-muted-foreground text-xs">
1136
- {t('connect_calendar_accounts_desc') ||
1137
- 'Link Google or Outlook to keep external calendars visible and continuously synchronized.'}
1138
- </p>
1139
- </div>
1140
- <div className="mt-3 grid grid-cols-2 gap-2">
1141
- <Button
1142
- variant="outline"
1143
- size="sm"
1144
- className="gap-2"
1145
- onClick={() => googleAuthMutation.mutate()}
1146
- disabled={googleAuthMutation.isPending}
1147
- >
1148
- {googleAuthMutation.isPending ? (
1149
- <Loader2 className="h-4 w-4 animate-spin" />
1150
- ) : (
1151
- <Image
1152
- src="/media/logos/google.svg"
1153
- alt="Google"
1154
- width={16}
1155
- height={16}
1156
- />
1157
- )}
1158
- {t('google')}
1159
- </Button>
1160
- <Button
1161
- variant="outline"
1162
- size="sm"
1163
- className="gap-2"
1164
- onClick={() => microsoftAuthMutation.mutate()}
1165
- disabled={microsoftAuthMutation.isPending}
1166
- >
1167
- {microsoftAuthMutation.isPending ? (
1168
- <Loader2 className="h-4 w-4 animate-spin" />
1169
- ) : (
1170
- <Image
1171
- src="/media/logos/microsoft.svg"
1172
- alt="Microsoft"
1173
- width={16}
1174
- height={16}
1175
- />
1176
- )}
1177
- {t('outlook')}
1178
- </Button>
1179
- </div>
1180
- </div>
1181
- )}
1182
-
1183
- <Separator />
1184
-
1185
- {/* Calendar list grouped by source */}
1186
- <div className="max-h-64 space-y-3 overflow-y-auto">
1187
- {isLoadingCalendars ? (
1188
- <div className="flex items-center justify-center py-6">
1189
- <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
1190
- </div>
1191
- ) : (
1192
- <>
1193
- {/* Tuturuuu Calendars */}
1194
- {workspaceCalendars.length > 0 && (
1195
- <Collapsible
1196
- open={expandedPopoverAccounts.has('tuturuuu')}
1197
- onOpenChange={(open) => {
1198
- setExpandedPopoverAccounts((prev) => {
1199
- const next = new Set(prev);
1200
- if (open) next.add('tuturuuu');
1201
- else next.delete('tuturuuu');
1202
- return next;
1203
- });
1204
- }}
1205
- >
1206
- <CollapsibleTrigger className="flex w-full cursor-pointer items-center gap-2 rounded-md px-1 py-1 hover:bg-muted/50">
1207
- <ChevronDown
1208
- className={`h-3 w-3 shrink-0 text-muted-foreground transition-transform ${expandedPopoverAccounts.has('tuturuuu') ? '' : '-rotate-90'}`}
1209
- />
1210
- <Image
1211
- src="/icon-512x512.png"
1212
- alt="Tuturuuu"
1213
- width={14}
1214
- height={14}
1215
- />
1216
- <div className="flex min-w-0 flex-1 flex-col text-left">
1217
- <span className="font-medium text-muted-foreground text-xs">
1218
- {t('tuturuuu_calendars') || 'Tuturuuu'}
1219
- </span>
1220
- {userEmail && (
1221
- <span className="truncate text-[10px] text-muted-foreground">
1222
- {userEmail}
1223
- </span>
1224
- )}
1225
- </div>
1226
- </CollapsibleTrigger>
1227
- <CollapsibleContent className="space-y-1 pt-1">
1228
- {workspaceCalendars.map((cal) => (
1229
- <div
1230
- key={cal.id}
1231
- className="flex items-center justify-between rounded-md px-2 py-1 hover:bg-muted/50"
1232
- >
1233
- <div className="flex items-center gap-2">
1234
- <div
1235
- className="h-2.5 w-2.5 rounded-full"
1236
- style={{
1237
- backgroundColor: getCalendarColor(
1238
- cal.color || 'BLUE'
1239
- ),
1240
- }}
1241
- />
1242
- <span className="line-clamp-1 max-w-45 break-all text-sm">
1243
- {cal.name}
1244
- </span>
1245
- {cal.is_system && (
1246
- <Lock className="h-2.5 w-2.5 text-muted-foreground" />
1247
- )}
1248
- </div>
1249
- <Button
1250
- variant="ghost"
1251
- size="icon"
1252
- className="h-6 w-6"
1253
- onClick={() =>
1254
- toggleWorkspaceCalendarMutation.mutate({
1255
- id: cal.id,
1256
- is_enabled: !cal.is_enabled,
1257
- })
1258
- }
1259
- disabled={togglingTuturuuuIds.has(cal.id)}
1260
- >
1261
- {togglingTuturuuuIds.has(cal.id) ? (
1262
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
1263
- ) : cal.is_enabled ? (
1264
- <Eye className="h-3.5 w-3.5 text-primary" />
1265
- ) : (
1266
- <EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
1267
- )}
1268
- </Button>
1269
- </div>
1270
- ))}
1271
- </CollapsibleContent>
1272
- </Collapsible>
1273
- )}
1274
-
1275
- {/* External accounts calendars */}
1276
- {accounts.map((account) => {
1277
- const accountCals = calendarsByAccount[account.id] || [];
1278
- if (accountCals.length === 0) return null;
1279
- const accountKey = `account-${account.id}`;
1280
-
1281
- return (
1282
- <Collapsible
1283
- key={account.id}
1284
- open={expandedPopoverAccounts.has(accountKey)}
1285
- onOpenChange={(open) => {
1286
- setExpandedPopoverAccounts((prev) => {
1287
- const next = new Set(prev);
1288
- if (open) next.add(accountKey);
1289
- else next.delete(accountKey);
1290
- return next;
1291
- });
1292
- }}
1293
- >
1294
- <CollapsibleTrigger className="flex w-full cursor-pointer items-center gap-2 rounded-md px-1 py-1 hover:bg-muted/50">
1295
- <ChevronDown
1296
- className={`h-3 w-3 shrink-0 text-muted-foreground transition-transform ${expandedPopoverAccounts.has(accountKey) ? '' : '-rotate-90'}`}
1297
- />
1298
- <Image
1299
- src={
1300
- account.provider === 'google'
1301
- ? '/media/logos/google.svg'
1302
- : '/media/logos/microsoft.svg'
1303
- }
1304
- alt={account.provider}
1305
- width={12}
1306
- height={12}
1307
- />
1308
- <span className="min-w-0 flex-1 truncate text-left text-muted-foreground text-xs">
1309
- {account.account_email || account.account_name}
1310
- </span>
1311
- </CollapsibleTrigger>
1312
- <CollapsibleContent className="space-y-1 pt-1">
1313
- {accountCals.map((cal) => (
1314
- <div
1315
- key={cal.id}
1316
- className="flex items-center justify-between rounded-md px-2 py-1 hover:bg-muted/50"
1317
- >
1318
- <div className="flex items-center gap-2">
1319
- <div
1320
- className="h-2.5 w-2.5 rounded-full"
1321
- style={{
1322
- backgroundColor: cal.color || '#4285f4',
1323
- }}
1324
- />
1325
- <span className="line-clamp-1 max-w-45 break-all text-sm">
1326
- {cal.calendar_name}
1327
- </span>
1328
- </div>
1329
- <Button
1330
- variant="ghost"
1331
- size="icon"
1332
- className="h-6 w-6"
1333
- onClick={() =>
1334
- handleToggle(cal.id, cal.is_enabled, {
1335
- calendar_id: cal.calendar_id,
1336
- calendar_name: cal.calendar_name,
1337
- color: cal.color,
1338
- connectionExists: cal.connectionExists,
1339
- accountId: cal.accountId,
1340
- })
1341
- }
1342
- disabled={togglingIds.has(cal.id)}
1343
- >
1344
- {togglingIds.has(cal.id) ? (
1345
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
1346
- ) : cal.is_enabled ? (
1347
- <Eye className="h-3.5 w-3.5 text-primary" />
1348
- ) : (
1349
- <EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
1350
- )}
1351
- </Button>
1352
- </div>
1353
- ))}
1354
- </CollapsibleContent>
1355
- </Collapsible>
1356
- );
1357
- })}
1358
-
1359
- {calendarConnections.length === 0 &&
1360
- workspaceCalendars.length === 0 && (
1361
- <div className="rounded-lg border border-dashed p-4 text-center">
1362
- <p className="font-medium text-sm">
1363
- {t('no_calendars_synced')}
1364
- </p>
1365
- <p className="mt-1 text-muted-foreground text-xs">
1366
- {t('choose_calendars_to_show') ||
1367
- 'Connect an account, then choose which calendars should appear here.'}
1368
- </p>
1369
- </div>
1370
- )}
1371
- </>
1372
- )}
1373
- </div>
1374
-
1375
- <Separator />
1376
-
1377
- {/* Sync actions */}
1378
- <div className="flex items-center justify-between">
1379
- <div
1380
- className={`rounded-full px-2 py-0.5 text-xs ${syncStatusStyles}`}
1381
- >
1382
- {syncHealth?.state === 'syncing' && (
1383
- <Loader2 className="mr-1 inline h-3 w-3 animate-spin" />
1384
- )}
1385
- {syncHealth?.state === 'degraded'
1386
- ? t('degraded') || 'Degraded'
1387
- : syncHealth?.state === 'healthy'
1388
- ? t('healthy') || 'Healthy'
1389
- : syncHealth?.state === 'disconnected'
1390
- ? t('connect_accounts') || 'Connect accounts'
1391
- : t('syncing_calendars') || 'Syncing calendars'}
1392
- </div>
1393
- <div className="flex gap-1">
1394
- <Button
1395
- variant="ghost"
1396
- size="icon"
1397
- className="h-7 w-7"
1398
- onClick={() => syncMutation.mutate()}
1399
- disabled={manualSyncDisabled}
1400
- title={t('sync_now') || 'Sync now'}
1401
- >
1402
- <ExternalLink
1403
- className={`h-3.5 w-3.5 ${
1404
- manualSyncDisabled ? 'animate-pulse' : ''
1405
- }`}
1406
- />
1407
- </Button>
1408
- <Button
1409
- variant="ghost"
1410
- size="icon"
1411
- className="h-7 w-7"
1412
- onClick={() => syncToTuturuuu()}
1413
- disabled={manualSyncDisabled}
1414
- title={t('sync_from_google')}
1415
- >
1416
- <RefreshCw
1417
- className={`h-3.5 w-3.5 ${manualSyncDisabled ? 'animate-spin' : ''}`}
1418
- />
1419
- </Button>
1420
- </div>
1421
- </div>
1422
- {syncHealth?.lastSuccessAt && (
1423
- <p className="text-muted-foreground text-xs">
1424
- {t('last_synced_at') || 'Last synced'}:{' '}
1425
- {new Date(syncHealth.lastSuccessAt).toLocaleString()}
1426
- </p>
1427
- )}
1428
- </div>
1429
- </PopoverContent>
1430
- </Popover>
1431
- </div>
1432
- );
6
+ type CalendarConnectionsUnifiedVariant,
7
+ useCalendarConnectionsManager,
8
+ } from './use-calendar-connections-manager';
9
+
10
+ export type { CalendarConnectionsUnifiedVariant };
11
+
12
+ export default function CalendarConnectionsUnified({
13
+ wsId,
14
+ variant = 'compact',
15
+ className,
16
+ }: {
17
+ wsId: string;
18
+ variant?: CalendarConnectionsUnifiedVariant;
19
+ className?: string;
20
+ }) {
21
+ const state = useCalendarConnectionsManager(wsId);
22
+
23
+ if (variant === 'settings') {
24
+ return (
25
+ <CalendarConnectionsSettingsContent state={state} className={className} />
26
+ );
27
+ }
28
+
29
+ return <CalendarConnectionsCompact state={state} />;
1433
30
  }