@tuturuuu/ui 0.13.0 → 0.16.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 +90 -0
- package/biome.json +1 -1
- package/package.json +62 -62
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
- package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
- package/src/components/ui/custom/notification-popover-client.tsx +5 -1
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
- package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
- package/src/components/ui/custom/settings/task-settings.tsx +55 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
- package/src/components/ui/custom/sidebar-context.tsx +58 -3
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
- package/src/components/ui/custom/statistic-card.tsx +104 -0
- package/src/components/ui/custom/structure.test.tsx +29 -0
- package/src/components/ui/custom/structure.tsx +103 -88
- package/src/components/ui/finance/invoices/civil-month.ts +224 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
- package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
- package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
- package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
- package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
- package/src/components/ui/finance/invoices/utils.test.ts +84 -0
- package/src/components/ui/finance/invoices/utils.ts +56 -134
- package/src/components/ui/resizable.test.ts +15 -0
- package/src/components/ui/resizable.tsx +28 -6
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
- package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
- package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
- package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
- package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
- package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
- package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
- package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
- package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
- package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
- package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
- package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
- package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
- package/src/components/ui/tu-do/projects/types.ts +4 -1
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
- package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
- package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
- package/src/components/ui/tu-do/templates/client.tsx +97 -78
- package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
- package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
- package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
- package/src/hooks/task-actions-personal-external.ts +12 -6
- package/src/hooks/use-notifications.ts +14 -2
- package/src/hooks/use-user-config.ts +10 -2
- package/vitest.config.ts +4 -0
|
@@ -11,6 +11,7 @@ const invoiceMocks = vi.hoisted(() => ({
|
|
|
11
11
|
|
|
12
12
|
const nuqsState = vi.hoisted(() => ({
|
|
13
13
|
invoiceType: 'standard' as 'standard' | 'subscription',
|
|
14
|
+
values: new Map<string, number | null>(),
|
|
14
15
|
}));
|
|
15
16
|
|
|
16
17
|
vi.mock('next-intl', () => ({
|
|
@@ -23,7 +24,11 @@ vi.mock('nuqs', () => ({
|
|
|
23
24
|
return [nuqsState.invoiceType, vi.fn()];
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
if (key
|
|
27
|
+
if (nuqsState.values.has(key)) {
|
|
28
|
+
return [nuqsState.values.get(key), vi.fn()];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (['amount', 'billable_quantity', 'suggested_total'].includes(key)) {
|
|
27
32
|
return [null, vi.fn()];
|
|
28
33
|
}
|
|
29
34
|
|
|
@@ -80,11 +85,13 @@ describe('NewInvoicePage', () => {
|
|
|
80
85
|
invoiceMocks.StandardInvoice.mockClear();
|
|
81
86
|
invoiceMocks.SubscriptionInvoice.mockClear();
|
|
82
87
|
nuqsState.invoiceType = 'standard';
|
|
88
|
+
nuqsState.values.clear();
|
|
83
89
|
fetchMock = vi.fn().mockResolvedValue({
|
|
84
90
|
ok: true,
|
|
85
91
|
json: async () => ({
|
|
86
92
|
DEFAULT_CURRENCY: 'SGD',
|
|
87
93
|
DEFAULT_SUBSCRIPTION_CATEGORY_ID: 'category-1',
|
|
94
|
+
default_category_id: 'category-general',
|
|
88
95
|
default_wallet_id: 'wallet-1',
|
|
89
96
|
}),
|
|
90
97
|
});
|
|
@@ -101,7 +108,7 @@ describe('NewInvoicePage', () => {
|
|
|
101
108
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
102
109
|
|
|
103
110
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
104
|
-
'/api/v1/workspaces/ws-1/settings/configs?ids=default_wallet_id,DEFAULT_SUBSCRIPTION_CATEGORY_ID,DEFAULT_CURRENCY',
|
|
111
|
+
'/api/v1/workspaces/ws-1/settings/configs?ids=default_wallet_id,default_category_id,DEFAULT_SUBSCRIPTION_CATEGORY_ID,DEFAULT_CURRENCY',
|
|
105
112
|
{ cache: 'no-store' }
|
|
106
113
|
);
|
|
107
114
|
expect(invoiceMocks.StandardInvoice).toHaveBeenCalled();
|
|
@@ -110,6 +117,7 @@ describe('NewInvoicePage', () => {
|
|
|
110
117
|
await waitFor(() =>
|
|
111
118
|
expect(invoiceMocks.StandardInvoice).toHaveBeenLastCalledWith(
|
|
112
119
|
expect.objectContaining({
|
|
120
|
+
defaultCategoryId: 'category-general',
|
|
113
121
|
defaultCurrency: 'SGD',
|
|
114
122
|
defaultWalletId: 'wallet-1',
|
|
115
123
|
})
|
|
@@ -133,6 +141,86 @@ describe('NewInvoicePage', () => {
|
|
|
133
141
|
defaultCategoryId: 'category-1',
|
|
134
142
|
defaultCurrency: 'SGD',
|
|
135
143
|
defaultWalletId: 'wallet-1',
|
|
144
|
+
workspaceTimezone: undefined,
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('forwards a pending invoice quantity and suggested monetary total', async () => {
|
|
151
|
+
nuqsState.invoiceType = 'subscription';
|
|
152
|
+
nuqsState.values.set('billable_quantity', 3);
|
|
153
|
+
nuqsState.values.set('suggested_total', 1_500_000);
|
|
154
|
+
|
|
155
|
+
renderPage();
|
|
156
|
+
|
|
157
|
+
await waitFor(() =>
|
|
158
|
+
expect(invoiceMocks.SubscriptionInvoice).toHaveBeenLastCalledWith(
|
|
159
|
+
expect.objectContaining({
|
|
160
|
+
prefillQuantity: 3,
|
|
161
|
+
suggestedTotal: 1_500_000,
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('keeps legacy amount links working as a quantity fallback', async () => {
|
|
168
|
+
nuqsState.invoiceType = 'subscription';
|
|
169
|
+
nuqsState.values.set('amount', 4);
|
|
170
|
+
|
|
171
|
+
renderPage();
|
|
172
|
+
|
|
173
|
+
await waitFor(() =>
|
|
174
|
+
expect(invoiceMocks.SubscriptionInvoice).toHaveBeenLastCalledWith(
|
|
175
|
+
expect.objectContaining({ prefillQuantity: 4 })
|
|
176
|
+
)
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('forwards server-seeded defaults and timezone before config refresh completes', async () => {
|
|
181
|
+
nuqsState.invoiceType = 'subscription';
|
|
182
|
+
fetchMock.mockReturnValueOnce(new Promise(() => {}));
|
|
183
|
+
|
|
184
|
+
renderPage({
|
|
185
|
+
defaultCurrency: 'VND',
|
|
186
|
+
initialDefaultCategoryId: 'category-general-server',
|
|
187
|
+
initialDefaultSubscriptionCategoryId: 'category-subscription-server',
|
|
188
|
+
initialDefaultWalletId: 'wallet-server',
|
|
189
|
+
workspaceTimezone: 'Asia/Ho_Chi_Minh',
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await waitFor(() =>
|
|
193
|
+
expect(invoiceMocks.SubscriptionInvoice).toHaveBeenLastCalledWith(
|
|
194
|
+
expect.objectContaining({
|
|
195
|
+
defaultCategoryId: 'category-subscription-server',
|
|
196
|
+
defaultCurrency: 'VND',
|
|
197
|
+
defaultWalletId: 'wallet-server',
|
|
198
|
+
workspaceTimezone: 'Asia/Ho_Chi_Minh',
|
|
199
|
+
})
|
|
200
|
+
)
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('falls subscription invoice category back to the transaction default', async () => {
|
|
205
|
+
nuqsState.invoiceType = 'subscription';
|
|
206
|
+
fetchMock.mockResolvedValueOnce({
|
|
207
|
+
ok: true,
|
|
208
|
+
json: async () => ({
|
|
209
|
+
DEFAULT_CURRENCY: 'SGD',
|
|
210
|
+
DEFAULT_SUBSCRIPTION_CATEGORY_ID: null,
|
|
211
|
+
default_category_id: 'category-general',
|
|
212
|
+
default_wallet_id: 'wallet-1',
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
renderPage();
|
|
217
|
+
|
|
218
|
+
await waitFor(() =>
|
|
219
|
+
expect(invoiceMocks.SubscriptionInvoice).toHaveBeenLastCalledWith(
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
defaultCategoryId: 'category-general',
|
|
222
|
+
defaultCurrency: 'SGD',
|
|
223
|
+
defaultWalletId: 'wallet-1',
|
|
136
224
|
})
|
|
137
225
|
)
|
|
138
226
|
);
|
|
@@ -27,6 +27,7 @@ import { SubscriptionInvoice } from './subscription-invoice';
|
|
|
27
27
|
|
|
28
28
|
const INVOICE_DEFAULT_CONFIG_IDS = [
|
|
29
29
|
'default_wallet_id',
|
|
30
|
+
'default_category_id',
|
|
30
31
|
'DEFAULT_SUBSCRIPTION_CATEGORY_ID',
|
|
31
32
|
'DEFAULT_CURRENCY',
|
|
32
33
|
] as const;
|
|
@@ -40,7 +41,11 @@ interface Props {
|
|
|
40
41
|
canReadInvoiceProductStock?: boolean;
|
|
41
42
|
canReadGroupLinkedProducts?: boolean;
|
|
42
43
|
defaultCurrency?: string;
|
|
44
|
+
initialDefaultCategoryId?: string | null;
|
|
45
|
+
initialDefaultSubscriptionCategoryId?: string | null;
|
|
46
|
+
initialDefaultWalletId?: string | null;
|
|
43
47
|
permissionRequestUser?: FinancePermissionRequestUser | null;
|
|
48
|
+
workspaceTimezone?: string | null;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
export default function NewInvoicePage({
|
|
@@ -52,7 +57,11 @@ export default function NewInvoicePage({
|
|
|
52
57
|
canReadInvoiceProductStock = true,
|
|
53
58
|
canReadGroupLinkedProducts = true,
|
|
54
59
|
defaultCurrency: workspaceDefaultCurrency,
|
|
60
|
+
initialDefaultCategoryId,
|
|
61
|
+
initialDefaultSubscriptionCategoryId,
|
|
62
|
+
initialDefaultWalletId,
|
|
55
63
|
permissionRequestUser,
|
|
64
|
+
workspaceTimezone,
|
|
56
65
|
}: Props) {
|
|
57
66
|
const t = useTranslations();
|
|
58
67
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
@@ -63,20 +72,44 @@ export default function NewInvoicePage({
|
|
|
63
72
|
serialize: (value) => value,
|
|
64
73
|
});
|
|
65
74
|
|
|
66
|
-
const [
|
|
75
|
+
const [billableQuantity] = useQueryState('billable_quantity', {
|
|
67
76
|
parse: (value) => {
|
|
68
77
|
const parsed = Number(value);
|
|
69
78
|
return Number.isFinite(parsed) ? parsed : null;
|
|
70
79
|
},
|
|
71
80
|
});
|
|
81
|
+
const [legacyPrefillAmount] = useQueryState('amount', {
|
|
82
|
+
parse: (value) => {
|
|
83
|
+
const parsed = Number(value);
|
|
84
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const [suggestedTotal] = useQueryState('suggested_total', {
|
|
88
|
+
parse: (value) => {
|
|
89
|
+
const parsed = Number(value);
|
|
90
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
72
93
|
|
|
73
|
-
const { data:
|
|
94
|
+
const { data: refreshedDefaultConfigs = {} } = useWorkspaceConfigs(
|
|
74
95
|
wsId,
|
|
75
96
|
INVOICE_DEFAULT_CONFIG_IDS
|
|
76
97
|
);
|
|
98
|
+
const defaultConfigs = {
|
|
99
|
+
DEFAULT_CURRENCY: workspaceDefaultCurrency ?? null,
|
|
100
|
+
DEFAULT_SUBSCRIPTION_CATEGORY_ID:
|
|
101
|
+
initialDefaultSubscriptionCategoryId ?? null,
|
|
102
|
+
default_category_id: initialDefaultCategoryId ?? null,
|
|
103
|
+
default_wallet_id: initialDefaultWalletId ?? null,
|
|
104
|
+
...refreshedDefaultConfigs,
|
|
105
|
+
};
|
|
77
106
|
const defaultWalletId = defaultConfigs.default_wallet_id ?? undefined;
|
|
78
|
-
const
|
|
107
|
+
const defaultTransactionCategoryId =
|
|
108
|
+
defaultConfigs.default_category_id ?? undefined;
|
|
109
|
+
const defaultSubscriptionCategoryId =
|
|
79
110
|
defaultConfigs.DEFAULT_SUBSCRIPTION_CATEGORY_ID ?? undefined;
|
|
111
|
+
const defaultCategoryId =
|
|
112
|
+
defaultSubscriptionCategoryId ?? defaultTransactionCategoryId;
|
|
80
113
|
const defaultCurrency = resolveSupportedCurrency(
|
|
81
114
|
defaultConfigs.DEFAULT_CURRENCY,
|
|
82
115
|
resolveSupportedCurrency(workspaceDefaultCurrency)
|
|
@@ -231,6 +264,7 @@ export default function NewInvoicePage({
|
|
|
231
264
|
<StandardInvoice
|
|
232
265
|
wsId={wsId}
|
|
233
266
|
defaultWalletId={defaultWalletId}
|
|
267
|
+
defaultCategoryId={defaultTransactionCategoryId}
|
|
234
268
|
defaultCurrency={defaultCurrency}
|
|
235
269
|
canChangeFinanceWallets={canChangeFinanceWallets}
|
|
236
270
|
canSetFinanceWalletsOnCreate={canSetFinanceWalletsOnCreate}
|
|
@@ -247,10 +281,12 @@ export default function NewInvoicePage({
|
|
|
247
281
|
{invoiceType === 'subscription' ? (
|
|
248
282
|
<SubscriptionInvoice
|
|
249
283
|
wsId={wsId}
|
|
250
|
-
|
|
284
|
+
prefillQuantity={billableQuantity ?? legacyPrefillAmount}
|
|
285
|
+
suggestedTotal={suggestedTotal}
|
|
251
286
|
defaultWalletId={defaultWalletId}
|
|
252
287
|
defaultCategoryId={defaultCategoryId}
|
|
253
288
|
defaultCurrency={defaultCurrency}
|
|
289
|
+
workspaceTimezone={workspaceTimezone}
|
|
254
290
|
canChangeFinanceWallets={canChangeFinanceWallets}
|
|
255
291
|
canSetFinanceWalletsOnCreate={canSetFinanceWalletsOnCreate}
|
|
256
292
|
canReadInvoiceProducts={canReadInvoiceProducts}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildPendingInvoiceCreateUrl } from './pending-columns';
|
|
3
|
+
|
|
4
|
+
describe('buildPendingInvoiceCreateUrl', () => {
|
|
5
|
+
it('carries both the billable quantity and suggested monetary total', () => {
|
|
6
|
+
const url = new URL(
|
|
7
|
+
buildPendingInvoiceCreateUrl({
|
|
8
|
+
attendanceDays: 3,
|
|
9
|
+
financePrefix: '',
|
|
10
|
+
groupIds: ['group-1', 'group-2'],
|
|
11
|
+
month: '2026-07',
|
|
12
|
+
potentialTotal: 1_500_000,
|
|
13
|
+
useAttendanceBased: true,
|
|
14
|
+
userId: 'user-1',
|
|
15
|
+
wsId: 'ws-1',
|
|
16
|
+
}),
|
|
17
|
+
'https://finance.test'
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
expect(url.pathname).toBe('/ws-1/invoices/new');
|
|
21
|
+
expect(url.searchParams.get('billable_quantity')).toBe('3');
|
|
22
|
+
expect(url.searchParams.get('suggested_total')).toBe('1500000');
|
|
23
|
+
expect(url.searchParams.get('amount')).toBeNull();
|
|
24
|
+
expect(url.searchParams.get('group_ids')).toBe('group-1,group-2');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -21,6 +21,40 @@ type PendingInvoiceRow = PendingInvoice & {
|
|
|
21
21
|
group_names?: string[];
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
export function buildPendingInvoiceCreateUrl({
|
|
25
|
+
attendanceDays,
|
|
26
|
+
financePrefix,
|
|
27
|
+
groupIds,
|
|
28
|
+
month,
|
|
29
|
+
potentialTotal,
|
|
30
|
+
useAttendanceBased,
|
|
31
|
+
userId,
|
|
32
|
+
wsId,
|
|
33
|
+
}: {
|
|
34
|
+
attendanceDays: number;
|
|
35
|
+
financePrefix: string;
|
|
36
|
+
groupIds: string[];
|
|
37
|
+
month: string;
|
|
38
|
+
potentialTotal: number;
|
|
39
|
+
useAttendanceBased: boolean;
|
|
40
|
+
userId: string;
|
|
41
|
+
wsId: string;
|
|
42
|
+
}) {
|
|
43
|
+
const searchParams = new URLSearchParams({
|
|
44
|
+
type: 'subscription',
|
|
45
|
+
user_id: userId,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (month) searchParams.set('month', month);
|
|
49
|
+
if (useAttendanceBased) {
|
|
50
|
+
searchParams.set('billable_quantity', String(attendanceDays));
|
|
51
|
+
}
|
|
52
|
+
searchParams.set('suggested_total', String(potentialTotal));
|
|
53
|
+
if (groupIds.length > 0) searchParams.set('group_ids', groupIds.join(','));
|
|
54
|
+
|
|
55
|
+
return `/${wsId}${financePrefix}/invoices/new?${searchParams.toString()}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
24
58
|
function PendingInvoicePotentialTotalCell({
|
|
25
59
|
amount,
|
|
26
60
|
currency,
|
|
@@ -268,6 +302,7 @@ export const pendingInvoiceColumns = (
|
|
|
268
302
|
).filter((id: string) => Boolean(id));
|
|
269
303
|
const monthsOwed = row.getValue<string[]>('months_owed');
|
|
270
304
|
const attendanceDays = row.getValue<number>('attendance_days');
|
|
305
|
+
const potentialTotal = row.getValue<number>('potential_total') || 0;
|
|
271
306
|
|
|
272
307
|
// Get the LAST (most recent) unpaid month from the array
|
|
273
308
|
const lastUnpaidMonth =
|
|
@@ -277,20 +312,16 @@ export const pendingInvoiceColumns = (
|
|
|
277
312
|
|
|
278
313
|
if (!wsId) return null;
|
|
279
314
|
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
searchParams.set('group_ids', groupIds.join(','));
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const createInvoiceUrl = `/${wsId}${financePrefix}/invoices/new?${searchParams.toString()}`;
|
|
315
|
+
const createInvoiceUrl = buildPendingInvoiceCreateUrl({
|
|
316
|
+
attendanceDays,
|
|
317
|
+
financePrefix,
|
|
318
|
+
groupIds,
|
|
319
|
+
month: lastUnpaidMonth ?? '',
|
|
320
|
+
potentialTotal,
|
|
321
|
+
useAttendanceBased,
|
|
322
|
+
userId,
|
|
323
|
+
wsId,
|
|
324
|
+
});
|
|
294
325
|
|
|
295
326
|
return (
|
|
296
327
|
<div className="flex items-center gap-2">
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveStandardInvoiceCategoryId } from './standard-invoice';
|
|
3
|
+
|
|
4
|
+
describe('resolveStandardInvoiceCategoryId', () => {
|
|
5
|
+
it('prefers a single linked product finance category', () => {
|
|
6
|
+
expect(
|
|
7
|
+
resolveStandardInvoiceCategoryId({
|
|
8
|
+
defaultCategoryId: 'category-default',
|
|
9
|
+
linkedFinanceCategoryIds: ['category-linked'],
|
|
10
|
+
})
|
|
11
|
+
).toBe('category-linked');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('requires explicit selection when linked product finance categories are mixed', () => {
|
|
15
|
+
expect(
|
|
16
|
+
resolveStandardInvoiceCategoryId({
|
|
17
|
+
defaultCategoryId: 'category-default',
|
|
18
|
+
linkedFinanceCategoryIds: ['category-a', 'category-b'],
|
|
19
|
+
})
|
|
20
|
+
).toBe('');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('falls back to the transaction default when products do not provide a category', () => {
|
|
24
|
+
expect(
|
|
25
|
+
resolveStandardInvoiceCategoryId({
|
|
26
|
+
defaultCategoryId: 'category-default',
|
|
27
|
+
linkedFinanceCategoryIds: [],
|
|
28
|
+
})
|
|
29
|
+
).toBe('category-default');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -57,6 +57,7 @@ interface Props {
|
|
|
57
57
|
printAfterCreate?: boolean;
|
|
58
58
|
downloadImageAfterCreate?: boolean;
|
|
59
59
|
defaultWalletId?: string;
|
|
60
|
+
defaultCategoryId?: string;
|
|
60
61
|
defaultCurrency?: string;
|
|
61
62
|
canChangeFinanceWallets?: boolean;
|
|
62
63
|
canSetFinanceWalletsOnCreate?: boolean;
|
|
@@ -71,6 +72,7 @@ export function StandardInvoice({
|
|
|
71
72
|
printAfterCreate = false,
|
|
72
73
|
downloadImageAfterCreate = false,
|
|
73
74
|
defaultWalletId,
|
|
75
|
+
defaultCategoryId,
|
|
74
76
|
defaultCurrency: rawDefaultCurrency = 'USD',
|
|
75
77
|
canChangeFinanceWallets = true,
|
|
76
78
|
canSetFinanceWalletsOnCreate = true,
|
|
@@ -194,7 +196,9 @@ export function StandardInvoice({
|
|
|
194
196
|
}, [defaultWalletId]);
|
|
195
197
|
const [selectedPromotionId, setSelectedPromotionId] =
|
|
196
198
|
useState<string>('none');
|
|
197
|
-
const [selectedCategoryId, setSelectedCategoryId] = useState<string>(
|
|
199
|
+
const [selectedCategoryId, setSelectedCategoryId] = useState<string>(
|
|
200
|
+
defaultCategoryId || ''
|
|
201
|
+
);
|
|
198
202
|
const [invoiceContent, setInvoiceContent] = useState<string>('');
|
|
199
203
|
const [invoiceNotes, setInvoiceNotes] = useState<string>('');
|
|
200
204
|
const [isCreating, setIsCreating] = useState(false);
|
|
@@ -280,7 +284,6 @@ export function StandardInvoice({
|
|
|
280
284
|
[selectedProducts]
|
|
281
285
|
);
|
|
282
286
|
const hasMixedLinkedFinanceCategories = linkedFinanceCategoryIds.length > 1;
|
|
283
|
-
const hasSingleLinkedFinanceCategory = linkedFinanceCategoryIds.length === 1;
|
|
284
287
|
|
|
285
288
|
// Auto-generate invoice content based on selected products
|
|
286
289
|
useEffect(() => {
|
|
@@ -318,19 +321,13 @@ export function StandardInvoice({
|
|
|
318
321
|
}, [selectedUserId]);
|
|
319
322
|
|
|
320
323
|
useEffect(() => {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
}, [
|
|
330
|
-
hasMixedLinkedFinanceCategories,
|
|
331
|
-
hasSingleLinkedFinanceCategory,
|
|
332
|
-
linkedFinanceCategoryIds,
|
|
333
|
-
]);
|
|
324
|
+
setSelectedCategoryId(
|
|
325
|
+
resolveStandardInvoiceCategoryId({
|
|
326
|
+
defaultCategoryId,
|
|
327
|
+
linkedFinanceCategoryIds,
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
}, [defaultCategoryId, linkedFinanceCategoryIds]);
|
|
334
331
|
|
|
335
332
|
useBestPromotionSelection({
|
|
336
333
|
enabled: promotionsAllowed,
|
|
@@ -422,7 +419,7 @@ export function StandardInvoice({
|
|
|
422
419
|
resetRounding();
|
|
423
420
|
setSelectedUserId(null);
|
|
424
421
|
setSelectedWalletId(defaultWalletId || '');
|
|
425
|
-
setSelectedCategoryId('');
|
|
422
|
+
setSelectedCategoryId(defaultCategoryId || '');
|
|
426
423
|
setCustomerSearch('');
|
|
427
424
|
}
|
|
428
425
|
} catch (error) {
|
|
@@ -657,3 +654,23 @@ export function StandardInvoice({
|
|
|
657
654
|
</div>
|
|
658
655
|
);
|
|
659
656
|
}
|
|
657
|
+
|
|
658
|
+
export function resolveStandardInvoiceCategoryId({
|
|
659
|
+
defaultCategoryId,
|
|
660
|
+
linkedFinanceCategoryIds,
|
|
661
|
+
}: {
|
|
662
|
+
defaultCategoryId?: string;
|
|
663
|
+
linkedFinanceCategoryIds: string[];
|
|
664
|
+
}) {
|
|
665
|
+
const uniqueLinkedCategoryIds = [...new Set(linkedFinanceCategoryIds)];
|
|
666
|
+
|
|
667
|
+
if (uniqueLinkedCategoryIds.length === 1) {
|
|
668
|
+
return uniqueLinkedCategoryIds[0] ?? '';
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (uniqueLinkedCategoryIds.length > 1) {
|
|
672
|
+
return '';
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return defaultCategoryId || '';
|
|
676
|
+
}
|