@tuturuuu/ui 0.2.0 → 0.3.1

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 (116) hide show
  1. package/CHANGELOG.md +53 -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/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
  12. package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
  13. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
  14. package/src/components/ui/custom/common-footer.tsx +16 -1
  15. package/src/components/ui/custom/production-indicator.tsx +1 -1
  16. package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
  17. package/src/components/ui/custom/settings/task-settings.tsx +18 -0
  18. package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
  19. package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
  20. package/src/components/ui/custom/sidebar-context.tsx +61 -61
  21. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
  22. package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
  23. package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
  24. package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
  25. package/src/components/ui/custom/workspace-select.tsx +33 -12
  26. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
  27. package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
  28. package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
  29. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
  30. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
  31. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
  32. package/src/components/ui/finance/invoices/hooks.ts +75 -20
  33. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
  34. package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
  35. package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
  36. package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
  37. package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
  38. package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
  39. package/src/components/ui/finance/invoices/utils.test.ts +50 -0
  40. package/src/components/ui/finance/invoices/utils.ts +75 -17
  41. package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
  42. package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
  43. package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
  44. package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
  45. package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
  46. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  47. package/src/components/ui/finance/transactions/form.test.tsx +43 -0
  48. package/src/components/ui/finance/transactions/form.tsx +60 -0
  49. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
  50. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
  51. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
  52. package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
  53. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  54. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
  55. package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
  56. package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
  57. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
  58. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
  59. package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
  60. package/src/components/ui/legacy/meet/page.test.ts +180 -0
  61. package/src/components/ui/legacy/meet/page.tsx +87 -39
  62. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
  63. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
  64. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
  65. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
  66. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
  67. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
  68. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
  69. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
  70. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
  71. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
  72. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
  73. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
  74. package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
  75. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
  76. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
  77. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
  78. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
  79. package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
  80. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
  81. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
  82. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
  83. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
  84. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
  85. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
  86. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
  87. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
  88. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
  89. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
  90. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
  91. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
  92. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
  93. package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
  94. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
  95. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
  96. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
  97. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
  98. package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
  99. package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
  100. package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
  101. package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
  102. package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
  103. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
  104. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
  105. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
  107. package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
  108. package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
  109. package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
  110. package/src/hooks/use-calendar-sync.tsx +22 -277
  111. package/src/hooks/use-calendar.tsx +95 -525
  112. package/src/hooks/use-task-actions.ts +43 -117
  113. package/src/hooks/use-user-config.ts +1 -1
  114. package/src/hooks/use-workspace-config.ts +6 -2
  115. package/src/hooks/use-workspace-presence.ts +1 -1
  116. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
@@ -13,12 +13,20 @@ import { useQueryState } from 'nuqs';
13
13
  import { useEffect, useMemo, useState } from 'react';
14
14
  import { useDebounce } from '../../../../hooks/use-debounce';
15
15
  import { useFinanceHref } from '../finance-route-context';
16
+ import {
17
+ type FinancePermissionRequestUser,
18
+ FinancePermissionWarningDialog,
19
+ } from '../shared/finance-permission-warning-dialog';
16
20
  import { useFinanceConfidentialVisibility } from '../shared/use-finance-confidential-visibility';
17
21
  import { InvoiceBlockedState } from './components/invoice-blocked-state';
18
22
  import { InvoiceCheckoutSummary } from './components/invoice-checkout-summary';
19
23
  import { InvoiceContentEditor } from './components/invoice-content-editor';
20
24
  import { InvoiceCustomerSelectCard } from './components/invoice-customer-select-card';
21
25
  import { InvoicePaymentSettings } from './components/invoice-payment-settings';
26
+ import {
27
+ InvoiceProductsPermissionWarning,
28
+ isPermissionRequestError,
29
+ } from './components/invoice-products-permission-warning';
22
30
  import { InvoiceUserHistoryAccordion } from './components/invoice-user-history-accordion';
23
31
  import { CreatePromotionDialog } from './create-promotion-dialog';
24
32
  import type { AvailablePromotion } from './hooks';
