@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.
Files changed (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -0,0 +1,438 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from '@testing-library/react';
10
+ import type { ComponentProps } from 'react';
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import {
13
+ useWorkspaceActor,
14
+ WorkspaceVisibilityProvider,
15
+ } from '../../../../../hooks/use-workspace-visibility';
16
+ import InvoiceCard from './invoice-card';
17
+
18
+ const notices = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
19
+ const translate = vi.hoisted(() => (key: string) => key);
20
+ vi.mock('next-intl', () => ({ useTranslations: () => translate }));
21
+ vi.mock('@tuturuuu/ui/sonner', () => ({ toast: notices }));
22
+ const renderer = vi.fn();
23
+ const importStarted = vi.fn();
24
+ const downloads: string[] = [];
25
+ let lease: ReturnType<typeof useWorkspaceActor>;
26
+ function Witness() {
27
+ lease = useWorkspaceActor();
28
+ return null;
29
+ }
30
+ function held<T>() {
31
+ let resolve!: (value: T) => void;
32
+ let reject!: (error: Error) => void;
33
+ const promise = new Promise<T>((yes, no) => {
34
+ resolve = yes;
35
+ reject = no;
36
+ });
37
+ return { promise, resolve, reject };
38
+ }
39
+ function canvas() {
40
+ return {
41
+ toBlob: (callback: BlobCallback) =>
42
+ callback(new Blob(['synthetic PNG'], { type: 'image/png' })),
43
+ };
44
+ }
45
+ function invoice(id: string): ComponentProps<typeof InvoiceCard>['invoice'] {
46
+ return {
47
+ id,
48
+ category_id: 'synthetic-category',
49
+ completed_at: null,
50
+ creator_id: null,
51
+ customer_id: null,
52
+ note: null,
53
+ notice: null,
54
+ paid_amount: 0,
55
+ platform_creator_id: null,
56
+ subscription_months: null,
57
+ transaction_id: null,
58
+ valid_until: null,
59
+ wallet_id: 'synthetic-wallet',
60
+ ws_id: 'synthetic-workspace',
61
+ created_at: '2026-10-01T10:00:00Z',
62
+ price: 100,
63
+ total_diff: 0,
64
+ customer_display_name: `Customer ${id}`,
65
+ customer_full_name: null,
66
+ wallet: null,
67
+ creator: null,
68
+ };
69
+ }
70
+ function mount(
71
+ options: {
72
+ actor?: string | null;
73
+ ws?: string;
74
+ invoiceWs?: string;
75
+ auto?: boolean;
76
+ } = {}
77
+ ) {
78
+ const client = new QueryClient({
79
+ defaultOptions: { queries: { retry: false } },
80
+ });
81
+ const content = (actor: string | null, id: string, customerName?: string) => {
82
+ const body = (
83
+ <>
84
+ <Witness />
85
+ <InvoiceCard
86
+ wsId={options.ws ?? 'synthetic-workspace'}
87
+ lang="en"
88
+ configs={[]}
89
+ invoice={{
90
+ ...invoice(id),
91
+ ws_id: options.invoiceWs ?? 'synthetic-workspace',
92
+ customer_display_name: customerName ?? `Customer ${id}`,
93
+ }}
94
+ products={[]}
95
+ promotions={[]}
96
+ />
97
+ </>
98
+ );
99
+ return (
100
+ <QueryClientProvider client={client}>
101
+ {actor !== null ? (
102
+ <WorkspaceVisibilityProvider actorId={actor}>
103
+ {body}
104
+ </WorkspaceVisibilityProvider>
105
+ ) : (
106
+ body
107
+ )}
108
+ </QueryClientProvider>
109
+ );
110
+ };
111
+ if (options.auto) window.history.replaceState({}, '', '/?image=true');
112
+ const view = render(
113
+ content(
114
+ options.actor === undefined ? 'account-A' : options.actor,
115
+ 'synthetic-A'
116
+ )
117
+ );
118
+ return {
119
+ ...view,
120
+ redraw: (
121
+ actor: string | null = 'account-A',
122
+ id = 'synthetic-A',
123
+ customerName?: string
124
+ ) => view.rerender(content(actor, id, customerName)),
125
+ };
126
+ }
127
+ async function exportPng() {
128
+ const openAction = screen.queryByRole('menuitem', {
129
+ name: 'invoices.download_image',
130
+ });
131
+ if (openAction) {
132
+ fireEvent.click(openAction);
133
+ return;
134
+ }
135
+ await screen.findByRole('tab', { name: 'invoices.full' });
136
+ fireEvent.pointerDown(screen.getByRole('button', { name: 'common.export' }), {
137
+ button: 0,
138
+ ctrlKey: false,
139
+ });
140
+ fireEvent.click(
141
+ await screen.findByRole('menuitem', { name: 'invoices.download_image' })
142
+ );
143
+ }
144
+ beforeEach(() => {
145
+ vi.resetModules();
146
+ renderer.mockReset().mockResolvedValue(canvas());
147
+ importStarted.mockReset();
148
+ notices.success.mockReset();
149
+ notices.error.mockReset();
150
+ downloads.length = 0;
151
+ vi.stubGlobal(
152
+ 'fetch',
153
+ vi.fn(() => {
154
+ throw new Error('Unexpected synthetic export network request');
155
+ })
156
+ );
157
+ // biome-ignore lint/suspicious/noDocumentCookie: Synthetic finance visibility fixture.
158
+ document.cookie = 'finance-confidential-mode=false;path=/';
159
+ window.localStorage.clear();
160
+ window.history.replaceState({}, '', '/');
161
+ vi.doMock('html2canvas-pro', () => {
162
+ importStarted();
163
+ return { default: renderer };
164
+ });
165
+ const NativeURL = URL;
166
+ vi.stubGlobal(
167
+ 'URL',
168
+ class extends NativeURL {
169
+ static createObjectURL = vi.fn(() => 'blob:synthetic-png');
170
+ static revokeObjectURL = vi.fn();
171
+ }
172
+ );
173
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
174
+ this: HTMLAnchorElement
175
+ ) {
176
+ downloads.push(this.download);
177
+ });
178
+ });
179
+ afterEach(() => {
180
+ cleanup();
181
+ vi.unstubAllGlobals();
182
+ vi.restoreAllMocks();
183
+ vi.doUnmock('html2canvas-pro');
184
+ });
185
+
186
+ describe('actual invoice card PNG export account and invoice lifetime', () => {
187
+ it('invalidates a held capture when actual confidential amount visibility changes', async () => {
188
+ const result = held<ReturnType<typeof canvas>>();
189
+ renderer.mockReturnValue(result.promise);
190
+ mount();
191
+ await exportPng();
192
+ await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
193
+ act(() => {
194
+ // biome-ignore lint/suspicious/noDocumentCookie: Real visibility hook reads this synthetic fixture.
195
+ document.cookie = 'finance-confidential-mode=true;path=/';
196
+ window.dispatchEvent(new Event('finance-confidential-mode-change'));
197
+ });
198
+ await waitFor(() =>
199
+ expect(screen.getAllByText('•••••').length).toBeGreaterThan(0)
200
+ );
201
+ await act(async () => {
202
+ result.resolve(canvas());
203
+ await result.promise;
204
+ });
205
+ expect(downloads).toEqual([]);
206
+ expect(notices.success).not.toHaveBeenCalled();
207
+ expect(notices.error).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it('does not capture replacement invoice DOM after a held renderer import', async () => {
211
+ const module = held<{ default: typeof renderer }>();
212
+ vi.doMock('html2canvas-pro', () => {
213
+ importStarted();
214
+ return module.promise;
215
+ });
216
+ const view = mount();
217
+ await exportPng();
218
+ await waitFor(() => expect(importStarted).toHaveBeenCalledOnce());
219
+ view.redraw('account-A', 'synthetic-B');
220
+ expect(screen.getByText('Customer synthetic-B')).toBeVisible();
221
+ await act(async () => {
222
+ module.resolve({ default: renderer });
223
+ await module.promise;
224
+ });
225
+ expect(renderer).not.toHaveBeenCalled();
226
+ expect(downloads).toEqual([]);
227
+ expect(notices.success).not.toHaveBeenCalled();
228
+ expect(notices.error).not.toHaveBeenCalled();
229
+ });
230
+ it('does not finish a held render for a replaced invoice', async () => {
231
+ const renderResult = held<ReturnType<typeof canvas>>();
232
+ renderer.mockReturnValueOnce(renderResult.promise);
233
+ const view = mount();
234
+ await exportPng();
235
+ await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
236
+ view.redraw('account-A', 'synthetic-B');
237
+ await act(async () => {
238
+ renderResult.resolve(canvas());
239
+ await renderResult.promise;
240
+ });
241
+ expect(downloads).toEqual([]);
242
+ expect(notices.success).not.toHaveBeenCalled();
243
+ expect(notices.error).not.toHaveBeenCalled();
244
+ });
245
+ for (const boundary of ['replacement', 'ABA', 'unmount'] as const) {
246
+ it(`does not finish a held render after actor ${boundary}`, async () => {
247
+ const renderResult = held<ReturnType<typeof canvas>>();
248
+ renderer.mockReturnValueOnce(renderResult.promise);
249
+ const view = mount();
250
+ await exportPng();
251
+ await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
252
+ const expired = lease;
253
+ if (boundary === 'unmount') view.unmount();
254
+ else {
255
+ view.redraw('account-B');
256
+ if (boundary === 'ABA') {
257
+ await act(async () => {
258
+ await Promise.resolve();
259
+ });
260
+ view.redraw('account-A');
261
+ }
262
+ }
263
+ expect(() => expired?.assertActive()).toThrow(
264
+ 'Workspace account changed'
265
+ );
266
+ await act(async () => {
267
+ renderResult.resolve(canvas());
268
+ await renderResult.promise;
269
+ });
270
+ expect(downloads).toEqual([]);
271
+ expect(notices.success).not.toHaveBeenCalled();
272
+ expect(notices.error).not.toHaveBeenCalled();
273
+ });
274
+ }
275
+ it('does not finish a held blob conversion after invoice replacement', async () => {
276
+ let deliver!: BlobCallback;
277
+ renderer.mockResolvedValueOnce({
278
+ toBlob: (callback: BlobCallback) => {
279
+ deliver = callback;
280
+ },
281
+ });
282
+ const view = mount();
283
+ await exportPng();
284
+ await waitFor(() => expect(deliver).toBeTypeOf('function'));
285
+ view.redraw('account-A', 'synthetic-B');
286
+ await act(async () => {
287
+ deliver(new Blob(['synthetic PNG'], { type: 'image/png' }));
288
+ await Promise.resolve();
289
+ });
290
+ expect(downloads).toEqual([]);
291
+ expect(notices.success).not.toHaveBeenCalled();
292
+ expect(notices.error).not.toHaveBeenCalled();
293
+ });
294
+ it('does not notify a replacement actor about an old renderer failure', async () => {
295
+ const renderResult = held<ReturnType<typeof canvas>>();
296
+ renderer.mockReturnValueOnce(renderResult.promise);
297
+ const view = mount();
298
+ await exportPng();
299
+ await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
300
+ view.redraw('account-B');
301
+ await act(async () => {
302
+ renderResult.reject(new Error('Synthetic renderer failure'));
303
+ });
304
+ expect(downloads).toEqual([]);
305
+ expect(notices.error).not.toHaveBeenCalled();
306
+ });
307
+ it('exports the actual current invoice with the correct filename and element', async () => {
308
+ mount();
309
+ await exportPng();
310
+ await waitFor(() => expect(downloads).toEqual(['invoice-synthetic_a.png']));
311
+ expect(renderer).toHaveBeenCalledOnce();
312
+ expect(renderer.mock.calls[0]?.[0]).toBe(
313
+ document.getElementById('printable-area')
314
+ );
315
+ expect(notices.success).toHaveBeenCalledWith('common.export-success');
316
+ expect(notices.error).not.toHaveBeenCalled();
317
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:synthetic-png');
318
+ });
319
+ it('keeps the same invoice export valid across an unchanged rerender', async () => {
320
+ const renderResult = held<ReturnType<typeof canvas>>();
321
+ renderer.mockReturnValueOnce(renderResult.promise);
322
+ const view = mount();
323
+ await exportPng();
324
+ await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
325
+ view.redraw();
326
+ await act(async () => {
327
+ renderResult.resolve(canvas());
328
+ await renderResult.promise;
329
+ });
330
+ expect(downloads).toEqual(['invoice-synthetic_a.png']);
331
+ expect(notices.success).toHaveBeenCalledOnce();
332
+ });
333
+ it('keeps current renderer failure truthful without a download', async () => {
334
+ renderer.mockRejectedValueOnce(
335
+ new Error('Synthetic current renderer failure')
336
+ );
337
+ mount();
338
+ await exportPng();
339
+ await waitFor(() =>
340
+ expect(notices.error).toHaveBeenCalledWith('common.export-error')
341
+ );
342
+ expect(downloads).toEqual([]);
343
+ expect(notices.success).not.toHaveBeenCalled();
344
+ });
345
+ for (const boundary of [
346
+ 'missing-provider',
347
+ 'missing-actor',
348
+ 'missing-workspace',
349
+ 'foreign-invoice-workspace',
350
+ ] as const) {
351
+ it(`denies PNG admission for ${boundary}`, async () => {
352
+ mount({
353
+ actor:
354
+ boundary === 'missing-provider'
355
+ ? null
356
+ : boundary === 'missing-actor'
357
+ ? ''
358
+ : 'account-A',
359
+ ws: boundary === 'missing-workspace' ? '' : 'synthetic-workspace',
360
+ invoiceWs:
361
+ boundary === 'foreign-invoice-workspace'
362
+ ? 'other-workspace'
363
+ : undefined,
364
+ });
365
+ await exportPng();
366
+ expect(renderer).not.toHaveBeenCalled();
367
+ expect(importStarted).not.toHaveBeenCalled();
368
+ expect(downloads).toEqual([]);
369
+ expect(notices.error).toHaveBeenCalledOnce();
370
+ expect(notices.error).toHaveBeenCalledWith('common.export-error');
371
+ });
372
+ }
373
+ it('invalidates held rendering when same-invoice content changes', async () => {
374
+ const result = held<ReturnType<typeof canvas>>();
375
+ renderer.mockReturnValueOnce(result.promise);
376
+ const view = mount();
377
+ await exportPng();
378
+ await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
379
+ view.redraw('account-A', 'synthetic-A', 'Updated synthetic customer');
380
+ await act(async () => {
381
+ result.resolve(canvas());
382
+ await result.promise;
383
+ });
384
+ expect(downloads).toEqual([]);
385
+ expect(notices.success).not.toHaveBeenCalled();
386
+ expect(notices.error).not.toHaveBeenCalled();
387
+ });
388
+ it('does not let an old finally clear a new invoice export owner', async () => {
389
+ const old = held<ReturnType<typeof canvas>>();
390
+ const next = held<ReturnType<typeof canvas>>();
391
+ renderer.mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise);
392
+ const view = mount();
393
+ await exportPng();
394
+ await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
395
+ view.redraw('account-A', 'synthetic-B');
396
+ await exportPng();
397
+ await waitFor(() => expect(renderer).toHaveBeenCalledTimes(2));
398
+ await act(async () => {
399
+ old.resolve(canvas());
400
+ await old.promise;
401
+ });
402
+ const exporting = screen.getByRole('menuitem', {
403
+ name: 'ws-reports.exporting_png',
404
+ });
405
+ expect(exporting).toHaveAttribute('aria-disabled', 'true');
406
+ fireEvent.click(exporting);
407
+ expect(renderer).toHaveBeenCalledTimes(2);
408
+ expect(downloads).toEqual([]);
409
+ await act(async () => {
410
+ next.resolve(canvas());
411
+ await next.promise;
412
+ });
413
+ expect(downloads).toEqual(['invoice-synthetic_b.png']);
414
+ expect(notices.success).toHaveBeenCalledOnce();
415
+ });
416
+ it('preserves a new auto-image intent until its own export completes', async () => {
417
+ const old = held<ReturnType<typeof canvas>>();
418
+ const next = held<ReturnType<typeof canvas>>();
419
+ renderer.mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise);
420
+ const view = mount({ auto: true });
421
+ await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
422
+ view.redraw('account-B', 'synthetic-B');
423
+ await waitFor(() => expect(renderer).toHaveBeenCalledTimes(2));
424
+ await act(async () => {
425
+ old.resolve(canvas());
426
+ await old.promise;
427
+ });
428
+ expect(window.location.search).toBe('?image=true');
429
+ expect(downloads).toEqual([]);
430
+ await act(async () => {
431
+ next.resolve(canvas());
432
+ await next.promise;
433
+ });
434
+ await waitFor(() => expect(window.location.search).toBe(''));
435
+ expect(downloads).toEqual(['invoice-synthetic_b.png']);
436
+ expect(notices.success).toHaveBeenCalledOnce();
437
+ });
438
+ });
@@ -0,0 +1,184 @@
1
+ 'use client';
2
+
3
+ import { toast } from '@tuturuuu/ui/sonner';
4
+ import { useTranslations } from 'next-intl';
5
+ import {
6
+ type RefObject,
7
+ useCallback,
8
+ useEffect,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
12
+ import { useWorkspaceActor } from '../../../../../hooks/use-workspace-visibility';
13
+
14
+ type Scope = {
15
+ active: boolean;
16
+ signature: string;
17
+ actor: ReturnType<typeof useWorkspaceActor>;
18
+ owner: Attempt | null;
19
+ };
20
+ type Attempt = { scope: Scope; element: HTMLDivElement; invoiceId: string };
21
+
22
+ function semanticSnapshot(value: unknown) {
23
+ return JSON.stringify(value, (_, item) =>
24
+ item && typeof item === 'object' && !Array.isArray(item)
25
+ ? Object.fromEntries(
26
+ Object.keys(item)
27
+ .sort()
28
+ .map((key) => [key, item[key]])
29
+ )
30
+ : item
31
+ );
32
+ }
33
+
34
+ export function useInvoicePngExport({
35
+ wsId,
36
+ invoice,
37
+ renderInputs,
38
+ isDarkPreview,
39
+ ready,
40
+ printableRef,
41
+ }: {
42
+ wsId: string;
43
+ invoice: { id: string; ws_id?: string };
44
+ renderInputs: unknown;
45
+ isDarkPreview: boolean;
46
+ ready: boolean;
47
+ printableRef: RefObject<HTMLDivElement | null>;
48
+ }) {
49
+ const t = useTranslations();
50
+ const actor = useWorkspaceActor();
51
+ const signature = semanticSnapshot([wsId, renderInputs]);
52
+ const currentScope = useRef<Scope | null>(null);
53
+ if (
54
+ currentScope.current?.signature !== signature ||
55
+ currentScope.current?.actor !== actor
56
+ ) {
57
+ if (currentScope.current) currentScope.current.active = false;
58
+ currentScope.current = { active: true, signature, actor, owner: null };
59
+ }
60
+ const scope = currentScope.current;
61
+ const [pending, setPending] = useState<Attempt | null>(null);
62
+ useEffect(() => {
63
+ scope.active = true;
64
+ return () => {
65
+ scope.active = false;
66
+ };
67
+ }, [scope]);
68
+
69
+ const handlePngExport = useCallback(async () => {
70
+ const activeScope = () => {
71
+ if (!scope.active || currentScope.current !== scope) return false;
72
+ try {
73
+ actor?.assertActive();
74
+ return !!actor;
75
+ } catch {
76
+ return false;
77
+ }
78
+ };
79
+ // A retained callback may never report a failure into a replacement scope.
80
+ if (!scope.active || currentScope.current !== scope) return false;
81
+ if (
82
+ !actor?.actorId ||
83
+ !wsId ||
84
+ invoice.ws_id !== wsId ||
85
+ !ready ||
86
+ !printableRef.current
87
+ ) {
88
+ toast.error(t('common.export-error'));
89
+ return false;
90
+ }
91
+ if (!activeScope() || scope.owner) return false;
92
+ const attempt: Attempt = {
93
+ scope,
94
+ element: printableRef.current,
95
+ invoiceId: invoice.id,
96
+ };
97
+ scope.owner = attempt;
98
+ setPending(attempt);
99
+ const current = () =>
100
+ activeScope() &&
101
+ scope.owner === attempt &&
102
+ printableRef.current === attempt.element &&
103
+ attempt.element.isConnected;
104
+ try {
105
+ const html2canvas = (await import('html2canvas-pro')).default;
106
+ if (!current()) return false;
107
+ const canvas = await html2canvas(attempt.element, {
108
+ scale: 2,
109
+ useCORS: true,
110
+ backgroundColor: isDarkPreview ? '#1a1a1a' : '#ffffff',
111
+ width: attempt.element.scrollWidth,
112
+ height: attempt.element.scrollHeight,
113
+ });
114
+ if (!current()) return false;
115
+ await new Promise<void>((resolve, reject) => {
116
+ canvas.toBlob(
117
+ (blob) => {
118
+ if (!current()) {
119
+ resolve();
120
+ return;
121
+ }
122
+ if (!blob) {
123
+ reject(new Error('Failed to create image'));
124
+ return;
125
+ }
126
+ try {
127
+ const url = URL.createObjectURL(blob);
128
+ const link = document.createElement('a');
129
+ try {
130
+ link.href = url;
131
+ const sanitizedId = attempt.invoiceId
132
+ .normalize('NFD')
133
+ .replace(/[\u0300-\u036f]/g, '')
134
+ .replace(/đ/g, 'd')
135
+ .replace(/Đ/g, 'D')
136
+ .replace(/[^a-z0-9]/gi, '_')
137
+ .toLowerCase()
138
+ .replace(/_+/g, '_')
139
+ .replace(/^_|_$/g, '');
140
+ link.download = `invoice-${sanitizedId}.png`;
141
+ document.body.appendChild(link);
142
+ link.click();
143
+ } finally {
144
+ link.remove();
145
+ URL.revokeObjectURL(url);
146
+ }
147
+ resolve();
148
+ } catch (error) {
149
+ reject(error);
150
+ }
151
+ },
152
+ 'image/png',
153
+ 1.0
154
+ );
155
+ });
156
+ if (!current()) return false;
157
+ toast.success(t('common.export-success'));
158
+ return true;
159
+ } catch {
160
+ if (!current()) return false;
161
+ toast.error(t('common.export-error'));
162
+ return true;
163
+ } finally {
164
+ if (current()) {
165
+ scope.owner = null;
166
+ setPending((value) => (value === attempt ? null : value));
167
+ }
168
+ }
169
+ }, [
170
+ actor,
171
+ scope,
172
+ wsId,
173
+ invoice.id,
174
+ invoice.ws_id,
175
+ ready,
176
+ printableRef,
177
+ isDarkPreview,
178
+ t,
179
+ ]);
180
+ return {
181
+ handlePngExport,
182
+ isExporting: pending?.scope === scope && scope.owner === pending,
183
+ };
184
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ hasInvalidSubscriptionLinks,
4
+ isSubscriptionContextReady,
5
+ } from './subscription-admission';
6
+ import type { Product, UserGroupProducts } from './types';
7
+
8
+ const valid: UserGroupProducts = {
9
+ group_id: 'g',
10
+ workspace_products: { id: 'p', name: null, product_categories: null },
11
+ inventory_units: { id: 'u', name: null },
12
+ warehouse_id: null,
13
+ };
14
+ const products = [
15
+ { id: 'p', inventory: [{ unit_id: 'u', amount: null }] },
16
+ ] as Product[];
17
+ describe('entire linked billing admission', () => {
18
+ it('accepts valid scoped links without imposing a category or warehouse', () => {
19
+ expect(hasInvalidSubscriptionLinks([valid], products)).toBe(false);
20
+ });
21
+ it.each([
22
+ { ...valid, inventory_units: null },
23
+ { ...valid, workspace_products: null },
24
+ { ...valid, inventory_units: { id: 'other', name: null } },
25
+ { ...valid, inventory_units: { id: ' ', name: null } },
26
+ ])(
27
+ 'blocks a malformed linked charge even when another charge is valid',
28
+ (invalid) => {
29
+ expect(hasInvalidSubscriptionLinks([valid, invalid], products)).toBe(
30
+ true
31
+ );
32
+ }
33
+ );
34
+ it('blocks a disappeared catalog product', () => {
35
+ expect(hasInvalidSubscriptionLinks([valid], [])).toBe(true);
36
+ });
37
+ });
38
+ describe('authoritative schedule readiness', () => {
39
+ const ready = {
40
+ groupIds: ['g'],
41
+ scheduledSessionsByGroupId: { g: [] },
42
+ loading: false,
43
+ error: null,
44
+ };
45
+ it('distinguishes an authoritative empty list from missing data', () => {
46
+ expect(isSubscriptionContextReady(ready)).toBe(true);
47
+ expect(
48
+ isSubscriptionContextReady({
49
+ ...ready,
50
+ scheduledSessionsByGroupId: undefined,
51
+ })
52
+ ).toBe(false);
53
+ expect(
54
+ isSubscriptionContextReady({ ...ready, scheduledSessionsByGroupId: {} })
55
+ ).toBe(false);
56
+ });
57
+ it('does not authorize creation while loading or after a failed refresh with retained data', () => {
58
+ expect(isSubscriptionContextReady({ ...ready, loading: true })).toBe(false);
59
+ expect(
60
+ isSubscriptionContextReady({
61
+ ...ready,
62
+ error: new Error('synthetic unavailable'),
63
+ })
64
+ ).toBe(false);
65
+ });
66
+ it.each(['2026-02-30', '2026-13-01', '2026-01-01T00:00:00Z', 'not-a-date'])(
67
+ 'rejects malformed civil schedule receipt %s',
68
+ (date) => {
69
+ expect(
70
+ isSubscriptionContextReady({
71
+ ...ready,
72
+ scheduledSessionsByGroupId: { g: [date] },
73
+ })
74
+ ).toBe(false);
75
+ }
76
+ );
77
+ });