@tuturuuu/ui 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +90 -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/task-settings.tsx +55 -0
  12. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  13. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  14. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  15. package/src/components/ui/custom/statistic-card.tsx +104 -0
  16. package/src/components/ui/custom/structure.test.tsx +29 -0
  17. package/src/components/ui/custom/structure.tsx +103 -88
  18. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  20. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  23. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  24. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  25. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  26. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  27. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  28. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  29. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  30. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  31. package/src/components/ui/finance/invoices/utils.ts +56 -134
  32. package/src/components/ui/resizable.test.ts +15 -0
  33. package/src/components/ui/resizable.tsx +28 -6
  34. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  36. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
  37. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
  38. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
  40. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
  42. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  43. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  44. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  45. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  46. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  47. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  48. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  49. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  50. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  51. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  52. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  53. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  54. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  55. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  56. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  57. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  58. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  59. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  60. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  61. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  62. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  63. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  64. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  65. package/src/components/ui/tu-do/projects/types.ts +4 -1
  66. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  67. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  68. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  69. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
  70. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  71. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  72. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  73. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  74. package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
  75. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  76. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  83. package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
  84. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  85. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  86. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  87. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  88. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  89. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  90. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  91. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  92. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  93. package/src/hooks/task-actions-personal-external.ts +12 -6
  94. package/src/hooks/use-notifications.ts +14 -2
  95. package/src/hooks/use-user-config.ts +10 -2
  96. package/vitest.config.ts +4 -0
