@tuturuuu/ui 0.33.0 → 0.34.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 (30) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +5 -5
  3. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  4. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  5. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  6. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  7. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  8. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  9. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  10. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  11. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  12. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  13. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  14. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  15. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  16. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  17. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  18. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  19. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  20. package/src/components/ui/finance/invoices/query-invalidation.ts +2 -0
  21. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  22. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  23. package/src/components/ui/finance/invoices/subscription-invoice.tsx +3 -6
  24. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  25. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  26. package/src/components/ui/finance/invoices/utils.ts +48 -106
  27. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  28. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  29. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  30. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
@@ -0,0 +1,229 @@
1
+ import {
2
+ listFinanceInvoices,
3
+ listPendingFinanceInvoices,
4
+ } from '@tuturuuu/internal-api/finance';
5
+ import { IncompleteInvoiceExportError } from './export-pagination';
6
+
7
+ /**
8
+ * Shape of invoice data as exported to CSV/Excel
9
+ * Flattened structure with complex objects decomposed into primitive fields
10
+ */
11
+ export interface InvoiceExportRow {
12
+ // Common fields
13
+ id: string;
14
+ ws_id: string;
15
+ created_at: string;
16
+ notice?: string;
17
+ note?: string;
18
+ price?: number | string;
19
+ total_diff?: number | string;
20
+
21
+ // Flattened customer fields
22
+ customer_name?: string;
23
+ customer_avatar_url?: string;
24
+
25
+ // Flattened creator fields (created invoices only)
26
+ creator_name?: string;
27
+ creator_email?: string;
28
+ creator_id?: string;
29
+
30
+ // Flattened wallet fields (created invoices only)
31
+ wallet_name?: string;
32
+
33
+ // Pending invoice specific fields
34
+ user_id?: string;
35
+ user_name?: string;
36
+ user_avatar_url?: string;
37
+ group_id?: string;
38
+ group_name?: string;
39
+ months_owed?: string;
40
+ attendance_days?: number;
41
+ total_sessions?: number;
42
+ potential_total?: number | string;
43
+ }
44
+
45
+ type PendingInvoiceExportData = {
46
+ attendance_days: number;
47
+ group_id: string;
48
+ group_name: string;
49
+ months_owed: string;
50
+ potential_total: number;
51
+ total_sessions: number;
52
+ user_avatar_url: string;
53
+ user_id: string;
54
+ user_name: string;
55
+ customer: {
56
+ full_name: string;
57
+ avatar_url: string;
58
+ } | null;
59
+ creator: null;
60
+ wallet: null;
61
+ };
62
+
63
+ export type CreatedInvoiceExportData = InvoiceExportRow & {
64
+ customer?: {
65
+ full_name?: string | null;
66
+ avatar_url?: string | null;
67
+ } | null;
68
+ creator?: {
69
+ display_name?: string | null;
70
+ full_name?: string | null;
71
+ email?: string | null;
72
+ } | null;
73
+ wallet?: {
74
+ name?: string | null;
75
+ } | null;
76
+ };
77
+
78
+ type PendingInvoiceApiRow = Omit<
79
+ PendingInvoiceExportData,
80
+ 'creator' | 'customer' | 'group_id' | 'group_name' | 'months_owed' | 'wallet'
81
+ > & {
82
+ group_id?: string | null;
83
+ group_ids?: string[] | null;
84
+ group_name?: string | null;
85
+ group_names?: string[] | null;
86
+ months_owed?: string | string[] | null;
87
+ };
88
+
89
+ function normalizeQueryArray(value?: string | string[]) {
90
+ if (!value) return [];
91
+ return (Array.isArray(value) ? value : [value]).filter(Boolean);
92
+ }
93
+
94
+ // Helper function to fetch pending invoices data for export
95
+ export async function getPendingInvoicesData(
96
+ wsId: string,
97
+ {
98
+ page = '1',
99
+ pageSize = '10',
100
+ q,
101
+ userIds,
102
+ groupByUser = false,
103
+ }: {
104
+ page?: string;
105
+ pageSize?: string;
106
+ q?: string;
107
+ userIds?: string | string[];
108
+ groupByUser?: boolean;
109
+ }
110
+ ): Promise<{ data: PendingInvoiceExportData[]; count: number }> {
111
+ const payload = await listPendingFinanceInvoices(wsId, {
112
+ groupByUser,
113
+ page,
114
+ pageSize,
115
+ q: q || '',
116
+ userIds: normalizeQueryArray(userIds),
117
+ });
118
+
119
+ if (
120
+ !Array.isArray(payload.data) ||
121
+ !Number.isSafeInteger(payload.count) ||
122
+ payload.count < 0
123
+ ) {
124
+ throw new IncompleteInvoiceExportError();
125
+ }
126
+ const rawData = payload.data as PendingInvoiceApiRow[];
127
+ const totalCount = payload.count;
128
+
129
+ const transformedData = rawData.map((invoice): PendingInvoiceExportData => {
130
+ const monthsOwed = Array.isArray(invoice.months_owed)
131
+ ? invoice.months_owed.join(', ')
132
+ : typeof invoice.months_owed === 'string'
133
+ ? invoice.months_owed
134
+ : '';
135
+
136
+ const baseInvoice = {
137
+ ...invoice,
138
+ attendance_days: invoice.attendance_days ?? 0,
139
+ months_owed: monthsOwed,
140
+ potential_total: invoice.potential_total ?? 0,
141
+ total_sessions: invoice.total_sessions ?? 0,
142
+ user_avatar_url: invoice.user_avatar_url || '',
143
+ user_id: invoice.user_id || '',
144
+ user_name: invoice.user_name || '',
145
+ };
146
+
147
+ if (groupByUser) {
148
+ const groupNames = Array.isArray(invoice.group_names)
149
+ ? invoice.group_names.filter(Boolean)
150
+ : [];
151
+ const groupedName = groupNames.join(', ');
152
+ const groupIdValue = Array.isArray(invoice.group_ids)
153
+ ? invoice.group_ids.join(',')
154
+ : '';
155
+
156
+ return {
157
+ ...baseInvoice,
158
+ group_id: groupIdValue,
159
+ group_name: groupedName,
160
+ customer: {
161
+ full_name: invoice.user_name || '',
162
+ avatar_url: invoice.user_avatar_url || '',
163
+ },
164
+ creator: null,
165
+ wallet: null,
166
+ };
167
+ }
168
+
169
+ return {
170
+ ...baseInvoice,
171
+ group_id: invoice.group_id || '',
172
+ group_name: invoice.group_name || '',
173
+ customer: invoice.user_id
174
+ ? {
175
+ full_name: invoice.user_name || '',
176
+ avatar_url: invoice.user_avatar_url || '',
177
+ }
178
+ : {
179
+ full_name: invoice.group_name || '',
180
+ avatar_url: '',
181
+ },
182
+ creator: null,
183
+ wallet: null,
184
+ };
185
+ });
186
+
187
+ return { data: transformedData, count: totalCount };
188
+ }
189
+
190
+ export async function getData(
191
+ wsId: string,
192
+ {
193
+ q,
194
+ page = '1',
195
+ pageSize = '10',
196
+ start,
197
+ end,
198
+ userIds,
199
+ walletId,
200
+ walletIds,
201
+ }: {
202
+ q?: string;
203
+ page?: string;
204
+ pageSize?: string;
205
+ start?: string;
206
+ end?: string;
207
+ userIds?: string | string[];
208
+ walletId?: string;
209
+ walletIds?: string | string[];
210
+ }
211
+ ) {
212
+ const walletFilterIds = [
213
+ ...(walletId ? [walletId] : []),
214
+ ...normalizeQueryArray(walletIds),
215
+ ];
216
+
217
+ return (await listFinanceInvoices(wsId, {
218
+ end,
219
+ page,
220
+ pageSize,
221
+ q,
222
+ start,
223
+ userIds: normalizeQueryArray(userIds),
224
+ walletIds: walletFilterIds,
225
+ })) as {
226
+ data: CreatedInvoiceExportData[];
227
+ count: number;
228
+ };
229
+ }
@@ -0,0 +1,245 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import type { PropsWithChildren } from 'react';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import ExportDialogContent from './export-dialog-content';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ listPending: vi.fn(),
8
+ listCreated: vi.fn(),
9
+ success: vi.fn(),
10
+ error: vi.fn(),
11
+ retry: vi.fn(),
12
+ sheet: vi.fn(),
13
+ csv: vi.fn(),
14
+ click: vi.fn(),
15
+ config: { data: 'false', isLoading: false, isError: false },
16
+ }));
17
+ vi.mock('@tuturuuu/internal-api/finance', () => ({
18
+ listPendingFinanceInvoices: mocks.listPending,
19
+ listFinanceInvoices: mocks.listCreated,
20
+ }));
21
+ vi.mock('../../../../hooks/use-workspace-config', () => ({
22
+ useWorkspaceConfig: () => ({ ...mocks.config, refetch: mocks.retry }),
23
+ }));
24
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
25
+ vi.mock('@tuturuuu/ui/sonner', () => ({
26
+ toast: { success: mocks.success, error: mocks.error },
27
+ }));
28
+ vi.mock('react-papaparse', () => ({ jsonToCSV: mocks.csv }));
29
+ vi.mock('../../../../xlsx', () => ({
30
+ XLSX: {
31
+ utils: {
32
+ json_to_sheet: mocks.sheet,
33
+ book_new: vi.fn(),
34
+ book_append_sheet: vi.fn(),
35
+ },
36
+ write: () => new Uint8Array([1]),
37
+ },
38
+ }));
39
+ vi.mock('@tuturuuu/ui/dialog', () => {
40
+ const Wrapper = ({ children }: PropsWithChildren) => <div>{children}</div>;
41
+ return {
42
+ DialogClose: Wrapper,
43
+ DialogDescription: Wrapper,
44
+ DialogFooter: Wrapper,
45
+ DialogHeader: Wrapper,
46
+ DialogTitle: Wrapper,
47
+ };
48
+ });
49
+ vi.mock('@tuturuuu/ui/select', () => ({
50
+ Select: ({
51
+ onValueChange,
52
+ disabled,
53
+ }: {
54
+ onValueChange: (value: string) => void;
55
+ disabled?: boolean;
56
+ }) => (
57
+ <select
58
+ aria-label="Format"
59
+ disabled={disabled}
60
+ onChange={(event) => onValueChange(event.target.value)}
61
+ >
62
+ <option value="excel">Excel</option>
63
+ <option value="csv">CSV</option>
64
+ </select>
65
+ ),
66
+ SelectContent: () => null,
67
+ SelectItem: () => null,
68
+ SelectTrigger: () => null,
69
+ SelectValue: () => null,
70
+ }));
71
+
72
+ const pendingRows = (n: number) =>
73
+ Array.from({ length: n }, (_, i) => ({
74
+ user_id: `student-${i}`,
75
+ user_name: `Student ${i}`,
76
+ group_id: `class-${i % 3}`,
77
+ group_name: `Class ${i % 3}`,
78
+ months_owed: ['2026-08', '2026-09'],
79
+ attendance_days: 0,
80
+ total_sessions: 8,
81
+ potential_total: 800,
82
+ }));
83
+ const props = {
84
+ wsId: 'ws',
85
+ exportType: 'invoices',
86
+ invoiceType: 'pending' as const,
87
+ searchParams: { q: 'Class', userIds: ['student-filter'] },
88
+ };
89
+ const exportButton = () =>
90
+ screen.getByRole('button', { name: 'common.export' });
91
+
92
+ beforeEach(() => {
93
+ vi.clearAllMocks();
94
+ mocks.config = { data: 'false', isLoading: false, isError: false };
95
+ mocks.listPending.mockResolvedValue({ count: 1, data: pendingRows(1) });
96
+ mocks.listCreated.mockResolvedValue({
97
+ count: 1,
98
+ data: [
99
+ {
100
+ id: 'invoice',
101
+ ws_id: 'ws',
102
+ created_at: '2026-09-01',
103
+ customer: { full_name: 'Student' },
104
+ wallet: { name: 'Cash' },
105
+ },
106
+ ],
107
+ });
108
+ mocks.csv.mockReturnValue('csv');
109
+ URL.createObjectURL = vi.fn(() => 'blob:test');
110
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
111
+ mocks.click
112
+ );
113
+ });
114
+
115
+ describe('invoice export download boundary', () => {
116
+ it.each(['excel', 'csv'])(
117
+ 'downloads every class across multiple pages as %s with filters preserved',
118
+ async (format) => {
119
+ const rows = pendingRows(1001);
120
+ mocks.listPending.mockImplementation(async (_ws, query) => ({
121
+ count: rows.length,
122
+ data: rows.slice(
123
+ (Number(query.page) - 1) * 1000,
124
+ Number(query.page) * 1000
125
+ ),
126
+ }));
127
+ render(<ExportDialogContent {...props} />);
128
+ fireEvent.change(screen.getByLabelText('Format'), {
129
+ target: { value: format },
130
+ });
131
+ fireEvent.click(exportButton());
132
+ await waitFor(() => expect(mocks.success).toHaveBeenCalled());
133
+ const exported = (format === 'excel' ? mocks.sheet : mocks.csv).mock
134
+ .calls[0]?.[0];
135
+ expect(exported).toHaveLength(1001);
136
+ expect(
137
+ new Set(exported.map((row: { group_id: string }) => row.group_id)).size
138
+ ).toBe(3);
139
+ expect(exported[0].months_owed).toBe('2026-08, 2026-09');
140
+ expect(mocks.listPending).toHaveBeenLastCalledWith(
141
+ 'ws',
142
+ expect.objectContaining({
143
+ page: '2',
144
+ pageSize: '1000',
145
+ q: 'Class',
146
+ userIds: ['student-filter'],
147
+ groupByUser: false,
148
+ })
149
+ );
150
+ expect(mocks.click).toHaveBeenCalledTimes(1);
151
+ }
152
+ );
153
+
154
+ it('preserves grouped class names and one row per student', async () => {
155
+ mocks.config.data = 'true';
156
+ mocks.listPending.mockResolvedValue({
157
+ count: 1,
158
+ data: [
159
+ {
160
+ ...pendingRows(1)[0],
161
+ group_ids: ['a', 'b'],
162
+ group_names: ['Class A', 'Class B'],
163
+ },
164
+ ],
165
+ });
166
+ render(<ExportDialogContent {...props} />);
167
+ fireEvent.click(exportButton());
168
+ await waitFor(() => expect(mocks.sheet).toHaveBeenCalled());
169
+ expect(mocks.sheet.mock.calls[0]?.[0][0]).toMatchObject({
170
+ group_id: 'a,b',
171
+ group_name: 'Class A, Class B',
172
+ });
173
+ expect(mocks.listPending).toHaveBeenCalledWith(
174
+ 'ws',
175
+ expect.objectContaining({ groupByUser: true })
176
+ );
177
+ });
178
+
179
+ it.each([
180
+ { name: 'short data', payload: { count: 2, data: pendingRows(1) } },
181
+ {
182
+ name: 'duplicate debt',
183
+ payload: { count: 2, data: [...pendingRows(1), ...pendingRows(1)] },
184
+ },
185
+ { name: 'missing rows', payload: { count: 0 } },
186
+ { name: 'missing count', payload: { data: [] } },
187
+ ])('prevents both download and success for $name', async ({ payload }) => {
188
+ mocks.listPending.mockResolvedValue(payload);
189
+ render(<ExportDialogContent {...props} />);
190
+ fireEvent.click(exportButton());
191
+ await waitFor(() =>
192
+ expect(screen.getByRole('alert')).toHaveTextContent(
193
+ 'ws-invoices.export_incomplete'
194
+ )
195
+ );
196
+ expect(mocks.click).not.toHaveBeenCalled();
197
+ expect(mocks.sheet).not.toHaveBeenCalled();
198
+ expect(mocks.success).not.toHaveBeenCalled();
199
+ expect(exportButton()).toBeEnabled();
200
+ });
201
+
202
+ it('allows a clean retry after a later-page failure without retaining prior rows', async () => {
203
+ mocks.listPending
204
+ .mockResolvedValueOnce({ count: 1001, data: pendingRows(1000) })
205
+ .mockRejectedValueOnce(new Error('Timeout'));
206
+ render(<ExportDialogContent {...props} />);
207
+ fireEvent.click(exportButton());
208
+ await waitFor(() => expect(mocks.error).toHaveBeenCalled());
209
+ expect(mocks.click).not.toHaveBeenCalled();
210
+ fireEvent.click(exportButton());
211
+ await waitFor(() => expect(mocks.success).toHaveBeenCalled());
212
+ expect(mocks.sheet.mock.calls[0]?.[0]).toHaveLength(1);
213
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
214
+ });
215
+
216
+ it('blocks pending export when grouping configuration fails and exposes retry', () => {
217
+ mocks.config.isError = true;
218
+ render(<ExportDialogContent {...props} />);
219
+ expect(exportButton()).toBeDisabled();
220
+ fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
221
+ expect(mocks.retry).toHaveBeenCalled();
222
+ expect(mocks.listPending).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it('does not block created exports on an unrelated grouping configuration error', async () => {
226
+ mocks.config.isError = true;
227
+ render(
228
+ <ExportDialogContent
229
+ {...props}
230
+ invoiceType="created"
231
+ searchParams={{ walletId: 'cash', start: '2026-09-01' }}
232
+ />
233
+ );
234
+ fireEvent.click(exportButton());
235
+ await waitFor(() => expect(mocks.success).toHaveBeenCalled());
236
+ expect(mocks.listCreated).toHaveBeenCalledWith(
237
+ 'ws',
238
+ expect.objectContaining({ walletIds: ['cash'], start: '2026-09-01' })
239
+ );
240
+ expect(mocks.sheet.mock.calls[0]?.[0][0]).toMatchObject({
241
+ customer_name: 'Student',
242
+ wallet_name: 'Cash',
243
+ });
244
+ });
245
+ });