@tuturuuu/ui 0.13.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/biome.json +1 -1
- package/package.json +62 -62
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
- package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
- package/src/components/ui/custom/notification-popover-client.tsx +5 -1
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
- package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
- package/src/components/ui/custom/settings/task-settings.tsx +55 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
- package/src/components/ui/custom/sidebar-context.tsx +58 -3
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
- package/src/components/ui/custom/statistic-card.tsx +104 -0
- package/src/components/ui/custom/structure.test.tsx +29 -0
- package/src/components/ui/custom/structure.tsx +103 -88
- package/src/components/ui/finance/invoices/civil-month.ts +224 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
- package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
- package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
- package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
- package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
- package/src/components/ui/finance/invoices/utils.test.ts +84 -0
- package/src/components/ui/finance/invoices/utils.ts +56 -134
- package/src/components/ui/resizable.test.ts +15 -0
- package/src/components/ui/resizable.tsx +28 -6
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
- package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
- package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
- package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
- package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
- package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
- package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
- package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
- package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
- package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
- package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
- package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
- package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
- package/src/components/ui/tu-do/projects/types.ts +4 -1
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
- package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
- package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
- package/src/components/ui/tu-do/templates/client.tsx +97 -78
- package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
- package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
- package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
- package/src/hooks/task-actions-personal-external.ts +12 -6
- package/src/hooks/use-notifications.ts +14 -2
- package/src/hooks/use-user-config.ts +10 -2
- package/vitest.config.ts +4 -0
|
@@ -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
|
-
|
|
81
|
-
<
|
|
82
|
-
<div className="
|
|
83
|
-
<div className="flex
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
</
|
|
97
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
139
|
-
isCollapsed ? 'justify-center' : '
|
|
139
|
+
'flex min-h-13 w-full items-center md:px-2',
|
|
140
|
+
isCollapsed ? 'justify-center' : ''
|
|
140
141
|
)}
|
|
141
142
|
>
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
<
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
199
|
-
? 'md:pl-
|
|
200
|
-
:
|
|
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
|
|
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
|
|