@tuturuuu/ui 0.38.0 → 0.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/package.json +8 -8
- package/src/components/ui/custom/public-profile-menu-item.tsx +25 -0
- package/src/components/ui/custom/satellite-user-menu-items.test.tsx +61 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +8 -0
- 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 +489 -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-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 -1554
- 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-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
- 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/public-link-button.test.tsx +116 -0
- package/src/components/ui/public-link-button.tsx +81 -0
- package/src/components/ui/text-editor/editor.tsx +3 -0
- 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/tool-bar.tsx +18 -74
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
- 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-yjs-collaboration.test.tsx +39 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +33 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/supabase-provider.ts +39 -41
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +24 -38
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-yjs-collaboration.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.39.1](https://github.com/tutur3u/platform/compare/ui-v0.39.0...ui-v0.39.1) (2026-10-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **release:** reconcile production history before Contacts rollout ([dc017a2](https://github.com/tutur3u/platform/commit/dc017a227de6f284268b34690198972c06c52434)) ([#6241](https://github.com/tutur3u/platform/issues/6241)) ([66654a5](https://github.com/tutur3u/platform/commit/66654a5d7ad441a93070d1502f267d6d4e08a6cd))
|
|
9
|
+
|
|
10
|
+
## [0.39.0](https://github.com/tutur3u/platform/compare/ui-v0.38.0...ui-v0.39.0) (2026-10-09)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **docs:** expand platform guides, enforce SEO, and pause inactive runtimes ([#6085](https://github.com/tutur3u/platform/issues/6085)) ([df7a159](https://github.com/tutur3u/platform/commit/df7a159e9c657fee4f6e0b593c776d6887b2ae11))
|
|
16
|
+
* **meet:** simplify the in-call document toolbar ([6020a1e](https://github.com/tutur3u/platform/commit/6020a1eddfc4f8cab9179daf1feb126f9dfc0501)) ([#5918](https://github.com/tutur3u/platform/issues/5918)) ([fa358e7](https://github.com/tutur3u/platform/commit/fa358e76517e5bc9793caf72658738d9bf2b42d6))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Bug Fixes
|
|
20
|
+
|
|
21
|
+
* **calendar:** stabilize civil dates and today rendering ([c1abdf0](https://github.com/tutur3u/platform/commit/c1abdf0df75c0a02cc00c37b7a0a5e55c862bf89)) ([#5950](https://github.com/tutur3u/platform/issues/5950)) ([5a00326](https://github.com/tutur3u/platform/commit/5a00326171bec65ec7210de161dd5272bfda22b2))
|
|
22
|
+
* **finance:** bound subscription schedules and block invalid inventory ([f4163bf](https://github.com/tutur3u/platform/commit/f4163bf6697316a06a8b2d3922d9664937dd6940)) ([#5990](https://github.com/tutur3u/platform/issues/5990)) ([f2096f6](https://github.com/tutur3u/platform/commit/f2096f6e20d27dd998a03d11c7b0769d7f06bb2e))
|
|
23
|
+
* **finance:** expose safe invoice history recovery ([8a53d36](https://github.com/tutur3u/platform/commit/8a53d367d6888e592a0d64f760d47234d858bd73)) ([#6034](https://github.com/tutur3u/platform/issues/6034)) ([fd65a3d](https://github.com/tutur3u/platform/commit/fd65a3db6f7472771374192ab07f5a97c6fcaee8))
|
|
24
|
+
* **finance:** fence customer search to verified actor scope ([416d710](https://github.com/tutur3u/platform/commit/416d710961a509c06637ac2a93878f24ae0a03ae)) ([#6031](https://github.com/tutur3u/platform/issues/6031)) ([1d5a023](https://github.com/tutur3u/platform/commit/1d5a023d917ab75e19f575dec51a03be841763bc))
|
|
25
|
+
* **finance:** fence invoice create completion to admitted draft ([5b1cd6f](https://github.com/tutur3u/platform/commit/5b1cd6ff9e228002ceee9d2d347e7a2f5bf61b03)) ([#6023](https://github.com/tutur3u/platform/issues/6023)) ([2e7f99f](https://github.com/tutur3u/platform/commit/2e7f99ffd26d2e877e6e8567c3873bffa6bdcf45))
|
|
26
|
+
* **finance:** fence invoice PNG export lifetimes ([7d21df4](https://github.com/tutur3u/platform/commit/7d21df4d3301a388e6fa2823f68fb25d0c71d1c4)) ([#6038](https://github.com/tutur3u/platform/issues/6038)) ([4a5365a](https://github.com/tutur3u/platform/commit/4a5365ae1a6d28ef142bdc904695491475a740d3))
|
|
27
|
+
* **finance:** isolate customer groups by actor lifetime ([d20c9d5](https://github.com/tutur3u/platform/commit/d20c9d53d23e5ce0ad6c94199d202cd30e522694)) ([#6028](https://github.com/tutur3u/platform/issues/6028)) ([c8f712c](https://github.com/tutur3u/platform/commit/c8f712cfd590b29b2d271cbf6a75895142427a52))
|
|
28
|
+
* **finance:** isolate subscription context by actor lifetime ([55f09f2](https://github.com/tutur3u/platform/commit/55f09f2a86f1956cdad5ddd28bb01a2ae92a0dad)) ([#6024](https://github.com/tutur3u/platform/issues/6024)) ([42e96b9](https://github.com/tutur3u/platform/commit/42e96b93ce1a4ebad8c7fb877bfdecd7d0451bed))
|
|
29
|
+
* **finance:** keep invoice templates usable with invalid dates ([2d4bc6d](https://github.com/tutur3u/platform/commit/2d4bc6d9f3cf0b7a1377b71a59f8efb581e3170e)) ([#6040](https://github.com/tutur3u/platform/issues/6040)) ([c84bb36](https://github.com/tutur3u/platform/commit/c84bb36369c941b67be8ca9be62fe076d34e957a))
|
|
30
|
+
* **finance:** reconcile attendance summary with current main ([fb00e6e](https://github.com/tutur3u/platform/commit/fb00e6ef4373adc1b6b9107f9bba883c101c3438))
|
|
31
|
+
* **finance:** retain history with unrenderable dates ([95b8513](https://github.com/tutur3u/platform/commit/95b851336bedc480d3e10fba6cea1e3a2c047ff1)) ([#6035](https://github.com/tutur3u/platform/issues/6035)) ([ad0a0d3](https://github.com/tutur3u/platform/commit/ad0a0d3720f560cfe0a8baec1c2b30c1e77c273d))
|
|
32
|
+
* **finance:** scope invoice history to account lifetime ([7fb93ef](https://github.com/tutur3u/platform/commit/7fb93efe2d60f17be5a936067b3141b0330fa0ab)) ([#6033](https://github.com/tutur3u/platform/issues/6033)) ([9dc373a](https://github.com/tutur3u/platform/commit/9dc373abef6838d3fe9cb2c511559d883a63be42))
|
|
33
|
+
* **finance:** stabilize attendance calendar status summaries ([0c22684](https://github.com/tutur3u/platform/commit/0c22684245e101a79e2b3c37b7304e71427eccc8)) ([#6075](https://github.com/tutur3u/platform/issues/6075)) ([0789e90](https://github.com/tutur3u/platform/commit/0789e9091d7218ac6341a2843f43b554839da454))
|
|
34
|
+
* **finance:** tolerate denied invoice preference storage ([0b17e29](https://github.com/tutur3u/platform/commit/0b17e297eae859dac01016e002397cb3a4d0d0ac)) ([#6003](https://github.com/tutur3u/platform/issues/6003)) ([56fa042](https://github.com/tutur3u/platform/commit/56fa04277bad4e3b57cc87be66952cf8ae28c44c))
|
|
35
|
+
* **mobile:** integrate Assistant and Secrets shell coverage ([141ae72](https://github.com/tutur3u/platform/commit/141ae72cab1c5cb1604dfc654fdc5ff666dc0037))
|
|
36
|
+
|
|
3
37
|
## [0.38.0](https://github.com/tutur3u/platform/compare/ui-v0.37.2...ui-v0.38.0) (2026-10-04)
|
|
4
38
|
|
|
5
39
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -87,14 +87,14 @@
|
|
|
87
87
|
"@tiptap/pm": "3.31.3",
|
|
88
88
|
"@tiptap/react": "3.31.3",
|
|
89
89
|
"@tiptap/starter-kit": "3.31.3",
|
|
90
|
-
"@tuturuuu/ai": "0.
|
|
90
|
+
"@tuturuuu/ai": "0.18.1",
|
|
91
91
|
"@tuturuuu/apis": "0.13.0",
|
|
92
92
|
"@tuturuuu/hooks": "0.1.2",
|
|
93
93
|
"@tuturuuu/icons": "0.1.1",
|
|
94
|
-
"@tuturuuu/internal-api": "0.
|
|
95
|
-
"@tuturuuu/realtime": "0.10.
|
|
96
|
-
"@tuturuuu/supabase": "0.
|
|
97
|
-
"@tuturuuu/utils": "0.
|
|
94
|
+
"@tuturuuu/internal-api": "0.51.0",
|
|
95
|
+
"@tuturuuu/realtime": "0.10.2",
|
|
96
|
+
"@tuturuuu/supabase": "0.6.0",
|
|
97
|
+
"@tuturuuu/utils": "0.36.0",
|
|
98
98
|
"@types/debug": "^4.1.13",
|
|
99
99
|
"browser-image-compression": "^2.0.2",
|
|
100
100
|
"class-variance-authority": "^0.7.1",
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
"lodash": "4.18.1",
|
|
116
116
|
"moment": "^2.30.1",
|
|
117
117
|
"motion": "^13.2.0",
|
|
118
|
-
"next": "^16.
|
|
118
|
+
"next": "^16.4.0",
|
|
119
119
|
"next-intl": "^4.14.4",
|
|
120
120
|
"next-themes": "^0.4.6",
|
|
121
121
|
"nuqs": "^2.10.1",
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
"@tanstack/react-table": "^9.2.4",
|
|
156
156
|
"@testing-library/jest-dom": "^7.0.1",
|
|
157
157
|
"@testing-library/react": "^16.3.3",
|
|
158
|
-
"@tuturuuu/types": "0.
|
|
158
|
+
"@tuturuuu/types": "0.35.0",
|
|
159
159
|
"@tuturuuu/typescript-config": "0.1.2",
|
|
160
160
|
"@types/html2canvas": "^1.0.0",
|
|
161
161
|
"@types/lodash": "^4.17.25",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { UserRound } from '@tuturuuu/icons';
|
|
3
|
+
import { DropdownMenuItem } from '@tuturuuu/ui/dropdown-menu';
|
|
4
|
+
|
|
5
|
+
export function PublicProfileMenuItem({
|
|
6
|
+
href,
|
|
7
|
+
label,
|
|
8
|
+
}: {
|
|
9
|
+
href: string;
|
|
10
|
+
label: string;
|
|
11
|
+
}) {
|
|
12
|
+
return (
|
|
13
|
+
<DropdownMenuItem asChild>
|
|
14
|
+
<a
|
|
15
|
+
href={href}
|
|
16
|
+
target="_blank"
|
|
17
|
+
rel="noopener noreferrer"
|
|
18
|
+
className="cursor-pointer"
|
|
19
|
+
>
|
|
20
|
+
<UserRound className="h-4 w-4 text-muted-foreground" />
|
|
21
|
+
<span>{label}</span>
|
|
22
|
+
</a>
|
|
23
|
+
</DropdownMenuItem>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { expect, it, vi } from 'vitest';
|
|
4
|
+
import { SatelliteUserMenuItems } from './satellite-user-menu-items';
|
|
5
|
+
|
|
6
|
+
vi.mock('@tuturuuu/ui/dropdown-menu', () => {
|
|
7
|
+
const Container = ({ children }: { children: ReactNode }) => <>{children}</>;
|
|
8
|
+
return Object.fromEntries(
|
|
9
|
+
[
|
|
10
|
+
'DropdownMenuGroup',
|
|
11
|
+
'DropdownMenuItem',
|
|
12
|
+
'DropdownMenuPortal',
|
|
13
|
+
'DropdownMenuSeparator',
|
|
14
|
+
'DropdownMenuSub',
|
|
15
|
+
'DropdownMenuSubContent',
|
|
16
|
+
'DropdownMenuSubTrigger',
|
|
17
|
+
].map((key) => [key, Container])
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
const props: ComponentProps<typeof SatelliteUserMenuItems> = {
|
|
21
|
+
centralUrl: 'https://tuturuuu.com',
|
|
22
|
+
t: (key) => key,
|
|
23
|
+
languageItems: null,
|
|
24
|
+
themeItems: null,
|
|
25
|
+
accountItems: null,
|
|
26
|
+
signedIn: true,
|
|
27
|
+
onReport: vi.fn(),
|
|
28
|
+
onLogout: vi.fn(),
|
|
29
|
+
};
|
|
30
|
+
it('renders a labelled public profile link separately from private account settings', () => {
|
|
31
|
+
const html = renderToStaticMarkup(
|
|
32
|
+
<SatelliteUserMenuItems
|
|
33
|
+
{...props}
|
|
34
|
+
publicProfile={{
|
|
35
|
+
href: 'https://tuturuuu.com/u/creator',
|
|
36
|
+
label: 'Public profile',
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
expect(html).toContain('href="https://tuturuuu.com/u/creator"');
|
|
41
|
+
expect(html).toContain('Public profile');
|
|
42
|
+
expect(html).toContain('rel="noopener noreferrer"');
|
|
43
|
+
expect(html).toContain('common.dashboard');
|
|
44
|
+
});
|
|
45
|
+
it('does not invent a profile link for users without a username', () => {
|
|
46
|
+
const html = renderToStaticMarkup(<SatelliteUserMenuItems {...props} />);
|
|
47
|
+
expect(html).not.toContain('/u/');
|
|
48
|
+
});
|
|
49
|
+
it('does not expose a profile link in a signed-out menu even if an adapter supplies one', () => {
|
|
50
|
+
const html = renderToStaticMarkup(
|
|
51
|
+
<SatelliteUserMenuItems
|
|
52
|
+
{...props}
|
|
53
|
+
signedIn={false}
|
|
54
|
+
publicProfile={{
|
|
55
|
+
href: 'https://tuturuuu.com/u/creator',
|
|
56
|
+
label: 'Public profile',
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
expect(html).not.toContain('/u/creator');
|
|
61
|
+
});
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
DropdownMenuSubTrigger,
|
|
23
23
|
} from '@tuturuuu/ui/dropdown-menu';
|
|
24
24
|
import type { ReactNode } from 'react';
|
|
25
|
+
import { PublicProfileMenuItem } from './public-profile-menu-item';
|
|
26
|
+
|
|
27
|
+
export { PublicProfileMenuItem } from './public-profile-menu-item';
|
|
25
28
|
|
|
26
29
|
export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover' | 'hidden';
|
|
27
30
|
|
|
@@ -35,6 +38,7 @@ export function SatelliteUserMenuItems({
|
|
|
35
38
|
accountItems,
|
|
36
39
|
workspaceSelect,
|
|
37
40
|
signedIn,
|
|
41
|
+
publicProfile,
|
|
38
42
|
onReport,
|
|
39
43
|
onLogout,
|
|
40
44
|
}: {
|
|
@@ -61,6 +65,7 @@ export function SatelliteUserMenuItems({
|
|
|
61
65
|
accountItems: ReactNode;
|
|
62
66
|
workspaceSelect?: ReactNode;
|
|
63
67
|
signedIn: boolean;
|
|
68
|
+
publicProfile?: { href: string; label: string };
|
|
64
69
|
onReport: () => void;
|
|
65
70
|
onLogout: () => void;
|
|
66
71
|
}) {
|
|
@@ -75,6 +80,9 @@ export function SatelliteUserMenuItems({
|
|
|
75
80
|
</>
|
|
76
81
|
) : null}
|
|
77
82
|
<DropdownMenuGroup>
|
|
83
|
+
{signedIn && publicProfile && (
|
|
84
|
+
<PublicProfileMenuItem {...publicProfile} />
|
|
85
|
+
)}
|
|
78
86
|
<DropdownMenuItem asChild>
|
|
79
87
|
<a
|
|
80
88
|
href={centralUrl}
|
|
@@ -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]);
|