@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  12. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  13. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  14. package/src/components/ui/custom/statistic-card.tsx +104 -0
  15. package/src/components/ui/custom/structure.test.tsx +29 -0
  16. package/src/components/ui/custom/structure.tsx +103 -88
  17. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  18. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  21. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  23. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  24. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  25. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  26. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  27. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  28. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  29. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  30. package/src/components/ui/finance/invoices/utils.ts +56 -134
  31. package/src/components/ui/resizable.test.ts +15 -0
  32. package/src/components/ui/resizable.tsx +28 -6
  33. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +32 -2
  36. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  37. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  38. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  39. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  40. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  41. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  45. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  46. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  47. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  48. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  49. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  50. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  51. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  52. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  53. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  54. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  55. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  56. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  57. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  58. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  59. package/src/components/ui/tu-do/projects/types.ts +4 -1
  60. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  61. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  62. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  63. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  64. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  65. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  66. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  67. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  68. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  69. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  70. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  71. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  72. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  73. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  75. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  76. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  77. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  78. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  79. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  80. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  81. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  82. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  83. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  84. package/src/hooks/task-actions-personal-external.ts +12 -6
  85. package/src/hooks/use-notifications.ts +14 -2
  86. 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 === 'amount') {
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 [prefillAmount] = useQueryState('amount', {
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: defaultConfigs = {} } = useWorkspaceConfigs(
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 defaultCategoryId =
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
- prefillAmount={prefillAmount}
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
+ });