@tuturuuu/ui 0.14.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 +78 -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-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/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
- 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 +32 -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__/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/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-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/vitest.config.ts +4 -0
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './components/invoice-products-permission-warning';
|
|
38
38
|
import { SubscriptionAttendanceSummary } from './components/subscription-attendance-summary';
|
|
39
39
|
import { SubscriptionGroupSelector } from './components/subscription-group-selector';
|
|
40
|
+
import { SubscriptionInvoiceProposal } from './components/subscription-invoice-proposal';
|
|
40
41
|
import { SubscriptionPrepaidControls } from './components/subscription-prepaid-controls';
|
|
41
42
|
import { CreatePromotionDialog } from './create-promotion-dialog';
|
|
42
43
|
import type { AvailablePromotion } from './hooks';
|
|
@@ -71,23 +72,27 @@ import {
|
|
|
71
72
|
getBillableAttendanceRecordsInRange,
|
|
72
73
|
getBillableSessionsForGroupsInRange,
|
|
73
74
|
getCoverageValidUntilMonthValue,
|
|
75
|
+
getCurrentBillingDate,
|
|
76
|
+
getCurrentMonthValue,
|
|
74
77
|
getGroupsDateRange,
|
|
75
|
-
getLinkedFinanceCategorySelection,
|
|
76
78
|
getMonthStartDate,
|
|
77
79
|
getSubscriptionAttendanceDisplayData,
|
|
78
80
|
isSubscriptionRangeFullyPaidForGroups,
|
|
79
81
|
PREPAID_MONTH_OPTION_HORIZON,
|
|
82
|
+
resolveSubscriptionInvoiceCategoryId,
|
|
80
83
|
} from './utils';
|
|
81
84
|
|
|
82
85
|
interface Props {
|
|
83
86
|
wsId: string;
|
|
84
|
-
|
|
87
|
+
prefillQuantity?: number | null;
|
|
88
|
+
suggestedTotal?: number | null;
|
|
85
89
|
createMultipleInvoices: boolean;
|
|
86
90
|
printAfterCreate?: boolean;
|
|
87
91
|
downloadImageAfterCreate?: boolean;
|
|
88
92
|
defaultWalletId?: string;
|
|
89
93
|
defaultCategoryId?: string;
|
|
90
94
|
defaultCurrency?: string;
|
|
95
|
+
workspaceTimezone?: string | null;
|
|
91
96
|
canChangeFinanceWallets?: boolean;
|
|
92
97
|
canSetFinanceWalletsOnCreate?: boolean;
|
|
93
98
|
canReadInvoiceProducts?: boolean;
|
|
@@ -98,13 +103,15 @@ interface Props {
|
|
|
98
103
|
|
|
99
104
|
export function SubscriptionInvoice({
|
|
100
105
|
wsId,
|
|
101
|
-
|
|
106
|
+
prefillQuantity,
|
|
107
|
+
suggestedTotal,
|
|
102
108
|
createMultipleInvoices,
|
|
103
109
|
printAfterCreate = false,
|
|
104
110
|
downloadImageAfterCreate = false,
|
|
105
111
|
defaultWalletId,
|
|
106
112
|
defaultCategoryId,
|
|
107
113
|
defaultCurrency: rawDefaultCurrency = 'USD',
|
|
114
|
+
workspaceTimezone,
|
|
108
115
|
canChangeFinanceWallets = true,
|
|
109
116
|
canSetFinanceWalletsOnCreate = true,
|
|
110
117
|
canReadInvoiceProducts = true,
|
|
@@ -133,7 +140,7 @@ export function SubscriptionInvoice({
|
|
|
133
140
|
})
|
|
134
141
|
);
|
|
135
142
|
const [selectedMonth, setSelectedMonth] = useQueryState('month', {
|
|
136
|
-
defaultValue:
|
|
143
|
+
defaultValue: getCurrentMonthValue(workspaceTimezone),
|
|
137
144
|
shallow: true,
|
|
138
145
|
});
|
|
139
146
|
const [prepaidMonthCount, setPrepaidMonthCount] = useState(1);
|
|
@@ -144,6 +151,8 @@ export function SubscriptionInvoice({
|
|
|
144
151
|
useState<SelectedProductItem[]>([]);
|
|
145
152
|
const hasSelectedGroups = selectedGroupIds.length > 0;
|
|
146
153
|
const hasSelectedProducts = subscriptionSelectedProducts.length > 0;
|
|
154
|
+
const shouldPreloadPaymentSettings =
|
|
155
|
+
hasSelectedProducts || Boolean(defaultWalletId || defaultCategoryId);
|
|
147
156
|
|
|
148
157
|
const updateSearchParam = useCallback(
|
|
149
158
|
(key: string, value: string) => {
|
|
@@ -197,10 +206,10 @@ export function SubscriptionInvoice({
|
|
|
197
206
|
{ enabled: hasSelectedProducts }
|
|
198
207
|
);
|
|
199
208
|
const { data: wallets = [] } = useWallets(wsId, {
|
|
200
|
-
enabled:
|
|
209
|
+
enabled: shouldPreloadPaymentSettings,
|
|
201
210
|
});
|
|
202
211
|
const { data: categories = [] } = useCategories(wsId, {
|
|
203
|
-
enabled:
|
|
212
|
+
enabled: shouldPreloadPaymentSettings,
|
|
204
213
|
});
|
|
205
214
|
|
|
206
215
|
// Blocked groups check
|
|
@@ -236,9 +245,7 @@ export function SubscriptionInvoice({
|
|
|
236
245
|
) : null;
|
|
237
246
|
|
|
238
247
|
useEffect(() => {
|
|
239
|
-
|
|
240
|
-
setSelectedWalletId(defaultWalletId);
|
|
241
|
-
}
|
|
248
|
+
setSelectedWalletId(defaultWalletId || '');
|
|
242
249
|
}, [defaultWalletId]);
|
|
243
250
|
|
|
244
251
|
const [selectedPromotionId, setSelectedPromotionId] =
|
|
@@ -286,9 +293,10 @@ export function SubscriptionInvoice({
|
|
|
286
293
|
[],
|
|
287
294
|
locale,
|
|
288
295
|
selectedMonth,
|
|
289
|
-
PREPAID_MONTH_OPTION_HORIZON
|
|
296
|
+
PREPAID_MONTH_OPTION_HORIZON,
|
|
297
|
+
{ workspaceTimezone }
|
|
290
298
|
),
|
|
291
|
-
[locale, selectedGroupIds, selectedMonth, userGroups]
|
|
299
|
+
[locale, selectedGroupIds, selectedMonth, userGroups, workspaceTimezone]
|
|
292
300
|
);
|
|
293
301
|
|
|
294
302
|
const effectiveSelectedMonth = useMemo(() => {
|
|
@@ -309,7 +317,7 @@ export function SubscriptionInvoice({
|
|
|
309
317
|
return selectedMonth;
|
|
310
318
|
}
|
|
311
319
|
|
|
312
|
-
const now =
|
|
320
|
+
const now = getCurrentBillingDate(workspaceTimezone);
|
|
313
321
|
let defaultMonth: Date;
|
|
314
322
|
|
|
315
323
|
if (!latestEnd || (now >= earliestStart && now <= latestEnd)) {
|
|
@@ -321,7 +329,13 @@ export function SubscriptionInvoice({
|
|
|
321
329
|
}
|
|
322
330
|
|
|
323
331
|
return formatMonthValue(defaultMonth);
|
|
324
|
-
}, [
|
|
332
|
+
}, [
|
|
333
|
+
availableMonthOptions,
|
|
334
|
+
selectedGroupIds,
|
|
335
|
+
selectedMonth,
|
|
336
|
+
userGroups,
|
|
337
|
+
workspaceTimezone,
|
|
338
|
+
]);
|
|
325
339
|
|
|
326
340
|
const {
|
|
327
341
|
data: subscriptionInvoiceContext,
|
|
@@ -538,7 +552,7 @@ export function SubscriptionInvoice({
|
|
|
538
552
|
enabled: true,
|
|
539
553
|
selectedGroupIds,
|
|
540
554
|
selectedMonth: effectiveSelectedMonth,
|
|
541
|
-
prefillAmount,
|
|
555
|
+
prefillAmount: prefillQuantity,
|
|
542
556
|
groupProducts,
|
|
543
557
|
products,
|
|
544
558
|
userGroups,
|
|
@@ -547,6 +561,7 @@ export function SubscriptionInvoice({
|
|
|
547
561
|
latestSubscriptionInvoices,
|
|
548
562
|
onSelectedProductsChange: setSubscriptionSelectedProducts,
|
|
549
563
|
prepaidMonthCount,
|
|
564
|
+
workspaceTimezone,
|
|
550
565
|
});
|
|
551
566
|
|
|
552
567
|
useSubscriptionInvoiceContent({
|
|
@@ -566,21 +581,18 @@ export function SubscriptionInvoice({
|
|
|
566
581
|
});
|
|
567
582
|
|
|
568
583
|
const subtotal = useInvoiceSubtotal(subscriptionSelectedProducts);
|
|
569
|
-
const
|
|
570
|
-
() =>
|
|
571
|
-
|
|
584
|
+
const resolvedSubscriptionCategoryId = useMemo(
|
|
585
|
+
() =>
|
|
586
|
+
resolveSubscriptionInvoiceCategoryId({
|
|
587
|
+
defaultCategoryId,
|
|
588
|
+
items: subscriptionSelectedProducts,
|
|
589
|
+
}),
|
|
590
|
+
[defaultCategoryId, subscriptionSelectedProducts]
|
|
572
591
|
);
|
|
573
592
|
|
|
574
593
|
useEffect(() => {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
if (linkedFinanceCategorySelection.hasMixedCategories) {
|
|
581
|
-
setSelectedCategoryId('');
|
|
582
|
-
}
|
|
583
|
-
}, [linkedFinanceCategorySelection]);
|
|
594
|
+
setSelectedCategoryId(resolvedSubscriptionCategoryId);
|
|
595
|
+
}, [resolvedSubscriptionCategoryId]);
|
|
584
596
|
|
|
585
597
|
useBestPromotionSelection({
|
|
586
598
|
enabled: true,
|
|
@@ -677,7 +689,8 @@ export function SubscriptionInvoice({
|
|
|
677
689
|
latestSubscriptionInvoices,
|
|
678
690
|
locale,
|
|
679
691
|
effectiveSelectedMonth,
|
|
680
|
-
PREPAID_MONTH_OPTION_HORIZON
|
|
692
|
+
PREPAID_MONTH_OPTION_HORIZON,
|
|
693
|
+
{ workspaceTimezone }
|
|
681
694
|
),
|
|
682
695
|
[
|
|
683
696
|
effectiveSelectedMonth,
|
|
@@ -685,6 +698,7 @@ export function SubscriptionInvoice({
|
|
|
685
698
|
selectedGroupIds,
|
|
686
699
|
latestSubscriptionInvoices,
|
|
687
700
|
locale,
|
|
701
|
+
workspaceTimezone,
|
|
688
702
|
]
|
|
689
703
|
);
|
|
690
704
|
|
|
@@ -732,7 +746,7 @@ export function SubscriptionInvoice({
|
|
|
732
746
|
if (lastValidatedKeyRef.current === monthValidationKey) return;
|
|
733
747
|
lastValidatedKeyRef.current = monthValidationKey;
|
|
734
748
|
|
|
735
|
-
const now =
|
|
749
|
+
const now = getCurrentBillingDate(workspaceTimezone);
|
|
736
750
|
let defaultMonth: Date;
|
|
737
751
|
|
|
738
752
|
if (now >= earliestStart && now <= latestEnd) defaultMonth = now;
|
|
@@ -754,6 +768,7 @@ export function SubscriptionInvoice({
|
|
|
754
768
|
selectedMonth,
|
|
755
769
|
updateSearchParam,
|
|
756
770
|
userGroups,
|
|
771
|
+
workspaceTimezone,
|
|
757
772
|
]);
|
|
758
773
|
|
|
759
774
|
const navigateMonth = (direction: 'prev' | 'next') => {
|
|
@@ -1020,6 +1035,16 @@ export function SubscriptionInvoice({
|
|
|
1020
1035
|
<InvoiceBlockedState type="subscription" />
|
|
1021
1036
|
) : (
|
|
1022
1037
|
<>
|
|
1038
|
+
{suggestedTotal != null && (
|
|
1039
|
+
<SubscriptionInvoiceProposal
|
|
1040
|
+
billableQuantity={prefillQuantity}
|
|
1041
|
+
currency={defaultCurrency}
|
|
1042
|
+
currentTotal={subscriptionRoundedTotal}
|
|
1043
|
+
hasSelectedProducts={hasSelectedProducts}
|
|
1044
|
+
suggestedTotal={suggestedTotal}
|
|
1045
|
+
/>
|
|
1046
|
+
)}
|
|
1047
|
+
|
|
1023
1048
|
{selectedGroupIds.length > 0 && effectiveSelectedMonth && (
|
|
1024
1049
|
<SubscriptionAttendanceSummary
|
|
1025
1050
|
selectedGroupIds={selectedGroupIds}
|
|
@@ -6,11 +6,14 @@ import {
|
|
|
6
6
|
getBillableQuantityMapForGroupsRange,
|
|
7
7
|
getBillableSessionsForGroups,
|
|
8
8
|
getCoverageMonths,
|
|
9
|
+
getCurrentMonthValue,
|
|
9
10
|
getLinkedFinanceCategorySelection,
|
|
10
11
|
getSubscriptionAttendanceDisplayData,
|
|
11
12
|
getSubscriptionCoverageInvoiceForGroup,
|
|
12
13
|
isSubscriptionMonthPaidForGroup,
|
|
13
14
|
isSubscriptionRangeFullyPaidForGroups,
|
|
15
|
+
resolveBillingTimezone,
|
|
16
|
+
resolveSubscriptionInvoiceCategoryId,
|
|
14
17
|
type UserGroup,
|
|
15
18
|
} from './utils';
|
|
16
19
|
|
|
@@ -169,6 +172,19 @@ describe('subscription invoice coverage', () => {
|
|
|
169
172
|
).toBe(false);
|
|
170
173
|
});
|
|
171
174
|
|
|
175
|
+
it('treats timestamp valid_until values as civil invoice months', () => {
|
|
176
|
+
const latestInvoices = [
|
|
177
|
+
{ group_id: groupId, valid_until: '2026-07-01T00:00:00.000Z' },
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
expect(
|
|
181
|
+
isSubscriptionMonthPaidForGroup(groupId, '2026-06', latestInvoices)
|
|
182
|
+
).toBe(true);
|
|
183
|
+
expect(
|
|
184
|
+
isSubscriptionMonthPaidForGroup(groupId, '2026-07', latestInvoices)
|
|
185
|
+
).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
172
188
|
it('treats null and invalid valid_until values as unpaid', () => {
|
|
173
189
|
expect(
|
|
174
190
|
isSubscriptionMonthPaidForGroup(groupId, '2026-05', [
|
|
@@ -265,6 +281,50 @@ describe('subscription invoice coverage', () => {
|
|
|
265
281
|
})
|
|
266
282
|
).toEqual({ [groupId]: 2 });
|
|
267
283
|
});
|
|
284
|
+
|
|
285
|
+
it('uses workspace timezone, not browser timezone, for current-month recommendations', () => {
|
|
286
|
+
const groups = [
|
|
287
|
+
{
|
|
288
|
+
workspace_user_groups: {
|
|
289
|
+
id: groupId,
|
|
290
|
+
name: 'Math 7',
|
|
291
|
+
sessions: ['2026-07-05'],
|
|
292
|
+
starting_date: '2026-07-01',
|
|
293
|
+
ending_date: null,
|
|
294
|
+
} as NonNullable<UserGroup['workspace_user_groups']>,
|
|
295
|
+
},
|
|
296
|
+
] satisfies UserGroup[];
|
|
297
|
+
const now = new Date('2026-06-30T18:00:00.000Z');
|
|
298
|
+
|
|
299
|
+
expect(getCurrentMonthValue('UTC', now)).toBe('2026-06');
|
|
300
|
+
expect(getCurrentMonthValue('Asia/Ho_Chi_Minh', now)).toBe('2026-07');
|
|
301
|
+
expect(getCurrentMonthValue('America/Los_Angeles', now)).toBe('2026-06');
|
|
302
|
+
expect(resolveBillingTimezone('auto')).toBe('UTC');
|
|
303
|
+
expect(resolveBillingTimezone('Not/A_Timezone')).toBe('UTC');
|
|
304
|
+
|
|
305
|
+
expect(
|
|
306
|
+
getBillableQuantityMapForGroupsRange({
|
|
307
|
+
groupIds: [groupId],
|
|
308
|
+
now,
|
|
309
|
+
selectedMonth: '2026-07',
|
|
310
|
+
useAttendanceBased: true,
|
|
311
|
+
userAttendance: [],
|
|
312
|
+
userGroups: groups,
|
|
313
|
+
workspaceTimezone: 'Asia/Ho_Chi_Minh',
|
|
314
|
+
})
|
|
315
|
+
).toEqual({ [groupId]: 0 });
|
|
316
|
+
expect(
|
|
317
|
+
getBillableQuantityMapForGroupsRange({
|
|
318
|
+
groupIds: [groupId],
|
|
319
|
+
now,
|
|
320
|
+
selectedMonth: '2026-07',
|
|
321
|
+
useAttendanceBased: true,
|
|
322
|
+
userAttendance: [],
|
|
323
|
+
userGroups: groups,
|
|
324
|
+
workspaceTimezone: 'America/Los_Angeles',
|
|
325
|
+
})
|
|
326
|
+
).toEqual({ [groupId]: 1 });
|
|
327
|
+
});
|
|
268
328
|
});
|
|
269
329
|
|
|
270
330
|
describe('linked finance category selection', () => {
|
|
@@ -294,4 +354,28 @@ describe('linked finance category selection', () => {
|
|
|
294
354
|
hasSingleCategory: false,
|
|
295
355
|
});
|
|
296
356
|
});
|
|
357
|
+
|
|
358
|
+
it('resolves subscription checkout category precedence', () => {
|
|
359
|
+
expect(
|
|
360
|
+
resolveSubscriptionInvoiceCategoryId({
|
|
361
|
+
defaultCategoryId: 'category-default',
|
|
362
|
+
items: [{ product: { finance_category_id: 'category-linked' } }],
|
|
363
|
+
})
|
|
364
|
+
).toBe('category-linked');
|
|
365
|
+
expect(
|
|
366
|
+
resolveSubscriptionInvoiceCategoryId({
|
|
367
|
+
defaultCategoryId: 'category-default',
|
|
368
|
+
items: [
|
|
369
|
+
{ product: { finance_category_id: 'category-a' } },
|
|
370
|
+
{ product: { finance_category_id: 'category-b' } },
|
|
371
|
+
],
|
|
372
|
+
})
|
|
373
|
+
).toBe('');
|
|
374
|
+
expect(
|
|
375
|
+
resolveSubscriptionInvoiceCategoryId({
|
|
376
|
+
defaultCategoryId: 'category-default',
|
|
377
|
+
items: [{ product: { finance_category_id: null } }],
|
|
378
|
+
})
|
|
379
|
+
).toBe('category-default');
|
|
380
|
+
});
|
|
297
381
|
});
|
|
@@ -1,4 +1,30 @@
|
|
|
1
1
|
import type { Database } from '@tuturuuu/types';
|
|
2
|
+
import {
|
|
3
|
+
formatMonthLabel,
|
|
4
|
+
formatMonthValue,
|
|
5
|
+
getCoverageMonths,
|
|
6
|
+
getCurrentMonthStartDate,
|
|
7
|
+
getMonthStartDate,
|
|
8
|
+
parseLocalCalendarDate,
|
|
9
|
+
} from './civil-month';
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
addMonthsToMonthValue,
|
|
13
|
+
formatCoverageRangeLabel,
|
|
14
|
+
formatMonthLabel,
|
|
15
|
+
formatMonthValue,
|
|
16
|
+
getCoverageEndMonthValue,
|
|
17
|
+
getCoverageMonths,
|
|
18
|
+
getCoverageValidUntilMonthValue,
|
|
19
|
+
getCurrentBillingDate,
|
|
20
|
+
getCurrentMonthStartDate,
|
|
21
|
+
getCurrentMonthValue,
|
|
22
|
+
getMonthStartDate,
|
|
23
|
+
MAX_PREPAID_MONTH_COUNT,
|
|
24
|
+
PREPAID_MONTH_OPTION_HORIZON,
|
|
25
|
+
parseLocalCalendarDate,
|
|
26
|
+
resolveBillingTimezone,
|
|
27
|
+
} from './civil-month';
|
|
2
28
|
|
|
3
29
|
type AttendanceRecord = {
|
|
4
30
|
status: string;
|
|
@@ -67,134 +93,25 @@ export const getLinkedFinanceCategorySelection = (
|
|
|
67
93
|
};
|
|
68
94
|
};
|
|
69
95
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
export const PREPAID_MONTH_OPTION_HORIZON = 12;
|
|
74
|
-
|
|
75
|
-
export const parseLocalCalendarDate = (
|
|
76
|
-
value: string | Date | null | undefined
|
|
77
|
-
): Date => {
|
|
78
|
-
if (!value) {
|
|
79
|
-
return new Date(Number.NaN);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (value instanceof Date) {
|
|
83
|
-
return new Date(value.getTime());
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const monthMatch = MONTH_VALUE_PATTERN.exec(value);
|
|
87
|
-
if (monthMatch) {
|
|
88
|
-
const [, year, month] = monthMatch;
|
|
89
|
-
return new Date(Number(year), Number(month) - 1, 1);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const dateMatch = DATE_VALUE_PATTERN.exec(value);
|
|
93
|
-
if (dateMatch) {
|
|
94
|
-
const [, year, month, day] = dateMatch;
|
|
95
|
-
return new Date(Number(year), Number(month) - 1, Number(day));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return new Date(value);
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
export const getMonthStartDate = (
|
|
102
|
-
value: string | Date | null | undefined
|
|
103
|
-
): Date => {
|
|
104
|
-
const date = parseLocalCalendarDate(value);
|
|
105
|
-
if (Number.isNaN(date.getTime())) {
|
|
106
|
-
return date;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
export const formatMonthValue = (date: Date): string =>
|
|
113
|
-
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
|
114
|
-
|
|
115
|
-
export const formatMonthLabel = (month: string, locale: string): string => {
|
|
116
|
-
const date = getMonthStartDate(month);
|
|
117
|
-
if (Number.isNaN(date.getTime())) {
|
|
118
|
-
return month;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return date.toLocaleDateString(locale, {
|
|
122
|
-
year: 'numeric',
|
|
123
|
-
month: 'long',
|
|
124
|
-
});
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
export const addMonthsToMonthValue = (
|
|
128
|
-
month: string,
|
|
129
|
-
monthOffset: number
|
|
130
|
-
): string => {
|
|
131
|
-
const date = getMonthStartDate(month);
|
|
132
|
-
if (Number.isNaN(date.getTime())) return month;
|
|
133
|
-
|
|
134
|
-
date.setMonth(date.getMonth() + monthOffset);
|
|
135
|
-
return formatMonthValue(date);
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
export const normalizePrepaidMonthCount = (
|
|
139
|
-
value: number | null | undefined
|
|
140
|
-
): number => {
|
|
141
|
-
if (!Number.isFinite(value) || !value) return 1;
|
|
142
|
-
|
|
143
|
-
return Math.min(MAX_PREPAID_MONTH_COUNT, Math.max(1, Math.trunc(value)));
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
export const getCoverageMonths = (
|
|
147
|
-
selectedMonth: string,
|
|
148
|
-
prepaidMonthCount = 1
|
|
149
|
-
): string[] => {
|
|
150
|
-
const monthCount = normalizePrepaidMonthCount(prepaidMonthCount);
|
|
151
|
-
const startMonth = getMonthStartDate(selectedMonth);
|
|
152
|
-
if (Number.isNaN(startMonth.getTime())) return [];
|
|
153
|
-
|
|
154
|
-
return Array.from({ length: monthCount }, (_, index) => {
|
|
155
|
-
const date = new Date(startMonth);
|
|
156
|
-
date.setMonth(date.getMonth() + index);
|
|
157
|
-
return formatMonthValue(date);
|
|
158
|
-
});
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
export const getCoverageEndMonthValue = (
|
|
162
|
-
selectedMonth: string,
|
|
163
|
-
prepaidMonthCount = 1
|
|
164
|
-
): string => {
|
|
165
|
-
const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
|
|
166
|
-
return coverageMonths[coverageMonths.length - 1] ?? selectedMonth;
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
export const getCoverageValidUntilMonthValue = (
|
|
170
|
-
selectedMonth: string,
|
|
171
|
-
prepaidMonthCount = 1
|
|
172
|
-
): string => addMonthsToMonthValue(selectedMonth, prepaidMonthCount);
|
|
173
|
-
|
|
174
|
-
export const formatCoverageRangeLabel = ({
|
|
175
|
-
locale,
|
|
176
|
-
prepaidMonthCount,
|
|
177
|
-
selectedMonth,
|
|
96
|
+
export const resolveSubscriptionInvoiceCategoryId = ({
|
|
97
|
+
defaultCategoryId,
|
|
98
|
+
items,
|
|
178
99
|
}: {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const lastMonth = coverageMonths[coverageMonths.length - 1];
|
|
100
|
+
defaultCategoryId?: string | null;
|
|
101
|
+
items: FinanceCategoryLinkedItem[];
|
|
102
|
+
}) => {
|
|
103
|
+
const linkedFinanceCategorySelection =
|
|
104
|
+
getLinkedFinanceCategorySelection(items);
|
|
105
|
+
|
|
106
|
+
if (linkedFinanceCategorySelection.hasSingleCategory) {
|
|
107
|
+
return linkedFinanceCategorySelection.categoryId ?? '';
|
|
108
|
+
}
|
|
189
109
|
|
|
190
|
-
if (
|
|
191
|
-
return
|
|
110
|
+
if (linkedFinanceCategorySelection.hasMixedCategories) {
|
|
111
|
+
return '';
|
|
192
112
|
}
|
|
193
113
|
|
|
194
|
-
return
|
|
195
|
-
lastMonth,
|
|
196
|
-
locale
|
|
197
|
-
)}`;
|
|
114
|
+
return defaultCategoryId ?? '';
|
|
198
115
|
};
|
|
199
116
|
|
|
200
117
|
const getComparableTimestamp = (value: string | null | undefined): number => {
|
|
@@ -532,7 +449,6 @@ export const getSessionsUntilMonth = (
|
|
|
532
449
|
}
|
|
533
450
|
};
|
|
534
451
|
|
|
535
|
-
/** Date range (earliest start, latest end) for selected groups. */
|
|
536
452
|
export type GroupsDateRange = {
|
|
537
453
|
earliestStart: Date | null;
|
|
538
454
|
latestEnd: Date | null;
|
|
@@ -583,17 +499,20 @@ export const getAvailableMonths = (
|
|
|
583
499
|
latestInvoices: SubscriptionCoverageInvoice[],
|
|
584
500
|
locale: string,
|
|
585
501
|
selectedMonthFallback: string | null = null,
|
|
586
|
-
futureMonthHorizon = 0
|
|
502
|
+
futureMonthHorizon = 0,
|
|
503
|
+
options: {
|
|
504
|
+
now?: Date;
|
|
505
|
+
workspaceTimezone?: string | null;
|
|
506
|
+
} = {}
|
|
587
507
|
): AvailableMonthOption[] => {
|
|
588
508
|
if (groupIds.length === 0) return [];
|
|
589
509
|
const { earliestStart, latestEnd } = getGroupsDateRange(userGroups, groupIds);
|
|
590
510
|
if (!earliestStart) return [];
|
|
591
511
|
|
|
592
|
-
const currentMonthStart = (
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
})();
|
|
512
|
+
const currentMonthStart = getCurrentMonthStartDate(
|
|
513
|
+
options.workspaceTimezone,
|
|
514
|
+
options.now
|
|
515
|
+
);
|
|
597
516
|
const futureHorizonEnd = new Date(currentMonthStart);
|
|
598
517
|
futureHorizonEnd.setMonth(
|
|
599
518
|
futureHorizonEnd.getMonth() + Math.max(0, futureMonthHorizon)
|
|
@@ -648,6 +567,7 @@ export const getBillableQuantityForGroupRange = ({
|
|
|
648
567
|
now = new Date(),
|
|
649
568
|
prepaidMonthCount = 1,
|
|
650
569
|
selectedMonth,
|
|
570
|
+
workspaceTimezone,
|
|
651
571
|
useAttendanceBased,
|
|
652
572
|
userAttendance,
|
|
653
573
|
userGroups,
|
|
@@ -657,11 +577,12 @@ export const getBillableQuantityForGroupRange = ({
|
|
|
657
577
|
now?: Date;
|
|
658
578
|
prepaidMonthCount?: number;
|
|
659
579
|
selectedMonth: string;
|
|
580
|
+
workspaceTimezone?: string | null;
|
|
660
581
|
useAttendanceBased: boolean;
|
|
661
582
|
userAttendance: AttendanceRecord[];
|
|
662
583
|
userGroups: UserGroup[];
|
|
663
584
|
}): number => {
|
|
664
|
-
const currentMonthStart =
|
|
585
|
+
const currentMonthStart = getCurrentMonthStartDate(workspaceTimezone, now);
|
|
665
586
|
const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
|
|
666
587
|
|
|
667
588
|
return coverageMonths.reduce((total, month) => {
|
|
@@ -708,6 +629,7 @@ export const getBillableQuantityMapForGroupsRange = ({
|
|
|
708
629
|
now,
|
|
709
630
|
prepaidMonthCount = 1,
|
|
710
631
|
selectedMonth,
|
|
632
|
+
workspaceTimezone,
|
|
711
633
|
useAttendanceBased,
|
|
712
634
|
userAttendance,
|
|
713
635
|
userGroups,
|
|
@@ -717,6 +639,7 @@ export const getBillableQuantityMapForGroupsRange = ({
|
|
|
717
639
|
now?: Date;
|
|
718
640
|
prepaidMonthCount?: number;
|
|
719
641
|
selectedMonth: string;
|
|
642
|
+
workspaceTimezone?: string | null;
|
|
720
643
|
useAttendanceBased: boolean;
|
|
721
644
|
userAttendance: AttendanceRecord[];
|
|
722
645
|
userGroups: UserGroup[];
|
|
@@ -730,6 +653,7 @@ export const getBillableQuantityMapForGroupsRange = ({
|
|
|
730
653
|
now,
|
|
731
654
|
prepaidMonthCount,
|
|
732
655
|
selectedMonth,
|
|
656
|
+
workspaceTimezone,
|
|
733
657
|
useAttendanceBased,
|
|
734
658
|
userAttendance,
|
|
735
659
|
userGroups,
|
|
@@ -737,11 +661,9 @@ export const getBillableQuantityMapForGroupsRange = ({
|
|
|
737
661
|
])
|
|
738
662
|
);
|
|
739
663
|
|
|
740
|
-
/** Days before valid_until to consider "expiring soon" */
|
|
741
664
|
const EXPIRING_SOON_DAYS = 14;
|
|
742
665
|
|
|
743
666
|
export type GroupPaymentStatus = 'active' | 'expiringSoon' | 'expired';
|
|
744
|
-
|
|
745
667
|
export function getGroupPaymentStatus(
|
|
746
668
|
group: WorkspaceUserGroup | null,
|
|
747
669
|
latestInvoice:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { normalizeLegacyPanelSize } from './resizable';
|
|
3
|
+
|
|
4
|
+
describe('normalizeLegacyPanelSize', () => {
|
|
5
|
+
it('preserves the shared wrapper numeric percentage contract', () => {
|
|
6
|
+
expect(normalizeLegacyPanelSize(38)).toBe('38%');
|
|
7
|
+
expect(normalizeLegacyPanelSize(0)).toBe('0%');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('leaves explicitly unitized sizes unchanged', () => {
|
|
11
|
+
expect(normalizeLegacyPanelSize('20rem')).toBe('20rem');
|
|
12
|
+
expect(normalizeLegacyPanelSize('38%')).toBe('38%');
|
|
13
|
+
expect(normalizeLegacyPanelSize(undefined)).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -16,6 +16,13 @@ interface ResizablePanelGroupProps
|
|
|
16
16
|
onLayout?: (sizes: number[]) => void;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function normalizeLegacyPanelSize(size: number | string | undefined) {
|
|
20
|
+
// react-resizable-panels v3 and the shadcn wrapper treated numeric values as
|
|
21
|
+
// percentages. v4 treats them as pixels, so preserve the shared wrapper's
|
|
22
|
+
// established contract while still allowing explicit CSS units.
|
|
23
|
+
return typeof size === 'number' ? `${size}%` : size;
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
function ResizablePanelGroup({
|
|
20
27
|
className,
|
|
21
28
|
direction,
|
|
@@ -26,11 +33,11 @@ function ResizablePanelGroup({
|
|
|
26
33
|
<Group
|
|
27
34
|
data-slot="resizable-panel-group"
|
|
28
35
|
orientation={direction}
|
|
29
|
-
|
|
36
|
+
onLayoutChanged={
|
|
30
37
|
onLayout
|
|
31
38
|
? (layout) => {
|
|
32
|
-
//
|
|
33
|
-
//
|
|
39
|
+
// Layout values are flex percentages (0-100). Preserve the
|
|
40
|
+
// wrapper's array callback for existing consumers.
|
|
34
41
|
const sizes = Object.values(layout);
|
|
35
42
|
onLayout(sizes);
|
|
36
43
|
}
|
|
@@ -45,8 +52,23 @@ function ResizablePanelGroup({
|
|
|
45
52
|
);
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
function ResizablePanel({
|
|
49
|
-
|
|
55
|
+
function ResizablePanel({
|
|
56
|
+
collapsedSize,
|
|
57
|
+
defaultSize,
|
|
58
|
+
maxSize,
|
|
59
|
+
minSize,
|
|
60
|
+
...props
|
|
61
|
+
}: React.ComponentProps<typeof Panel>) {
|
|
62
|
+
return (
|
|
63
|
+
<Panel
|
|
64
|
+
collapsedSize={normalizeLegacyPanelSize(collapsedSize)}
|
|
65
|
+
data-slot="resizable-panel"
|
|
66
|
+
defaultSize={normalizeLegacyPanelSize(defaultSize)}
|
|
67
|
+
maxSize={normalizeLegacyPanelSize(maxSize)}
|
|
68
|
+
minSize={normalizeLegacyPanelSize(minSize)}
|
|
69
|
+
{...props}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
50
72
|
}
|
|
51
73
|
|
|
52
74
|
function ResizableHandle({
|
|
@@ -60,7 +82,7 @@ function ResizableHandle({
|
|
|
60
82
|
<Separator
|
|
61
83
|
data-slot="resizable-handle"
|
|
62
84
|
className={cn(
|
|
63
|
-
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-
|
|
85
|
+
'relative flex w-px cursor-col-resize items-center justify-center bg-border transition-colors after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-primary/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-3 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
|
64
86
|
className
|
|
65
87
|
)}
|
|
66
88
|
{...props}
|