@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  23. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  24. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  25. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  26. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  27. package/src/components/ui/custom/report-preview.tsx +2 -3
  28. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  29. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  30. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  31. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  32. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  33. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  34. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  35. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  36. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  37. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  38. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  39. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  40. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  41. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  42. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  43. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  44. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  45. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  46. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  47. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  48. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  49. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  50. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  51. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  52. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  53. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  54. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  55. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  56. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  57. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  58. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  59. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  60. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  61. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  62. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  63. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  64. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  65. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  66. package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
  67. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  68. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  69. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  70. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  71. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  72. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  73. package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
  74. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  75. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  76. package/src/components/ui/finance/invoices/utils.ts +48 -106
  77. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  78. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  79. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  80. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  81. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  82. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  83. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  84. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  85. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  86. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  87. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  88. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  89. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
  90. package/src/components/ui/public-workspace-prices.tsx +13 -0
  91. package/src/components/ui/pwa-status.test.tsx +13 -2
  92. package/src/components/ui/pwa-status.tsx +15 -3
  93. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  94. package/src/components/ui/text-editor/editor.tsx +11 -3
  95. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  96. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  97. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  98. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  99. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  100. package/src/hooks/calendar-provider-sync.ts +33 -0
  101. package/src/hooks/notification-subscription.ts +74 -0
  102. package/src/hooks/use-calendar-sync.tsx +11 -14
  103. package/src/hooks/use-calendar.tsx +2 -2
  104. 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 (!isGroupChanged) return;
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
+ });