@tuturuuu/ui 0.2.0 → 0.3.2

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 (129) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/package.json +79 -67
  3. package/src/components/ui/__tests__/avatar.test.tsx +8 -5
  4. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
  8. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
  9. package/src/components/ui/chart.test.tsx +29 -0
  10. package/src/components/ui/chart.tsx +12 -3
  11. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +396 -2
  12. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +36 -8
  13. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +14 -0
  14. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +5 -0
  15. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +21 -7
  16. package/src/components/ui/chat/chat-agent-details-utils.test.ts +73 -0
  17. package/src/components/ui/chat/chat-agent-details-utils.tsx +100 -26
  18. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +517 -0
  19. package/src/components/ui/chat/chat-workspace.tsx +31 -1
  20. package/src/components/ui/chat/hooks-messages.test.tsx +45 -1
  21. package/src/components/ui/chat/hooks-messages.ts +1 -1
  22. package/src/components/ui/chat/hooks-realtime.ts +13 -16
  23. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
  24. package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
  25. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
  26. package/src/components/ui/custom/common-footer.tsx +16 -1
  27. package/src/components/ui/custom/production-indicator.tsx +1 -1
  28. package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
  29. package/src/components/ui/custom/settings/task-settings.tsx +18 -0
  30. package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
  31. package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
  32. package/src/components/ui/custom/sidebar-context.tsx +61 -61
  33. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
  34. package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
  35. package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
  36. package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
  37. package/src/components/ui/custom/workspace-select.tsx +33 -12
  38. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
  39. package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
  40. package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
  41. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
  42. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
  43. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
  44. package/src/components/ui/finance/invoices/hooks.ts +75 -20
  45. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
  46. package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
  47. package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
  48. package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
  49. package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
  50. package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
  51. package/src/components/ui/finance/invoices/utils.test.ts +50 -0
  52. package/src/components/ui/finance/invoices/utils.ts +75 -17
  53. package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
  54. package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
  55. package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
  56. package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
  57. package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
  58. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  59. package/src/components/ui/finance/transactions/form.test.tsx +43 -0
  60. package/src/components/ui/finance/transactions/form.tsx +60 -0
  61. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
  62. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
  63. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
  64. package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
  65. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  66. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
  67. package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
  68. package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
  69. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
  70. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
  71. package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
  72. package/src/components/ui/legacy/meet/page.test.ts +180 -0
  73. package/src/components/ui/legacy/meet/page.tsx +87 -39
  74. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
  75. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
  76. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
  77. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
  78. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
  79. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
  80. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
  81. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
  82. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
  83. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
  84. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
  85. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
  86. package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
  87. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
  88. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
  89. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
  90. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
  91. package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
  92. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
  93. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
  94. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
  95. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
  96. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
  97. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
  98. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
  99. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
  100. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
  101. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
  102. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
  103. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
  104. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
  105. package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
  106. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
  107. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
  108. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
  109. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
  110. package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
  111. package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
  112. package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
  113. package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
  114. package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
  115. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
  116. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
  117. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
  118. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
  119. package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
  120. package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
  121. package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
  122. package/src/hooks/use-calendar-sync.tsx +22 -277
  123. package/src/hooks/use-calendar.tsx +95 -525
  124. package/src/hooks/use-semantic-task-search.ts +10 -33
  125. package/src/hooks/use-task-actions.ts +43 -117
  126. package/src/hooks/use-user-config.ts +1 -1
  127. package/src/hooks/use-workspace-config.ts +6 -2
  128. package/src/hooks/use-workspace-presence.ts +1 -1
  129. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useUpdateUserConfig,