@@ -21,6 +21,7 @@ interface StructureProps {
21
21
  children: ReactNode;
22
22
  onMouseEnter?: () => void;
23
23
  onMouseLeave?: () => void;
24
+ sidebarHidden?: boolean;
24
25
  sidebarCollapsedWidth?: string;
25
26
  sidebarExpandedWidth?: string;
26
27
  sidebarHeaderClassName?: string;
@@ -42,6 +43,7 @@ export function Structure({
42
43
  children,
43
44
  onMouseEnter,
44
45
  onMouseLeave,
46
+ sidebarHidden = false,
45
47
  sidebarCollapsedWidth = '4rem',
46
48
  sidebarExpandedWidth = '16rem',
47
49
  sidebarHeaderClassName,
@@ -60,7 +62,7 @@ export function Structure({
60
62
  if (typeof window === 'undefined') return;
61
63
 
62
64
  const isMobile = window.innerWidth < 768; // md breakpoint
63
- if (isMobile && !isCollapsed) {
65
+ if (isMobile && !sidebarHidden && !isCollapsed) {
64
66
  const scrollY = window.scrollY;
65
67
  document.body.style.position = 'fixed';
66
68
  document.body.style.top = `-${scrollY}px`;
@@ -73,31 +75,35 @@ export function Structure({
73
75
  window.scrollTo(0, scrollY);
74
76
  };
75
77
  }
76
- }, [isCollapsed]);
78
+ }, [isCollapsed, sidebarHidden]);
77
79
 
78
80
  return (
79
81
  <>
80
- <nav className="safe-top safe-x fixed inset-x-0 top-0 z-30 max-sm:border-b md:hidden">
81
- <div className="bg-background/50 p-2 font-semibold backdrop-blur-md md:px-8 lg:px-16 xl:px-32">
82
- <div className="relative flex items-center justify-between gap-2 md:gap-4">
83
- <div className="flex w-full items-center gap-2">{mobileHeader}</div>
84
- <div className="flex w-fit items-center gap-2">
85
- {userPopover}
86
- <Button
87
- size="icon"
88
- variant="outline"
89
- className="h-auto w-auto flex-none rounded-lg p-2 md:hidden"
90
- onClick={() => setIsCollapsed(!isCollapsed)}
91
- >
92
- <Menu className="h-5 w-5" />
93
- </Button>
82
+ {!sidebarHidden && (
83
+ <nav className="safe-top safe-x fixed inset-x-0 top-0 z-30 max-sm:border-b md:hidden">
84
+ <div className="bg-background/50 p-2 font-semibold backdrop-blur-md md:px-8 lg:px-16 xl:px-32">
85
+ <div className="relative flex items-center justify-between gap-2 md:gap-4">
86
+ <div className="flex w-full items-center gap-2">
87
+ {mobileHeader}
88
+ </div>
89
+ <div className="flex w-fit items-center gap-2">
90
+ {userPopover}
91
+ <Button
92
+ size="icon"
93
+ variant="outline"
94
+ className="h-auto w-auto flex-none rounded-lg p-2 md:hidden"
95
+ onClick={() => setIsCollapsed(!isCollapsed)}
96
+ >
97
+ <Menu className="h-5 w-5" />
98
+ </Button>
99
+ </div>
94
100
  </div>
95
101
  </div>
96
- </div>
97
- </nav>
102
+ </nav>
103
+ )}
98
104
 
99
105
  <TooltipProvider delayDuration={0}>
100
- {!isCollapsed && (
106
+ {!sidebarHidden && !isCollapsed && (
101
107
  <button
102
108
  type="button"
103
109
  className="fixed inset-0 z-40 bg-black/20 backdrop-blur-lg md:hidden"
@@ -105,104 +111,113 @@ export function Structure({
105
111
  />
106
112
  )}
107
113
  <div className="relative w-full">
108
- <aside
109
- className={cn(
110
- 'group fixed top-0 right-0 left-auto z-50 flex h-dvh flex-col overflow-hidden border-l backdrop-blur-lg transition-all duration-300 ease-in-out md:left-0 md:z-20 md:border-r md:border-l-0',
111
- isCollapsed
112
- ? 'w-[var(--sidebar-collapsed-width)] bg-background/50 max-md:w-0'
113
- : 'w-[var(--sidebar-expanded-width)] bg-background max-sm:w-full',
114
- isCollapsed && 'max-md:translate-x-full',
115
- overlayOnExpand && !isCollapsed && 'md:z-40'
116
- )}
117
- onMouseEnter={onMouseEnter}
118
- onMouseLeave={onMouseLeave}
119
- style={sidebarStyle}
120
- >
121
- <div
114
+ {!sidebarHidden && (
115
+ <aside
122
116
  className={cn(
123
- sidebarHeaderHeight
124
- ? 'flex h-[var(--sidebar-header-height)] shrink-0 items-center p-2 md:px-0'
125
- : 'flex min-h-12 items-center p-2 md:px-0',
126
- isCollapsed ? 'justify-center' : 'py-1',
127
- sidebarHeaderClassName
117
+ 'group fixed top-0 right-0 left-auto z-50 flex h-dvh flex-col overflow-hidden border-l backdrop-blur-lg transition-all duration-300 ease-in-out md:left-0 md:z-20 md:border-r md:border-l-0',
118
+ isCollapsed
119
+ ? 'w-[var(--sidebar-collapsed-width)] bg-background/50 max-md:w-0'
120
+ : 'w-[var(--sidebar-expanded-width)] bg-background max-sm:w-full',
121
+ isCollapsed && 'max-md:translate-x-full',
122
+ overlayOnExpand && !isCollapsed && 'md:z-40'
128
123
  )}
124
+ onMouseEnter={onMouseEnter}
125
+ onMouseLeave={onMouseLeave}
126
+ style={sidebarStyle}
129
127
  >
130
128
  <div
131
129
  className={cn(
132
- 'flex min-h-13 w-full items-center md:px-2',
133
- isCollapsed ? 'justify-center' : ''
130
+ sidebarHeaderHeight
131
+ ? 'flex h-[var(--sidebar-header-height)] shrink-0 items-center p-2 md:px-0'
132
+ : 'flex min-h-12 items-center p-2 md:px-0',
133
+ isCollapsed ? 'justify-center' : 'py-1',
134
+ sidebarHeaderClassName
134
135
  )}
135
136
  >
136
137
  <div
137
138
  className={cn(
138
- 'flex w-full items-center gap-2',
139
- isCollapsed ? 'justify-center' : 'justify-between'
139
+ 'flex min-h-13 w-full items-center md:px-2',
140
+ isCollapsed ? 'justify-center' : ''
140
141
  )}
141
142
  >
142
- {sidebarHeader}
143
- {isCollapsed || (
144
- <Button
145
- size="icon"
146
- variant="outline"
147
- className="h-auto w-auto flex-none rounded-lg p-2 md:hidden"
148
- onClick={() => setIsCollapsed(!isCollapsed)}
149
- >
150
- <X className="h-5 w-5" />
151
- </Button>
152
- )}
143
+ <div
144
+ className={cn(
145
+ 'flex w-full items-center gap-2',
146
+ isCollapsed ? 'justify-center' : 'justify-between'
147
+ )}
148
+ >
149
+ {sidebarHeader}
150
+ {isCollapsed || (
151
+ <Button
152
+ size="icon"
153
+ variant="outline"
154
+ className="h-auto w-auto flex-none rounded-lg p-2 md:hidden"
155
+ onClick={() => setIsCollapsed(!isCollapsed)}
156
+ >
157
+ <X className="h-5 w-5" />
158
+ </Button>
159
+ )}
160
+ </div>
153
161
  </div>
154
162
  </div>
155
- </div>
156
- <div className="scrollbar-none flex flex-1 flex-col gap-y-1 overflow-y-auto overflow-x-hidden overscroll-contain">
157
- {sidebarContent}
158
- </div>
159
- {feedbackButton && (
163
+ <div className="scrollbar-none flex flex-1 flex-col gap-y-1 overflow-y-auto overflow-x-hidden overscroll-contain">
164
+ {sidebarContent}
165
+ </div>
166
+ {feedbackButton && (
167
+ <div
168
+ className={cn(
169
+ 'flex border-foreground/10 border-t p-2',
170
+ isCollapsed ? 'justify-center' : ''
171
+ )}
172
+ >
173
+ {feedbackButton}
174
+ </div>
175
+ )}
160
176
  <div
161
177
  className={cn(
162
- 'flex border-foreground/10 border-t p-2',
178
+ 'mt-auto flex border-foreground/10 border-t p-2',
163
179
  isCollapsed ? 'justify-center' : ''
164
180
  )}
165
181
  >
166
- {feedbackButton}
182
+ {isCollapsed ? userPopover : actions}
167
183
  </div>
168
- )}
169
- <div
170
- className={cn(
171
- 'mt-auto flex border-foreground/10 border-t p-2',
172
- isCollapsed ? 'justify-center' : ''
173
- )}
174
- >
175
- {isCollapsed ? userPopover : actions}
176
- </div>
177
184
 
178
- {!hideSizeToggle && (
179
- <Button
180
- size="icon"
181
- variant="outline"
182
- className="absolute top-1/2 -right-4 z-10 hidden h-auto w-auto -translate-y-1/2 rounded-full border-2 bg-background p-1.5 pl-1.5 opacity-0 transition duration-500 hover:bg-accent group-hover:opacity-100 md:block"
183
- onClick={() => setIsCollapsed(!isCollapsed)}
184
- >
185
- {isCollapsed ? (
186
- <ChevronRight className="mr-2 h-4 w-4" />
187
- ) : (
188
- <ChevronLeft className="mr-2 h-4 w-4" />
189
- )}
190
- </Button>
191
- )}
192
- </aside>
185
+ {!hideSizeToggle && (
186
+ <Button
187
+ size="icon"
188
+ variant="outline"
189
+ className="absolute top-1/2 -right-4 z-10 hidden h-auto w-auto -translate-y-1/2 rounded-full border-2 bg-background p-1.5 pl-1.5 opacity-0 transition duration-500 hover:bg-accent group-hover:opacity-100 md:block"
190
+ onClick={() => setIsCollapsed(!isCollapsed)}
191
+ >
192
+ {isCollapsed ? (
193
+ <ChevronRight className="mr-2 h-4 w-4" />
194
+ ) : (
195
+ <ChevronLeft className="mr-2 h-4 w-4" />
196
+ )}
197
+ </Button>
198
+ )}
199
+ </aside>
200
+ )}
193
201
  {/* Main content area - overflow-y-auto removed to prevent double scrollbars */}
194
202
  {/* Body element now handles page-level scrolling */}
195
203
  <main
196
204
  className={cn(
197
205
  'relative flex h-full min-h-screen flex-col overflow-x-clip overflow-y-visible transition-all duration-300 ease-in-out',
198
- isCollapsed || overlayOnExpand
199
- ? 'md:pl-[var(--sidebar-collapsed-width)]'
200
- : 'md:pl-[var(--sidebar-expanded-width)]'
206
+ sidebarHidden
207
+ ? 'md:pl-0'
208
+ : isCollapsed || overlayOnExpand
209
+ ? 'md:pl-[var(--sidebar-collapsed-width)]'
210
+ : 'md:pl-[var(--sidebar-expanded-width)]'
201
211
  )}
202
212
  style={sidebarStyle}
203
213
  >
204
214
  {header && <div className="mb-4 hidden md:block">{header}</div>}
205
- <div className="safe-bottom relative h-full w-full p-2 pt-17 pl-2 md:p-4 md:pt-4">
215
+ <div
216
+ className={cn(
217
+ 'safe-bottom relative h-full w-full p-2 md:p-4 md:pt-4',
218
+ sidebarHidden ? 'pt-2' : 'pt-17 pl-2'
219
+ )}
220
+ >
206
221
  {children}
207
222
  </div>
208
223
  </main>
@@ -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