@tuturuuu/ui 0.22.0 → 0.24.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 +36 -0
- package/biome.json +1 -1
- package/package.json +78 -78
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
- package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
- package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
- package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
- package/src/components/ui/chat/chat-sidebar-items.tsx +10 -2
- package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
- package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
- package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
- package/src/components/ui/chat/chat-utils.test.ts +107 -0
- package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
- package/src/components/ui/chat/chat-workspace.tsx +66 -24
- package/src/components/ui/chat/hooks-conversations.ts +9 -1
- package/src/components/ui/chat/hooks-messages.ts +1 -0
- package/src/components/ui/chat/message-bubble.tsx +49 -4
- package/src/components/ui/chat/message-composer.tsx +29 -23
- package/src/components/ui/chat/message-markdown.tsx +4 -1
- package/src/components/ui/chat/query-keys.ts +7 -1
- package/src/components/ui/chat/utils.ts +85 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
- package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
- package/src/components/ui/custom/workspace-select.tsx +37 -38
- package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
- package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
- package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
- package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
- package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
- package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
- package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
- package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
- package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
- package/src/components/ui/legacy/meet/form.tsx +25 -1
- package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
- package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
- package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
- package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
- package/src/components/ui/legacy/meet/page.tsx +1 -0
- package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
- package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
- package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
- package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
- package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
- package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
- package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
- package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
- package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
- package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
- package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
- package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
- package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
- package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
- package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
- package/src/components/ui/nuqs-adapter.tsx +1 -0
- package/src/hooks/time-blocking-provider.tsx +127 -211
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import {
|
|
5
|
+
CalendarCheck,
|
|
6
|
+
Check,
|
|
7
|
+
ExternalLink,
|
|
8
|
+
LockKeyhole,
|
|
9
|
+
RotateCcw,
|
|
10
|
+
} from '@tuturuuu/icons';
|
|
11
|
+
import {
|
|
12
|
+
finalizeMeetPlan,
|
|
13
|
+
type MeetPlanSnapshot,
|
|
14
|
+
reopenMeetPlan,
|
|
15
|
+
} from '@tuturuuu/internal-api';
|
|
16
|
+
import type {
|
|
17
|
+
MeetFinalizedTimeframe,
|
|
18
|
+
MeetTogetherPlan,
|
|
19
|
+
PlanUser,
|
|
20
|
+
} from '@tuturuuu/types/primitives/MeetTogetherPlan';
|
|
21
|
+
import type { Timeblock } from '@tuturuuu/types/primitives/Timeblock';
|
|
22
|
+
import { Badge } from '@tuturuuu/ui/badge';
|
|
23
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
24
|
+
import {
|
|
25
|
+
Dialog,
|
|
26
|
+
DialogContent,
|
|
27
|
+
DialogDescription,
|
|
28
|
+
DialogFooter,
|
|
29
|
+
DialogHeader,
|
|
30
|
+
DialogTitle,
|
|
31
|
+
DialogTrigger,
|
|
32
|
+
} from '@tuturuuu/ui/dialog';
|
|
33
|
+
import { useTimeBlocking } from '@tuturuuu/ui/hooks/time-blocking-provider';
|
|
34
|
+
import { useToast } from '@tuturuuu/ui/hooks/use-toast';
|
|
35
|
+
import {
|
|
36
|
+
Select,
|
|
37
|
+
SelectContent,
|
|
38
|
+
SelectItem,
|
|
39
|
+
SelectTrigger,
|
|
40
|
+
SelectValue,
|
|
41
|
+
} from '@tuturuuu/ui/select';
|
|
42
|
+
import { Slider } from '@tuturuuu/ui/slider';
|
|
43
|
+
import { Switch } from '@tuturuuu/ui/switch';
|
|
44
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
45
|
+
import dayjs from 'dayjs';
|
|
46
|
+
import { useTranslations } from 'next-intl';
|
|
47
|
+
import { useMemo, useState } from 'react';
|
|
48
|
+
import { createMeetIcs } from '../lib/meet-ics';
|
|
49
|
+
import {
|
|
50
|
+
formatMinuteOfDay,
|
|
51
|
+
type MeetRankedTimeframe,
|
|
52
|
+
rankMeetTimeframes,
|
|
53
|
+
} from '../lib/meet-insights';
|
|
54
|
+
import { candidateToAbsoluteRange } from '../lib/meet-timezone';
|
|
55
|
+
import { meetPlanQueryKey } from './meet-query';
|
|
56
|
+
|
|
57
|
+
function candidateKey(candidate: MeetRankedTimeframe) {
|
|
58
|
+
return `${candidate.date}:${candidate.startMinute}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function MeetSuggestionsPanel({
|
|
62
|
+
plan,
|
|
63
|
+
users,
|
|
64
|
+
timeblocks,
|
|
65
|
+
finalizedTimeframes,
|
|
66
|
+
isCreator,
|
|
67
|
+
}: {
|
|
68
|
+
plan: MeetTogetherPlan;
|
|
69
|
+
users: PlanUser[];
|
|
70
|
+
timeblocks: Timeblock[];
|
|
71
|
+
finalizedTimeframes: MeetFinalizedTimeframe[];
|
|
72
|
+
isCreator: boolean;
|
|
73
|
+
}) {
|
|
74
|
+
const t = useTranslations('meet-together-plan-details');
|
|
75
|
+
const { toast } = useToast();
|
|
76
|
+
const queryClient = useQueryClient();
|
|
77
|
+
const { filteredUserIds } = useTimeBlocking();
|
|
78
|
+
const [minimumOverlap, setMinimumOverlap] = useState(0);
|
|
79
|
+
const [includeWeekends, setIncludeWeekends] = useState(true);
|
|
80
|
+
const [includeTentative, setIncludeTentative] = useState(true);
|
|
81
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
82
|
+
const [calendarDestination, setCalendarDestination] = useState('personal');
|
|
83
|
+
const [calendarRange, setCalendarRange] = useState('all');
|
|
84
|
+
const rankedUsers = useMemo(
|
|
85
|
+
() =>
|
|
86
|
+
filteredUserIds.length
|
|
87
|
+
? users.filter((user) => user.id && filteredUserIds.includes(user.id))
|
|
88
|
+
: users,
|
|
89
|
+
[filteredUserIds, users]
|
|
90
|
+
);
|
|
91
|
+
const ranked = useMemo(
|
|
92
|
+
() =>
|
|
93
|
+
rankMeetTimeframes({
|
|
94
|
+
plan,
|
|
95
|
+
users: rankedUsers,
|
|
96
|
+
timeblocks,
|
|
97
|
+
includeTentative,
|
|
98
|
+
}),
|
|
99
|
+
[includeTentative, plan, rankedUsers, timeblocks]
|
|
100
|
+
);
|
|
101
|
+
const visible = ranked.filter((candidate) => {
|
|
102
|
+
const isWeekend = [0, 6].includes(dayjs(candidate.date).day());
|
|
103
|
+
return (
|
|
104
|
+
candidate.confirmedPercent >= minimumOverlap &&
|
|
105
|
+
(includeWeekends || !isWeekend) &&
|
|
106
|
+
candidateToAbsoluteRange(candidate, plan) !== null
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
const queryKey = plan.id ? meetPlanQueryKey(plan.id) : ['meet-plan'];
|
|
110
|
+
|
|
111
|
+
const mutation = useMutation({
|
|
112
|
+
mutationFn: async (action: 'finalize' | 'reopen') => {
|
|
113
|
+
if (!plan.id) throw new Error('Plan id is required');
|
|
114
|
+
if (action === 'reopen') return reopenMeetPlan(plan.id);
|
|
115
|
+
const timeframes = selected.flatMap((key) => {
|
|
116
|
+
const candidate = ranked.find((item) => candidateKey(item) === key);
|
|
117
|
+
const timeframe = candidate
|
|
118
|
+
? candidateToAbsoluteRange(candidate, plan)
|
|
119
|
+
: null;
|
|
120
|
+
return timeframe ? [timeframe] : [];
|
|
121
|
+
});
|
|
122
|
+
return finalizeMeetPlan(plan.id, { timeframes });
|
|
123
|
+
},
|
|
124
|
+
onMutate: async (action) => {
|
|
125
|
+
await queryClient.cancelQueries({ queryKey });
|
|
126
|
+
const previous = queryClient.getQueryData<MeetPlanSnapshot>(queryKey);
|
|
127
|
+
if (previous) {
|
|
128
|
+
queryClient.setQueryData<MeetPlanSnapshot>(queryKey, {
|
|
129
|
+
...previous,
|
|
130
|
+
plan: { ...previous.plan, is_confirmed: action === 'finalize' },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { previous };
|
|
134
|
+
},
|
|
135
|
+
onError: (_error, _action, context) => {
|
|
136
|
+
if (context?.previous)
|
|
137
|
+
queryClient.setQueryData(queryKey, context.previous);
|
|
138
|
+
toast({
|
|
139
|
+
title: t('finalization_failed'),
|
|
140
|
+
description: t('finalization_failed_description'),
|
|
141
|
+
variant: 'destructive',
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
onSuccess: (snapshot) => {
|
|
145
|
+
queryClient.setQueryData(queryKey, snapshot);
|
|
146
|
+
setSelected([]);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const downloadIcs = () => {
|
|
151
|
+
if (!plan.id) return;
|
|
152
|
+
const content = createMeetIcs({
|
|
153
|
+
planId: plan.id,
|
|
154
|
+
title: plan.name || t('tuturuuu_meet'),
|
|
155
|
+
description: plan.description,
|
|
156
|
+
timeframes: finalizedTimeframes,
|
|
157
|
+
});
|
|
158
|
+
const href = URL.createObjectURL(
|
|
159
|
+
new Blob([content], { type: 'text/calendar' })
|
|
160
|
+
);
|
|
161
|
+
const link = document.createElement('a');
|
|
162
|
+
link.href = href;
|
|
163
|
+
link.download = `${plan.name || 'tuturuuu-meet'}.ics`;
|
|
164
|
+
link.click();
|
|
165
|
+
URL.revokeObjectURL(href);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const openCalendarHandoff = () => {
|
|
169
|
+
if (!plan.id) return;
|
|
170
|
+
const timeframes =
|
|
171
|
+
calendarRange === 'all'
|
|
172
|
+
? finalizedTimeframes
|
|
173
|
+
: finalizedTimeframes.filter(
|
|
174
|
+
(timeframe) => timeframe.id === calendarRange
|
|
175
|
+
);
|
|
176
|
+
const query = new URLSearchParams({
|
|
177
|
+
meetPlanId: plan.id,
|
|
178
|
+
ranges: JSON.stringify(
|
|
179
|
+
timeframes.map(({ start_at, end_at }) => ({
|
|
180
|
+
startAt: start_at,
|
|
181
|
+
endAt: end_at,
|
|
182
|
+
}))
|
|
183
|
+
),
|
|
184
|
+
title: plan.name || t('tuturuuu_meet'),
|
|
185
|
+
});
|
|
186
|
+
window.open(
|
|
187
|
+
`https://calendar.tuturuuu.com/${calendarDestination}?${query}`,
|
|
188
|
+
'_blank',
|
|
189
|
+
'noopener,noreferrer'
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (plan.is_confirmed) {
|
|
194
|
+
return (
|
|
195
|
+
<section className="space-y-4">
|
|
196
|
+
<div className="rounded-2xl border bg-primary/[0.04] p-5">
|
|
197
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
198
|
+
<div>
|
|
199
|
+
<Badge className="mb-3 gap-1">
|
|
200
|
+
<LockKeyhole className="h-3 w-3" /> {t('finalized')}
|
|
201
|
+
</Badge>
|
|
202
|
+
<h3 className="font-semibold text-lg">
|
|
203
|
+
{t('locked_meeting_alternatives')}
|
|
204
|
+
</h3>
|
|
205
|
+
<p className="text-muted-foreground text-sm">
|
|
206
|
+
{t('locked_meeting_alternatives_description')}
|
|
207
|
+
</p>
|
|
208
|
+
</div>
|
|
209
|
+
<div className="flex flex-wrap gap-2">
|
|
210
|
+
<Button variant="outline" onClick={downloadIcs}>
|
|
211
|
+
<CalendarCheck className="mr-2 h-4 w-4" />{' '}
|
|
212
|
+
{t('download_calendar_file')}
|
|
213
|
+
</Button>
|
|
214
|
+
<Dialog>
|
|
215
|
+
<DialogTrigger asChild>
|
|
216
|
+
<Button variant="outline">
|
|
217
|
+
<ExternalLink className="mr-2 h-4 w-4" />{' '}
|
|
218
|
+
{t('open_in_calendar')}
|
|
219
|
+
</Button>
|
|
220
|
+
</DialogTrigger>
|
|
221
|
+
<DialogContent>
|
|
222
|
+
<DialogHeader>
|
|
223
|
+
<DialogTitle>{t('calendar_handoff_title')}</DialogTitle>
|
|
224
|
+
<DialogDescription>
|
|
225
|
+
{t('calendar_handoff_description')}
|
|
226
|
+
</DialogDescription>
|
|
227
|
+
</DialogHeader>
|
|
228
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
229
|
+
<label className="grid gap-2 text-sm">
|
|
230
|
+
{t('calendar_destination')}
|
|
231
|
+
<Select
|
|
232
|
+
value={calendarDestination}
|
|
233
|
+
onValueChange={setCalendarDestination}
|
|
234
|
+
>
|
|
235
|
+
<SelectTrigger>
|
|
236
|
+
<SelectValue />
|
|
237
|
+
</SelectTrigger>
|
|
238
|
+
<SelectContent>
|
|
239
|
+
<SelectItem value="personal">
|
|
240
|
+
{t('personal_calendar')}
|
|
241
|
+
</SelectItem>
|
|
242
|
+
<SelectItem value="internal">
|
|
243
|
+
{t('workspace_calendar')}
|
|
244
|
+
</SelectItem>
|
|
245
|
+
</SelectContent>
|
|
246
|
+
</Select>
|
|
247
|
+
</label>
|
|
248
|
+
<label className="grid gap-2 text-sm">
|
|
249
|
+
{t('calendar_ranges')}
|
|
250
|
+
<Select
|
|
251
|
+
value={calendarRange}
|
|
252
|
+
onValueChange={setCalendarRange}
|
|
253
|
+
>
|
|
254
|
+
<SelectTrigger>
|
|
255
|
+
<SelectValue />
|
|
256
|
+
</SelectTrigger>
|
|
257
|
+
<SelectContent>
|
|
258
|
+
<SelectItem value="all">
|
|
259
|
+
{t('all_finalized_ranges')}
|
|
260
|
+
</SelectItem>
|
|
261
|
+
{finalizedTimeframes.map((timeframe, index) => (
|
|
262
|
+
<SelectItem key={timeframe.id} value={timeframe.id}>
|
|
263
|
+
{t('alternative_number', { number: index + 1 })}
|
|
264
|
+
</SelectItem>
|
|
265
|
+
))}
|
|
266
|
+
</SelectContent>
|
|
267
|
+
</Select>
|
|
268
|
+
</label>
|
|
269
|
+
</div>
|
|
270
|
+
<DialogFooter>
|
|
271
|
+
<Button onClick={openCalendarHandoff}>
|
|
272
|
+
{t('continue_to_calendar')}
|
|
273
|
+
</Button>
|
|
274
|
+
</DialogFooter>
|
|
275
|
+
</DialogContent>
|
|
276
|
+
</Dialog>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
<div className="mt-5 grid gap-2 md:grid-cols-2">
|
|
280
|
+
{finalizedTimeframes.map((timeframe, index) => (
|
|
281
|
+
<div
|
|
282
|
+
key={timeframe.id}
|
|
283
|
+
className="rounded-xl border bg-background p-4"
|
|
284
|
+
>
|
|
285
|
+
<span className="text-muted-foreground text-xs">
|
|
286
|
+
{t('alternative_number', { number: index + 1 })}
|
|
287
|
+
</span>
|
|
288
|
+
<p className="mt-1 font-medium tabular-nums">
|
|
289
|
+
{dayjs(timeframe.start_at).format('ddd, MMM D · HH:mm')}–
|
|
290
|
+
{dayjs(timeframe.end_at).format('HH:mm')}
|
|
291
|
+
</p>
|
|
292
|
+
</div>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
{isCreator && (
|
|
297
|
+
<Dialog>
|
|
298
|
+
<DialogTrigger asChild>
|
|
299
|
+
<Button variant="ghost">
|
|
300
|
+
<RotateCcw className="mr-2 h-4 w-4" /> {t('reopen_plan')}
|
|
301
|
+
</Button>
|
|
302
|
+
</DialogTrigger>
|
|
303
|
+
<DialogContent>
|
|
304
|
+
<DialogHeader>
|
|
305
|
+
<DialogTitle>{t('reopen_availability_title')}</DialogTitle>
|
|
306
|
+
<DialogDescription>
|
|
307
|
+
{t('reopen_availability_description')}
|
|
308
|
+
</DialogDescription>
|
|
309
|
+
</DialogHeader>
|
|
310
|
+
<DialogFooter>
|
|
311
|
+
<Button
|
|
312
|
+
onClick={() => mutation.mutate('reopen')}
|
|
313
|
+
disabled={mutation.isPending}
|
|
314
|
+
>
|
|
315
|
+
{t('reopen_plan')}
|
|
316
|
+
</Button>
|
|
317
|
+
</DialogFooter>
|
|
318
|
+
</DialogContent>
|
|
319
|
+
</Dialog>
|
|
320
|
+
)}
|
|
321
|
+
</section>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<section className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_17rem]">
|
|
327
|
+
<div className="space-y-3">
|
|
328
|
+
{visible.slice(0, 12).map((candidate, index) => {
|
|
329
|
+
const key = candidateKey(candidate);
|
|
330
|
+
const active = selected.includes(key);
|
|
331
|
+
return (
|
|
332
|
+
<button
|
|
333
|
+
key={key}
|
|
334
|
+
type="button"
|
|
335
|
+
onClick={() => {
|
|
336
|
+
if (selected.includes(key)) {
|
|
337
|
+
setSelected((current) =>
|
|
338
|
+
current.filter((item) => item !== key)
|
|
339
|
+
);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const overlaps = selected.some((selectedKey) => {
|
|
343
|
+
const selectedCandidate = ranked.find(
|
|
344
|
+
(item) => candidateKey(item) === selectedKey
|
|
345
|
+
);
|
|
346
|
+
return Boolean(
|
|
347
|
+
selectedCandidate &&
|
|
348
|
+
selectedCandidate.date === candidate.date &&
|
|
349
|
+
selectedCandidate.startMinute < candidate.endMinute &&
|
|
350
|
+
candidate.startMinute < selectedCandidate.endMinute
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
if (overlaps) {
|
|
354
|
+
toast({
|
|
355
|
+
title: t('overlapping_timeframes'),
|
|
356
|
+
description: t('overlapping_timeframes_description'),
|
|
357
|
+
variant: 'destructive',
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
setSelected((current) => [...current, key]);
|
|
362
|
+
}}
|
|
363
|
+
className={cn(
|
|
364
|
+
'flex w-full items-center gap-4 rounded-2xl border p-4 text-left transition duration-200 active:scale-[0.995]',
|
|
365
|
+
active
|
|
366
|
+
? 'border-primary bg-primary/[0.06]'
|
|
367
|
+
: 'bg-background hover:border-primary/40'
|
|
368
|
+
)}
|
|
369
|
+
>
|
|
370
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted font-semibold text-xs tabular-nums">
|
|
371
|
+
{active ? <Check className="h-4 w-4" /> : `#${index + 1}`}
|
|
372
|
+
</span>
|
|
373
|
+
<span className="min-w-0 flex-1">
|
|
374
|
+
<span className="block font-semibold tabular-nums">
|
|
375
|
+
{dayjs(candidate.date).format('ddd, MMM D')} ·{' '}
|
|
376
|
+
{formatMinuteOfDay(candidate.startMinute)}–
|
|
377
|
+
{formatMinuteOfDay(candidate.endMinute)}
|
|
378
|
+
</span>
|
|
379
|
+
<span className="mt-1 block text-muted-foreground text-xs">
|
|
380
|
+
{t('recommendation_coverage', {
|
|
381
|
+
confirmed: candidate.confirmedUserIds.length,
|
|
382
|
+
tentative: candidate.tentativeUserIds.length,
|
|
383
|
+
unavailable: candidate.unavailableUserIds.length,
|
|
384
|
+
})}
|
|
385
|
+
</span>
|
|
386
|
+
</span>
|
|
387
|
+
<Badge
|
|
388
|
+
variant={
|
|
389
|
+
candidate.confirmedPercent === 100 ? 'default' : 'secondary'
|
|
390
|
+
}
|
|
391
|
+
className="tabular-nums"
|
|
392
|
+
>
|
|
393
|
+
{Math.round(candidate.confirmedPercent)}%
|
|
394
|
+
</Badge>
|
|
395
|
+
</button>
|
|
396
|
+
);
|
|
397
|
+
})}
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
{visible.length === 0 && (
|
|
401
|
+
<div className="rounded-2xl border border-dashed p-8 text-center text-muted-foreground text-sm">
|
|
402
|
+
{t('no_matching_suggestions')}
|
|
403
|
+
</div>
|
|
404
|
+
)}
|
|
405
|
+
|
|
406
|
+
<aside className="h-fit space-y-5 rounded-2xl border bg-muted/20 p-4 lg:sticky lg:top-4">
|
|
407
|
+
<div>
|
|
408
|
+
<div className="mb-2 flex items-center justify-between text-sm">
|
|
409
|
+
<span>{t('minimum_confirmed')}</span>
|
|
410
|
+
<span className="tabular-nums">{minimumOverlap}%</span>
|
|
411
|
+
</div>
|
|
412
|
+
<Slider
|
|
413
|
+
value={[minimumOverlap]}
|
|
414
|
+
min={0}
|
|
415
|
+
max={100}
|
|
416
|
+
step={10}
|
|
417
|
+
onValueChange={([value]) => setMinimumOverlap(value ?? 0)}
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
<label className="flex items-center justify-between gap-4 text-sm">
|
|
421
|
+
{t('include_weekends')}
|
|
422
|
+
<Switch
|
|
423
|
+
checked={includeWeekends}
|
|
424
|
+
onCheckedChange={setIncludeWeekends}
|
|
425
|
+
/>
|
|
426
|
+
</label>
|
|
427
|
+
<label className="flex items-center justify-between gap-4 text-sm">
|
|
428
|
+
{t('include_tentative')}
|
|
429
|
+
<Switch
|
|
430
|
+
checked={includeTentative}
|
|
431
|
+
onCheckedChange={setIncludeTentative}
|
|
432
|
+
/>
|
|
433
|
+
</label>
|
|
434
|
+
{isCreator && (
|
|
435
|
+
<Dialog>
|
|
436
|
+
<DialogTrigger asChild>
|
|
437
|
+
<Button className="w-full" disabled={selected.length === 0}>
|
|
438
|
+
<LockKeyhole className="mr-2 h-4 w-4" />{' '}
|
|
439
|
+
{t('finalize_timeframes', { count: selected.length })}
|
|
440
|
+
</Button>
|
|
441
|
+
</DialogTrigger>
|
|
442
|
+
<DialogContent>
|
|
443
|
+
<DialogHeader>
|
|
444
|
+
<DialogTitle>{t('finalize_selected_title')}</DialogTitle>
|
|
445
|
+
<DialogDescription>
|
|
446
|
+
{t('finalize_selected_description')}
|
|
447
|
+
</DialogDescription>
|
|
448
|
+
</DialogHeader>
|
|
449
|
+
<DialogFooter>
|
|
450
|
+
<Button
|
|
451
|
+
onClick={() => mutation.mutate('finalize')}
|
|
452
|
+
disabled={mutation.isPending}
|
|
453
|
+
>
|
|
454
|
+
{t('finalize_plan')}
|
|
455
|
+
</Button>
|
|
456
|
+
</DialogFooter>
|
|
457
|
+
</DialogContent>
|
|
458
|
+
</Dialog>
|
|
459
|
+
)}
|
|
460
|
+
</aside>
|
|
461
|
+
</section>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
@@ -5,13 +5,13 @@ import type {
|
|
|
5
5
|
PollOption,
|
|
6
6
|
UserVoteWithUserInfo,
|
|
7
7
|
} from '@tuturuuu/types/primitives/Poll';
|
|
8
|
-
import {
|
|
8
|
+
import type { User as PlatformUser } from '@tuturuuu/types/primitives/User';
|
|
9
9
|
import { getPlan } from '@tuturuuu/utils/plan-helpers';
|
|
10
10
|
import { getCurrentUser } from '@tuturuuu/utils/user-helper';
|
|
11
11
|
import 'dayjs/locale/vi';
|
|
12
12
|
import { notFound } from 'next/navigation';
|
|
13
13
|
import { Suspense } from 'react';
|
|
14
|
-
import
|
|
14
|
+
import { MeetPlanLiveRoot } from './meet-plan-live-root';
|
|
15
15
|
|
|
16
16
|
interface Props {
|
|
17
17
|
params: Promise<{
|
|
@@ -28,7 +28,7 @@ export default async function MeetTogetherPlanDetailsPage({
|
|
|
28
28
|
}: Props) {
|
|
29
29
|
const { planId } = await params;
|
|
30
30
|
|
|
31
|
-
const platformUser = await getCurrentUser();
|
|
31
|
+
const platformUser = (await getCurrentUser()) as PlatformUser | null;
|
|
32
32
|
const plan = await getPlan(planId, { actorUserId });
|
|
33
33
|
|
|
34
34
|
if (!plan) return notFound();
|
|
@@ -39,32 +39,72 @@ export default async function MeetTogetherPlanDetailsPage({
|
|
|
39
39
|
const users: PlanUser[] = await getUsers(canonicalPlanId);
|
|
40
40
|
const polls = await getPollsForPlan(canonicalPlanId);
|
|
41
41
|
const timeblocks = await getTimeBlocks(canonicalPlanId);
|
|
42
|
+
const finalizedTimeframes = await getFinalizedTimeframes(canonicalPlanId);
|
|
43
|
+
|
|
44
|
+
const resolvedPlatformUser =
|
|
45
|
+
platformUser ??
|
|
46
|
+
(actorUserId
|
|
47
|
+
? ({ id: actorUserId, is_guest: false } as PlatformUser)
|
|
48
|
+
: null);
|
|
42
49
|
|
|
43
50
|
return (
|
|
44
51
|
<div className="flex min-h-screen w-full flex-col items-center">
|
|
45
52
|
<Suspense fallback={null}>
|
|
46
53
|
{plan.id && (
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
54
|
+
<MeetPlanLiveRoot
|
|
55
|
+
platformUser={resolvedPlatformUser}
|
|
56
|
+
baseUrl={baseUrl}
|
|
57
|
+
initialSnapshot={{
|
|
58
|
+
plan,
|
|
59
|
+
polls,
|
|
60
|
+
users,
|
|
61
|
+
timeblocks,
|
|
62
|
+
finalizedTimeframes,
|
|
63
|
+
viewer: {
|
|
64
|
+
id: resolvedPlatformUser?.id ?? null,
|
|
65
|
+
isCreator: resolvedPlatformUser?.id === plan.creator_id,
|
|
66
|
+
},
|
|
67
|
+
revision:
|
|
68
|
+
finalizedTimeframes.at(-1)?.updated_at ??
|
|
69
|
+
plan.finalized_at ??
|
|
70
|
+
plan.created_at ??
|
|
71
|
+
plan.id,
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
62
74
|
)}
|
|
63
75
|
</Suspense>
|
|
64
76
|
</div>
|
|
65
77
|
);
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
async function getFinalizedTimeframes(planId: string) {
|
|
81
|
+
const admin = await createAdminClient();
|
|
82
|
+
const query = (
|
|
83
|
+
admin as unknown as {
|
|
84
|
+
from(table: string): {
|
|
85
|
+
select(columns: string): {
|
|
86
|
+
eq(
|
|
87
|
+
column: string,
|
|
88
|
+
value: string
|
|
89
|
+
): {
|
|
90
|
+
order(column: string): PromiseLike<{
|
|
91
|
+
data: unknown[] | null;
|
|
92
|
+
error: unknown;
|
|
93
|
+
}>;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
.from('meet_together_finalized_timeframes')
|
|
100
|
+
.select('*')
|
|
101
|
+
.eq('plan_id', planId)
|
|
102
|
+
.order('position');
|
|
103
|
+
const { data, error } = await query;
|
|
104
|
+
if (error) return [];
|
|
105
|
+
return data as import('@tuturuuu/types/primitives/MeetTogetherPlan').MeetFinalizedTimeframe[];
|
|
106
|
+
}
|
|
107
|
+
|
|
68
108
|
async function getUsers(planId: string) {
|
|
69
109
|
const sbAdmin = await createAdminClient();
|
|
70
110
|
|