5
+ useUserConfig,
6
+ } from '@tuturuuu/ui/hooks/use-user-config';
7
+ import { useEffect, useRef } from 'react';
8
+
9
+ type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
10
+
11
+ const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
12
+
13
+ const isValidBehavior = (value: string | undefined): value is SidebarBehavior =>
14
+ value === 'expanded' || value === 'collapsed' || value === 'hover';
15
+
16
+ interface SidebarRemoteBehaviorBridgeProps {
17
+ behavior: SidebarBehavior;
18
+ localOverride: boolean;
19
+ localOverrideVersion: number;
20
+ onApplyRemoteBehavior: (newBehavior: SidebarBehavior) => void;
21
+ onRemoteBehaviorAvailable: (remoteBehavior: SidebarBehavior) => void;
22
+ userChangeVersion: number;
23
+ }
24
+
25
+ export function SidebarRemoteBehaviorBridge({
26
+ behavior,
27
+ localOverride,
28
+ localOverrideVersion,
29
+ onApplyRemoteBehavior,
30
+ onRemoteBehaviorAvailable,
31
+ userChangeVersion,
32
+ }: SidebarRemoteBehaviorBridgeProps) {
33
+ const { data: remoteBehavior, isSuccess: remoteLoaded } = useUserConfig(
34
+ SIDEBAR_BEHAVIOR_CONFIG_KEY,
35
+ 'expanded'
36
+ );
37
+ const updateConfig = useUpdateUserConfig();
38
+ const hasAppliedRemote = useRef(false);
39
+ const persistedUserChangeVersion = useRef(0);
40
+ const handledLocalOverrideVersion = useRef(0);
41
+
42
+ useEffect(() => {
43
+ if (!remoteLoaded || !isValidBehavior(remoteBehavior)) return;
44
+
45
+ onRemoteBehaviorAvailable(remoteBehavior);
46
+ }, [onRemoteBehaviorAvailable, remoteBehavior, remoteLoaded]);
47
+
48
+ useEffect(() => {
49
+ if (
50
+ !remoteLoaded ||
51
+ localOverride ||
52
+ hasAppliedRemote.current ||
53
+ userChangeVersion > 0 ||
54
+ !isValidBehavior(remoteBehavior)
55
+ ) {
56
+ return;
57
+ }
58
+
59
+ hasAppliedRemote.current = true;
60
+
61
+ if (remoteBehavior !== behavior) {
62
+ onApplyRemoteBehavior(remoteBehavior);
63
+ }
64
+ }, [
65
+ behavior,
66
+ localOverride,
67
+ onApplyRemoteBehavior,
68
+ remoteBehavior,
69
+ remoteLoaded,
70
+ userChangeVersion,
71
+ ]);
72
+
73
+ useEffect(() => {
74
+ if (
75
+ !remoteLoaded ||
76
+ localOverride ||
77
+ userChangeVersion === 0 ||
78
+ persistedUserChangeVersion.current === userChangeVersion
79
+ ) {
80
+ return;
81
+ }
82
+
83
+ persistedUserChangeVersion.current = userChangeVersion;
84
+ updateConfig.mutate({
85
+ configId: SIDEBAR_BEHAVIOR_CONFIG_KEY,
86
+ value: behavior,
87
+ });
88
+ }, [behavior, localOverride, remoteLoaded, updateConfig, userChangeVersion]);
89
+
90
+ useEffect(() => {
91
+ if (
92
+ !remoteLoaded ||
93
+ localOverrideVersion === 0 ||
94
+ handledLocalOverrideVersion.current === localOverrideVersion
95
+ ) {
96
+ return;
97
+ }
98
+
99
+ handledLocalOverrideVersion.current = localOverrideVersion;
100
+
101
+ if (localOverride) {
102
+ updateConfig.mutate({
103
+ configId: SIDEBAR_BEHAVIOR_CONFIG_KEY,
104
+ value: behavior,
105
+ });
106
+ return;
107
+ }
108
+
109
+ if (isValidBehavior(remoteBehavior)) {
110
+ onApplyRemoteBehavior(remoteBehavior);
111
+ }
112
+ }, [
113
+ behavior,
114
+ localOverride,
115
+ localOverrideVersion,
116
+ onApplyRemoteBehavior,
117
+ remoteBehavior,
118
+ remoteLoaded,
119
+ updateConfig,
120
+ ]);
121
+
122
+ return null;
123
+ }
@@ -0,0 +1,6 @@
1
+ /** Absolute URL to the Tuturuuu logo hosted on the production domain. */
2
+ export const TUTURUUU_REMOTE_LOGO_URL =
3
+ 'https://tuturuuu.com/media/logos/transparent.png';
4
+
5
+ export const TUTURUUU_LOCAL_LOGO_URL = '/media/logos/transparent.png';
6
+ export const TUTURUUU_LOGO_URL = TUTURUUU_REMOTE_LOGO_URL;
@@ -1,16 +1,34 @@
1
- import Image from 'next/image';
1
+ import Image, { type ImageProps } from 'next/image';
2
+ import { TUTURUUU_LOGO_URL } from './tuturuuu-logo-urls';
2
3
 