@@ -51,6 +59,9 @@ interface Props {
51
59
  defaultCurrency?: 'VND' | 'USD';
52
60
  canChangeFinanceWallets?: boolean;
53
61
  canSetFinanceWalletsOnCreate?: boolean;
62
+ canReadInvoiceProducts?: boolean;
63
+ canReadInvoiceProductStock?: boolean;
64
+ permissionRequestUser?: FinancePermissionRequestUser | null;
54
65
  }
55
66
 
56
67
  export function StandardInvoice({
@@ -62,6 +73,9 @@ export function StandardInvoice({
62
73
  defaultCurrency = 'USD',
63
74
  canChangeFinanceWallets = true,
64
75
  canSetFinanceWalletsOnCreate = true,
76
+ canReadInvoiceProducts = true,
77
+ canReadInvoiceProductStock = true,
78
+ permissionRequestUser,
65
79
  }: Props) {
66
80
  const t = useTranslations();
67
81
  const router = useRouter();
@@ -77,6 +91,10 @@ export function StandardInvoice({
77
91
  const [selectedUserId, setSelectedUserId] = useQueryState('user_id', {
78
92
  defaultValue: '',
79
93
  });
94
+ const [selectedProducts, setSelectedProducts] = useState<
95
+ SelectedProductItem[]
96
+ >([]);
97
+ const hasSelectedProducts = selectedProducts.length > 0;
80
98
 
81
99
  // Data queries
82
100
  const {
@@ -89,24 +107,42 @@ export function StandardInvoice({
89
107
  isFetching: isFetchingCustomers,
90
108
  isFetchingNextPage: isFetchingMoreCustomers,
91
109
  } = useInvoiceCustomerSearch(wsId, debouncedCustomerSearch, selectedUserId);
92
- const { data: products = [], isLoading: productsLoading } = useProducts(wsId);
93
- const { data: availablePromotions = [], isLoading: promotionsLoading } =
94
- useAvailablePromotions(wsId, selectedUserId);
95
- const { data: promotionsAllowed = true } = useInvoicePromotionConfig(wsId);
110
+ const {
111
+ data: products = [],
112
+ error: productsError,
113
+ isLoading: productsLoading,
114
+ } = useProducts(wsId, { enabled: canReadInvoiceProducts });
115
+ const { data: availablePromotions = [] } = useAvailablePromotions(
116
+ wsId,
117
+ selectedUserId,
118
+ {
119
+ enabled: hasSelectedProducts,
120
+ }
121
+ );
122
+ const { data: promotionsAllowed = true } = useInvoicePromotionConfig(wsId, {
123
+ enabled: !!selectedUserId && hasSelectedProducts,
124
+ });
96
125
  const { data: linkedPromotions = [] } = useUserLinkedPromotions(
97
126
  wsId,
98
- selectedUserId
127
+ selectedUserId,
128
+ { enabled: hasSelectedProducts }
99
129
  );
100
130
  const { data: referralDiscountRows = [] } = useUserReferralDiscounts(
101
131
  wsId,
102
- selectedUserId
132
+ selectedUserId,
133
+ { enabled: hasSelectedProducts }
103
134
  );
104
- const { data: wallets = [], isLoading: walletsLoading } = useWallets(wsId);
105
- const { data: categories = [], isLoading: categoriesLoading } =
106
- useCategories(wsId);
135
+ const { data: wallets = [] } = useWallets(wsId, {
136
+ enabled: hasSelectedProducts,
137
+ });
138
+ const { data: categories = [] } = useCategories(wsId, {
139
+ enabled: hasSelectedProducts,
140
+ });
107
141
 
108
142
  // Blocked groups check
109
- const { data: blockedGroupIds = [] } = useInvoiceBlockedGroups(wsId);
143
+ const { data: blockedGroupIds = [] } = useInvoiceBlockedGroups(wsId, {
144
+ enabled: !!selectedUserId,
145
+ });
110
146
  const { data: userGroups = [], isLoading: userGroupsLoading } = useUserGroups(
111
147
  wsId,
112
148
  selectedUserId
@@ -128,9 +164,6 @@ export function StandardInvoice({
128
164
  }, [selectedUserId, blockedGroupIds, userGroups]);
129
165
 
130
166
  // State management
131
- const [selectedProducts, setSelectedProducts] = useState<
132
- SelectedProductItem[]
133
- >([]);
134
167
  const [selectedWalletId, setSelectedWalletId] = useState<string>(
135
168
  defaultWalletId || ''
136
169
  );
@@ -139,6 +172,18 @@ export function StandardInvoice({
139
172
  canChangeFinanceWallets,
140
173
  canSetFinanceWalletsOnCreate,
141
174
  });
175
+ const walletPermissionWarning =
176
+ isWalletSelectionLocked && permissionRequestUser ? (
177
+ <FinancePermissionWarningDialog
178
+ missingPermissions={['set_finance_wallets_on_create']}
179
+ user={permissionRequestUser}
180
+ trigger={
181
+ <Button type="button" variant="outline" size="sm">
182
+ {t('finance-permission-warning.open_request')}
183
+ </Button>
184
+ }
185
+ />
186
+ ) : null;
142
187
 
143
188
  useEffect(() => {
144
189
  if (defaultWalletId) {
@@ -160,13 +205,23 @@ export function StandardInvoice({
160
205
  (promotion: AvailablePromotion) =>
161
206
  promotion.id === selectedPromotionId
162
207
  );
163
- const isLoadingData =
164
- usersLoading ||
165
- productsLoading ||
166
- promotionsLoading ||
167
- walletsLoading ||
168
- categoriesLoading ||
169
- userGroupsLoading;
208
+ const isLoadingData = usersLoading || productsLoading || userGroupsLoading;
209
+
210
+ const invoiceProductMissingPermissions = useMemo(() => {
211
+ const missingPermissions = new Set<string>();
212
+
213
+ if (!canReadInvoiceProducts || isPermissionRequestError(productsError)) {
214
+ missingPermissions.add('create_inventory_sales');
215
+ missingPermissions.add('view_inventory_catalog');
216
+ }
217
+
218
+ if (!canReadInvoiceProductStock) {
219
+ missingPermissions.add('create_inventory_sales');
220
+ missingPermissions.add('view_inventory_stock');
221
+ }
222
+
223
+ return [...missingPermissions];
224
+ }, [canReadInvoiceProductStock, canReadInvoiceProducts, productsError]);
170
225
 
171
226
  const referralDiscountMap = useMemo(() => {
172
227
  const map = new Map<string, number>();
@@ -439,12 +494,18 @@ export function StandardInvoice({
439
494
  </InvoiceCustomerSelectCard>
440
495
 
441
496
  {!isBlocked && (
442
- <ProductSelection
443
- products={products}
444
- selectedProducts={selectedProducts}
445
- onSelectedProductsChange={setSelectedProducts}
446
- currency={defaultCurrency}
447
- />
497
+ <div className="space-y-3">
498
+ <ProductSelection
499
+ products={products}
500
+ selectedProducts={selectedProducts}
501
+ onSelectedProductsChange={setSelectedProducts}
502
+ currency={defaultCurrency}
503
+ />
504
+ <InvoiceProductsPermissionWarning
505
+ missingPermissions={invoiceProductMissingPermissions}
506
+ user={permissionRequestUser}
507
+ />
508
+ </div>
448
509
  )}
449
510
  </div>
450
511
  <div className="space-y-6">
@@ -472,6 +533,7 @@ export function StandardInvoice({
472
533
  onWalletChange={setSelectedWalletId}
473
534
  onCategoryChange={setSelectedCategoryId}
474
535
  walletDisabled={isWalletSelectionLocked}
536
+ walletPermissionWarning={walletPermissionWarning}
475
537
  showPromotion
476
538
  currency={defaultCurrency}
477
539
  promotionsAllowed={promotionsAllowed}
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useQueryClient } from '@tanstack/react-query';
4
- import { Calculator, Loader2, Plus } from '@tuturuuu/icons';
4
+ import { AlertTriangle, Calculator, Loader2, Plus } from '@tuturuuu/icons';
5
5
  import { Button } from '@tuturuuu/ui/button';
6
6
  import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card';
7
7
  import { Separator } from '@tuturuuu/ui/separator';
@@ -20,12 +20,20 @@ import {
20
20
  } from 'react';
21
21
  import { useDebounce } from '../../../../hooks/use-debounce';
22
22
  import { useFinanceHref } from '../finance-route-context';
23
+ import {
24
+ type FinancePermissionRequestUser,
25
+ FinancePermissionWarningDialog,
26
+ } from '../shared/finance-permission-warning-dialog';
23
27
  import { useFinanceConfidentialVisibility } from '../shared/use-finance-confidential-visibility';
24
28
  import { InvoiceBlockedState } from './components/invoice-blocked-state';
25
29
  import { InvoiceCheckoutSummary } from './components/invoice-checkout-summary';
26
30
  import { InvoiceContentEditor } from './components/invoice-content-editor';
27
31
  import { InvoiceCustomerSelectCard } from './components/invoice-customer-select-card';
28
32
  import { InvoicePaymentSettings } from './components/invoice-payment-settings';
33
+ import {
34
+ InvoiceProductsPermissionWarning,
35
+ isPermissionRequestError,
36
+ } from './components/invoice-products-permission-warning';
29
37
  import { SubscriptionAttendanceSummary } from './components/subscription-attendance-summary';
30
38
  import { SubscriptionGroupSelector } from './components/subscription-group-selector';
31
39
  import { CreatePromotionDialog } from './create-promotion-dialog';
@@ -62,6 +70,7 @@ import {
62
70
  getLinkedFinanceCategorySelection,
63
71
  getMonthStartDate,
64
72
  getSubscriptionAttendanceDisplayData,
73
+ isSubscriptionMonthPaidForGroup,
65
74
  } from './utils';
66
75
 
67
76
  interface Props {
@@ -75,6 +84,10 @@ interface Props {
75
84
  defaultCurrency?: 'VND' | 'USD';
76
85
  canChangeFinanceWallets?: boolean;
77
86
  canSetFinanceWalletsOnCreate?: boolean;
87
+ canReadInvoiceProducts?: boolean;
88
+ canReadInvoiceProductStock?: boolean;
89
+ canReadGroupLinkedProducts?: boolean;
90
+ permissionRequestUser?: FinancePermissionRequestUser | null;
78
91
  }
79
92
 
80
93
  export function SubscriptionInvoice({
@@ -88,6 +101,10 @@ export function SubscriptionInvoice({
88
101
  defaultCurrency = 'USD',
89
102
  canChangeFinanceWallets = true,
90
103
  canSetFinanceWalletsOnCreate = true,
104
+ canReadInvoiceProducts = true,
105
+ canReadInvoiceProductStock = true,
106
+ canReadGroupLinkedProducts = true,
107
+ permissionRequestUser,
91
108
  }: Props) {
92
109
  const t = useTranslations();
93
110
  const locale = useLocale();
@@ -115,6 +132,10 @@ export function SubscriptionInvoice({
115
132
 
116
133
  const [customerSearch, setCustomerSearch] = useState('');
117
134
  const [debouncedCustomerSearch] = useDebounce(customerSearch, 300);
135
+ const [subscriptionSelectedProducts, setSubscriptionSelectedProducts] =
136
+ useState<SelectedProductItem[]>([]);
137
+ const hasSelectedGroups = selectedGroupIds.length > 0;
138
+ const hasSelectedProducts = subscriptionSelectedProducts.length > 0;
118
139
 
119
140
  const updateSearchParam = useCallback(
120
141
  (key: string, value: string) => {
@@ -143,23 +164,41 @@ export function SubscriptionInvoice({
143
164
  isFetching: isFetchingCustomers,
144
165
  isFetchingNextPage: isFetchingMoreCustomers,
145
166
  } = useInvoiceCustomerSearch(wsId, debouncedCustomerSearch, selectedUserId);
146
- const { data: products = [], isLoading: productsLoading } = useProducts(wsId);
147
- const { data: availablePromotions = [], isLoading: promotionsLoading } =
148
- useAvailablePromotions(wsId, selectedUserId);
167
+ const {
168
+ data: products = [],
169
+ error: productsError,
170
+ isLoading: productsLoading,
171
+ } = useProducts(wsId, {
172
+ enabled: canReadInvoiceProducts && hasSelectedGroups,
173
+ });
174
+ const { data: availablePromotions = [] } = useAvailablePromotions(
175
+ wsId,
176
+ selectedUserId,
177
+ {
178
+ enabled: hasSelectedProducts,
179
+ }
180
+ );
149
181
  const { data: linkedPromotions = [] } = useUserLinkedPromotions(
150
182
  wsId,
151
- selectedUserId
183
+ selectedUserId,
184
+ { enabled: hasSelectedProducts }
152
185
  );
153
186
  const { data: referralDiscountRows = [] } = useUserReferralDiscounts(
154
187
  wsId,
155
- selectedUserId
188
+ selectedUserId,
189
+ { enabled: hasSelectedProducts }
156
190
  );
157
- const { data: wallets = [], isLoading: walletsLoading } = useWallets(wsId);
158
- const { data: categories = [], isLoading: categoriesLoading } =
159
- useCategories(wsId);
191
+ const { data: wallets = [] } = useWallets(wsId, {
192
+ enabled: hasSelectedProducts,
193
+ });
194
+ const { data: categories = [] } = useCategories(wsId, {
195
+ enabled: hasSelectedProducts,
196
+ });
160
197
 
161
198
  // Blocked groups check
162
- const { data: blockedGroupIds = [] } = useInvoiceBlockedGroups(wsId);
199
+ const { data: blockedGroupIds = [] } = useInvoiceBlockedGroups(wsId, {
200
+ enabled: hasSelectedGroups,
201
+ });
163
202
 
164
203
  const isBlocked = useMemo(
165
204
  () => selectedGroupIds.some((groupId) => blockedGroupIds.includes(groupId)),
@@ -175,6 +214,18 @@ export function SubscriptionInvoice({
175
214
  canChangeFinanceWallets,
176
215
  canSetFinanceWalletsOnCreate,
177
216
  });
217
+ const walletPermissionWarning =
218
+ isWalletSelectionLocked && permissionRequestUser ? (
219
+ <FinancePermissionWarningDialog
220
+ missingPermissions={['set_finance_wallets_on_create']}
221
+ user={permissionRequestUser}
222
+ trigger={
223
+ <Button type="button" variant="outline" size="sm">
224
+ {t('finance-permission-warning.open_request')}
225
+ </Button>
226
+ }
227
+ />
228
+ ) : null;
178
229
 
179
230
  useEffect(() => {
180
231
  if (defaultWalletId) {
@@ -202,19 +253,22 @@ export function SubscriptionInvoice({
202
253
  // Track previous user ID to detect user changes (skip initial mount for reset)
203
254
  const prevUserIdRef = useRef<string | null>(null);
204
255
 
205
- // Product selection state
206
- const [subscriptionSelectedProducts, setSubscriptionSelectedProducts] =
207
- useState<SelectedProductItem[]>([]);
208
-
209
256
  // Subscription-specific queries
210
257
  const { data: userGroups = [], isLoading: userGroupsLoading } = useUserGroups(
211
258
  wsId,
212
259
  selectedUserId
213
260
  );
214
- const { data: groupProducts = [], isLoading: groupProductsLoading } =
215
- useMultiGroupProducts(wsId, selectedGroupIds);
261
+ const {
262
+ data: groupProducts = [],
263
+ error: groupProductsError,
264
+ isLoading: groupProductsLoading,
265
+ } = useMultiGroupProducts(wsId, selectedGroupIds, {
266
+ enabled: canReadGroupLinkedProducts,
267
+ });
216
268
 
217
- const { data: useAttendanceBased = true } = useInvoiceAttendanceConfig(wsId);
269
+ const { data: useAttendanceBased = true } = useInvoiceAttendanceConfig(wsId, {
270
+ enabled: hasSelectedGroups,
271
+ });
218
272
 
219
273
  const availableMonthOptions = useMemo(
220
274
  () =>
@@ -286,15 +340,15 @@ export function SubscriptionInvoice({
286
340
 
287
341
  // A month is considered paid ONLY if ALL selected groups have paid for it.
288
342
  // If ANY selected group has not paid, we allow creating an invoice.
289
- return selectedGroupIds.every((groupId) => {
290
- const latestInvoice = latestSubscriptionInvoices.find(
291
- (inv) => inv.group_id === groupId
292
- );
293
- if (!latestInvoice?.valid_until) return false;
343
+ if (Number.isNaN(selectedMonthStart.getTime())) return false;
294
344
 
295
- const validUntilMonthStart = getMonthStartDate(latestInvoice.valid_until);
296
- return selectedMonthStart < validUntilMonthStart;
297
- });
345
+ return selectedGroupIds.every((groupId) =>
346
+ isSubscriptionMonthPaidForGroup(
347
+ groupId,
348
+ effectiveSelectedMonth,
349
+ latestSubscriptionInvoices
350
+ )
351
+ );
298
352
  }, [effectiveSelectedMonth, latestSubscriptionInvoices, selectedGroupIds]);
299
353
 
300
354
  const billableAttendance = useMemo(
@@ -386,12 +440,42 @@ export function SubscriptionInvoice({
386
440
  subscriptionInvoiceContextLoading ||
387
441
  groupProductsLoading;
388
442
 
389
- const isLoadingData =
390
- usersLoading ||
391
- productsLoading ||
392
- promotionsLoading ||
393
- walletsLoading ||
394
- categoriesLoading;
443
+ const isLoadingData = usersLoading || productsLoading;
444
+
445
+ const invoiceProductMissingPermissions = useMemo(() => {
446
+ const missingPermissions = new Set<string>();
447
+
448
+ if (!canReadInvoiceProducts || isPermissionRequestError(productsError)) {
449
+ missingPermissions.add('create_inventory_sales');
450
+ missingPermissions.add('view_inventory_catalog');
451
+ }
452
+
453
+ if (!canReadInvoiceProductStock) {
454
+ missingPermissions.add('create_inventory_sales');
455
+ missingPermissions.add('view_inventory_stock');
456
+ }
457
+
458
+ if (
459
+ !canReadGroupLinkedProducts ||
460
+ isPermissionRequestError(groupProductsError)
461
+ ) {
462
+ missingPermissions.add('create_invoices');
463
+ missingPermissions.add('view_user_groups');
464
+ }
465
+
466
+ if (isPermissionRequestError(subscriptionInvoiceContextError)) {
467
+ missingPermissions.add('create_invoices');
468
+ }
469
+
470
+ return [...missingPermissions];
471
+ }, [
472
+ canReadGroupLinkedProducts,
473
+ canReadInvoiceProductStock,
474
+ canReadInvoiceProducts,
475
+ groupProductsError,
476
+ productsError,
477
+ subscriptionInvoiceContextError,
478
+ ]);
395
479
 
396
480
  const referralDiscountMap = useMemo(() => {
397
481
  const map = new Map<string, number>();
@@ -820,22 +904,45 @@ export function SubscriptionInvoice({
820
904
  )}
821
905
 
822
906
  {selectedGroupIds.length > 0 && !isSelectedMonthPaid && !isBlocked && (
823
- <ProductSelection
824
- products={products}
825
- selectedProducts={subscriptionSelectedProducts}
826
- onSelectedProductsChange={setSubscriptionSelectedProducts}
827
- currency={defaultCurrency}
828
- groupLinkedProducts={(groupProducts || [])
829
- .map((item) => ({
830
- productId: item.workspace_products?.id,
831
- groupName:
832
- item.workspace_user_groups?.name || t('ws-invoices.no_name'),
833
- }))
834
- .filter(
835
- (item): item is { productId: string; groupName: string } =>
836
- !!item.productId
907
+ <>
908
+ <ProductSelection
909
+ products={products}
910
+ selectedProducts={subscriptionSelectedProducts}
911
+ onSelectedProductsChange={setSubscriptionSelectedProducts}
912
+ currency={defaultCurrency}
913
+ groupLinkedProducts={(groupProducts || [])
914
+ .map((item) => ({
915
+ productId: item.workspace_products?.id,
916
+ groupName:
917
+ item.workspace_user_groups?.name ||
918
+ t('ws-invoices.no_name'),
919
+ }))
920
+ .filter(
921
+ (item): item is { productId: string; groupName: string } =>
922
+ !!item.productId
923
+ )}
924
+ />
925
+ <InvoiceProductsPermissionWarning
926
+ missingPermissions={invoiceProductMissingPermissions}
927
+ user={permissionRequestUser}
928
+ />
929
+ {!isLoadingSubscriptionData &&
930
+ subscriptionSelectedProducts.length === 0 && (
931
+ <div className="rounded-lg border border-dynamic-yellow/30 bg-dynamic-yellow/10 p-3 text-dynamic-yellow text-sm">
932
+ <div className="flex items-start gap-2">
933
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
934
+ <div>
935
+ <p className="font-medium">
936
+ {t('ws-invoices.month_unpaid')}
937
+ </p>
938
+ <p className="text-muted-foreground">
939
+ {t('ws-invoices.no_products_to_invoice')}
940
+ </p>
941
+ </div>
942
+ </div>
943
+ </div>
837
944
  )}
838
- />
945
+ </>
839
946
  )}
840
947
 
841
948
  <InvoiceContentEditor
@@ -890,6 +997,7 @@ export function SubscriptionInvoice({
890
997
  onWalletChange={setSelectedWalletId}
891
998
  onCategoryChange={setSelectedCategoryId}
892
999
  walletDisabled={isWalletSelectionLocked}
1000
+ walletPermissionWarning={walletPermissionWarning}
893
1001
  showPromotion
894
1002
  currency={defaultCurrency}
895
1003
  promotionsAllowed={true}
@@ -4,6 +4,8 @@ import {
4
4
  getBillableSessionsForGroups,
5
5
  getLinkedFinanceCategorySelection,
6
6
  getSubscriptionAttendanceDisplayData,
7
+ getSubscriptionCoverageInvoiceForGroup,
8
+ isSubscriptionMonthPaidForGroup,
7
9
  type UserGroup,
8
10
  } from './utils';
9
11
 
@@ -135,6 +137,54 @@ describe('subscription invoice attendance display data', () => {
135
137
  });
136
138
  });
137
139
 
140
+ describe('subscription invoice coverage', () => {
141
+ it('treats valid_until as the exclusive first unpaid month', () => {
142
+ const latestInvoices = [{ group_id: groupId, valid_until: '2026-06-01' }];
143
+
144
+ expect(
145
+ isSubscriptionMonthPaidForGroup(groupId, '2026-05', latestInvoices)
146
+ ).toBe(true);
147
+ expect(
148
+ isSubscriptionMonthPaidForGroup(groupId, '2026-06', latestInvoices)
149
+ ).toBe(false);
150
+ });
151
+
152
+ it('treats null and invalid valid_until values as unpaid', () => {
153
+ expect(
154
+ isSubscriptionMonthPaidForGroup(groupId, '2026-05', [
155
+ { group_id: groupId, valid_until: null },
156
+ ])
157
+ ).toBe(false);
158
+ expect(
159
+ isSubscriptionMonthPaidForGroup(groupId, '2026-05', [
160
+ { group_id: groupId, valid_until: 'not-a-date' },
161
+ ])
162
+ ).toBe(false);
163
+ });
164
+
165
+ it('uses the furthest valid_until over the newest created_at', () => {
166
+ const latestInvoices = [
167
+ {
168
+ created_at: '2026-06-10T00:00:00.000Z',
169
+ group_id: groupId,
170
+ valid_until: '2026-06-01',
171
+ },
172
+ {
173
+ created_at: '2026-05-10T00:00:00.000Z',
174
+ group_id: groupId,
175
+ valid_until: '2026-07-01',
176
+ },
177
+ ];
178
+
179
+ expect(
180
+ getSubscriptionCoverageInvoiceForGroup(latestInvoices, groupId)
181
+ ).toEqual(latestInvoices[1]);
182
+ expect(
183
+ isSubscriptionMonthPaidForGroup(groupId, '2026-06', latestInvoices)
184
+ ).toBe(true);
185
+ });
186
+ });
187
+
138
188
  describe('linked finance category selection', () => {
139
189
  it('returns the single linked finance category across selected products', () => {
140
190
  expect(