@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
@@ -20,7 +20,8 @@ export type UserGroup = {
20
20
  workspace_user_groups: WorkspaceUserGroup | null;
21
21
  };
22
22
 
23
- type Invoice = {
23
+ export type SubscriptionCoverageInvoice = {
24
+ created_at?: string | null;
24
25
  group_id?: string;
25
26
  valid_until?: string | null;
26
27
  };
@@ -119,6 +120,67 @@ export const formatMonthLabel = (month: string, locale: string): string => {
119
120
  });
120
121
  };
121
122
 
123
+ const getComparableTimestamp = (value: string | null | undefined): number => {
124
+ if (!value) return 0;
125
+ const timestamp = new Date(value).getTime();
126
+ return Number.isNaN(timestamp) ? 0 : timestamp;
127
+ };
128
+
129
+ const getComparableValidUntilTimestamp = (
130
+ invoice: SubscriptionCoverageInvoice
131
+ ): number => {
132
+ const validUntil = parseLocalCalendarDate(invoice.valid_until);
133
+ return Number.isNaN(validUntil.getTime()) ? 0 : validUntil.getTime();
134
+ };
135
+
136
+ export const getSubscriptionCoverageInvoiceForGroup = (
137
+ latestInvoices: SubscriptionCoverageInvoice[],
138
+ groupId: string
139
+ ): SubscriptionCoverageInvoice | undefined =>
140
+ latestInvoices
141
+ .filter((invoice) => invoice.group_id === groupId)
142
+ .filter((invoice) => getComparableValidUntilTimestamp(invoice) > 0)
143
+ .sort((a, b) => {
144
+ const validUntilDiff =
145
+ getComparableValidUntilTimestamp(b) -
146
+ getComparableValidUntilTimestamp(a);
147
+ if (validUntilDiff !== 0) return validUntilDiff;
148
+
149
+ return (
150
+ getComparableTimestamp(b.created_at) -
151
+ getComparableTimestamp(a.created_at)
152
+ );
153
+ })[0];
154
+
155
+ export const isSubscriptionMonthCoveredByInvoice = (
156
+ selectedMonth: string,
157
+ invoice: SubscriptionCoverageInvoice | null | undefined
158
+ ): boolean => {
159
+ if (!invoice?.valid_until) return false;
160
+
161
+ const selectedMonthStart = getMonthStartDate(selectedMonth);
162
+ const validUntilMonthStart = getMonthStartDate(invoice.valid_until);
163
+
164
+ if (
165
+ Number.isNaN(selectedMonthStart.getTime()) ||
166
+ Number.isNaN(validUntilMonthStart.getTime())
167
+ ) {
168
+ return false;
169
+ }
170
+
171
+ return selectedMonthStart < validUntilMonthStart;
172
+ };
173
+
174
+ export const isSubscriptionMonthPaidForGroup = (
175
+ groupId: string,
176
+ selectedMonth: string,
177
+ latestInvoices: SubscriptionCoverageInvoice[]
178
+ ): boolean =>
179
+ isSubscriptionMonthCoveredByInvoice(
180
+ selectedMonth,
181
+ getSubscriptionCoverageInvoiceForGroup(latestInvoices, groupId)
182
+ );
183
+
122
184
  export const getAttendanceStats = (
123
185
  attendance: AttendanceRecord[]
124
186
  ): AttendanceStats => {
@@ -159,10 +221,13 @@ export const getEffectiveAttendanceDays = (
159
221
  };
160
222
 
161
223
  const getGroupValidUntilDate = (
162
- latestInvoices: Invoice[],
224
+ latestInvoices: SubscriptionCoverageInvoice[],
163
225
  groupId: string
164
226
  ): Date | null => {
165
- const latestInvoice = latestInvoices.find((inv) => inv.group_id === groupId);
227
+ const latestInvoice = getSubscriptionCoverageInvoiceForGroup(
228
+ latestInvoices,
229
+ groupId
230
+ );
166
231
  if (!latestInvoice?.valid_until) return null;
167
232
 
168
233
  const validUntil = parseLocalCalendarDate(latestInvoice.valid_until);
@@ -185,7 +250,7 @@ export const getBillableSessionsForGroups = (
185
250
  userGroups: UserGroup[],
186
251
  groupIds: string[],
187
252
  selectedMonth: string,
188
- latestInvoices: Invoice[] = []
253
+ latestInvoices: SubscriptionCoverageInvoice[] = []
189
254
  ): BillableSession[] => {
190
255
  if (!selectedMonth || groupIds.length === 0) return [];
191
256
 
@@ -220,7 +285,7 @@ export const getBillableAttendanceRecords = (
220
285
  attendance: AttendanceRecord[],
221
286
  groupIds: string[],
222
287
  selectedMonth: string,
223
- latestInvoices: Invoice[] = []
288
+ latestInvoices: SubscriptionCoverageInvoice[] = []
224
289
  ): AttendanceRecord[] => {
225
290
  if (!Array.isArray(attendance) || !selectedMonth || groupIds.length === 0) {
226
291
  return [];
@@ -384,7 +449,7 @@ export type AvailableMonthOption = {
384
449
  export const getAvailableMonths = (
385
450
  userGroups: UserGroup[],
386
451
  groupIds: string[],
387
- latestInvoices: { group_id?: string; valid_until?: string | null }[],
452
+ latestInvoices: SubscriptionCoverageInvoice[],
388
453
  locale: string,
389
454
  selectedMonthFallback: string | null = null
390
455
  ): AvailableMonthOption[] => {
@@ -411,17 +476,10 @@ export const getAvailableMonths = (
411
476
  while (currentDate <= normalizedLatestEnd) {
412
477
  const value = formatMonthValue(currentDate);
413
478
  const label = formatMonthLabel(value, locale);
414
- const itemMonthStart = new Date(currentDate);
415
- itemMonthStart.setDate(1);
416
479
 
417
- const isPaid = groupIds.every((groupId) => {
418
- const latestInvoice = latestInvoices.find(
419
- (inv) => inv.group_id === groupId
420
- );
421
- if (!latestInvoice?.valid_until) return false;
422
- const validUntilMonthStart = getMonthStartDate(latestInvoice.valid_until);
423
- return itemMonthStart < validUntilMonthStart;
424
- });
480
+ const isPaid = groupIds.every((groupId) =>
481
+ isSubscriptionMonthPaidForGroup(groupId, value, latestInvoices)
482
+ );
425
483
 
426
484
  months.push({ value, label, isPaid });
427
485
  currentDate.setMonth(currentDate.getMonth() + 1);
@@ -434,7 +492,7 @@ export const getTotalSessionsForGroups = (
434
492
  userGroups: UserGroup[],
435
493
  groupIds: string[],
436
494
  selectedMonth: string,
437
- latestInvoices: Invoice[] = []
495
+ latestInvoices: SubscriptionCoverageInvoice[] = []
438
496
  ): number => {
439
497
  return getBillableSessionsForGroups(
440
498
  userGroups,
@@ -8,18 +8,20 @@ import {
8
8
  interface FinanceDisplayAmountProps {
9
9
  value: string;
10
10
  className?: string;
11
+ alwaysShow?: boolean;
11
12
  }
12
13
 
13
14
  export function FinanceDisplayAmount({
14
15
  value,
15
16
  className,
17
+ alwaysShow = false,
16
18
  }: FinanceDisplayAmountProps) {
17
19
  const { isConfidential: areNumbersHidden } =
18
20
  useFinanceConfidentialVisibility();
19
21
 
20
22
  return (
21
23
  <span className={className}>
22
- {areNumbersHidden ? FINANCE_HIDDEN_AMOUNT : value}
24
+ {areNumbersHidden && !alwaysShow ? FINANCE_HIDDEN_AMOUNT : value}
23
25
  </span>
24
26
  );
25
27
  }
@@ -0,0 +1,34 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { FinancePermissionWarningContent } from './finance-permission-warning-dialog';
4
+
5
+ vi.mock('next-intl', () => ({
6
+ useTranslations: () => (key: string, values?: Record<string, string>) =>
7
+ values ? `${key}:${Object.values(values).join(',')}` : key,
8
+ }));
9
+
10
+ vi.mock('@tuturuuu/ui/sonner', () => ({
11
+ toast: {
12
+ error: vi.fn(),
13
+ success: vi.fn(),
14
+ },
15
+ }));
16
+
17
+ describe('FinancePermissionWarningContent', () => {
18
+ it('shows missing permissions and request identity details', () => {
19
+ render(
20
+ <FinancePermissionWarningContent
21
+ missingPermissions={['create_invoices']}
22
+ user={{
23
+ displayName: 'Jane Doe',
24
+ email: 'jane@example.com',
25
+ id: 'user-1',
26
+ }}
27
+ />
28
+ );
29
+
30
+ expect(screen.getByText('create_invoices')).toBeVisible();
31
+ expect(screen.getByText('user-1')).toBeVisible();
32
+ expect(screen.getByText('Jane Doe')).toBeVisible();
33
+ });
34
+ });
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ import { AlertTriangle, Copy } from '@tuturuuu/icons';
4
+ import { Button } from '@tuturuuu/ui/button';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DialogTrigger,
13
+ } from '@tuturuuu/ui/dialog';
14
+ import { toast } from '@tuturuuu/ui/sonner';
15
+ import { useTranslations } from 'next-intl';
16
+ import type { ReactNode } from 'react';
17
+ import { useMemo, useState } from 'react';
18
+
19
+ export interface FinancePermissionRequestUser {
20
+ displayName?: string | null;
21
+ email?: string | null;
22
+ id: string;
23
+ }
24
+
25
+ interface FinancePermissionWarningContentProps {
26
+ missingPermissions: string[];
27
+ user?: FinancePermissionRequestUser | null;
28
+ }
29
+
30
+ interface FinancePermissionWarningDialogProps
31
+ extends FinancePermissionWarningContentProps {
32
+ defaultOpen?: boolean;
33
+ trigger?: ReactNode;
34
+ }
35
+
36
+ function resolveDisplayName(
37
+ user: FinancePermissionRequestUser | null | undefined,
38
+ fallback: string
39
+ ) {
40
+ return user?.displayName || user?.email || user?.id || fallback;
41
+ }
42
+
43
+ export function FinancePermissionWarningContent({
44
+ missingPermissions,
45
+ user,
46
+ }: FinancePermissionWarningContentProps) {
47
+ const t = useTranslations();
48
+ const fallbackUser = t('finance-permission-warning.unknown_user');
49
+ const displayName = resolveDisplayName(user, fallbackUser);
50
+ const userId = user?.id || fallbackUser;
51
+ const permissionList = missingPermissions.join(', ');
52
+ const requestText = useMemo(
53
+ () =>
54
+ t('finance-permission-warning.request_template', {
55
+ displayName,
56
+ permissions: permissionList,
57
+ userId,
58
+ }),
59
+ [displayName, permissionList, t, userId]
60
+ );
61
+
62
+ const copyRequest = async () => {
63
+ try {
64
+ await navigator.clipboard.writeText(requestText);
65
+ toast.success(t('finance-permission-warning.copy_success'));
66
+ } catch {
67
+ toast.error(t('finance-permission-warning.copy_error'));
68
+ }
69
+ };
70
+
71
+ return (
72
+ <div className="space-y-4">
73
+ <div className="rounded-lg border border-dynamic-orange/30 bg-dynamic-orange/5 p-3">
74
+ <div className="flex items-start gap-3">
75
+ <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-dynamic-orange" />
76
+ <div className="space-y-1">
77
+ <p className="font-medium text-sm">
78
+ {t('finance-permission-warning.summary')}
79
+ </p>
80
+ <p className="text-muted-foreground text-sm">
81
+ {t('finance-permission-warning.description')}
82
+ </p>
83
+ </div>
84
+ </div>
85
+ </div>
86
+
87
+ <div className="grid gap-3 rounded-lg border p-3 text-sm">
88
+ <div className="grid gap-1">
89
+ <span className="font-medium text-muted-foreground">
90
+ {t('finance-permission-warning.permissions_to_add')}
91
+ </span>
92
+ <div className="flex flex-wrap gap-2">
93
+ {missingPermissions.map((permission) => (
94
+ <code
95
+ key={permission}
96
+ className="rounded bg-muted px-2 py-1 font-mono text-xs"
97
+ >
98
+ {permission}
99
+ </code>
100
+ ))}
101
+ </div>
102
+ </div>
103
+
104
+ <div className="grid gap-1">
105
+ <span className="font-medium text-muted-foreground">
106
+ {t('finance-permission-warning.user_id')}
107
+ </span>
108
+ <code className="break-all rounded bg-muted px-2 py-1 font-mono text-xs">
109
+ {userId}
110
+ </code>
111
+ </div>
112
+
113
+ <div className="grid gap-1">
114
+ <span className="font-medium text-muted-foreground">
115
+ {t('finance-permission-warning.display_name')}
116
+ </span>
117
+ <span>{displayName}</span>
118
+ </div>
119
+ </div>
120
+
121
+ <DialogFooter>
122
+ <Button type="button" variant="outline" onClick={copyRequest}>
123
+ <Copy className="mr-2 h-4 w-4" />
124
+ {t('finance-permission-warning.copy_request')}
125
+ </Button>
126
+ </DialogFooter>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ export function FinancePermissionWarningDialog({
132
+ defaultOpen = false,
133
+ missingPermissions,
134
+ trigger,
135
+ user,
136
+ }: FinancePermissionWarningDialogProps) {
137
+ const t = useTranslations();
138
+ const [open, setOpen] = useState(defaultOpen);
139
+
140
+ return (
141
+ <Dialog open={open} onOpenChange={setOpen}>
142
+ {trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
143
+ <DialogContent>
144
+ <DialogHeader>
145
+ <DialogTitle>{t('finance-permission-warning.title')}</DialogTitle>
146
+ <DialogDescription>
147
+ {t('finance-permission-warning.dialog_description')}
148
+ </DialogDescription>
149
+ </DialogHeader>
150
+ <FinancePermissionWarningContent
151
+ missingPermissions={missingPermissions}
152
+ user={user}
153
+ />
154
+ </DialogContent>
155
+ </Dialog>
156
+ );
157
+ }
@@ -27,6 +27,7 @@ import { computeAccessibleLabelStyles } from '@tuturuuu/utils/label-colors';
27
27
  import { format } from 'date-fns';
28
28
  import { enUS, vi } from 'date-fns/locale';
29
29
  import { useTranslations } from 'next-intl';
30
+ import type { ReactNode } from 'react';
30
31
  import type { UseFormReturn } from 'react-hook-form';
31
32
 
32
33
  import type { TransactionFormValues } from './form-schema';
@@ -44,7 +45,9 @@ interface FormBasicTabProps {
44
45
  loading: boolean;
45
46
  hasFormPermission: boolean;
46
47
  originWalletDisabled?: boolean;
48
+ originWalletPermissionWarning?: ReactNode;
47
49
  destinationWalletDisabled?: boolean;
50
+ destinationWalletPermissionWarning?: ReactNode;
48
51
  isTransfer: boolean;
49
52
  suggestedExchangeRate: number | null;
50
53
  isDestinationOverridden: boolean;
@@ -73,7 +76,9 @@ export function FormBasicTab({
73
76
  loading,
74
77
  hasFormPermission,
75
78
  originWalletDisabled = false,
79
+ originWalletPermissionWarning,
76
80
  destinationWalletDisabled = false,
81
+ destinationWalletPermissionWarning,
77
82
  isTransfer,
78
83
  suggestedExchangeRate,
79
84
  isDestinationOverridden,
@@ -138,6 +143,7 @@ export function FormBasicTab({
138
143
  })}
139
144
  </FormDescription>
140
145
  )}
146
+ {originWalletDisabled && originWalletPermissionWarning}
141
147
  <FormMessage />
142
148
  </FormItem>
143
149
  )}
@@ -192,6 +198,8 @@ export function FormBasicTab({
192
198
  destinationWalletDisabled
193
199
  }
194
200
  />
201
+ {destinationWalletDisabled &&
202
+ destinationWalletPermissionWarning}
195
203
  <FormMessage />
196
204
  </FormItem>
197
205
  )}
@@ -12,6 +12,7 @@ import {
12
12
  } from '@tuturuuu/ui/form';
13
13
  import { Switch } from '@tuturuuu/ui/switch';
14
14
  import { useTranslations } from 'next-intl';
15
+ import type { ReactNode } from 'react';
15
16
  import type { UseFormReturn } from 'react-hook-form';
16
17
  import type { TransactionFormValues } from './form-schema';
17
18
  import type { NewContent, NewContentType } from './form-types';
@@ -23,6 +24,7 @@ interface FormMoreTabProps {
23
24
  loading: boolean;
24
25
  hasFormPermission: boolean;
25
26
  canManageConfidential: boolean;
27
+ confidentialPermissionWarning?: ReactNode;
26
28
  isTransfer: boolean;
27
29
  setNewContentType: (value: NewContentType) => void;
28
30
  setNewContent: (value: NewContent) => void;
@@ -35,6 +37,7 @@ export function FormMoreTab({
35
37
  loading,
36
38
  hasFormPermission,
37
39
  canManageConfidential,
40
+ confidentialPermissionWarning,
38
41
  isTransfer,
39
42
  setNewContentType,
40
43
  setNewContent,
@@ -198,6 +201,11 @@ export function FormMoreTab({
198
201
  </div>
199
202
  </div>
200
203
  )}
204
+
205
+ {!canManageConfidential &&
206
+ !isTransfer &&
207
+ hasFormPermission &&
208
+ confidentialPermissionWarning}
201
209
  </div>
202
210
  );
203
211
  }
@@ -1,4 +1,5 @@
1
1
  import type { Database } from '@tuturuuu/types';
2
+ import type { FinancePermissionRequestUser } from '../shared/finance-permission-warning-dialog';
2
3
  import type { TransactionFormValues } from './form-schema';
3
4
 
4
5
  type DbTransaction = Database['public']['Tables']['wallet_transactions']['Row'];
@@ -32,6 +33,7 @@ export interface TransactionFormProps {
32
33
  canUpdateConfidentialTransactions?: boolean;
33
34
  canChangeFinanceWallets?: boolean;
34
35
  canSetFinanceWalletsOnCreate?: boolean;
36
+ permissionRequestUser?: FinancePermissionRequestUser | null;
35
37
  }
36
38
 
37
39
  export type NewContentType = 'wallet' | 'transaction-category' | 'tag';
@@ -63,6 +63,13 @@ vi.mock('@tuturuuu/internal-api/finance', () => ({
63
63
  updateWallet: vi.fn(),
64
64
  }));
65
65
 
66
+ vi.mock('@tuturuuu/ui/sonner', () => ({
67
+ toast: {
68
+ error: vi.fn(),
69
+ success: vi.fn(),
70
+ },
71
+ }));
72
+
66
73
  function renderTransactionForm() {
67
74
  const queryClient = new QueryClient({
68
75
  defaultOptions: {
@@ -148,4 +155,40 @@ describe('TransactionForm', () => {
148
155
 
149
156
  expect(screen.getByText('transaction-data-table.tab_basic')).toBeVisible();
150
157
  });
158
+
159
+ it('shows a permission request when create transaction access is missing', () => {
160
+ const queryClient = new QueryClient({
161
+ defaultOptions: {
162
+ queries: {
163
+ retry: false,
164
+ },
165
+ },
166
+ });
167
+
168
+ render(
169
+ <QueryClientProvider client={queryClient}>
170
+ <TransactionsCreateSummary
171
+ canChangeFinanceWallets
172
+ canCreateConfidentialTransactions={false}
173
+ canCreateTransactions={false}
174
+ canSetFinanceWalletsOnCreate
175
+ createDescription="Create description"
176
+ createTitle="Create"
177
+ defaultOpen
178
+ description="Transactions description"
179
+ permissionRequestUser={{
180
+ displayName: 'Jane Doe',
181
+ id: 'user-1',
182
+ }}
183
+ pluralTitle="Transactions"
184
+ singularTitle="Transaction"
185
+ wsId="ws-1"
186
+ />
187
+ </QueryClientProvider>
188
+ );
189
+
190
+ expect(screen.getByText('create_transactions')).toBeVisible();
191
+ expect(screen.getByText('user-1')).toBeVisible();
192
+ expect(screen.getByText('Jane Doe')).toBeVisible();
193
+ });
151
194
  });
@@ -21,6 +21,10 @@ import {
21
21
  type WorkspaceStorageListItem,
22
22
  } from '@tuturuuu/internal-api';
23
23
  import { Button } from '@tuturuuu/ui/button';
24
+ import {
25
+ FinancePermissionWarningContent,
26
+ FinancePermissionWarningDialog,
27
+ } from '@tuturuuu/ui/finance/shared/finance-permission-warning-dialog';
24
28
  import { Form } from '@tuturuuu/ui/form';
25
29
  import { useExchangeRates } from '@tuturuuu/ui/hooks/use-exchange-rates';
26
30
  import { useFinanceTransactionPreferences } from '@tuturuuu/ui/hooks/use-finance-transaction-preferences';
@@ -69,6 +73,7 @@ export function TransactionForm({
69
73
  canUpdateConfidentialTransactions,
70
74
  canChangeFinanceWallets = true,
71
75
  canSetFinanceWalletsOnCreate = true,
76
+ permissionRequestUser,
72
77
  }: TransactionFormProps) {
73
78
  const t = useTranslations();
74
79
  const locale = useLocale();
@@ -325,6 +330,43 @@ export function TransactionForm({
325
330
  isEditMode &&
326
331
  !canChangeFinanceWallets &&
327
332
  !!data?.transfer?.linked_wallet_id;
333
+ const createWalletPermissionWarning = (
334
+ <FinancePermissionWarningDialog
335
+ missingPermissions={['set_finance_wallets_on_create']}
336
+ user={permissionRequestUser}
337
+ trigger={
338
+ <Button type="button" variant="outline" size="sm">
339
+ {t('finance-permission-warning.open_request')}
340
+ </Button>
341
+ }
342
+ />
343
+ );
344
+ const editWalletPermissionWarning = (
345
+ <FinancePermissionWarningDialog
346
+ missingPermissions={['change_finance_wallets']}
347
+ user={permissionRequestUser}
348
+ trigger={
349
+ <Button type="button" variant="outline" size="sm">
350
+ {t('finance-permission-warning.open_request')}
351
+ </Button>
352
+ }
353
+ />
354
+ );
355
+ const confidentialPermissionWarning = (
356
+ <FinancePermissionWarningDialog
357
+ missingPermissions={[
358
+ isCreateMode
359
+ ? 'create_confidential_transactions'
360
+ : 'update_confidential_transactions',
361
+ ]}
362
+ user={permissionRequestUser}
363
+ trigger={
364
+ <Button type="button" variant="outline" size="sm">
365
+ {t('finance-permission-warning.open_request')}
366
+ </Button>
367
+ }
368
+ />
369
+ );
328
370
 
329
371
  const refreshTransactions = async () => {
330
372
  await invalidateTransactionMutationQueries(queryClient, wsId);
@@ -790,6 +832,17 @@ export function TransactionForm({
790
832
  // Disable transfer mode when editing existing non-transfer transactions
791
833
  const canToggleTransfer = !data?.id || !!data?.transfer;
792
834
 
835
+ if (!hasFormPermission) {
836
+ return (
837
+ <FinancePermissionWarningContent
838
+ missingPermissions={[
839
+ isCreateMode ? 'create_transactions' : 'update_transactions',
840
+ ]}
841
+ user={permissionRequestUser}
842
+ />
843
+ );
844
+ }
845
+
793
846
  return (
794
847
  <>
795
848
  <FormContentDialog
@@ -858,7 +911,13 @@ export function TransactionForm({
858
911
  originWalletDisabled={
859
912
  shouldLockCreateOriginWallet || shouldLockEditOriginWallet
860
913
  }
914
+ originWalletPermissionWarning={
915
+ shouldLockCreateOriginWallet
916
+ ? createWalletPermissionWarning
917
+ : editWalletPermissionWarning
918
+ }
861
919
  destinationWalletDisabled={shouldLockEditDestinationWallet}
920
+ destinationWalletPermissionWarning={editWalletPermissionWarning}
862
921
  isTransfer={isTransfer}
863
922
  suggestedExchangeRate={suggestedExchangeRate}
864
923
  isDestinationOverridden={isDestinationOverridden}
@@ -913,6 +972,7 @@ export function TransactionForm({
913
972
  loading={loading}
914
973
  hasFormPermission={!!hasFormPermission}
915
974
  canManageConfidential={!!canManageConfidential}
975
+ confidentialPermissionWarning={confidentialPermissionWarning}
916
976
  isTransfer={isTransfer}
917
977
  setNewContentType={setNewContentType}
918
978
  setNewContent={setNewContent}
@@ -51,6 +51,10 @@ dayjs.extend(timezone);
51
51
  dayjs.extend(isoWeek);
52
52
 
53
53
  import ModifiableDialogTrigger from '@tuturuuu/ui/custom/modifiable-dialog-trigger';
54
+ import {
55
+ type FinancePermissionRequestUser,
56
+ FinancePermissionWarningDialog,
57
+ } from '@tuturuuu/ui/finance/shared/finance-permission-warning-dialog';
54
58
  import { useLocale, useTranslations } from 'next-intl';
55
59
  import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
56
60
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -92,6 +96,7 @@ interface InfiniteTransactionsListProps {
92
96
  canViewConfidentialCategory?: boolean;
93
97
  /** Hide transaction creator (useful for personal workspaces) */
94
98
  isPersonalWorkspace?: boolean;
99
+ permissionRequestUser?: FinancePermissionRequestUser | null;
95
100
  }
96
101
 
97
102
  interface TransactionResponse {
@@ -133,6 +138,7 @@ export function InfiniteTransactionsList({
133
138
  canViewConfidentialDescription: _canViewConfidentialDescription,
134
139
  canViewConfidentialCategory: _canViewConfidentialCategory,
135
140
  isPersonalWorkspace,
141
+ permissionRequestUser,
136
142
  }: InfiniteTransactionsListProps) {
137
143
  const t = useTranslations();
138
144
  const locale = useLocale();
@@ -886,6 +892,25 @@ export function InfiniteTransactionsList({
886
892
  </span>
887
893
  </Button>
888
894
  )}
895
+ {canCreateTransactions === false && (
896
+ <FinancePermissionWarningDialog
897
+ missingPermissions={['create_transactions']}
898
+ user={permissionRequestUser}
899
+ trigger={
900
+ <Button
901
+ type="button"
902
+ variant="outline"
903
+ size="icon"
904
+ className="h-8 w-8 shrink-0"
905
+ >
906
+ <Plus className="h-4 w-4" />
907
+ <span className="sr-only">
908
+ {t('finance-permission-warning.open_request')}
909
+ </span>
910
+ </Button>
911
+ }
912
+ />
913
+ )}
889
914
  </div>
890
915
  </div>
891
916
  </div>
@@ -1029,6 +1054,7 @@ export function InfiniteTransactionsList({
1029
1054
  canUpdateConfidentialTransactions={
1030
1055
  canUpdateConfidentialTransactions
1031
1056
  }
1057
+ permissionRequestUser={permissionRequestUser}
1032
1058
  onFinish={() => {
1033
1059
  handleTransactionUpdate();
1034
1060
  handleCloseDialog();
@@ -1061,6 +1087,7 @@ export function InfiniteTransactionsList({
1061
1087
  canCreateConfidentialTransactions={
1062
1088
  canCreateConfidentialTransactions
1063
1089
  }
1090
+ permissionRequestUser={permissionRequestUser}
1064
1091
  onFinish={() => {
1065
1092
  handleTransactionUpdate();
1066
1093
  setCreateForGroupDate(null);