3
- /** Absolute URL to the Tuturuuu logo hosted on the production domain. */
4
- export const TUTURUUU_LOGO_URL =
5
- 'https://tuturuuu.com/media/logos/transparent.png';
4
+ export {
5
+ TUTURUUU_LOCAL_LOGO_URL,
6
+ TUTURUUU_LOGO_URL,
7
+ TUTURUUU_REMOTE_LOGO_URL,
8
+ } from './tuturuuu-logo-urls';
6
9
 
7
10
  /**
8
11
  * Convenience wrapper around `<Image>` pre-configured with the Tuturuuu logo.
9
- * Uses `unoptimized` so no per-app `remotePatterns` config is required.
12
+ * Remote logo usage stays unoptimized so no per-app `remotePatterns` config is required.
10
13
  */
11
14
  export function TuturuuLogo({
12
15
  alt = 'Tuturuuu Logo',
16
+ src = TUTURUUU_LOGO_URL,
17
+ unoptimized,
13
18
  ...props
14
- }: Omit<React.ComponentProps<typeof Image>, 'src' | 'alt'> & { alt?: string }) {
15
- return <Image src={TUTURUUU_LOGO_URL} alt={alt} unoptimized {...props} />;
19
+ }: Omit<ImageProps, 'alt' | 'src'> & {
20
+ alt?: string;
21
+ src?: ImageProps['src'];
22
+ }) {
23
+ const shouldSkipOptimization =
24
+ unoptimized ?? (typeof src === 'string' && /^https?:\/\//u.test(src));
25
+
26
+ return (
27
+ <Image
28
+ src={src}
29
+ alt={alt}
30
+ unoptimized={shouldSkipOptimization}
31
+ {...props}
32
+ />
33
+ );
16
34
  }
@@ -0,0 +1,20 @@
1
+ import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
2
+
3
+ export function mergeWorkspaceSelectWorkspaces(
4
+ workspaces: InternalApiWorkspaceSummary[] | undefined,
5
+ currentWorkspaceFallback: InternalApiWorkspaceSummary | null | undefined
6
+ ) {
7
+ const workspaceList = workspaces ?? [];
8
+
9
+ if (!currentWorkspaceFallback) return workspaceList;
10
+
11
+ if (
12
+ workspaceList.some(
13
+ (workspace) => workspace.id === currentWorkspaceFallback.id
14
+ )
15
+ ) {
16
+ return workspaceList;
17
+ }
18
+
19
+ return [...workspaceList, currentWorkspaceFallback];
20
+ }
@@ -10,7 +10,10 @@ import {
10
10
  Star,
11
11
  } from '@tuturuuu/icons';
12
12
  import { updateCurrentUserDefaultWorkspace } from '@tuturuuu/internal-api/users';
13
- import { acceptWorkspaceInvite } from '@tuturuuu/internal-api/workspaces';
13
+ import {
14
+ acceptWorkspaceInvite,
15
+ getWorkspace,
16
+ } from '@tuturuuu/internal-api/workspaces';
14
17
  import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
15
18
  import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
16
19
  import {
@@ -63,6 +66,7 @@ import {
63
66
  import { Input } from '../input';
64
67
  import { Popover, PopoverContent, PopoverTrigger } from '../popover';
65
68
  import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
69
+ import { mergeWorkspaceSelectWorkspaces } from './workspace-select-helpers';
66
70
 
67
71
  const FormSchema = z.object({
68
72
  name: z.string().min(1).max(100),
@@ -161,17 +165,34 @@ export function WorkspaceSelect({
161
165
  const pathname = usePathname();
162
166
  const queryClient = useQueryClient();
163
167
 
164
- const { data: workspaces } = useQuery({
168
+ const resolvedWorkspaceId =
169
+ wsId && wsId !== PERSONAL_WORKSPACE_SLUG
170
+ ? resolveWorkspaceId(wsId)
171
+ : undefined;
172
+ const { data: listedWorkspaces } = useQuery({
165
173
  queryKey: ['workspaces'],
166
174
  queryFn: fetchWorkspaces,
167
175
  enabled: !!wsId,
168
176
  });
177
+ const hasListedCurrentWorkspace = Boolean(
178
+ resolvedWorkspaceId &&
179
+ listedWorkspaces?.some(
180
+ (workspace) => workspace.id === resolvedWorkspaceId
181
+ )
182
+ );
183
+ const { data: currentWorkspaceFallback } = useQuery({
184
+ queryKey: ['workspace-select-current-workspace', resolvedWorkspaceId],
185
+ queryFn: async () =>
186
+ (await getWorkspace(resolvedWorkspaceId!)) as InternalApiWorkspaceSummary,
187
+ enabled: Boolean(resolvedWorkspaceId && !hasListedCurrentWorkspace),
188
+ retry: 1,
189
+ });
190
+ const workspaces = mergeWorkspaceSelectWorkspaces(
191
+ listedWorkspaces,
192
+ currentWorkspaceFallback
193
+ );
169
194
  const { data: currentUser } = useWorkspaceUser();
170
195
 
171
- const resolvedWorkspaceId =
172
- wsId && wsId !== PERSONAL_WORKSPACE_SLUG
173
- ? resolveWorkspaceId(wsId)
174
- : undefined;
175
196
  const defaultWorkspaceId = currentUser?.default_workspace_id || null;
176
197
 
177
198
  const form = useForm({
@@ -435,9 +456,12 @@ export function WorkspaceSelect({
435
456
  const workspace =
436
457
  wsId === PERSONAL_WORKSPACE_SLUG
437
458
  ? personalWorkspace
438
- : workspaces?.find((ws) => ws.id === resolvedWorkspaceId);
459
+ : (workspaces.find((ws) => ws.id === resolvedWorkspaceId) ??
460
+ guestWorkspaces.find((ws) => ws.id === resolvedWorkspaceId));
439
461
  if (!wsId) return <div />;
440
462
 
463
+ const hasSelectableWorkspaces = workspaces.length > 0;
464
+
441
465
  async function onJoinByHandleSubmit(
442
466
  formData: z.infer<typeof JoinWorkspaceByHandleFormSchema>
443
467
  ) {
@@ -540,10 +564,7 @@ export function WorkspaceSelect({
540
564
  }}
541
565
  >
542
566
  <Popover open={open} onOpenChange={setOpen}>
543
- <PopoverTrigger
544
- asChild
545
- disabled={!workspaces || workspaces.length === 0}
546
- >
567
+ <PopoverTrigger asChild disabled={!hasSelectableWorkspaces}>
547
568
  <Button
548
569
  size="xs"
549
570
  variant="outline"
@@ -553,7 +574,7 @@ export function WorkspaceSelect({
553
574
  hideLeading ? 'justify-center p-0' : 'justify-start',
554
575
  'w-full whitespace-normal text-start'
555
576
  )}
556
- disabled={!workspaces || workspaces.length === 0}
577
+ disabled={!hasSelectableWorkspaces}
557
578
  >
558
579
  <WorkspaceIcon
559
580
  fallbackLogoUrl={fallbackLogoUrl}
@@ -45,7 +45,10 @@ export function InvoiceCheckoutSummary({
45
45
  <span className="text-muted-foreground">
46
46
  {t('ws-invoices.subtotal')}
47
47
  </span>
48
- <FinanceDisplayAmount value={formatCurrency(subtotal, currency)} />
48
+ <FinanceDisplayAmount
49
+ alwaysShow
50
+ value={formatCurrency(subtotal, currency)}
51
+ />
49
52
  </div>
50
53
 
51
54
  {discountAmount !== undefined && discountLabel && (
@@ -54,6 +57,7 @@ export function InvoiceCheckoutSummary({
54
57
  {t('ws-invoices.discount')} ({discountLabel})
55
58
  </span>
56
59
  <FinanceDisplayAmount
60
+ alwaysShow
57
61
  className={discountClassName}
58
62
  value={`-${formatCurrency(discountAmount, currency)}`}
59
63
  />
@@ -65,6 +69,7 @@ export function InvoiceCheckoutSummary({
65
69
  <div className="flex justify-between font-semibold">
66
70
  <span>{t('ws-invoices.total')}</span>
67
71
  <FinanceDisplayAmount
72
+ alwaysShow
68
73
  value={formatCurrency(roundedTotal, currency)}
69
74
  />
70
75
  </div>
@@ -73,6 +78,7 @@ export function InvoiceCheckoutSummary({
73
78
  <div className="flex justify-between text-muted-foreground text-sm">
74
79
  <span>{t('ws-invoices.adjustment')}</span>
75
80
  <FinanceDisplayAmount
81
+ alwaysShow
76
82
  value={`${roundedTotal > totalBeforeRounding ? '+' : ''}${formatCurrency(
77
83
  roundedTotal - totalBeforeRounding,
78
84
  currency
@@ -51,6 +51,7 @@ interface InvoicePaymentSettingsProps {
51
51
  categoryLabelClassName?: string;
52
52
  currency?: string;
53
53
  walletDisabled?: boolean;
54
+ walletPermissionWarning?: ReactNode;
54
55
  }
55
56
 
56
57
  export function InvoicePaymentSettings({
@@ -76,6 +77,7 @@ export function InvoicePaymentSettings({
76
77
  categoryLabelClassName = 'text-dynamic-red',
77
78
  currency = 'USD',
78
79
  walletDisabled = false,
80
+ walletPermissionWarning,
79
81
  }: InvoicePaymentSettingsProps) {
80
82
  const t = useTranslations();
81
83
  const shouldShowPromotion = showPromotion && promotionsAllowed;
@@ -168,6 +170,7 @@ export function InvoicePaymentSettings({
168
170
  ))}
169
171
  </SelectContent>
170
172
  </Select>
173
+ {walletDisabled && walletPermissionWarning}
171
174
  </div>
172
175
 
173
176
  <div className="space-y-2">
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import { AlertTriangle } from '@tuturuuu/icons';
4
+ import { Button } from '@tuturuuu/ui/button';
5
+ import { useTranslations } from 'next-intl';
6
+ import {
7
+ type FinancePermissionRequestUser,
8
+ FinancePermissionWarningDialog,
9
+ } from '../../shared/finance-permission-warning-dialog';
10
+
11
+ interface InvoiceProductsPermissionWarningProps {
12
+ missingPermissions: string[];
13
+ user?: FinancePermissionRequestUser | null;
14
+ }
15
+
16
+ export function InvoiceProductsPermissionWarning({
17
+ missingPermissions,
18
+ user,
19
+ }: InvoiceProductsPermissionWarningProps) {
20
+ const t = useTranslations();
21
+ const uniqueMissingPermissions = [...new Set(missingPermissions)];
22
+
23
+ if (uniqueMissingPermissions.length === 0) return null;
24
+
25
+ return (
26
+ <div className="rounded-lg border border-dynamic-orange/30 bg-dynamic-orange/5 p-3">
27
+ <div className="flex items-start gap-3">
28
+ <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-dynamic-orange" />
29
+ <div className="space-y-3">
30
+ <div className="space-y-1">
31
+ <p className="font-medium text-sm">
32
+ {t('finance-permission-warning.summary')}
33
+ </p>
34
+ <p className="text-muted-foreground text-sm">
35
+ {t('finance-permission-warning.description')}
36
+ </p>
37
+ </div>
38
+ <FinancePermissionWarningDialog
39
+ missingPermissions={uniqueMissingPermissions}
40
+ user={user}
41
+ trigger={
42
+ <Button type="button" variant="outline" size="sm">
43
+ {t('finance-permission-warning.open_request')}
44
+ </Button>
45
+ }
46
+ />
47
+ </div>
48
+ </div>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ export function isPermissionRequestError(error: unknown) {
54
+ if (!error || typeof error !== 'object') return false;
55
+
56
+ const status = 'status' in error ? Number(error.status) : 0;
57
+ return status === 401 || status === 403;
58
+ }
@@ -26,7 +26,8 @@ import { useState } from 'react';
26
26
  import {
27
27
  type GroupPaymentStatus,
28
28
  getGroupPaymentStatus,
29
- getMonthStartDate,
29
+ getSubscriptionCoverageInvoiceForGroup,
30
+ isSubscriptionMonthPaidForGroup,
30
31
  parseLocalCalendarDate,
31
32
  } from '../utils';
32
33
 
@@ -47,18 +48,6 @@ function hasSchedule(group: UserGroupItem['workspace_user_groups']): boolean {
47
48
  return Array.isArray(sessions) && sessions.length > 0;
48
49
  }
49
50
 
50
- function isMonthPaidForGroup(
51
- groupId: string,
52
- selectedMonth: string,
53
- latestInvoices: LatestInvoice[]
54
- ): boolean {
55
- const inv = latestInvoices.find((i) => i.group_id === groupId);
56
- if (!inv?.valid_until) return false;
57
- const monthStart = getMonthStartDate(selectedMonth);
58
- const validUntilStart = getMonthStartDate(inv.valid_until);
59
- return monthStart < validUntilStart;
60
- }
61
-
62
51
  interface SubscriptionGroupSelectorProps {
63
52
  userGroups: UserGroupItem[];
64
53
  userGroupsLoading: boolean;
@@ -267,10 +256,11 @@ export function SubscriptionGroupSelector({
267
256
  const group = groupItem.workspace_user_groups;
268
257
  if (!group) return null;
269
258
  const isSelected = selectedGroupIds.includes(group.id);
270
- const latestInvoice = latestSubscriptionInvoices.find(
271
- (inv) => inv.group_id === group.id
259
+ const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
260
+ latestSubscriptionInvoices,
261
+ group.id
272
262
  );
273
- const isMonthPaid = isMonthPaidForGroup(
263
+ const isMonthPaid = isSubscriptionMonthPaidForGroup(
274
264
  group.id,
275
265
  selectedMonth,
276
266
  latestSubscriptionInvoices
@@ -312,10 +302,12 @@ export function SubscriptionGroupSelector({
312
302
  const group = groupItem.workspace_user_groups;
313
303
  if (!group) return null;
314
304
  const isSelected = selectedGroupIds.includes(group.id);
315
- const latestInvoice = latestSubscriptionInvoices.find(
316
- (inv) => inv.group_id === group.id
317
- );
318
- const isMonthPaid = isMonthPaidForGroup(
305
+ const latestInvoice =
306
+ getSubscriptionCoverageInvoiceForGroup(
307
+ latestSubscriptionInvoices,
308
+ group.id
309
+ );
310
+ const isMonthPaid = isSubscriptionMonthPaidForGroup(
319
311
  group.id,
320
312
  selectedMonth,
321
313
  latestSubscriptionInvoices
@@ -18,10 +18,11 @@ import type { UserGroup } from '../utils';
18
18
  import {
19
19
  getEffectiveAttendanceDays,
20
20
  getEffectiveDays,
21
- getMonthStartDate,
22
21
  getSessionsForMonth,
23
22
  getSessionsUntilMonth,
23
+ getSubscriptionCoverageInvoiceForGroup,
24
24
  getTotalSessionsForGroups,
25
+ isSubscriptionMonthPaidForGroup,
25
26
  parseLocalCalendarDate,
26
27
  } from '../utils';
27
28
 
@@ -174,19 +175,19 @@ const computeGroupAttendanceDaysMap = (
174
175
  );
175
176
  if (!group) continue;
176
177
 
177
- const latestInvoice = latestSubscriptionInvoices.find(
178
- (inv) => inv.group_id === groupId
178
+ const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
179
+ latestSubscriptionInvoices,
180
+ groupId
179
181
  );
180
182
  const validUntil = latestInvoice?.valid_until
181
183
  ? parseLocalCalendarDate(latestInvoice.valid_until)
182
184
  : null;
183
185
 
184
- const isGroupPaid = (() => {
185
- if (!validUntil) return false;
186
- const selectedMonthStart = getMonthStartDate(selectedMonth);
187
- const validUntilMonthStart = getMonthStartDate(validUntil);
188
- return selectedMonthStart < validUntilMonthStart;
189
- })();
186
+ const isGroupPaid = isSubscriptionMonthPaidForGroup(
187
+ groupId,
188
+ selectedMonth,
189
+ latestSubscriptionInvoices
190
+ );
190
191
 
191
192
  const sessionsArray = group.workspace_user_groups?.sessions || [];
192
193
  const groupSessions = isGroupPaid
@@ -5,6 +5,7 @@ import type { UserGroup } from '../utils';
5
5
  import {
6
6
  formatMonthLabel,
7
7
  getAttendanceStats,
8
+ getSubscriptionCoverageInvoiceForGroup,
8
9
  getTotalSessionsForGroups,
9
10
  parseLocalCalendarDate,
10
11
  } from '../utils';
@@ -72,8 +73,9 @@ export function useSubscriptionInvoiceContent({
72
73
  if (!group) return;
73
74
 
74
75
  const groupName = group.workspace_user_groups?.name || 'Unknown Group';
75
- const latestInvoice = latestSubscriptionInvoices.find(
76
- (inv) => inv.group_id === groupId
76
+ const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
77
+ latestSubscriptionInvoices,
78
+ groupId
77
79
  );
78
80
 
79
81
  const startMonth = latestInvoice?.valid_until
@@ -126,9 +128,12 @@ export function useSubscriptionInvoiceContent({
126
128
  let autoNotes: string | null = null;
127
129
  if (selectedGroupIds.length > 0 && userAttendance.length > 0) {
128
130
  const filteredAttendance = userAttendance.filter((a) => {
129
- const latestInvoice = latestSubscriptionInvoices.find(
130
- (inv) => inv.group_id === a.group_id
131
- );
131
+ const latestInvoice = a.group_id
132
+ ? getSubscriptionCoverageInvoiceForGroup(
133
+ latestSubscriptionInvoices,
134
+ a.group_id
135
+ )
136
+ : undefined;
132
137
  if (!latestInvoice?.valid_until) return true;
133
138
  const validUntil = parseLocalCalendarDate(latestInvoice.valid_until);
134
139
  const attendanceDate = parseLocalCalendarDate(a.date);