@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.34.0](https://github.com/tutur3u/platform/compare/ui-v0.33.0...ui-v0.34.0) (2026-09-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * **auth:** enforce required account MFA lifecycle ([e08f27d](https://github.com/tutur3u/platform/commit/e08f27dbc082f7d9c951bb0792357638f3dcfb0a)) ([#5448](https://github.com/tutur3u/platform/issues/5448)) ([d7a5ed4](https://github.com/tutur3u/platform/commit/d7a5ed453963e619bd1dc3bd8c8a18c5e027317c))
9
+ * **finance:** track tuition coverage and harden invoice exports ([72e07be](https://github.com/tutur3u/platform/commit/72e07bebef58dfee398bcd0d896304d13a2d9aad)) ([#5447](https://github.com/tutur3u/platform/issues/5447)) ([6e822d8](https://github.com/tutur3u/platform/commit/6e822d89d079d9ea8b2677f208c16364cd70d586))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **calendar:** treat working locations as non-reminder events ([454e666](https://github.com/tutur3u/platform/commit/454e666a6465e3debd06260aedc1de7b96ab5d67)) ([#5511](https://github.com/tutur3u/platform/issues/5511)) ([6508ad6](https://github.com/tutur3u/platform/commit/6508ad681aaa174b186022fcbd526e20be682973))
15
+
3
16
  ## [0.33.0](https://github.com/tutur3u/platform/compare/ui-v0.32.0...ui-v0.33.0) (2026-09-20)
4
17
 
5
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -87,13 +87,13 @@
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.14.0",
90
+ "@tuturuuu/ai": "0.15.0",
91
91
  "@tuturuuu/apis": "0.11.4",
92
92
  "@tuturuuu/hooks": "0.1.2",
93
93
  "@tuturuuu/icons": "0.1.1",
94
- "@tuturuuu/internal-api": "0.39.0",
94
+ "@tuturuuu/internal-api": "0.41.0",
95
95
  "@tuturuuu/supabase": "0.5.2",
96
- "@tuturuuu/utils": "0.29.0",
96
+ "@tuturuuu/utils": "0.30.0",
97
97
  "@types/debug": "^4.1.13",
98
98
  "browser-image-compression": "^2.0.2",
99
99
  "class-variance-authority": "^0.7.1",
@@ -154,7 +154,7 @@
154
154
  "@tanstack/react-table": "^9.2.4",
155
155
  "@testing-library/jest-dom": "^7.0.1",
156
156
  "@testing-library/react": "^16.3.3",
157
- "@tuturuuu/types": "0.31.0",
157
+ "@tuturuuu/types": "0.32.0",
158
158
  "@tuturuuu/typescript-config": "0.1.2",
159
159
  "@types/html2canvas": "^1.0.0",
160
160
  "@types/lodash": "^4.17.25",
@@ -0,0 +1,42 @@
1
+ import {
2
+ focusManager,
3
+ QueryClient,
4
+ QueryClientProvider,
5
+ } from '@tanstack/react-query';
6
+ import { render, screen, waitFor } from '@testing-library/react';
7
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
8
+ import { AccountAssuranceRefresh } from './account-assurance-refresh';
9
+
10
+ const getProfile = vi.hoisted(() => vi.fn());
11
+ vi.mock('@tuturuuu/internal-api/users', () => ({
12
+ getCurrentUserProfile: getProfile,
13
+ }));
14
+ vi.mock('next/navigation', () => ({ usePathname: () => '/personal' }));
15
+ beforeEach(() => {
16
+ getProfile.mockReset();
17
+ focusManager.setFocused(true);
18
+ });
19
+ afterEach(() => focusManager.setFocused(undefined));
20
+ function mount() {
21
+ render(
22
+ <QueryClientProvider client={new QueryClient()}>
23
+ <AccountAssuranceRefresh />
24
+ <p>cached workspace</p>
25
+ </QueryClientProvider>
26
+ );
27
+ }
28
+ it('keeps cached content visible and revalidates on focus', async () => {
29
+ getProfile.mockResolvedValue({ id: 'actor' });
30
+ mount();
31
+ expect(screen.getByText('cached workspace')).toBeTruthy();
32
+ await waitFor(() => expect(getProfile).toHaveBeenCalledTimes(1));
33
+ focusManager.setFocused(false);
34
+ focusManager.setFocused(true);
35
+ await waitFor(() => expect(getProfile).toHaveBeenCalledTimes(2));
36
+ });
37
+ it('does not block optional cached content when offline', async () => {
38
+ getProfile.mockRejectedValue(new Error('Offline'));
39
+ mount();
40
+ await waitFor(() => expect(getProfile).toHaveBeenCalledOnce());
41
+ expect(screen.getByText('cached workspace')).toBeTruthy();
42
+ });
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
5
+ import { usePathname } from 'next/navigation';
6
+
7
+ /** Recheck on reconnect/resume without blocking optional accounts' cached UI.
8
+ * The internal API routes MFA_REQUIRED through the server auth proxy. Offline
9
+ * clients cannot discover a policy/recovery change until they reconnect.
10
+ */
11
+ export function AccountAssuranceRefresh() {
12
+ const pathname = usePathname();
13
+ const protectedPage = Boolean(
14
+ pathname &&
15
+ !/^\/(?:en|vi)?\/?$/.test(pathname) &&
16
+ !/(?:^|\/)(?:login|verify-token|auth)(?:\/|$)/.test(pathname)
17
+ );
18
+ useQuery({
19
+ queryKey: ['account-assurance-refresh'],
20
+ queryFn: () => getCurrentUserProfile(),
21
+ enabled: protectedPage,
22
+ staleTime: 0,
23
+ gcTime: 0,
24
+ retry: false,
25
+ refetchOnWindowFocus: 'always',
26
+ refetchOnReconnect: 'always',
27
+ throwOnError: false,
28
+ });
29
+ return null;
30
+ }
@@ -0,0 +1,137 @@
1
+ 'use client';
2
+
3
+ import type { SubscriptionPaymentPeriod } from '@tuturuuu/internal-api/finance-subscription-analytics';
4
+ import { useLocale, useTranslations } from 'next-intl';
5
+ import {
6
+ Bar,
7
+ BarChart,
8
+ CartesianGrid,
9
+ ResponsiveContainer,
10
+ Tooltip,
11
+ XAxis,
12
+ YAxis,
13
+ } from 'recharts';
14
+ import { FINANCE_HIDDEN_AMOUNT } from '../../shared/use-finance-confidential-visibility';
15
+
16
+ export type PaymentMetric =
17
+ | 'amount'
18
+ | 'paidUsers'
19
+ | 'paidGroups'
20
+ | 'memberships';
21
+ export function SubscriptionPaymentChart({
22
+ periods,
23
+ metric,
24
+ currency,
25
+ hidden,
26
+ }: {
27
+ periods: SubscriptionPaymentPeriod[];
28
+ metric: PaymentMetric;
29
+ currency: string;
30
+ hidden: boolean;
31
+ }) {
32
+ const t = useTranslations('invoice-subscriptions');
33
+ const locale = useLocale();
34
+ const number = new Intl.NumberFormat(locale);
35
+ const money = new Intl.NumberFormat(locale, { style: 'currency', currency });
36
+ const label = (value: string) =>
37
+ value.length === 4
38
+ ? value
39
+ : new Intl.DateTimeFormat(locale, {
40
+ month: 'short',
41
+ year: 'numeric',
42
+ timeZone: 'UTC',
43
+ }).format(new Date(`${value}-01T00:00:00Z`));
44
+ const format = (value: number, key: string) =>
45
+ key === 'amount'
46
+ ? hidden
47
+ ? FINANCE_HIDDEN_AMOUNT
48
+ : money.format(value)
49
+ : number.format(value);
50
+ if (hidden && metric === 'amount')
51
+ return (
52
+ <div className="flex h-64 items-center justify-center rounded-lg bg-muted/40 text-muted-foreground text-sm">
53
+ {t('hidden')}
54
+ </div>
55
+ );
56
+ return (
57
+ <div
58
+ className="h-72 w-full min-w-0"
59
+ role="img"
60
+ aria-label={t('chart_label', { metric: t(metric) })}
61
+ >
62
+ <ResponsiveContainer width="100%" height="100%">
63
+ <BarChart
64
+ data={periods}
65
+ accessibilityLayer
66
+ margin={{ top: 16, right: 12, left: 12, bottom: 0 }}
67
+ >
68
+ <CartesianGrid
69
+ vertical={false}
70
+ stroke="var(--border)"
71
+ strokeDasharray="3 3"
72
+ />
73
+ <XAxis
74
+ dataKey="period"
75
+ tickFormatter={label}
76
+ tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }}
77
+ tickLine={false}
78
+ axisLine={false}
79
+ minTickGap={24}
80
+ />
81
+ <YAxis
82
+ tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }}
83
+ tickLine={false}
84
+ axisLine={false}
85
+ allowDecimals={metric === 'amount'}
86
+ width={66}
87
+ tickFormatter={(value) =>
88
+ new Intl.NumberFormat(locale, { notation: 'compact' }).format(
89
+ value
90
+ )
91
+ }
92
+ />
93
+ <Tooltip
94
+ cursor={{ fill: 'var(--muted)', opacity: 0.5 }}
95
+ content={({ active, payload }) => {
96
+ const row = payload?.[0]?.payload as
97
+ | SubscriptionPaymentPeriod
98
+ | undefined;
99
+ if (!active || !row) return null;
100
+ return (
101
+ <div className="min-w-52 rounded-xl border bg-popover p-4 text-popover-foreground shadow-lg">
102
+ <p className="mb-3 font-semibold">{label(row.period)}</p>
103
+ {(
104
+ [
105
+ 'amount',
106
+ 'paidUsers',
107
+ 'paidGroups',
108
+ 'memberships',
109
+ ] as const
110
+ ).map((key) => (
111
+ <div
112
+ key={key}
113
+ className="flex justify-between gap-6 py-1 text-sm"
114
+ >
115
+ <span className="text-muted-foreground">{t(key)}</span>
116
+ <span className="font-medium tabular-nums">
117
+ {format(row[key], key)}
118
+ </span>
119
+ </div>
120
+ ))}
121
+ </div>
122
+ );
123
+ }}
124
+ />
125
+ <Bar
126
+ dataKey={metric}
127
+ name={t(metric)}
128
+ fill="var(--primary)"
129
+ radius={[5, 5, 0, 0]}
130
+ maxBarSize={48}
131
+ isAnimationActive={false}
132
+ />
133
+ </BarChart>
134
+ </ResponsiveContainer>
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import type { SubscriptionPaymentCurrency } from '@tuturuuu/internal-api/finance-subscription-analytics';
4
+ import { useLocale, useTranslations } from 'next-intl';
5
+ import {
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipProvider,
9
+ TooltipTrigger,
10
+ } from '../../../tooltip';
11
+ import { FINANCE_HIDDEN_AMOUNT } from '../../shared/use-finance-confidential-visibility';
12
+
13
+ export function SubscriptionPaymentDetails({
14
+ data,
15
+ hidden,
16
+ }: {
17
+ data: SubscriptionPaymentCurrency;
18
+ hidden: boolean;
19
+ }) {
20
+ const t = useTranslations('invoice-subscriptions');
21
+ const locale = useLocale();
22
+ const format = (value: number) =>
23
+ hidden
24
+ ? FINANCE_HIDDEN_AMOUNT
25
+ : new Intl.NumberFormat(locale, {
26
+ style: 'currency',
27
+ currency: data.currency,
28
+ }).format(value);
29
+ const max = Math.max(1, ...data.distribution.map((bin) => bin.userCount));
30
+ return (
31
+ <div className="space-y-5">
32
+ <div className="rounded-xl border bg-muted/20 p-4">
33
+ <h3 className="font-medium text-sm">{t('distribution')}</h3>
34
+ <p className="mt-1 text-muted-foreground text-xs">
35
+ {t('distribution_help')}
36
+ </p>
37
+ <TooltipProvider>
38
+ <div className="mt-4 flex flex-wrap gap-3">
39
+ {data.distribution.map((bin) => (
40
+ <Tooltip key={bin.groupCount}>
41
+ <TooltipTrigger asChild>
42
+ <button
43
+ type="button"
44
+ className="min-w-36 flex-1 rounded-lg border bg-background p-3 outline-none focus-visible:ring-2 focus-visible:ring-ring"
45
+ aria-label={t('distribution_value', bin)}
46
+ >
47
+ <div className="flex justify-between gap-4 text-sm">
48
+ <span>
49
+ {t('groups_count', { count: bin.groupCount })}
50
+ </span>
51
+ <strong className="tabular-nums">{bin.userCount}</strong>
52
+ </div>
53
+ <div className="mt-2 h-1.5 rounded-full bg-muted">
54
+ <div
55
+ className="h-full rounded-full bg-primary"
56
+ style={{ width: `${(bin.userCount / max) * 100}%` }}
57
+ />
58
+ </div>
59
+ </button>
60
+ </TooltipTrigger>
61
+ <TooltipContent>{t('distribution_value', bin)}</TooltipContent>
62
+ </Tooltip>
63
+ ))}
64
+ </div>
65
+ </TooltipProvider>
66
+ </div>
67
+ <details className="rounded-xl border p-4">
68
+ <summary className="cursor-pointer font-medium text-sm focus-visible:outline-ring">
69
+ {t('table')}
70
+ </summary>
71
+ <div className="mt-4 overflow-x-auto">
72
+ <table className="w-full text-sm tabular-nums">
73
+ <caption className="sr-only">{t('title')}</caption>
74
+ <thead>
75
+ <tr className="border-b text-muted-foreground">
76
+ {[
77
+ 'period',
78
+ 'paidUsers',
79
+ 'paidGroups',
80
+ 'memberships',
81
+ 'amount',
82
+ ].map((key) => (
83
+ <th
84
+ key={key}
85
+ scope="col"
86
+ className="px-3 py-2 text-start font-medium"
87
+ >
88
+ {t(key)}
89
+ </th>
90
+ ))}
91
+ </tr>
92
+ </thead>
93
+ <tbody>
94
+ {data.periods.map((row) => (
95
+ <tr key={row.period} className="border-b last:border-0">
96
+ <th scope="row" className="px-3 py-2 text-start font-medium">
97
+ {row.period}
98
+ </th>
99
+ <td className="px-3 py-2">{row.paidUsers}</td>
100
+ <td className="px-3 py-2">{row.paidGroups}</td>
101
+ <td className="px-3 py-2">{row.memberships}</td>
102
+ <td className="px-3 py-2">{format(row.amount)}</td>
103
+ </tr>
104
+ ))}
105
+ </tbody>
106
+ </table>
107
+ </div>
108
+ </details>
109
+ </div>
110
+ );
111
+ }
@@ -35,6 +35,7 @@ type LatestInvoice = {
35
35
  group_id?: string;
36
36
  valid_until?: string | null;
37
37
  created_at?: string | null;
38
+ covered_months?: string[] | null;
38
39
  };
39
40
 
40
41
  type UserGroupItem = {
@@ -77,6 +78,7 @@ function GroupRow({
77
78
  isSelected,
78
79
  isMonthPaid,
79
80
  latestInvoice,
81
+ coverageInvoices,
80
82
  locale,
81
83
  isLoadingSubscriptionData,
82
84
  onToggle,
@@ -86,12 +88,17 @@ function GroupRow({
86
88
  isSelected: boolean;
87
89
  isMonthPaid: boolean;
88
90
  latestInvoice: LatestInvoice | undefined;
91
+ coverageInvoices: LatestInvoice[];
89
92
  locale: string;
90
93
  isLoadingSubscriptionData: boolean;
91
94
  onToggle: () => void;
92
95
  t: (key: string) => string;
93
96
  }) {
94
- const paymentStatus = getGroupPaymentStatus(group, latestInvoice);
97
+ const paymentStatus = getGroupPaymentStatus(
98
+ group,
99
+ latestInvoice,
100
+ coverageInvoices
101
+ );
95
102
  return (
96
103
  <button
97
104
  type="button"
@@ -151,13 +158,23 @@ function GroupRow({
151
158
  {new Date(latestInvoice.created_at).toLocaleDateString(locale)}
152
159
  </span>
153
160
  )}
154
- {latestInvoice?.valid_until && (
161
+ {latestInvoice?.covered_months != null ? (
155
162
  <span>
156
- {t('ws-invoices.valid_until')}{' '}
157
- {parseLocalCalendarDate(
158
- latestInvoice.valid_until
159
- ).toLocaleDateString(locale)}
163
+ {t('invoice-subscriptions.coverage_months')}:{' '}
164
+ {latestInvoice.covered_months
165
+ .map((month) => month.slice(0, 7))
166
+ .sort()
167
+ .join(', ')}
160
168
  </span>
169
+ ) : (
170
+ latestInvoice?.valid_until && (
171
+ <span>
172
+ {t('ws-invoices.valid_until')}{' '}
173
+ {parseLocalCalendarDate(
174
+ latestInvoice.valid_until
175
+ ).toLocaleDateString(locale)}
176
+ </span>
177
+ )
161
178
  )}
162
179
  </p>
163
180
  )}
@@ -270,6 +287,9 @@ export function SubscriptionGroupSelector({
270
287
  isSelected={isSelected}
271
288
  isMonthPaid={isMonthPaid}
272
289
  latestInvoice={latestInvoice}
290
+ coverageInvoices={latestSubscriptionInvoices.filter(
291
+ (invoice) => invoice.group_id === group.id
292
+ )}
273
293
  locale={locale}
274
294
  isLoadingSubscriptionData={isLoadingSubscriptionData}
275
295
  onToggle={() => onGroupSelect(group.id)}
@@ -317,6 +337,9 @@ export function SubscriptionGroupSelector({
317
337
  isSelected={isSelected}
318
338
  isMonthPaid={isMonthPaid}
319
339
  latestInvoice={latestInvoice}
340
+ coverageInvoices={latestSubscriptionInvoices.filter(
341
+ (invoice) => invoice.group_id === group.id
342
+ )}
320
343
  locale={locale}
321
344
  isLoadingSubscriptionData={isLoadingSubscriptionData}
322
345
  onToggle={() => onGroupSelect(group.id)}
@@ -0,0 +1,121 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { XLSX } from '../../../../xlsx';
3
+ import { getData, getPendingInvoicesData } from './export-data';
4
+
5
+ const mocks = vi.hoisted(() => ({ pending: vi.fn(), created: vi.fn() }));
6
+ vi.mock('@tuturuuu/internal-api/finance', () => ({
7
+ listPendingFinanceInvoices: mocks.pending,
8
+ listFinanceInvoices: mocks.created,
9
+ }));
10
+ beforeEach(() => vi.clearAllMocks());
11
+
12
+ describe('invoice export normalization', () => {
13
+ it('round-trips Vietnamese class names and all debt rows through a real Excel workbook', async () => {
14
+ const data = [
15
+ {
16
+ user_id: 'student-1',
17
+ user_name: 'Nguyễn An',
18
+ group_id: 'class-1',
19
+ group_name: 'Lớp cô Ngân',
20
+ months_owed: ['2026-08', '2026-09'],
21
+ potential_total: 1200000,
22
+ total_sessions: 8,
23
+ },
24
+ {
25
+ user_id: 'student-1',
26
+ user_name: 'Nguyễn An',
27
+ group_id: 'class-2',
28
+ group_name: 'Lớp thầy Bình',
29
+ months_owed: '2026-09',
30
+ potential_total: 800000,
31
+ total_sessions: 4,
32
+ },
33
+ ];
34
+ mocks.pending.mockResolvedValue({ count: 2, data });
35
+ const result = await getPendingInvoicesData('ws', {});
36
+ const flat = result.data.map(
37
+ ({ customer: _customer, creator: _creator, wallet: _wallet, ...row }) =>
38
+ row
39
+ );
40
+ const workbook = XLSX.utils.book_new();
41
+ XLSX.utils.book_append_sheet(
42
+ workbook,
43
+ XLSX.utils.json_to_sheet(flat),
44
+ 'Debt'
45
+ );
46
+ const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
47
+ const parsed = XLSX.read(bytes, { type: 'array' });
48
+ const rows = XLSX.utils.sheet_to_json(parsed.Sheets.Debt!);
49
+ expect(rows).toHaveLength(2);
50
+ expect(rows).toEqual(
51
+ expect.arrayContaining([
52
+ expect.objectContaining({
53
+ user_name: 'Nguyễn An',
54
+ group_name: 'Lớp cô Ngân',
55
+ months_owed: '2026-08, 2026-09',
56
+ potential_total: 1200000,
57
+ }),
58
+ expect.objectContaining({
59
+ user_name: 'Nguyễn An',
60
+ group_name: 'Lớp thầy Bình',
61
+ months_owed: '2026-09',
62
+ potential_total: 800000,
63
+ }),
64
+ ])
65
+ );
66
+ });
67
+
68
+ it('preserves grouped classes and scalar user filters', async () => {
69
+ mocks.pending.mockResolvedValue({
70
+ count: 1,
71
+ data: [
72
+ {
73
+ user_id: 'student',
74
+ group_ids: ['a', 'b'],
75
+ group_names: ['A', 'B'],
76
+ months_owed: ['2026-09'],
77
+ },
78
+ ],
79
+ });
80
+ const result = await getPendingInvoicesData('ws', {
81
+ groupByUser: true,
82
+ userIds: 'student',
83
+ q: 'A',
84
+ });
85
+ expect(result.data[0]).toMatchObject({
86
+ group_id: 'a,b',
87
+ group_name: 'A, B',
88
+ months_owed: '2026-09',
89
+ });
90
+ expect(mocks.pending).toHaveBeenCalledWith(
91
+ 'ws',
92
+ expect.objectContaining({
93
+ userIds: ['student'],
94
+ q: 'A',
95
+ groupByUser: true,
96
+ })
97
+ );
98
+ });
99
+
100
+ it('preserves both legacy and multiple-wallet filters in created exports', async () => {
101
+ mocks.created.mockResolvedValue({ count: 0, data: [] });
102
+ await getData('ws', {
103
+ walletId: 'cash',
104
+ walletIds: ['bank'],
105
+ userIds: 'student',
106
+ start: '2026-09-01',
107
+ end: '2026-09-30',
108
+ q: 'tuition',
109
+ });
110
+ expect(mocks.created).toHaveBeenCalledWith(
111
+ 'ws',
112
+ expect.objectContaining({
113
+ walletIds: ['cash', 'bank'],
114
+ userIds: ['student'],
115
+ start: '2026-09-01',
116
+ end: '2026-09-30',
117
+ q: 'tuition',
118
+ })
119
+ );
120
+ });
121
+ });