@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.
- package/CHANGELOG.md +13 -0
- package/package.json +5 -5
- package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
- package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
- package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
- package/src/components/ui/finance/invoices/export-data.ts +229 -0
- package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
- package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
- package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
- package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
- package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
- package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +2 -0
- package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
- package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +3 -6
- package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
- package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
- package/src/components/ui/finance/invoices/utils.ts +48 -106
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
- package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
- package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
- 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.
|
|
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.
|
|
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.
|
|
94
|
+
"@tuturuuu/internal-api": "0.41.0",
|
|
95
95
|
"@tuturuuu/supabase": "0.5.2",
|
|
96
|
-
"@tuturuuu/utils": "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.
|
|
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(
|
|
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?.
|
|
161
|
+
{latestInvoice?.covered_months != null ? (
|
|
155
162
|
<span>
|
|
156
|
-
{t('
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
});
|