@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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
const MONTH_VALUE_PATTERN = /^(\d{4})-(\d{2})$/;
|
|
2
|
+
const DATE_VALUE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:$|[T\s])/;
|
|
3
|
+
|
|
4
|
+
export const MAX_PREPAID_MONTH_COUNT = 12;
|
|
5
|
+
export const PREPAID_MONTH_OPTION_HORIZON = 12;
|
|
6
|
+
|
|
7
|
+
type CivilDateParts = {
|
|
8
|
+
day: number;
|
|
9
|
+
month: number;
|
|
10
|
+
year: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const invalidDate = () => new Date(Number.NaN);
|
|
14
|
+
|
|
15
|
+
const getDaysInMonth = (year: number, month: number) =>
|
|
16
|
+
new Date(year, month, 0).getDate();
|
|
17
|
+
|
|
18
|
+
const isValidCivilDate = ({ day, month, year }: CivilDateParts) =>
|
|
19
|
+
Number.isInteger(year) &&
|
|
20
|
+
Number.isInteger(month) &&
|
|
21
|
+
Number.isInteger(day) &&
|
|
22
|
+
month >= 1 &&
|
|
23
|
+
month <= 12 &&
|
|
24
|
+
day >= 1 &&
|
|
25
|
+
day <= getDaysInMonth(year, month);
|
|
26
|
+
|
|
27
|
+
const parseCivilDateParts = (
|
|
28
|
+
value: string | Date | null | undefined
|
|
29
|
+
): CivilDateParts | null => {
|
|
30
|
+
if (!value) return null;
|
|
31
|
+
|
|
32
|
+
if (value instanceof Date) {
|
|
33
|
+
if (Number.isNaN(value.getTime())) return null;
|
|
34
|
+
return {
|
|
35
|
+
day: value.getDate(),
|
|
36
|
+
month: value.getMonth() + 1,
|
|
37
|
+
year: value.getFullYear(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
const monthMatch = MONTH_VALUE_PATTERN.exec(trimmed);
|
|
43
|
+
if (monthMatch) {
|
|
44
|
+
const [, year, month] = monthMatch;
|
|
45
|
+
const parts = {
|
|
46
|
+
day: 1,
|
|
47
|
+
month: Number(month),
|
|
48
|
+
year: Number(year),
|
|
49
|
+
};
|
|
50
|
+
return isValidCivilDate(parts) ? parts : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const dateMatch = DATE_VALUE_PATTERN.exec(trimmed);
|
|
54
|
+
if (dateMatch) {
|
|
55
|
+
const [, year, month, day] = dateMatch;
|
|
56
|
+
const parts = {
|
|
57
|
+
day: Number(day),
|
|
58
|
+
month: Number(month),
|
|
59
|
+
year: Number(year),
|
|
60
|
+
};
|
|
61
|
+
return isValidCivilDate(parts) ? parts : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const formatCivilMonthValue = (year: number, month: number) =>
|
|
68
|
+
`${year}-${String(month).padStart(2, '0')}`;
|
|
69
|
+
|
|
70
|
+
export const parseLocalCalendarDate = (
|
|
71
|
+
value: string | Date | null | undefined
|
|
72
|
+
): Date => {
|
|
73
|
+
const parts = parseCivilDateParts(value);
|
|
74
|
+
return parts
|
|
75
|
+
? new Date(parts.year, parts.month - 1, parts.day)
|
|
76
|
+
: invalidDate();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const getMonthStartDate = (
|
|
80
|
+
value: string | Date | null | undefined
|
|
81
|
+
): Date => {
|
|
82
|
+
const date = parseLocalCalendarDate(value);
|
|
83
|
+
if (Number.isNaN(date.getTime())) {
|
|
84
|
+
return date;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const formatMonthValue = (date: Date): string =>
|
|
91
|
+
formatCivilMonthValue(date.getFullYear(), date.getMonth() + 1);
|
|
92
|
+
|
|
93
|
+
export const formatMonthLabel = (month: string, locale: string): string => {
|
|
94
|
+
const date = getMonthStartDate(month);
|
|
95
|
+
if (Number.isNaN(date.getTime())) {
|
|
96
|
+
return month;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return date.toLocaleDateString(locale, {
|
|
100
|
+
year: 'numeric',
|
|
101
|
+
month: 'long',
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const addMonthsToMonthValue = (
|
|
106
|
+
month: string,
|
|
107
|
+
monthOffset: number
|
|
108
|
+
): string => {
|
|
109
|
+
const parts = parseCivilDateParts(month);
|
|
110
|
+
if (!parts) return month;
|
|
111
|
+
|
|
112
|
+
const totalMonthIndex = parts.year * 12 + (parts.month - 1) + monthOffset;
|
|
113
|
+
const year = Math.floor(totalMonthIndex / 12);
|
|
114
|
+
const monthNumber = (totalMonthIndex % 12) + 1;
|
|
115
|
+
|
|
116
|
+
return formatCivilMonthValue(year, monthNumber);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const normalizePrepaidMonthCount = (
|
|
120
|
+
value: number | null | undefined
|
|
121
|
+
): number => {
|
|
122
|
+
if (!Number.isFinite(value) || !value) return 1;
|
|
123
|
+
|
|
124
|
+
return Math.min(MAX_PREPAID_MONTH_COUNT, Math.max(1, Math.trunc(value)));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const getCoverageMonths = (
|
|
128
|
+
selectedMonth: string,
|
|
129
|
+
prepaidMonthCount = 1
|
|
130
|
+
): string[] => {
|
|
131
|
+
const monthCount = normalizePrepaidMonthCount(prepaidMonthCount);
|
|
132
|
+
const startMonth = parseCivilDateParts(selectedMonth);
|
|
133
|
+
if (!startMonth) return [];
|
|
134
|
+
|
|
135
|
+
return Array.from({ length: monthCount }, (_, index) =>
|
|
136
|
+
addMonthsToMonthValue(selectedMonth, index)
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const getCoverageEndMonthValue = (
|
|
141
|
+
selectedMonth: string,
|
|
142
|
+
prepaidMonthCount = 1
|
|
143
|
+
): string => {
|
|
144
|
+
const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
|
|
145
|
+
return coverageMonths[coverageMonths.length - 1] ?? selectedMonth;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const getCoverageValidUntilMonthValue = (
|
|
149
|
+
selectedMonth: string,
|
|
150
|
+
prepaidMonthCount = 1
|
|
151
|
+
): string => addMonthsToMonthValue(selectedMonth, prepaidMonthCount);
|
|
152
|
+
|
|
153
|
+
export const formatCoverageRangeLabel = ({
|
|
154
|
+
locale,
|
|
155
|
+
prepaidMonthCount,
|
|
156
|
+
selectedMonth,
|
|
157
|
+
}: {
|
|
158
|
+
locale: string;
|
|
159
|
+
prepaidMonthCount?: number | null;
|
|
160
|
+
selectedMonth: string;
|
|
161
|
+
}): string => {
|
|
162
|
+
const coverageMonths = getCoverageMonths(
|
|
163
|
+
selectedMonth,
|
|
164
|
+
prepaidMonthCount ?? 1
|
|
165
|
+
);
|
|
166
|
+
const firstMonth = coverageMonths[0];
|
|
167
|
+
const lastMonth = coverageMonths[coverageMonths.length - 1];
|
|
168
|
+
|
|
169
|
+
if (!firstMonth || !lastMonth || firstMonth === lastMonth) {
|
|
170
|
+
return formatMonthLabel(selectedMonth, locale);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return `${formatMonthLabel(firstMonth, locale)} - ${formatMonthLabel(
|
|
174
|
+
lastMonth,
|
|
175
|
+
locale
|
|
176
|
+
)}`;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const resolveBillingTimezone = (
|
|
180
|
+
timezone: string | null | undefined
|
|
181
|
+
): string => {
|
|
182
|
+
const trimmed = timezone?.trim();
|
|
183
|
+
if (!trimmed || trimmed === 'auto') return 'UTC';
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
new Intl.DateTimeFormat('en-US', { timeZone: trimmed }).format(new Date(0));
|
|
187
|
+
return trimmed;
|
|
188
|
+
} catch {
|
|
189
|
+
return 'UTC';
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const getCurrentBillingDate = (
|
|
194
|
+
timezone: string | null | undefined,
|
|
195
|
+
now = new Date()
|
|
196
|
+
): Date => {
|
|
197
|
+
const resolvedTimezone = resolveBillingTimezone(timezone);
|
|
198
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
199
|
+
day: '2-digit',
|
|
200
|
+
month: '2-digit',
|
|
201
|
+
timeZone: resolvedTimezone,
|
|
202
|
+
year: 'numeric',
|
|
203
|
+
}).formatToParts(now);
|
|
204
|
+
const getPart = (type: Intl.DateTimeFormatPartTypes) =>
|
|
205
|
+
parts.find((part) => part.type === type)?.value;
|
|
206
|
+
const year = Number(getPart('year'));
|
|
207
|
+
const month = Number(getPart('month'));
|
|
208
|
+
const day = Number(getPart('day'));
|
|
209
|
+
const civilDate = { day, month, year };
|
|
210
|
+
|
|
211
|
+
return isValidCivilDate(civilDate)
|
|
212
|
+
? new Date(year, month - 1, day)
|
|
213
|
+
: invalidDate();
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
export const getCurrentMonthValue = (
|
|
217
|
+
timezone: string | null | undefined,
|
|
218
|
+
now = new Date()
|
|
219
|
+
): string => formatMonthValue(getCurrentBillingDate(timezone, now));
|
|
220
|
+
|
|
221
|
+
export const getCurrentMonthStartDate = (
|
|
222
|
+
timezone: string | null | undefined,
|
|
223
|
+
now = new Date()
|
|
224
|
+
): Date => getMonthStartDate(getCurrentMonthValue(timezone, now));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { SubscriptionInvoiceProposal } from './subscription-invoice-proposal';
|
|
4
|
+
|
|
5
|
+
vi.mock('next-intl', () => ({
|
|
6
|
+
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
|
7
|
+
values ? `${key}:${JSON.stringify(values)}` : key,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('../../shared/finance-display-amount', () => ({
|
|
11
|
+
FinanceDisplayAmount: ({ value }: { value: string }) => <span>{value}</span>,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
describe('SubscriptionInvoiceProposal', () => {
|
|
15
|
+
it('shows the suggested total and billable quantity before products load', () => {
|
|
16
|
+
render(
|
|
17
|
+
<SubscriptionInvoiceProposal
|
|
18
|
+
billableQuantity={3}
|
|
19
|
+
currency="VND"
|
|
20
|
+
currentTotal={0}
|
|
21
|
+
hasSelectedProducts={false}
|
|
22
|
+
suggestedTotal={1_500_000}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(screen.getByText(/1[,.]500[,.]000/)).toBeInTheDocument();
|
|
27
|
+
expect(
|
|
28
|
+
screen.getByText(/based_on_billable_quantity.*"count":3/)
|
|
29
|
+
).toBeInTheDocument();
|
|
30
|
+
expect(
|
|
31
|
+
screen.getByText('ws-invoices.suggested_total_requires_products')
|
|
32
|
+
).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('warns when selected products calculate a different total', () => {
|
|
36
|
+
render(
|
|
37
|
+
<SubscriptionInvoiceProposal
|
|
38
|
+
currency="VND"
|
|
39
|
+
currentTotal={1_200_000}
|
|
40
|
+
hasSelectedProducts
|
|
41
|
+
suggestedTotal={1_500_000}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(
|
|
46
|
+
screen.getByText(/ws-invoices.suggested_total_mismatch/)
|
|
47
|
+
).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, Calculator } from '@tuturuuu/icons';
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card';
|
|
5
|
+
import { formatCurrency } from '@tuturuuu/utils/format';
|
|
6
|
+
import { useTranslations } from 'next-intl';
|
|
7
|
+
import { FinanceDisplayAmount } from '../../shared/finance-display-amount';
|
|
8
|
+
|
|
9
|
+
interface SubscriptionInvoiceProposalProps {
|
|
10
|
+
billableQuantity?: number | null;
|
|
11
|
+
currency: string;
|
|
12
|
+
currentTotal: number;
|
|
13
|
+
hasSelectedProducts: boolean;
|
|
14
|
+
suggestedTotal: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SubscriptionInvoiceProposal({
|
|
18
|
+
billableQuantity,
|
|
19
|
+
currency,
|
|
20
|
+
currentTotal,
|
|
21
|
+
hasSelectedProducts,
|
|
22
|
+
suggestedTotal,
|
|
23
|
+
}: SubscriptionInvoiceProposalProps) {
|
|
24
|
+
const t = useTranslations();
|
|
25
|
+
const totalsDiffer =
|
|
26
|
+
hasSelectedProducts && Math.abs(currentTotal - suggestedTotal) > 0.01;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Card className="overflow-hidden border-dynamic-blue/30 bg-dynamic-blue/5">
|
|
30
|
+
<CardHeader className="pb-3">
|
|
31
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
32
|
+
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-dynamic-blue/15 text-dynamic-blue">
|
|
33
|
+
<Calculator className="h-4 w-4" />
|
|
34
|
+
</span>
|
|
35
|
+
{t('ws-invoices.invoice_proposal')}
|
|
36
|
+
</CardTitle>
|
|
37
|
+
</CardHeader>
|
|
38
|
+
<CardContent className="space-y-3">
|
|
39
|
+
<div className="flex items-end justify-between gap-4 rounded-lg border bg-background/80 p-4">
|
|
40
|
+
<div>
|
|
41
|
+
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
|
42
|
+
{t('ws-invoices.suggested_invoice_total')}
|
|
43
|
+
</p>
|
|
44
|
+
{billableQuantity != null && (
|
|
45
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
46
|
+
{t('ws-invoices.based_on_billable_quantity', {
|
|
47
|
+
count: billableQuantity,
|
|
48
|
+
})}
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
<FinanceDisplayAmount
|
|
53
|
+
className="shrink-0 font-bold text-2xl text-dynamic-blue tabular-nums"
|
|
54
|
+
value={formatCurrency(suggestedTotal, currency)}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{!hasSelectedProducts && (
|
|
59
|
+
<div className="flex items-start gap-2 rounded-lg border border-dynamic-yellow/30 bg-dynamic-yellow/10 p-3 text-sm">
|
|
60
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-dynamic-yellow" />
|
|
61
|
+
<p className="text-muted-foreground">
|
|
62
|
+
{t('ws-invoices.suggested_total_requires_products')}
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{totalsDiffer && (
|
|
68
|
+
<div className="flex items-start gap-2 rounded-lg border border-dynamic-yellow/30 bg-dynamic-yellow/10 p-3 text-sm">
|
|
69
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-dynamic-yellow" />
|
|
70
|
+
<p className="text-muted-foreground">
|
|
71
|
+
{t('ws-invoices.suggested_total_mismatch', {
|
|
72
|
+
current: formatCurrency(currentTotal, currency),
|
|
73
|
+
suggested: formatCurrency(suggestedTotal, currency),
|
|
74
|
+
})}
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</CardContent>
|
|
79
|
+
</Card>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -38,6 +38,7 @@ interface UseSubscriptionAutoSelectionProps {
|
|
|
38
38
|
}[];
|
|
39
39
|
onSelectedProductsChange: Dispatch<SetStateAction<SelectedProductItem[]>>;
|
|
40
40
|
prepaidMonthCount?: number;
|
|
41
|
+
workspaceTimezone?: string | null;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export type UseSubscriptionAutoSelectionResult = undefined;
|
|
@@ -162,13 +163,15 @@ const computeGroupAttendanceDaysMap = (
|
|
|
162
163
|
selectedMonth: string,
|
|
163
164
|
userAttendance: { status: string; date: string; group_id?: string }[],
|
|
164
165
|
useAttendanceBased: boolean,
|
|
165
|
-
prepaidMonthCount = 1
|
|
166
|
+
prepaidMonthCount = 1,
|
|
167
|
+
workspaceTimezone?: string | null
|
|
166
168
|
) => {
|
|
167
169
|
return getBillableQuantityMapForGroupsRange({
|
|
168
170
|
groupIds: selectedGroupIds,
|
|
169
171
|
latestInvoices: latestSubscriptionInvoices,
|
|
170
172
|
prepaidMonthCount,
|
|
171
173
|
selectedMonth,
|
|
174
|
+
workspaceTimezone,
|
|
172
175
|
useAttendanceBased,
|
|
173
176
|
userAttendance,
|
|
174
177
|
userGroups,
|
|
@@ -229,6 +232,7 @@ export function useSubscriptionAutoSelection({
|
|
|
229
232
|
latestSubscriptionInvoices,
|
|
230
233
|
onSelectedProductsChange,
|
|
231
234
|
prepaidMonthCount = 1,
|
|
235
|
+
workspaceTimezone,
|
|
232
236
|
}: UseSubscriptionAutoSelectionProps): UseSubscriptionAutoSelectionResult {
|
|
233
237
|
const t = useTranslations();
|
|
234
238
|
const previousGroupIdRef = useRef<string>('');
|
|
@@ -251,7 +255,8 @@ export function useSubscriptionAutoSelection({
|
|
|
251
255
|
selectedMonth,
|
|
252
256
|
userAttendance,
|
|
253
257
|
useAttendanceBased,
|
|
254
|
-
prepaidMonthCount
|
|
258
|
+
prepaidMonthCount,
|
|
259
|
+
workspaceTimezone
|
|
255
260
|
),
|
|
256
261
|
[
|
|
257
262
|
sortedSelectedGroupIds,
|
|
@@ -261,6 +266,7 @@ export function useSubscriptionAutoSelection({
|
|
|
261
266
|
userAttendance,
|
|
262
267
|
useAttendanceBased,
|
|
263
268
|
prepaidMonthCount,
|
|
269
|
+
workspaceTimezone,
|
|
264
270
|
]
|
|
265
271
|
);
|
|
266
272
|
|
|
@@ -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
|
+
});
|