@tuturuuu/ui 0.32.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 +62 -0
- package/biome.json +1 -1
- package/package.json +53 -51
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
- package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
- package/src/components/ui/chat/ai-message-parts.tsx +23 -16
- package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
- package/src/components/ui/commercial-comparison-style.ts +104 -0
- package/src/components/ui/commercial-comparison-table.tsx +159 -0
- package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
- package/src/components/ui/commercial-comparison.test.tsx +86 -0
- package/src/components/ui/commercial-comparison.tsx +224 -0
- package/src/components/ui/comparison-value.test.tsx +32 -0
- package/src/components/ui/comparison-value.tsx +72 -0
- package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
- package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
- 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/custom/education/modules/resources/pdf-viewer.tsx +13 -2
- package/src/components/ui/custom/report-preview-pagination.ts +5 -3
- package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
- package/src/components/ui/custom/report-preview.tsx +2 -3
- package/src/components/ui/finance/categories-tags-tabs.tsx +3 -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/columns.tsx +2 -0
- package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
- package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
- package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -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-invoice-customer-search.test.tsx +89 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -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/hooks.ts +5 -110
- package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
- package/src/components/ui/finance/invoices/internal-api.ts +19 -1
- package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
- package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
- package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
- package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
- package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
- package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
- package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
- package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
- package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
- package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
- package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
- package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
- package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
- package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
- 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-props.ts +20 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
- 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/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
- package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
- 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/src/components/ui/public-workspace-prices.tsx +13 -0
- package/src/components/ui/pwa-status.test.tsx +13 -2
- package/src/components/ui/pwa-status.tsx +15 -3
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
- package/src/components/ui/text-editor/editor.tsx +11 -3
- package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
- package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
- package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
- package/src/hooks/calendar-provider-sync.ts +33 -0
- package/src/hooks/notification-subscription.ts +74 -0
- package/src/hooks/use-calendar-sync.tsx +11 -14
- package/src/hooks/use-calendar.tsx +2 -2
- package/src/hooks/use-notifications.ts +5 -141
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { PropsWithChildren } from 'react';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { useInvoiceCustomerSearch } from './use-invoice-customer-search';
|
|
6
|
+
|
|
7
|
+
const api = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn() }));
|
|
8
|
+
vi.mock('../internal-api', () => ({
|
|
9
|
+
listWorkspaceUsersWithInternalApi: api.list,
|
|
10
|
+
getWorkspaceUserWithInternalApi: api.get,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
api.list.mockReset();
|
|
15
|
+
api.get.mockReset();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function renderSearch(selectedUserId = '') {
|
|
19
|
+
const client = new QueryClient();
|
|
20
|
+
function Wrapper({ children }: PropsWithChildren) {
|
|
21
|
+
return (
|
|
22
|
+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return renderHook(
|
|
26
|
+
() => useInvoiceCustomerSearch('ws-1', '', selectedUserId),
|
|
27
|
+
{
|
|
28
|
+
wrapper: Wrapper,
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('invoice customer loading recovery', () => {
|
|
34
|
+
it('surfaces a failed read immediately and allows a manual retry', async () => {
|
|
35
|
+
api.list.mockRejectedValueOnce(new Error('Timed out')).mockResolvedValue({
|
|
36
|
+
data: [{ id: 'customer-1' }],
|
|
37
|
+
count: 1,
|
|
38
|
+
});
|
|
39
|
+
const { result } = renderSearch();
|
|
40
|
+
await waitFor(() => expect(result.current.error).toBeTruthy());
|
|
41
|
+
expect(result.current.isLoading).toBe(false);
|
|
42
|
+
expect(api.list).toHaveBeenCalledOnce();
|
|
43
|
+
|
|
44
|
+
await act(async () => {
|
|
45
|
+
await result.current.refetch();
|
|
46
|
+
});
|
|
47
|
+
await waitFor(() => expect(result.current.error).toBeNull());
|
|
48
|
+
expect(result.current.customers).toEqual([{ id: 'customer-1' }]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('retries the selected customer as well as the customer list', async () => {
|
|
52
|
+
api.list.mockResolvedValue({ data: [], count: 0 });
|
|
53
|
+
api.get
|
|
54
|
+
.mockRejectedValueOnce(new Error('Timed out'))
|
|
55
|
+
.mockResolvedValue({ id: 'selected' });
|
|
56
|
+
const { result } = renderSearch('selected');
|
|
57
|
+
await waitFor(() => expect(result.current.error).toBeTruthy());
|
|
58
|
+
await act(async () => {
|
|
59
|
+
await result.current.refetch();
|
|
60
|
+
});
|
|
61
|
+
await waitFor(() =>
|
|
62
|
+
expect(result.current.selectedUser?.id).toBe('selected')
|
|
63
|
+
);
|
|
64
|
+
expect(result.current.error).toBeNull();
|
|
65
|
+
expect(api.get).toHaveBeenCalledTimes(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('ignores an old detail error once the list contains the selected customer', async () => {
|
|
69
|
+
api.list.mockResolvedValueOnce({ data: [], count: 0 }).mockResolvedValue({
|
|
70
|
+
data: [{ id: 'selected' }],
|
|
71
|
+
count: 1,
|
|
72
|
+
});
|
|
73
|
+
api.get.mockRejectedValue(new Error('Detail unavailable'));
|
|
74
|
+
const { result } = renderSearch('selected');
|
|
75
|
+
await waitFor(() => expect(result.current.error).toBeTruthy());
|
|
76
|
+
await act(async () => {
|
|
77
|
+
await result.current.refetch();
|
|
78
|
+
});
|
|
79
|
+
await waitFor(() => expect(result.current.error).toBeNull());
|
|
80
|
+
expect(result.current.selectedUser?.id).toBe('selected');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('stops offering more customers when an empty page has a stale count', async () => {
|
|
84
|
+
api.list.mockResolvedValue({ data: [], count: 10 });
|
|
85
|
+
const { result } = renderSearch();
|
|
86
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
87
|
+
expect(result.current.hasNextPage).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
|
2
|
+
import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
|
|
3
|
+
import {
|
|
4
|
+
getWorkspaceUserWithInternalApi,
|
|
5
|
+
listWorkspaceUsersWithInternalApi,
|
|
6
|
+
} from '../internal-api';
|
|
7
|
+
|
|
8
|
+
const INVOICE_CUSTOMER_PAGE_SIZE = 25;
|
|
9
|
+
|
|
10
|
+
type WorkspaceUsersPage = {
|
|
11
|
+
data: WorkspaceUser[];
|
|
12
|
+
count: number;
|
|
13
|
+
offset: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
async function fetchInvoiceCustomerPage(
|
|
17
|
+
wsId: string,
|
|
18
|
+
searchQuery: string,
|
|
19
|
+
offset: number
|
|
20
|
+
): Promise<WorkspaceUsersPage> {
|
|
21
|
+
const payload = await listWorkspaceUsersWithInternalApi(wsId, {
|
|
22
|
+
from: offset,
|
|
23
|
+
limit: INVOICE_CUSTOMER_PAGE_SIZE,
|
|
24
|
+
q: searchQuery.trim() || undefined,
|
|
25
|
+
to: offset + INVOICE_CUSTOMER_PAGE_SIZE - 1,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
data: payload.data,
|
|
30
|
+
count: payload.count,
|
|
31
|
+
offset,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function fetchWorkspaceUserById(
|
|
36
|
+
wsId: string,
|
|
37
|
+
userId: string
|
|
38
|
+
): Promise<WorkspaceUser | null> {
|
|
39
|
+
return getWorkspaceUserWithInternalApi(wsId, userId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useInvoiceCustomerSearch(
|
|
43
|
+
wsId: string,
|
|
44
|
+
searchQuery: string,
|
|
45
|
+
selectedUserId: string
|
|
46
|
+
) {
|
|
47
|
+
const normalizedSearchQuery = searchQuery.trim();
|
|
48
|
+
|
|
49
|
+
const usersQuery = useInfiniteQuery({
|
|
50
|
+
queryKey: ['invoice-customer-search', wsId, normalizedSearchQuery],
|
|
51
|
+
queryFn: async ({ pageParam = 0 }) =>
|
|
52
|
+
fetchInvoiceCustomerPage(wsId, normalizedSearchQuery, pageParam),
|
|
53
|
+
initialPageParam: 0,
|
|
54
|
+
placeholderData: (previousData) => previousData,
|
|
55
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
56
|
+
const loadedCount = allPages.reduce(
|
|
57
|
+
(total, page) => total + page.data.length,
|
|
58
|
+
0
|
|
59
|
+
);
|
|
60
|
+
return lastPage.data.length > 0 && loadedCount < lastPage.count
|
|
61
|
+
? loadedCount
|
|
62
|
+
: undefined;
|
|
63
|
+
},
|
|
64
|
+
enabled: !!wsId,
|
|
65
|
+
retry: false,
|
|
66
|
+
staleTime: 30 * 1000,
|
|
67
|
+
gcTime: 5 * 60 * 1000,
|
|
68
|
+
refetchOnWindowFocus: false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const loadedCustomers =
|
|
72
|
+
usersQuery.data?.pages.flatMap((page) => page.data) ?? [];
|
|
73
|
+
const needsSelectedUser =
|
|
74
|
+
!!wsId &&
|
|
75
|
+
!!selectedUserId &&
|
|
76
|
+
!loadedCustomers.some((user) => user.id === selectedUserId);
|
|
77
|
+
const selectedUserQuery = useQuery({
|
|
78
|
+
queryKey: ['invoice-customer', wsId, selectedUserId],
|
|
79
|
+
queryFn: async () => fetchWorkspaceUserById(wsId, selectedUserId),
|
|
80
|
+
enabled: needsSelectedUser,
|
|
81
|
+
retry: false,
|
|
82
|
+
staleTime: 30 * 1000,
|
|
83
|
+
gcTime: 5 * 60 * 1000,
|
|
84
|
+
refetchOnWindowFocus: false,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const customersById = new Map<string, WorkspaceUser>();
|
|
88
|
+
|
|
89
|
+
if (selectedUserQuery.data) {
|
|
90
|
+
customersById.set(selectedUserQuery.data.id, selectedUserQuery.data);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const customer of loadedCustomers) {
|
|
94
|
+
customersById.set(customer.id, customer);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const customers = Array.from(customersById.values());
|
|
98
|
+
const selectedUser =
|
|
99
|
+
(selectedUserId
|
|
100
|
+
? customers.find((user) => user.id === selectedUserId)
|
|
101
|
+
: undefined) ?? undefined;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
...usersQuery,
|
|
105
|
+
customers,
|
|
106
|
+
selectedUser,
|
|
107
|
+
error:
|
|
108
|
+
usersQuery.error ?? (needsSelectedUser ? selectedUserQuery.error : null),
|
|
109
|
+
isLoading: usersQuery.isLoading || selectedUserQuery.isLoading,
|
|
110
|
+
isFetchingSelectedUser: selectedUserQuery.isFetching,
|
|
111
|
+
refetch: () =>
|
|
112
|
+
Promise.all([
|
|
113
|
+
usersQuery.refetch(),
|
|
114
|
+
...(needsSelectedUser ? [selectedUserQuery.refetch()] : []),
|
|
115
|
+
]),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { Product, SelectedProductItem, UserGroupProducts } from '../types';
|
|
5
|
+
import type { UserGroup } from '../utils';
|
|
6
|
+
import { useSubscriptionAutoSelection } from './use-subscription-auto-selection';
|
|
7
|
+
|
|
8
|
+
const translate = (key: string) => key;
|
|
9
|
+
vi.mock('next-intl', () => ({ useTranslations: () => translate }));
|
|
10
|
+
vi.mock('@tuturuuu/ui/sonner', () => ({ toast: vi.fn() }));
|
|
11
|
+
|
|
12
|
+
const inventory = {
|
|
13
|
+
unit_id: 'unit',
|
|
14
|
+
warehouse_id: 'warehouse',
|
|
15
|
+
amount: null,
|
|
16
|
+
min_amount: 0,
|
|
17
|
+
price: 100,
|
|
18
|
+
unit_name: 'Session',
|
|
19
|
+
warehouse_name: 'Main',
|
|
20
|
+
};
|
|
21
|
+
const products: Product[] = [
|
|
22
|
+
{
|
|
23
|
+
id: 'tuition',
|
|
24
|
+
name: 'Tuition',
|
|
25
|
+
manufacturer: null,
|
|
26
|
+
description: null,
|
|
27
|
+
usage: null,
|
|
28
|
+
category: null,
|
|
29
|
+
category_id: 'cat',
|
|
30
|
+
ws_id: 'ws',
|
|
31
|
+
created_at: null,
|
|
32
|
+
inventory: [inventory],
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
const groupProducts: UserGroupProducts[] = [
|
|
36
|
+
{
|
|
37
|
+
group_id: 'class-a',
|
|
38
|
+
workspace_products: {
|
|
39
|
+
id: 'tuition',
|
|
40
|
+
name: 'Tuition',
|
|
41
|
+
product_categories: { name: null },
|
|
42
|
+
},
|
|
43
|
+
inventory_units: { id: 'unit', name: 'Session' },
|
|
44
|
+
warehouse_id: 'warehouse',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
const userGroups: UserGroup[] = [
|
|
48
|
+
{
|
|
49
|
+
workspace_user_groups: {
|
|
50
|
+
id: 'class-a',
|
|
51
|
+
name: 'Class A',
|
|
52
|
+
starting_date: '2026-01-01',
|
|
53
|
+
ending_date: null,
|
|
54
|
+
sessions: ['2026-01-05', '2026-01-12'],
|
|
55
|
+
} as NonNullable<UserGroup['workspace_user_groups']>,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
const baseProps = {
|
|
59
|
+
enabled: true,
|
|
60
|
+
selectedGroupIds: ['class-a'],
|
|
61
|
+
selectedMonth: '2026-01',
|
|
62
|
+
groupProducts,
|
|
63
|
+
products,
|
|
64
|
+
userGroups,
|
|
65
|
+
useAttendanceBased: false,
|
|
66
|
+
userAttendance: [],
|
|
67
|
+
latestSubscriptionInvoices: [],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type Props = Omit<
|
|
71
|
+
Parameters<typeof useSubscriptionAutoSelection>[0],
|
|
72
|
+
'onSelectedProductsChange'
|
|
73
|
+
>;
|
|
74
|
+
function useSelection(props: Props) {
|
|
75
|
+
const [selected, setSelected] = useState<SelectedProductItem[]>([]);
|
|
76
|
+
useSubscriptionAutoSelection({
|
|
77
|
+
...props,
|
|
78
|
+
onSelectedProductsChange: setSelected,
|
|
79
|
+
});
|
|
80
|
+
return selected;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('subscription selection reflects current billable sessions', () => {
|
|
84
|
+
it('clears prior charges when switching to a month with no sessions', () => {
|
|
85
|
+
const { result, rerender } = renderHook(useSelection, {
|
|
86
|
+
initialProps: baseProps as Props,
|
|
87
|
+
});
|
|
88
|
+
expect(result.current.map((row) => row.quantity)).toEqual([2]);
|
|
89
|
+
rerender({ ...baseProps, selectedMonth: '2026-02' });
|
|
90
|
+
expect(result.current).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('clears prior charges after payment coverage makes the month fully paid', () => {
|
|
94
|
+
const { result, rerender } = renderHook(useSelection, {
|
|
95
|
+
initialProps: baseProps as Props,
|
|
96
|
+
});
|
|
97
|
+
expect(result.current).toHaveLength(1);
|
|
98
|
+
rerender({
|
|
99
|
+
...baseProps,
|
|
100
|
+
latestSubscriptionInvoices: [
|
|
101
|
+
{ group_id: 'class-a', valid_until: '2026-02-01' },
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
expect(result.current).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('keeps charges for an earlier gap when a later month was explicitly paid', () => {
|
|
108
|
+
const { result } = renderHook(useSelection, {
|
|
109
|
+
initialProps: {
|
|
110
|
+
...baseProps,
|
|
111
|
+
latestSubscriptionInvoices: [
|
|
112
|
+
{
|
|
113
|
+
group_id: 'class-a',
|
|
114
|
+
valid_until: '2026-03-01',
|
|
115
|
+
covered_months: ['2026-02-01'],
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
} as Props,
|
|
119
|
+
});
|
|
120
|
+
expect(result.current.map((row) => row.quantity)).toEqual([2]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('clears charges for an explicitly paid month without relying on expiry', () => {
|
|
124
|
+
const { result } = renderHook(useSelection, {
|
|
125
|
+
initialProps: {
|
|
126
|
+
...baseProps,
|
|
127
|
+
latestSubscriptionInvoices: [
|
|
128
|
+
{ group_id: 'class-a', covered_months: ['2026-01-01'] },
|
|
129
|
+
],
|
|
130
|
+
} as Props,
|
|
131
|
+
});
|
|
132
|
+
expect(result.current).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('clears charges when linked inventory becomes unavailable', () => {
|
|
136
|
+
const { result, rerender } = renderHook(useSelection, {
|
|
137
|
+
initialProps: baseProps as Props,
|
|
138
|
+
});
|
|
139
|
+
expect(result.current).toHaveLength(1);
|
|
140
|
+
rerender({
|
|
141
|
+
...baseProps,
|
|
142
|
+
products: [{ ...products[0]!, inventory: [{ ...inventory, amount: 0 }] }],
|
|
143
|
+
});
|
|
144
|
+
expect(result.current).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('repopulates charges when switching back to a billable month', () => {
|
|
148
|
+
const { result, rerender } = renderHook(useSelection, {
|
|
149
|
+
initialProps: { ...baseProps, selectedMonth: '2026-02' } as Props,
|
|
150
|
+
});
|
|
151
|
+
expect(result.current).toEqual([]);
|
|
152
|
+
rerender(baseProps);
|
|
153
|
+
expect(result.current.map((row) => row.quantity)).toEqual([2]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('subscription class and inventory updates', () => {
|
|
158
|
+
it('updates the selected price when inventory pricing refreshes', () => {
|
|
159
|
+
const { result, rerender } = renderHook(useSelection, {
|
|
160
|
+
initialProps: baseProps as Props,
|
|
161
|
+
});
|
|
162
|
+
expect(result.current[0]?.inventory.price).toBe(100);
|
|
163
|
+
rerender({
|
|
164
|
+
...baseProps,
|
|
165
|
+
products: [
|
|
166
|
+
{ ...products[0]!, inventory: [{ ...inventory, price: 150 }] },
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
expect(result.current[0]?.inventory.price).toBe(150);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('keeps the unpaid class when another selected class is covered', () => {
|
|
173
|
+
const otherGroup = {
|
|
174
|
+
workspace_user_groups: {
|
|
175
|
+
...userGroups[0]!.workspace_user_groups!,
|
|
176
|
+
id: 'class-b',
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const props: Props = {
|
|
180
|
+
...baseProps,
|
|
181
|
+
selectedGroupIds: ['class-a', 'class-b'],
|
|
182
|
+
userGroups: [...userGroups, otherGroup],
|
|
183
|
+
groupProducts: [
|
|
184
|
+
...groupProducts,
|
|
185
|
+
{ ...groupProducts[0]!, group_id: 'class-b' },
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
const { result, rerender } = renderHook(useSelection, {
|
|
189
|
+
initialProps: props,
|
|
190
|
+
});
|
|
191
|
+
expect(result.current.map((row) => row.quantity)).toEqual([2, 2]);
|
|
192
|
+
rerender({
|
|
193
|
+
...props,
|
|
194
|
+
latestSubscriptionInvoices: [
|
|
195
|
+
{ group_id: 'class-a', valid_until: '2026-02-01' },
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
expect(result.current.map((row) => row.quantity)).toEqual([2]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('counts only present and late attendance in attendance-based billing', () => {
|
|
202
|
+
const props: Props = {
|
|
203
|
+
...baseProps,
|
|
204
|
+
useAttendanceBased: true,
|
|
205
|
+
userAttendance: [
|
|
206
|
+
{ group_id: 'class-a', date: '2026-01-05', status: 'PRESENT' },
|
|
207
|
+
{ group_id: 'class-a', date: '2026-01-12', status: 'LATE' },
|
|
208
|
+
{ group_id: 'class-a', date: '2026-01-19', status: 'ABSENT' },
|
|
209
|
+
{ group_id: 'class-b', date: '2026-01-05', status: 'PRESENT' },
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
const { result, rerender } = renderHook(useSelection, {
|
|
213
|
+
initialProps: props,
|
|
214
|
+
});
|
|
215
|
+
expect(result.current.map((row) => row.quantity)).toEqual([2]);
|
|
216
|
+
rerender({ ...props, userAttendance: [] });
|
|
217
|
+
expect(result.current).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('respects finite stock and updates when stock recovers', () => {
|
|
221
|
+
const props: Props = {
|
|
222
|
+
...baseProps,
|
|
223
|
+
products: [{ ...products[0]!, inventory: [{ ...inventory, amount: 1 }] }],
|
|
224
|
+
};
|
|
225
|
+
const { result, rerender } = renderHook(useSelection, {
|
|
226
|
+
initialProps: props,
|
|
227
|
+
});
|
|
228
|
+
expect(result.current.map((row) => row.quantity)).toEqual([1]);
|
|
229
|
+
rerender(baseProps);
|
|
230
|
+
expect(result.current.map((row) => row.quantity)).toEqual([2]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('does not populate selection while auto-selection is disabled', () => {
|
|
234
|
+
const { result } = renderHook(useSelection, {
|
|
235
|
+
initialProps: { ...baseProps, enabled: false },
|
|
236
|
+
});
|
|
237
|
+
expect(result.current).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('prefilled subscription quantities', () => {
|
|
242
|
+
it('retains an explicit initial quantity until billing inputs change', () => {
|
|
243
|
+
const props: Props = { ...baseProps, prefillAmount: 5 };
|
|
244
|
+
const { result, rerender } = renderHook(useSelection, {
|
|
245
|
+
initialProps: props,
|
|
246
|
+
});
|
|
247
|
+
expect(result.current.map((row) => row.quantity)).toEqual([5]);
|
|
248
|
+
rerender(props);
|
|
249
|
+
expect(result.current.map((row) => row.quantity)).toEqual([5]);
|
|
250
|
+
rerender({ ...props, selectedMonth: '2026-02' });
|
|
251
|
+
expect(result.current).toEqual([]);
|
|
252
|
+
rerender(props);
|
|
253
|
+
expect(result.current.map((row) => row.quantity)).toEqual([2]);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
SelectedProductItem,
|
|
15
15
|
UserGroupProducts,
|
|
16
16
|
} from '../types';
|
|
17
|
-
import type { UserGroup } from '../utils';
|
|
17
|
+
import type { SubscriptionCoverageInvoice, UserGroup } from '../utils';
|
|
18
18
|
import {
|
|
19
19
|
getBillableQuantityMapForGroupsRange,
|
|
20
20
|
getEffectiveDays,
|
|
@@ -31,11 +31,7 @@ interface UseSubscriptionAutoSelectionProps {
|
|
|
31
31
|
userGroups: UserGroup[];
|
|
32
32
|
useAttendanceBased: boolean;
|
|
33
33
|
userAttendance: { status: string; date: string; group_id?: string }[];
|
|
34
|
-
latestSubscriptionInvoices:
|
|
35
|
-
group_id?: string;
|
|
36
|
-
valid_until?: string | null;
|
|
37
|
-
created_at?: string | null;
|
|
38
|
-
}[];
|
|
34
|
+
latestSubscriptionInvoices: SubscriptionCoverageInvoice[];
|
|
39
35
|
onSelectedProductsChange: Dispatch<SetStateAction<SelectedProductItem[]>>;
|
|
40
36
|
prepaidMonthCount?: number;
|
|
41
37
|
workspaceTimezone?: string | null;
|
|
@@ -129,7 +125,7 @@ const areSelectedProductsEqual = (
|
|
|
129
125
|
if (current.length !== next.length) return false;
|
|
130
126
|
|
|
131
127
|
const toKey = (item: SelectedProductItem) =>
|
|
132
|
-
`${item.product.id}|${item.inventory.unit_id}|${item.inventory.warehouse_id}|${item.quantity}`;
|
|
128
|
+
`${item.product.id}|${item.inventory.unit_id}|${item.inventory.warehouse_id}|${item.inventory.price}|${item.inventory.amount}|${item.quantity}`;
|
|
133
129
|
|
|
134
130
|
const buildCounts = (items: SelectedProductItem[]) => {
|
|
135
131
|
const counts = new Map<string, number>();
|
|
@@ -155,11 +151,7 @@ const areSelectedProductsEqual = (
|
|
|
155
151
|
const computeGroupAttendanceDaysMap = (
|
|
156
152
|
selectedGroupIds: string[],
|
|
157
153
|
userGroups: UserGroup[],
|
|
158
|
-
latestSubscriptionInvoices:
|
|
159
|
-
group_id?: string;
|
|
160
|
-
valid_until?: string | null;
|
|
161
|
-
created_at?: string | null;
|
|
162
|
-
}[],
|
|
154
|
+
latestSubscriptionInvoices: SubscriptionCoverageInvoice[],
|
|
163
155
|
selectedMonth: string,
|
|
164
156
|
userAttendance: { status: string; date: string; group_id?: string }[],
|
|
165
157
|
useAttendanceBased: boolean,
|
|
@@ -192,11 +184,7 @@ const computeFallbackAttendanceDays = ({
|
|
|
192
184
|
userGroups,
|
|
193
185
|
}: {
|
|
194
186
|
groupAttendanceDaysMap: Record<string, number>;
|
|
195
|
-
latestSubscriptionInvoices:
|
|
196
|
-
group_id?: string;
|
|
197
|
-
valid_until?: string | null;
|
|
198
|
-
created_at?: string | null;
|
|
199
|
-
}[];
|
|
187
|
+
latestSubscriptionInvoices: SubscriptionCoverageInvoice[];
|
|
200
188
|
prepaidMonthCount: number;
|
|
201
189
|
selectedGroupIds: string[];
|
|
202
190
|
selectedMonth: string;
|
|
@@ -313,9 +301,7 @@ export function useSubscriptionAutoSelection({
|
|
|
313
301
|
const isGroupChanged = previousGroupIdRef.current !== currentGroupIdsKey;
|
|
314
302
|
previousGroupIdRef.current = currentGroupIdsKey;
|
|
315
303
|
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
fallbackToastShownRef.current = false;
|
|
304
|
+
if (isGroupChanged) fallbackToastShownRef.current = false;
|
|
319
305
|
|
|
320
306
|
const shouldUsePrefill =
|
|
321
307
|
prepaidMonthCount === 1 &&
|
|
@@ -356,76 +342,4 @@ export function useSubscriptionAutoSelection({
|
|
|
356
342
|
groupAttendanceDaysMap,
|
|
357
343
|
prepaidMonthCount,
|
|
358
344
|
]);
|
|
359
|
-
|
|
360
|
-
// Auto-add group products based on attendance when group is selected
|
|
361
|
-
useEffect(() => {
|
|
362
|
-
if (
|
|
363
|
-
!enabled ||
|
|
364
|
-
sortedSelectedGroupIds.length === 0 ||
|
|
365
|
-
groupProducts.length === 0
|
|
366
|
-
) {
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
prepaidMonthCount === 1 &&
|
|
372
|
-
prefillAmount != null &&
|
|
373
|
-
!initialPrefillUsedRef.current
|
|
374
|
-
) {
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const attendanceDays = computeFallbackAttendanceDays({
|
|
379
|
-
groupAttendanceDaysMap,
|
|
380
|
-
latestSubscriptionInvoices,
|
|
381
|
-
prepaidMonthCount,
|
|
382
|
-
selectedGroupIds: sortedSelectedGroupIds,
|
|
383
|
-
selectedMonth,
|
|
384
|
-
useAttendanceBased,
|
|
385
|
-
userAttendance,
|
|
386
|
-
userGroups,
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
if (attendanceDays === 0) return;
|
|
390
|
-
|
|
391
|
-
const { autoSelected, fallbackTriggered } =
|
|
392
|
-
buildAutoSelectedProductsForGroup(
|
|
393
|
-
groupProducts,
|
|
394
|
-
products,
|
|
395
|
-
attendanceDays,
|
|
396
|
-
groupAttendanceDaysMap
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
if (autoSelected.length === 0) return;
|
|
400
|
-
|
|
401
|
-
onSelectedProductsChange((prev) =>
|
|
402
|
-
areSelectedProductsEqual(prev, autoSelected) ? prev : autoSelected
|
|
403
|
-
);
|
|
404
|
-
|
|
405
|
-
if (fallbackTriggered && !fallbackToastShownRef.current) {
|
|
406
|
-
toast(
|
|
407
|
-
t('ws-invoices.inventory_fallback_used', {
|
|
408
|
-
default:
|
|
409
|
-
'Some items used fallback warehouses due to missing preference.',
|
|
410
|
-
})
|
|
411
|
-
);
|
|
412
|
-
fallbackToastShownRef.current = true;
|
|
413
|
-
}
|
|
414
|
-
}, [
|
|
415
|
-
enabled,
|
|
416
|
-
sortedSelectedGroupIds,
|
|
417
|
-
selectedMonth,
|
|
418
|
-
userAttendance?.length,
|
|
419
|
-
prefillAmount,
|
|
420
|
-
groupProducts,
|
|
421
|
-
userGroups,
|
|
422
|
-
useAttendanceBased,
|
|
423
|
-
t,
|
|
424
|
-
userAttendance,
|
|
425
|
-
latestSubscriptionInvoices,
|
|
426
|
-
onSelectedProductsChange,
|
|
427
|
-
products,
|
|
428
|
-
groupAttendanceDaysMap,
|
|
429
|
-
prepaidMonthCount,
|
|
430
|
-
]);
|
|
431
345
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { UserGroup } from '../utils';
|
|
4
|
+
import { useSubscriptionInvoiceContent } from './use-subscription-invoice-content';
|
|
5
|
+
|
|
6
|
+
const translate = Object.assign(
|
|
7
|
+
(key: string, values?: Record<string, unknown>) =>
|
|
8
|
+
values?.monthName ? String(values.monthName) : key,
|
|
9
|
+
{ has: () => true }
|
|
10
|
+
);
|
|
11
|
+
vi.mock('next-intl', () => ({ useTranslations: () => translate }));
|
|
12
|
+
const base = {
|
|
13
|
+
enabled: true,
|
|
14
|
+
selectedGroupIds: ['g1'],
|
|
15
|
+
selectedMonth: '2026-01',
|
|
16
|
+
prepaidMonthCount: 3,
|
|
17
|
+
userGroups: [
|
|
18
|
+
{
|
|
19
|
+
workspace_user_groups: {
|
|
20
|
+
id: 'g1',
|
|
21
|
+
name: 'Class A',
|
|
22
|
+
sessions: [],
|
|
23
|
+
ws_id: 'ws',
|
|
24
|
+
archived: false,
|
|
25
|
+
cert_template: 'original',
|
|
26
|
+
created_at: null,
|
|
27
|
+
creator_id: null,
|
|
28
|
+
description: null,
|
|
29
|
+
ending_date: null,
|
|
30
|
+
is_course_published: false,
|
|
31
|
+
is_guest: false,
|
|
32
|
+
notes: null,
|
|
33
|
+
starting_date: '2026-01-01',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
] satisfies UserGroup[],
|
|
37
|
+
groupProducts: [],
|
|
38
|
+
subscriptionSelectedProducts: [],
|
|
39
|
+
userAttendance: [],
|
|
40
|
+
isSelectedMonthPaid: false,
|
|
41
|
+
locale: 'en',
|
|
42
|
+
onNotesChange: vi.fn(),
|
|
43
|
+
};
|
|
44
|
+
describe('subscription invoice month descriptions', () => {
|
|
45
|
+
it('lists separated unpaid months without claiming the paid month between them', () => {
|
|
46
|
+
const onContentChange = vi.fn();
|
|
47
|
+
renderHook(() =>
|
|
48
|
+
useSubscriptionInvoiceContent({
|
|
49
|
+
...base,
|
|
50
|
+
onContentChange,
|
|
51
|
+
latestSubscriptionInvoices: [
|
|
52
|
+
{
|
|
53
|
+
group_id: 'g1',
|
|
54
|
+
valid_until: '2026-03-01',
|
|
55
|
+
covered_months: ['2026-02-01'],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
expect(onContentChange).toHaveBeenLastCalledWith(
|
|
61
|
+
'January 2026, March 2026'
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
it('keeps a compact range for contiguous unpaid months', () => {
|
|
65
|
+
const onContentChange = vi.fn();
|
|
66
|
+
renderHook(() =>
|
|
67
|
+
useSubscriptionInvoiceContent({
|
|
68
|
+
...base,
|
|
69
|
+
onContentChange,
|
|
70
|
+
latestSubscriptionInvoices: [],
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
expect(onContentChange).toHaveBeenLastCalledWith(
|
|
74
|
+
'January 2026 - March 2026'
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
it('does not substitute a future expiry for the selected fully covered range', () => {
|
|
78
|
+
const onContentChange = vi.fn();
|
|
79
|
+
renderHook(() =>
|
|
80
|
+
useSubscriptionInvoiceContent({
|
|
81
|
+
...base,
|
|
82
|
+
onContentChange,
|
|
83
|
+
isSelectedMonthPaid: true,
|
|
84
|
+
latestSubscriptionInvoices: [
|
|
85
|
+
{ group_id: 'g1', valid_until: '2027-01-01' },
|
|
86
|
+
],
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
expect(onContentChange).toHaveBeenLastCalledWith(
|
|
90
|
+
'January 2026 - March 2026'
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|