@tuturuuu/ui 0.37.2 → 0.39.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 +55 -0
- package/package.json +10 -7
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
- package/src/components/ui/custom/education-todo.test.tsx +131 -0
- package/src/components/ui/custom/education-todo.tsx +191 -0
- package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
- package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
- package/src/components/ui/custom/workspace-select.tsx +12 -4
- package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
- package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
- package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
- package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
- package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
- package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
- package/src/components/ui/popover.tsx +3 -1
- package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
- package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
- package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
- package/src/components/ui/text-editor/editor.tsx +11 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
- package/src/components/ui/text-editor/markdown.ts +11 -0
- package/src/components/ui/text-editor/tool-bar.tsx +45 -102
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-creation-request.test.ts +62 -0
- package/src/hooks/calendar-creation-request.ts +34 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +57 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +76 -112
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +44 -82
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-yjs-collaboration.ts +68 -27
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { ArrowLeft, Eye, EyeOff, Plus, Search, Star, X } from '@tuturuuu/icons';
|
|
4
4
|
import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
|
|
5
5
|
import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
|
|
6
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
6
7
|
import { useTranslations } from 'next-intl';
|
|
7
8
|
import { type ReactNode, useRef, useState } from 'react';
|
|
8
9
|
import type { useWorkspaceVisibility } from '../../../hooks/use-workspace-visibility';
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
DialogTitle,
|
|
15
16
|
} from '../dialog';
|
|
16
17
|
import { Input } from '../input';
|
|
18
|
+
import { PopoverClose, PopoverContent } from '../popover';
|
|
17
19
|
import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
|
|
18
20
|
import { WorkspaceIcon } from './workspace-select-icon';
|
|
19
21
|
|
|
@@ -32,6 +34,8 @@ export function WorkspacePickerContent({
|
|
|
32
34
|
listLoading = false,
|
|
33
35
|
listError,
|
|
34
36
|
onRetryList,
|
|
37
|
+
presentation = 'fullscreen',
|
|
38
|
+
onClose,
|
|
35
39
|
}: {
|
|
36
40
|
workspaces: InternalApiWorkspaceSummary[];
|
|
37
41
|
currentId?: string;
|
|
@@ -46,10 +50,15 @@ export function WorkspacePickerContent({
|
|
|
46
50
|
listLoading?: boolean;
|
|
47
51
|
listError?: Error | null;
|
|
48
52
|
onRetryList?: () => void;
|
|
53
|
+
presentation?: 'fullscreen' | 'dropdown';
|
|
54
|
+
onClose?: () => void;
|
|
49
55
|
}) {
|
|
56
|
+
const dropdown = presentation === 'dropdown';
|
|
57
|
+
const Title = dropdown ? 'h2' : DialogTitle;
|
|
58
|
+
const Description = dropdown ? 'p' : DialogDescription;
|
|
50
59
|
const t = useTranslations('common');
|
|
51
60
|
const [restoring, setRestoring] = useState(restoreOnly);
|
|
52
|
-
const [searchVisible, setSearchVisible] = useState(
|
|
61
|
+
const [searchVisible, setSearchVisible] = useState(dropdown);
|
|
53
62
|
const [search, setSearch] = useState('');
|
|
54
63
|
const input = useRef<HTMLInputElement>(null);
|
|
55
64
|
const query = search.trim().toLocaleLowerCase();
|
|
@@ -75,13 +84,14 @@ export function WorkspacePickerContent({
|
|
|
75
84
|
setSearchVisible(true);
|
|
76
85
|
requestAnimationFrame(() => input.current?.focus());
|
|
77
86
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
const content = (
|
|
88
|
+
<>
|
|
89
|
+
<div
|
|
90
|
+
className={cn(
|
|
91
|
+
'flex items-center gap-3 border-b p-4',
|
|
92
|
+
!dropdown && 'pt-[max(1rem,env(safe-area-inset-top))]'
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
85
95
|
{restoring && !restoreOnly && (
|
|
86
96
|
<Button
|
|
87
97
|
size="icon"
|
|
@@ -96,18 +106,31 @@ export function WorkspacePickerContent({
|
|
|
96
106
|
</Button>
|
|
97
107
|
)}
|
|
98
108
|
<WorkspaceIcon fallbackLogoUrl={TUTURUUU_LOGO_URL} name="Tuturuuu" />
|
|
99
|
-
<
|
|
109
|
+
<Title className="min-w-0 flex-1 font-semibold text-lg leading-none">
|
|
100
110
|
{restoring ? t('hidden_workspaces') : t('workspaces')}
|
|
101
|
-
</
|
|
102
|
-
|
|
103
|
-
<
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
</Title>
|
|
112
|
+
{dropdown ? (
|
|
113
|
+
<PopoverClose asChild>
|
|
114
|
+
<Button
|
|
115
|
+
size="icon"
|
|
116
|
+
variant="ghost"
|
|
117
|
+
aria-label={t('close')}
|
|
118
|
+
onClick={onClose}
|
|
119
|
+
>
|
|
120
|
+
<X />
|
|
121
|
+
</Button>
|
|
122
|
+
</PopoverClose>
|
|
123
|
+
) : (
|
|
124
|
+
<DialogClose asChild>
|
|
125
|
+
<Button size="icon" variant="ghost" aria-label={t('close')}>
|
|
126
|
+
<X />
|
|
127
|
+
</Button>
|
|
128
|
+
</DialogClose>
|
|
129
|
+
)}
|
|
107
130
|
</div>
|
|
108
|
-
<
|
|
131
|
+
<Description className={restoring ? 'px-4 pt-3' : 'sr-only'}>
|
|
109
132
|
{restoring ? t('hidden_workspaces_description') : t('select_workspace')}
|
|
110
|
-
</
|
|
133
|
+
</Description>
|
|
111
134
|
{searchVisible && (
|
|
112
135
|
<div className="flex gap-2 px-4 pt-3">
|
|
113
136
|
<Input
|
|
@@ -148,7 +171,12 @@ export function WorkspacePickerContent({
|
|
|
148
171
|
</Button>
|
|
149
172
|
</div>
|
|
150
173
|
)}
|
|
151
|
-
<div
|
|
174
|
+
<div
|
|
175
|
+
className={cn(
|
|
176
|
+
'min-h-0 flex-1 overflow-y-auto px-4 pt-4',
|
|
177
|
+
dropdown ? 'pb-3' : 'pb-28'
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
152
180
|
{!visibility.known || listLoading || listError ? (
|
|
153
181
|
<p role="status">
|
|
154
182
|
{visibility.isError || listError
|
|
@@ -161,7 +189,10 @@ export function WorkspacePickerContent({
|
|
|
161
189
|
{results.map((workspace) => (
|
|
162
190
|
<div
|
|
163
191
|
key={workspace.id}
|
|
164
|
-
className=
|
|
192
|
+
className={cn(
|
|
193
|
+
'flex items-center gap-2',
|
|
194
|
+
dropdown ? 'mb-1 rounded-md' : 'mb-2 rounded-xl border p-2'
|
|
195
|
+
)}
|
|
165
196
|
>
|
|
166
197
|
{restoring ? (
|
|
167
198
|
<div className="flex min-w-0 flex-1 items-center gap-3 p-2">
|
|
@@ -174,7 +205,10 @@ export function WorkspacePickerContent({
|
|
|
174
205
|
) : (
|
|
175
206
|
<Button
|
|
176
207
|
variant="ghost"
|
|
177
|
-
className=
|
|
208
|
+
className={cn(
|
|
209
|
+
'h-auto min-w-0 flex-1 justify-start gap-3 whitespace-normal text-start',
|
|
210
|
+
dropdown ? 'py-2' : 'py-3'
|
|
211
|
+
)}
|
|
178
212
|
aria-label={[
|
|
179
213
|
workspace.name ?? workspace.id,
|
|
180
214
|
workspace.tier ?? 'FREE',
|
|
@@ -268,26 +302,55 @@ export function WorkspacePickerContent({
|
|
|
268
302
|
</Button>
|
|
269
303
|
)}
|
|
270
304
|
</div>
|
|
271
|
-
<div
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
{!
|
|
305
|
+
<div
|
|
306
|
+
className={
|
|
307
|
+
dropdown
|
|
308
|
+
? restoring || !onCreate
|
|
309
|
+
? 'hidden'
|
|
310
|
+
: 'flex gap-2 border-t p-2'
|
|
311
|
+
: 'absolute right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] flex gap-3 rounded-full border bg-background p-2 shadow-lg'
|
|
312
|
+
}
|
|
313
|
+
>
|
|
314
|
+
{!dropdown && (
|
|
281
315
|
<Button
|
|
282
316
|
size="icon"
|
|
283
317
|
className="rounded-full"
|
|
318
|
+
aria-label={t('search_workspace')}
|
|
319
|
+
onClick={openSearch}
|
|
320
|
+
>
|
|
321
|
+
<Search />
|
|
322
|
+
</Button>
|
|
323
|
+
)}
|
|
324
|
+
{!restoring && onCreate && (
|
|
325
|
+
<Button
|
|
326
|
+
size={dropdown ? 'sm' : 'icon'}
|
|
327
|
+
className={dropdown ? 'w-full justify-start' : 'rounded-full'}
|
|
284
328
|
aria-label={t('create_workspace_action')}
|
|
285
329
|
onClick={onCreate}
|
|
286
330
|
>
|
|
287
331
|
<Plus />
|
|
332
|
+
{dropdown && t('create_workspace_action')}
|
|
288
333
|
</Button>
|
|
289
334
|
)}
|
|
290
335
|
</div>
|
|
336
|
+
</>
|
|
337
|
+
);
|
|
338
|
+
return dropdown ? (
|
|
339
|
+
<PopoverContent
|
|
340
|
+
align="start"
|
|
341
|
+
aria-label={t(restoring ? 'hidden_workspaces' : 'workspaces')}
|
|
342
|
+
collisionPadding={8}
|
|
343
|
+
className="flex max-h-[min(32rem,var(--radix-popover-content-available-height))] w-80 max-w-[min(calc(100vw-1rem),var(--radix-popover-content-available-width))] flex-col overflow-hidden p-0"
|
|
344
|
+
>
|
|
345
|
+
{content}
|
|
346
|
+
</PopoverContent>
|
|
347
|
+
) : (
|
|
348
|
+
<DialogContent
|
|
349
|
+
presentation="fullscreen"
|
|
350
|
+
showCloseButton={false}
|
|
351
|
+
className="flex-col"
|
|
352
|
+
>
|
|
353
|
+
{content}
|
|
291
354
|
</DialogContent>
|
|
292
355
|
);
|
|
293
356
|
}
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
FormMessage,
|
|
55
55
|
} from '../form';
|
|
56
56
|
import { Input } from '../input';
|
|
57
|
+
import { Popover, PopoverTrigger } from '../popover';
|
|
57
58
|
import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
|
|
58
59
|
import { WorkspacePickerContent } from './workspace-picker-content';
|
|
59
60
|
import {
|
|
@@ -93,6 +94,7 @@ export function WorkspaceSelect({
|
|
|
93
94
|
triggerClassName,
|
|
94
95
|
platformWorkspaceSetupUrl,
|
|
95
96
|
cacheScope,
|
|
97
|
+
presentation = 'dropdown',
|
|
96
98
|
}: {
|
|
97
99
|
wsId: string;
|
|
98
100
|
hideLeading?: boolean;
|
|
@@ -113,7 +115,11 @@ export function WorkspaceSelect({
|
|
|
113
115
|
platformWorkspaceSetupUrl?: string;
|
|
114
116
|
/** Authenticated identity used to isolate user-specific picker caches. */
|
|
115
117
|
cacheScope?: string;
|
|
118
|
+
presentation?: 'fullscreen' | 'dropdown';
|
|
116
119
|
}) {
|
|
120
|
+
const Picker = presentation === 'dropdown' ? Popover : Dialog;
|
|
121
|
+
const PickerTrigger =
|
|
122
|
+
presentation === 'dropdown' ? PopoverTrigger : DialogTrigger;
|
|
117
123
|
const t = useTranslations();
|
|
118
124
|
const locale = useLocale();
|
|
119
125
|
const router = useRouter();
|
|
@@ -417,8 +423,8 @@ export function WorkspaceSelect({
|
|
|
417
423
|
setShowNewWorkspaceDialog(open);
|
|
418
424
|
}}
|
|
419
425
|
>
|
|
420
|
-
<
|
|
421
|
-
<
|
|
426
|
+
<Picker open={open} onOpenChange={setOpen}>
|
|
427
|
+
<PickerTrigger asChild disabled={!hasSelectableWorkspaces}>
|
|
422
428
|
<Button
|
|
423
429
|
size="xs"
|
|
424
430
|
variant="outline"
|
|
@@ -485,8 +491,10 @@ export function WorkspaceSelect({
|
|
|
485
491
|
<ChevronDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
|
|
486
492
|
)}
|
|
487
493
|
</Button>
|
|
488
|
-
</
|
|
494
|
+
</PickerTrigger>
|
|
489
495
|
<WorkspacePickerContent
|
|
496
|
+
presentation={presentation}
|
|
497
|
+
onClose={() => setOpen(false)}
|
|
490
498
|
workspaces={workspaces ?? []}
|
|
491
499
|
currentId={workspace?.id}
|
|
492
500
|
defaultId={defaultWorkspaceId}
|
|
@@ -517,7 +525,7 @@ export function WorkspaceSelect({
|
|
|
517
525
|
</Command>
|
|
518
526
|
}
|
|
519
527
|
/>
|
|
520
|
-
</
|
|
528
|
+
</Picker>
|
|
521
529
|
<DialogContent>
|
|
522
530
|
<DialogHeader>
|
|
523
531
|
<DialogTitle>{t('common.create_workspace')}</DialogTitle>
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cleanup,
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
waitFor,
|
|
6
|
+
within,
|
|
7
|
+
} from '@testing-library/react';
|
|
8
|
+
import type { Database } from '@tuturuuu/types/supabase';
|
|
9
|
+
import { TooltipProvider } from '@tuturuuu/ui/tooltip';
|
|
10
|
+
import { NextIntlClientProvider } from 'next-intl';
|
|
11
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
12
|
+
import en from '../../../../../../../apps/finance/messages/en.json';
|
|
13
|
+
import vi from '../../../../../../../apps/finance/messages/vi.json';
|
|
14
|
+
import { AttendanceCalendar } from './attendance-calendar';
|
|
15
|
+
|
|
16
|
+
type Props = Parameters<typeof AttendanceCalendar>[0];
|
|
17
|
+
type Occurrence = Pick<
|
|
18
|
+
Database['public']['Tables']['user_group_attendance']['Row'],
|
|
19
|
+
'id' | 'session_id' | 'user_id' | 'group_id' | 'date' | 'status'
|
|
20
|
+
>;
|
|
21
|
+
type Summary = 'ABSENT' | 'LATE' | 'PRESENT' | 'UNKNOWN';
|
|
22
|
+
const date = '2026-10-08';
|
|
23
|
+
const groupId = '10000000-0000-4000-8000-000000000001';
|
|
24
|
+
const otherGroupId = '10000000-0000-4000-8000-000000000002';
|
|
25
|
+
const sessions: Props['sessions'] = [{ date, groupId, groupName: 'Class A' }];
|
|
26
|
+
const colors = { ABSENT: 'red', LATE: 'yellow', PRESENT: 'green' };
|
|
27
|
+
const keys = {
|
|
28
|
+
ABSENT: 'absent',
|
|
29
|
+
LATE: 'late',
|
|
30
|
+
PRESENT: 'present',
|
|
31
|
+
UNKNOWN: 'no_attendance',
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
function attendance(statuses: string[]): Props['userAttendance'] {
|
|
35
|
+
const occurrences: Occurrence[] = statuses.map((status, index) => ({
|
|
36
|
+
id: `20000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
|
|
37
|
+
session_id: `30000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
|
|
38
|
+
user_id: '40000000-0000-4000-8000-000000000001',
|
|
39
|
+
group_id: groupId,
|
|
40
|
+
date,
|
|
41
|
+
status,
|
|
42
|
+
}));
|
|
43
|
+
return occurrences.map(({ date, status, group_id }) => ({
|
|
44
|
+
date,
|
|
45
|
+
status,
|
|
46
|
+
group_id,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mount(
|
|
51
|
+
locale: string,
|
|
52
|
+
messages: typeof en | typeof vi,
|
|
53
|
+
overrides: Partial<Props> = {}
|
|
54
|
+
) {
|
|
55
|
+
const props: Props = {
|
|
56
|
+
userAttendance: [],
|
|
57
|
+
selectedMonth: '2026-10',
|
|
58
|
+
sessions,
|
|
59
|
+
locale,
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
const before = JSON.stringify(props);
|
|
63
|
+
props.userAttendance.forEach(Object.freeze);
|
|
64
|
+
props.sessions.forEach(Object.freeze);
|
|
65
|
+
Object.freeze(props.userAttendance);
|
|
66
|
+
Object.freeze(props.sessions);
|
|
67
|
+
const view = render(
|
|
68
|
+
<NextIntlClientProvider locale={locale} messages={messages} timeZone="UTC">
|
|
69
|
+
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
|
|
70
|
+
<AttendanceCalendar {...props} />
|
|
71
|
+
</TooltipProvider>
|
|
72
|
+
</NextIntlClientProvider>
|
|
73
|
+
);
|
|
74
|
+
const grids = view.container.querySelectorAll('.grid-cols-7');
|
|
75
|
+
expect(grids).toHaveLength(2);
|
|
76
|
+
expect(grids[0]?.children).toHaveLength(7);
|
|
77
|
+
expect(grids[1]?.children).toHaveLength(42);
|
|
78
|
+
const cell = Array.from(grids[1]?.children ?? []).find(
|
|
79
|
+
(child) => child.textContent === '8' && child.hasAttribute('data-slot')
|
|
80
|
+
);
|
|
81
|
+
if (!(cell instanceof HTMLElement))
|
|
82
|
+
throw new Error('Current-month October 8 is missing');
|
|
83
|
+
return {
|
|
84
|
+
...view,
|
|
85
|
+
cell,
|
|
86
|
+
grid: grids[1]!,
|
|
87
|
+
unchanged: () => expect(JSON.stringify(props)).toBe(before),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function hover(cell: HTMLElement) {
|
|
92
|
+
fireEvent.pointerMove(cell, { pointerType: 'mouse', clientX: 1, clientY: 1 });
|
|
93
|
+
return waitFor(() => {
|
|
94
|
+
const content = document.querySelector('[data-slot="tooltip-content"]');
|
|
95
|
+
expect(content).toBeVisible();
|
|
96
|
+
if (!(content instanceof HTMLElement))
|
|
97
|
+
throw new Error('Real tooltip portal is missing');
|
|
98
|
+
return content;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function groupStatus(content: HTMLElement, groupName: string) {
|
|
103
|
+
// Accept the visible portal itself; exclude any nested accessibility copy.
|
|
104
|
+
const label = Array.from(content.querySelectorAll('span')).find(
|
|
105
|
+
(span) =>
|
|
106
|
+
span.textContent === `${groupName}:` &&
|
|
107
|
+
(!span.closest('[role="tooltip"]') ||
|
|
108
|
+
span.closest('[role="tooltip"]') === content)
|
|
109
|
+
);
|
|
110
|
+
if (!label?.parentElement)
|
|
111
|
+
throw new Error(`Visible tooltip group ${groupName} is missing`);
|
|
112
|
+
return label.parentElement;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function assertSummary(
|
|
116
|
+
cell: HTMLElement,
|
|
117
|
+
line: HTMLElement,
|
|
118
|
+
messages: typeof en | typeof vi,
|
|
119
|
+
status: Summary
|
|
120
|
+
) {
|
|
121
|
+
const label = messages['ws-invoices'][keys[status]];
|
|
122
|
+
expect.soft(line.lastElementChild).toHaveTextContent(label);
|
|
123
|
+
expect
|
|
124
|
+
.soft(line.lastElementChild)
|
|
125
|
+
.toHaveClass(
|
|
126
|
+
status === 'UNKNOWN'
|
|
127
|
+
? 'text-muted-foreground'
|
|
128
|
+
: `text-dynamic-${colors[status]}`
|
|
129
|
+
);
|
|
130
|
+
if (status === 'UNKNOWN') {
|
|
131
|
+
expect
|
|
132
|
+
.soft(cell)
|
|
133
|
+
.toHaveClass('text-foreground/50', 'bg-muted/50', 'border-foreground/20');
|
|
134
|
+
} else {
|
|
135
|
+
const color = colors[status];
|
|
136
|
+
expect
|
|
137
|
+
.soft(cell)
|
|
138
|
+
.toHaveClass(
|
|
139
|
+
`text-dynamic-${color}`,
|
|
140
|
+
`bg-dynamic-${color}/10`,
|
|
141
|
+
`border-dynamic-${color}/20`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
afterEach(cleanup);
|
|
147
|
+
|
|
148
|
+
const cases: [string, string[], Summary][] = [
|
|
149
|
+
['forward PRESENT ABSENT control', ['PRESENT', 'ABSENT'], 'ABSENT'],
|
|
150
|
+
['reverse ABSENT PRESENT causal regression', ['ABSENT', 'PRESENT'], 'ABSENT'],
|
|
151
|
+
['PRESENT LATE', ['PRESENT', 'LATE'], 'LATE'],
|
|
152
|
+
['LATE PRESENT', ['LATE', 'PRESENT'], 'LATE'],
|
|
153
|
+
['ABSENT LATE', ['ABSENT', 'LATE'], 'ABSENT'],
|
|
154
|
+
['LATE ABSENT', ['LATE', 'ABSENT'], 'ABSENT'],
|
|
155
|
+
['triple P L A', ['PRESENT', 'LATE', 'ABSENT'], 'ABSENT'],
|
|
156
|
+
['triple P A L', ['PRESENT', 'ABSENT', 'LATE'], 'ABSENT'],
|
|
157
|
+
['triple L P A', ['LATE', 'PRESENT', 'ABSENT'], 'ABSENT'],
|
|
158
|
+
['triple L A P', ['LATE', 'ABSENT', 'PRESENT'], 'ABSENT'],
|
|
159
|
+
['triple A P L', ['ABSENT', 'PRESENT', 'LATE'], 'ABSENT'],
|
|
160
|
+
['triple A L P', ['ABSENT', 'LATE', 'PRESENT'], 'ABSENT'],
|
|
161
|
+
['single present', ['PRESENT'], 'PRESENT'],
|
|
162
|
+
['single late', ['LATE'], 'LATE'],
|
|
163
|
+
['single absent', ['ABSENT'], 'ABSENT'],
|
|
164
|
+
['duplicate absent', ['ABSENT', 'ABSENT'], 'ABSENT'],
|
|
165
|
+
['lowercase normalization', ['present', 'absent'], 'ABSENT'],
|
|
166
|
+
['unknown then known', ['EXCUSED', 'PRESENT'], 'PRESENT'],
|
|
167
|
+
['known then unknown', ['PRESENT', 'EXCUSED'], 'PRESENT'],
|
|
168
|
+
['unknown then late', ['EXCUSED', 'LATE'], 'LATE'],
|
|
169
|
+
['late then unknown', ['LATE', 'EXCUSED'], 'LATE'],
|
|
170
|
+
['unknown then absent', ['EXCUSED', 'ABSENT'], 'ABSENT'],
|
|
171
|
+
['absent then unknown', ['ABSENT', 'EXCUSED'], 'ABSENT'],
|
|
172
|
+
['unknown only', ['EXCUSED'], 'UNKNOWN'],
|
|
173
|
+
['empty status compatibility', [''], 'PRESENT'],
|
|
174
|
+
['scheduled without attendance', [], 'UNKNOWN'],
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
describe.each([
|
|
178
|
+
['en', en],
|
|
179
|
+
['vi', vi],
|
|
180
|
+
] as const)('AttendanceCalendar %s', (locale, messages) => {
|
|
181
|
+
it.each(cases)('%s', async (_name, statuses, expected) => {
|
|
182
|
+
const view = mount(locale, messages, {
|
|
183
|
+
userAttendance: attendance(statuses),
|
|
184
|
+
});
|
|
185
|
+
const content = await hover(view.cell);
|
|
186
|
+
assertSummary(
|
|
187
|
+
view.cell,
|
|
188
|
+
groupStatus(content, 'Class A'),
|
|
189
|
+
messages,
|
|
190
|
+
expected
|
|
191
|
+
);
|
|
192
|
+
view.unchanged();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('retains null runtime status fallback', async () => {
|
|
196
|
+
const rows = attendance(['']);
|
|
197
|
+
// Props declare string, but the existing optional-chain fallback accepts null at runtime.
|
|
198
|
+
Reflect.set(rows[0]!, 'status', null);
|
|
199
|
+
const view = mount(locale, messages, { userAttendance: rows });
|
|
200
|
+
const content = await hover(view.cell);
|
|
201
|
+
assertSummary(
|
|
202
|
+
view.cell,
|
|
203
|
+
groupStatus(content, 'Class A'),
|
|
204
|
+
messages,
|
|
205
|
+
'PRESENT'
|
|
206
|
+
);
|
|
207
|
+
view.unchanged();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it.each([false, true])(
|
|
211
|
+
'keeps separate group statuses, reverse=%s',
|
|
212
|
+
async (reverse) => {
|
|
213
|
+
const rows = attendance(['PRESENT', 'ABSENT']);
|
|
214
|
+
rows[1]!.group_id = otherGroupId;
|
|
215
|
+
const view = mount(locale, messages, {
|
|
216
|
+
userAttendance: reverse ? rows.reverse() : rows,
|
|
217
|
+
sessions: [
|
|
218
|
+
...sessions,
|
|
219
|
+
{ date, groupId: otherGroupId, groupName: 'Class B' },
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
const content = await hover(view.cell);
|
|
223
|
+
expect(
|
|
224
|
+
within(groupStatus(content, 'Class A')).getByText(
|
|
225
|
+
messages['ws-invoices'].present
|
|
226
|
+
)
|
|
227
|
+
).toHaveClass('text-dynamic-green');
|
|
228
|
+
assertSummary(
|
|
229
|
+
view.cell,
|
|
230
|
+
groupStatus(content, 'Class B'),
|
|
231
|
+
messages,
|
|
232
|
+
'ABSENT'
|
|
233
|
+
);
|
|
234
|
+
view.unchanged();
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
it('preserves default group matching when group_id is omitted', async () => {
|
|
239
|
+
const rows = attendance(['PRESENT']);
|
|
240
|
+
delete rows[0]!.group_id;
|
|
241
|
+
const view = mount(locale, messages, {
|
|
242
|
+
userAttendance: rows,
|
|
243
|
+
sessions: [{ date, groupId: 'default', groupName: 'Class A' }],
|
|
244
|
+
});
|
|
245
|
+
const content = await hover(view.cell);
|
|
246
|
+
assertSummary(
|
|
247
|
+
view.cell,
|
|
248
|
+
groupStatus(content, 'Class A'),
|
|
249
|
+
messages,
|
|
250
|
+
'PRESENT'
|
|
251
|
+
);
|
|
252
|
+
view.unchanged();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('keeps unscheduled attendance muted with no-session tooltip', async () => {
|
|
256
|
+
const view = mount(locale, messages, {
|
|
257
|
+
userAttendance: attendance(['ABSENT']),
|
|
258
|
+
sessions: [],
|
|
259
|
+
});
|
|
260
|
+
const content = await hover(view.cell);
|
|
261
|
+
expect(view.cell).toHaveClass(
|
|
262
|
+
'text-foreground/30',
|
|
263
|
+
'bg-muted/30',
|
|
264
|
+
'border-transparent'
|
|
265
|
+
);
|
|
266
|
+
expect(
|
|
267
|
+
Array.from(content.querySelectorAll('p')).find(
|
|
268
|
+
(p) =>
|
|
269
|
+
p.textContent === messages['ws-invoices'].no_session &&
|
|
270
|
+
(!p.closest('[role="tooltip"]') ||
|
|
271
|
+
p.closest('[role="tooltip"]') === content)
|
|
272
|
+
)
|
|
273
|
+
).toBeVisible();
|
|
274
|
+
view.unchanged();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('keeps adjacent months muted and maintains the Monday-first 42-day grid on navigation', () => {
|
|
278
|
+
const view = mount(locale, messages);
|
|
279
|
+
const first = view.grid.children[0];
|
|
280
|
+
expect(first).toHaveTextContent('28');
|
|
281
|
+
expect(first).toHaveClass(
|
|
282
|
+
'text-foreground/20',
|
|
283
|
+
'bg-transparent',
|
|
284
|
+
'border-transparent'
|
|
285
|
+
);
|
|
286
|
+
expect(first).not.toHaveAttribute('data-slot');
|
|
287
|
+
view.unchanged();
|
|
288
|
+
view.unmount();
|
|
289
|
+
const next = mount(locale, messages, { selectedMonth: '2026-11' });
|
|
290
|
+
expect(next.grid.children[0]).toHaveTextContent('26');
|
|
291
|
+
expect(next.grid.children[41]).toHaveTextContent('6');
|
|
292
|
+
next.unchanged();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -41,12 +41,20 @@ export function AttendanceCalendar({
|
|
|
41
41
|
if (!map.has(dateKey)) {
|
|
42
42
|
map.set(dateKey, new Map());
|
|
43
43
|
}
|
|
44
|
-
map
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
const groupStatuses = map.get(dateKey)!;
|
|
45
|
+
const groupId = attendance.group_id || 'default';
|
|
46
|
+
const status = attendance.status?.toUpperCase() || 'PRESENT';
|
|
47
|
+
const priority = (value: string | undefined) =>
|
|
48
|
+
value === 'ABSENT'
|
|
49
|
+
? 3
|
|
50
|
+
: value === 'LATE'
|
|
51
|
+
? 2
|
|
52
|
+
: value === 'PRESENT'
|
|
53
|
+
? 1
|
|
54
|
+
: 0;
|
|
55
|
+
if (priority(status) >= priority(groupStatuses.get(groupId))) {
|
|
56
|
+
groupStatuses.set(groupId, status);
|
|
57
|
+
}
|
|
50
58
|
});
|
|
51
59
|
return map;
|
|
52
60
|
}, [userAttendance]);
|