@tuturuuu/ui 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/package.json +8 -8
  3. package/src/components/ui/currency-input.test.tsx +43 -0
  4. package/src/components/ui/currency-input.tsx +1 -1
  5. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  6. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  7. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  8. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  9. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  10. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  12. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  13. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  14. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  15. package/src/components/ui/money-input.test.tsx +64 -0
  16. package/src/components/ui/money-input.tsx +63 -0
  17. package/src/components/ui/storefront/cart-summary.tsx +114 -29
  18. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  19. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  20. package/src/components/ui/storefront/image-panel.tsx +6 -0
  21. package/src/components/ui/storefront/index.ts +11 -0
  22. package/src/components/ui/storefront/listing-card.tsx +84 -22
  23. package/src/components/ui/storefront/product-detail.tsx +289 -0
  24. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  25. package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
  26. package/src/components/ui/storefront/storefront-surface.tsx +333 -133
  27. package/src/components/ui/storefront/types.ts +23 -1
  28. package/src/components/ui/storefront/utils.ts +111 -27
  29. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  30. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  31. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  32. package/src/components/ui/text-editor/content-migration.ts +41 -18
  33. package/src/components/ui/text-editor/extensions.ts +1 -1
  34. package/src/components/ui/text-editor/image-extension.ts +40 -18
  35. package/src/components/ui/text-editor/video-extension.ts +11 -2
  36. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  37. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  38. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  40. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  42. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  43. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  44. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  46. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  50. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  51. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  52. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  53. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  54. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  55. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  56. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  57. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  58. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  59. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  60. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  61. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  62. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  63. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  64. package/src/hooks/useBoardRealtime.ts +6 -3
  65. package/src/hooks/useBoardRealtime.types.ts +11 -0
  66. package/src/hooks/useCursorTracking.ts +91 -27
  67. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
- import { Settings } from '@tuturuuu/icons';
4
+ import { TriangleAlert } from '@tuturuuu/icons';
5
5
  import type { SupabaseUser } from '@tuturuuu/supabase/next/user';
6
6
  import type { WorkspaceDefaultPermissionMemberType } from '@tuturuuu/types';
7
7
  import { Skeleton } from '@tuturuuu/ui/skeleton';
@@ -222,10 +222,10 @@ export function WorkspaceAccessPage({
222
222
 
223
223
  if (contextQuery.isPending) {
224
224
  return (
225
- <div className="space-y-4">
226
- <Skeleton className="h-28 rounded-lg" />
227
- <Skeleton className="h-12 rounded-lg" />
228
- <Skeleton className="h-96 rounded-lg" />
225
+ <div className="space-y-6">
226
+ <Skeleton className="h-32 rounded-xl" />
227
+ <Skeleton className="h-10 rounded-xl" />
228
+ <Skeleton className="h-96 rounded-xl" />
229
229
  </div>
230
230
  );
231
231
  }
@@ -255,42 +255,39 @@ export function WorkspaceAccessPage({
255
255
  search={search}
256
256
  />
257
257
 
258
- <TabsContent value="people" className="mt-4">
259
- <div className="rounded-xl border border-border bg-background p-6 shadow-sm">
260
- <WorkspaceAccessPeopleFilters
261
- filterOptions={filterOptions}
262
- filters={filters}
263
- labels={labels}
264
- onFiltersChange={setFilters}
265
- onStatusChange={setStatus}
266
- status={status}
267
- />
258
+ <TabsContent value="people" className="mt-6 space-y-4">
259
+ <WorkspaceAccessPeopleFilters
260
+ filterOptions={filterOptions}
261
+ filters={filters}
262
+ labels={labels}
263
+ onFiltersChange={setFilters}
264
+ onStatusChange={setStatus}
265
+ status={status}
266
+ />
268
267
 
269
- <WorkspaceAccessMembers
270
- canManageMembers={canManageMembers}
271
- canManageRoles={canManageRoles}
272
- isLoading={membersQuery.isPending}
273
- isMutating={
274
- removeMemberMutation.isPending ||
275
- roleMembershipMutation.isPending
276
- }
277
- labels={labels}
278
- members={visibleMembers}
279
- onAssignRole={(payload) =>
280
- roleMembershipMutation.mutate({ ...payload, action: 'add' })
281
- }
282
- onRemoveMember={(payload) => removeMemberMutation.mutate(payload)}
283
- onRemoveRole={(payload) =>
284
- roleMembershipMutation.mutate({ ...payload, action: 'remove' })
285
- }
286
- roles={roles}
287
- searchTerm={search}
288
- status={status}
289
- />
290
- </div>
268
+ <WorkspaceAccessMembers
269
+ canManageMembers={canManageMembers}
270
+ canManageRoles={canManageRoles}
271
+ isLoading={membersQuery.isPending}
272
+ isMutating={
273
+ removeMemberMutation.isPending || roleMembershipMutation.isPending
274
+ }
275
+ labels={labels}
276
+ members={visibleMembers}
277
+ onAssignRole={(payload) =>
278
+ roleMembershipMutation.mutate({ ...payload, action: 'add' })
279
+ }
280
+ onRemoveMember={(payload) => removeMemberMutation.mutate(payload)}
281
+ onRemoveRole={(payload) =>
282
+ roleMembershipMutation.mutate({ ...payload, action: 'remove' })
283
+ }
284
+ roles={roles}
285
+ searchTerm={search}
286
+ status={status}
287
+ />
291
288
  </TabsContent>
292
289
 
293
- <TabsContent value="roles" className="mt-4">
290
+ <TabsContent value="roles" className="mt-6">
294
291
  <WorkspaceAccessRoles
295
292
  canManageRoles={canManageRoles}
296
293
  isLoading={rolesQuery.isPending}
@@ -309,7 +306,7 @@ export function WorkspaceAccessPage({
309
306
  />
310
307
  </TabsContent>
311
308
 
312
- <TabsContent value="defaults-member" className="mt-4">
309
+ <TabsContent value="defaults-member" className="mt-6">
313
310
  <WorkspaceAccessDefaultRoleCard
314
311
  canManageRoles={canManageRoles}
315
312
  isLoading={memberDefaultQuery.isPending}
@@ -327,7 +324,7 @@ export function WorkspaceAccessPage({
327
324
  />
328
325
  </TabsContent>
329
326
 
330
- <TabsContent value="defaults-guest" className="mt-4">
327
+ <TabsContent value="defaults-guest" className="mt-6">
331
328
  <WorkspaceAccessDefaultRoleCard
332
329
  canManageRoles={canManageRoles}
333
330
  isLoading={guestDefaultQuery.isPending}
@@ -378,8 +375,8 @@ export function WorkspaceAccessPage({
378
375
  ) : null}
379
376
 
380
377
  {contextQuery.isError ? (
381
- <div className="rounded-lg border border-dynamic-red/20 bg-dynamic-red/5 p-4 text-dynamic-red text-sm">
382
- <Settings className="mr-2 inline h-4 w-4" />
378
+ <div className="flex items-center gap-2 rounded-xl border border-dynamic-red/20 bg-dynamic-red/5 p-4 text-dynamic-red text-sm">
379
+ <TriangleAlert className="h-4 w-4 shrink-0" />
383
380
  {t('common.error')}
384
381
  </div>
385
382
  ) : null}
@@ -52,7 +52,7 @@ export function WorkspaceAccessPeopleFilters({
52
52
  filters.permissionIds.length + filters.roleIds.length;
53
53
 
54
54
  return (
55
- <div className="mb-5 flex flex-col gap-3 rounded-lg border border-border bg-foreground/[0.02] p-3 sm:flex-row sm:items-center sm:justify-between">
55
+ <div className="flex flex-col gap-3 rounded-xl border border-border bg-foreground/[0.02] p-3 sm:flex-row sm:items-center sm:justify-between">
56
56
  <div className="flex flex-wrap items-center gap-2">
57
57
  <div className="mr-1 flex items-center gap-2 text-muted-foreground text-sm">
58
58
  <ListFilter className="h-4 w-4" />
@@ -1,16 +1,9 @@
1
1
  'use client';
2
2
 
3
- import { Pencil, Plus, Trash2 } from '@tuturuuu/icons';
3
+ import { Pencil, Plus, ShieldCheck, Trash2, Users } from '@tuturuuu/icons';
4
4
  import type { InternalApiEnhancedWorkspaceMember } from '@tuturuuu/types';
5
5
  import { Badge } from '@tuturuuu/ui/badge';
6
6
  import { Button } from '@tuturuuu/ui/button';
7
- import {
8
- Card,
9
- CardContent,
10
- CardDescription,
11
- CardHeader,
12
- CardTitle,
13
- } from '@tuturuuu/ui/card';
14
7
  import { Skeleton } from '@tuturuuu/ui/skeleton';
15
8
  import { useTranslations } from 'next-intl';
16
9
  import { getMemberDisplayName } from './member-filter-utils';
@@ -56,37 +49,36 @@ export function WorkspaceAccessRoles({
56
49
 
57
50
  return (
58
51
  <div className="space-y-4">
59
- <Card className="shadow-none">
60
- <CardHeader>
61
- <div className="flex flex-wrap items-start justify-between gap-4">
62
- <div>
63
- <CardTitle>{labels.accessLevelsLabel}</CardTitle>
64
- <CardDescription>{t('ws-roles.description')}</CardDescription>
65
- </div>
66
- {canManageRoles ? (
67
- <Button onClick={onCreateRole}>
68
- <Plus className="mr-2 h-4 w-4" />
69
- {t('ws-roles.create')}
70
- </Button>
71
- ) : null}
72
- </div>
73
- </CardHeader>
74
- </Card>
52
+ <div className="flex flex-wrap items-start justify-between gap-4">
53
+ <div>
54
+ <h2 className="font-semibold text-lg">{labels.accessLevelsLabel}</h2>
55
+ <p className="text-muted-foreground text-sm">
56
+ {t('ws-roles.description')}
57
+ </p>
58
+ </div>
59
+ {canManageRoles ? (
60
+ <Button onClick={onCreateRole}>
61
+ <Plus className="mr-2 h-4 w-4" />
62
+ {t('ws-roles.create')}
63
+ </Button>
64
+ ) : null}
65
+ </div>
75
66
 
76
67
  {isLoading ? (
77
68
  <div className="space-y-3">
78
- <Skeleton className="h-36 rounded-lg" />
79
- <Skeleton className="h-36 rounded-lg" />
69
+ <Skeleton className="h-36 rounded-xl" />
70
+ <Skeleton className="h-36 rounded-xl" />
80
71
  </div>
81
72
  ) : roles.length === 0 ? (
82
- <Card className="shadow-none">
83
- <CardContent className="flex min-h-44 flex-col items-center justify-center gap-2 text-center">
84
- <div className="font-medium">{labels.rolesEmptyTitle}</div>
85
- <div className="text-muted-foreground text-sm">
86
- {labels.rolesEmptyDescription}
87
- </div>
88
- </CardContent>
89
- </Card>
73
+ <div className="flex min-h-44 flex-col items-center justify-center gap-2 rounded-xl border border-border border-dashed p-8 text-center">
74
+ <div className="flex h-12 w-12 items-center justify-center rounded-full bg-dynamic-purple/10 text-dynamic-purple">
75
+ <ShieldCheck className="h-6 w-6" />
76
+ </div>
77
+ <div className="font-medium">{labels.rolesEmptyTitle}</div>
78
+ <div className="max-w-sm text-muted-foreground text-sm">
79
+ {labels.rolesEmptyDescription}
80
+ </div>
81
+ </div>
90
82
  ) : (
91
83
  <div className="grid gap-3">
92
84
  {roles.map((role) => {
@@ -94,74 +86,104 @@ export function WorkspaceAccessRoles({
94
86
  role.members && role.members.length > 0
95
87
  ? role.members
96
88
  : assignedMembersForRole(role.id, members);
89
+ const enabled = enabledPermissionCount(role);
90
+ const pct =
91
+ permissionCount > 0
92
+ ? Math.round((enabled / permissionCount) * 100)
93
+ : 0;
97
94
 
98
95
  return (
99
- <Card key={role.id} className="shadow-none">
100
- <CardContent className="space-y-4 p-5">
101
- <div className="flex flex-wrap items-start justify-between gap-4">
102
- <div className="space-y-2">
103
- <div className="font-medium text-lg">{role.name}</div>
104
- <div className="flex flex-wrap gap-2 text-muted-foreground text-sm">
105
- <span>
106
- {t('ws-roles.members')}: {assignedMembers.length}
107
- </span>
108
- <span>
109
- {t('ws-roles.permissions')}:{' '}
110
- {enabledPermissionCount(role)}/{permissionCount}
111
- </span>
112
- </div>
96
+ <div
97
+ key={role.id}
98
+ className="rounded-xl border border-border bg-background p-5 transition-colors hover:border-foreground/20"
99
+ >
100
+ <div className="flex flex-wrap items-start justify-between gap-4">
101
+ <div className="flex min-w-0 gap-3">
102
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-dynamic-purple/30 bg-dynamic-purple/10 text-dynamic-purple">
103
+ <ShieldCheck className="h-5 w-5" />
113
104
  </div>
114
-
115
- {canManageRoles ? (
116
- <div className="flex flex-wrap gap-2">
117
- <Button
118
- variant="outline"
119
- onClick={() => onEditRole(role)}
120
- >
121
- <Pencil className="mr-2 h-4 w-4" />
122
- {t('common.edit')}
123
- </Button>
124
- <Button
105
+ <div className="min-w-0">
106
+ <div className="flex items-center gap-2">
107
+ <span className="truncate font-semibold text-base">
108
+ {role.name}
109
+ </span>
110
+ <Badge
125
111
  variant="outline"
126
- onClick={() => onDeleteRole(role)}
112
+ className="h-5 gap-1 px-1.5 text-xs"
127
113
  >
128
- <Trash2 className="mr-2 h-4 w-4" />
129
- {t('common.delete')}
130
- </Button>
114
+ <Users className="h-3 w-3" />
115
+ {assignedMembers.length}
116
+ </Badge>
131
117
  </div>
132
- ) : null}
118
+ <div className="mt-2 max-w-xs">
119
+ <div className="flex items-center justify-between text-muted-foreground text-xs">
120
+ <span>{t('ws-roles.permissions')}</span>
121
+ <span className="tabular-nums">
122
+ {enabled}/{permissionCount}
123
+ </span>
124
+ </div>
125
+ <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-foreground/10">
126
+ <div
127
+ className="h-full rounded-full bg-dynamic-purple"
128
+ style={{ width: `${pct}%` }}
129
+ />
130
+ </div>
131
+ </div>
132
+ </div>
133
133
  </div>
134
134
 
135
- <div className="flex flex-wrap gap-2">
136
- <WorkspaceAccessPermissionPreview
137
- emptyLabel={t('ws-members.no_permissions')}
138
- permissionTitles={permissionTitles}
139
- role={role}
140
- />
141
- </div>
135
+ {canManageRoles ? (
136
+ <div className="flex flex-wrap gap-2">
137
+ <Button
138
+ variant="outline"
139
+ size="sm"
140
+ onClick={() => onEditRole(role)}
141
+ >
142
+ <Pencil className="mr-2 h-4 w-4" />
143
+ {t('common.edit')}
144
+ </Button>
145
+ <Button
146
+ variant="outline"
147
+ size="sm"
148
+ onClick={() => onDeleteRole(role)}
149
+ >
150
+ <Trash2 className="mr-2 h-4 w-4" />
151
+ {t('common.delete')}
152
+ </Button>
153
+ </div>
154
+ ) : null}
155
+ </div>
156
+
157
+ <div className="mt-4 flex flex-wrap gap-2">
158
+ <WorkspaceAccessPermissionPreview
159
+ emptyLabel={t('ws-members.no_permissions')}
160
+ permissionTitles={permissionTitles}
161
+ role={role}
162
+ />
163
+ </div>
142
164
 
143
- <div className="flex flex-wrap gap-2">
144
- {assignedMembers.length === 0 ? (
145
- <Badge variant="outline" className="rounded-full">
146
- {labels.noRolesLabel}
165
+ {assignedMembers.length > 0 ? (
166
+ <div className="mt-3 flex flex-wrap gap-1.5 border-border border-t pt-3">
167
+ {assignedMembers.slice(0, 6).map((member) => (
168
+ <Badge
169
+ key={`${role.id}-${member.id}`}
170
+ variant="secondary"
171
+ className="rounded-full text-xs"
172
+ >
173
+ {getMemberDisplayName(
174
+ member as InternalApiEnhancedWorkspaceMember,
175
+ t('common.unknown')
176
+ )}
147
177
  </Badge>
148
- ) : (
149
- assignedMembers.slice(0, 6).map((member) => (
150
- <Badge
151
- key={`${role.id}-${member.id}`}
152
- variant="outline"
153
- className="rounded-full"
154
- >
155
- {getMemberDisplayName(
156
- member as InternalApiEnhancedWorkspaceMember,
157
- t('common.unknown')
158
- )}
159
- </Badge>
160
- ))
161
- )}
178
+ ))}
179
+ {assignedMembers.length > 6 ? (
180
+ <Badge variant="outline" className="rounded-full text-xs">
181
+ +{assignedMembers.length - 6}
182
+ </Badge>
183
+ ) : null}
162
184
  </div>
163
- </CardContent>
164
- </Card>
185
+ ) : null}
186
+ </div>
165
187
  );
166
188
  })}
167
189
  </div>
@@ -1,10 +1,18 @@
1
1
  'use client';
2
2
 
3
- import { Search, UserPlus } from '@tuturuuu/icons';
3
+ import {
4
+ KeyRound,
5
+ Search,
6
+ ShieldCheck,
7
+ ShieldUser,
8
+ UserPlus,
9
+ Users,
10
+ } from '@tuturuuu/icons';
4
11
  import { Button } from '@tuturuuu/ui/button';
5
12
  import { Input } from '@tuturuuu/ui/input';
6
13
  import { TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
7
14
  import { useTranslations } from 'next-intl';
15
+ import type { ReactNode } from 'react';
8
16
  import type { WorkspaceAccessTab } from './types';
9
17
 
10
18
  type Props = {
@@ -18,6 +26,9 @@ type Props = {
18
26
  search: string;
19
27
  };
20
28
 
29
+ const TAB_TRIGGER_CLASS =
30
+ 'rounded-none border-transparent border-b-2 bg-transparent px-1 pt-1 pb-3 text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:border-dynamic-blue data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none';
31
+
21
32
  export function WorkspaceAccessTabsToolbar({
22
33
  activeTab,
23
34
  accessLevelsLabel,
@@ -30,41 +41,71 @@ export function WorkspaceAccessTabsToolbar({
30
41
  }: Props) {
31
42
  const t = useTranslations() as (key: string) => string;
32
43
 
44
+ const tabs: Array<{
45
+ disabled?: boolean;
46
+ icon: ReactNode;
47
+ label: string;
48
+ value: WorkspaceAccessTab;
49
+ }> = [
50
+ {
51
+ icon: <Users className="h-4 w-4" />,
52
+ label: t('ws-roles.members'),
53
+ value: 'people',
54
+ },
55
+ {
56
+ disabled: !canManageRoles,
57
+ icon: <ShieldCheck className="h-4 w-4" />,
58
+ label: accessLevelsLabel,
59
+ value: 'roles',
60
+ },
61
+ {
62
+ disabled: !canManageRoles,
63
+ icon: <ShieldUser className="h-4 w-4" />,
64
+ label: t('ws-roles.member_defaults_tab'),
65
+ value: 'defaults-member',
66
+ },
67
+ {
68
+ disabled: !canManageRoles,
69
+ icon: <KeyRound className="h-4 w-4" />,
70
+ label: t('ws-roles.guest_defaults_tab'),
71
+ value: 'defaults-guest',
72
+ },
73
+ ];
74
+
33
75
  return (
34
- <div className="rounded-xl border border-border bg-background p-3 shadow-sm">
35
- <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
36
- <TabsList className="grid h-auto w-full grid-cols-2 text-sm lg:w-auto lg:grid-cols-4">
37
- <TabsTrigger value="people">{t('ws-roles.members')}</TabsTrigger>
38
- <TabsTrigger value="roles" disabled={!canManageRoles}>
39
- {accessLevelsLabel}
40
- </TabsTrigger>
41
- <TabsTrigger value="defaults-member" disabled={!canManageRoles}>
42
- {t('ws-roles.member_defaults_tab')}
43
- </TabsTrigger>
44
- <TabsTrigger value="defaults-guest" disabled={!canManageRoles}>
45
- {t('ws-roles.guest_defaults_tab')}
76
+ <div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
77
+ <TabsList className="h-auto w-full justify-start gap-5 overflow-x-auto rounded-none border-border border-b bg-transparent p-0 lg:w-auto">
78
+ {tabs.map((tab) => (
79
+ <TabsTrigger
80
+ key={tab.value}
81
+ value={tab.value}
82
+ disabled={tab.disabled}
83
+ className={TAB_TRIGGER_CLASS}
84
+ >
85
+ {tab.icon}
86
+ {tab.label}
46
87
  </TabsTrigger>
47
- </TabsList>
88
+ ))}
89
+ </TabsList>
48
90
 
49
- <div className="flex w-full flex-col gap-3 sm:flex-row lg:w-auto">
50
- <div className="relative min-w-0 sm:min-w-[320px]">
51
- <Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
52
- <Input
53
- value={search}
54
- onChange={(event) => onSearchChange(event.target.value)}
55
- placeholder={t('common.search')}
56
- className="pl-9"
57
- />
58
- </div>
59
- {activeTab === 'people' ? (
60
- <Button disabled={!canInvite} onClick={onInviteClick}>
61
- <UserPlus className="mr-2 h-4 w-4" />
62
- {disableInvite
63
- ? t('ws-members.invite_member_disabled')
64
- : t('ws-members.invite_member')}
65
- </Button>
66
- ) : null}
91
+ <div className="flex w-full shrink-0 flex-col gap-2 sm:flex-row lg:w-auto">
92
+ <div className="relative min-w-0 sm:min-w-[280px]">
93
+ <Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
94
+ <Input
95
+ value={search}
96
+ onChange={(event) => onSearchChange(event.target.value)}
97
+ placeholder={t('common.search')}
98
+ className="pl-9"
99
+ />
67
100
  </div>
101
+ {activeTab === 'people' ? (
102
+ <Button disabled={!canInvite} onClick={onInviteClick}>
103
+ <UserPlus className="mr-2 h-4 w-4" />
104
+ {disableInvite
105
+ ? t('ws-members.invite_member_disabled')
106
+ : t('ws-members.invite_member')}
107
+ </Button>
108
+ ) : null}
68
109
  </div>
69
110
  </div>
70
111
  );
@@ -15,6 +15,8 @@ export type Transaction = DbTransaction & {
15
15
  linked_wallet_name: string;
16
16
  linked_wallet_currency?: string;
17
17
  linked_amount?: number;
18
+ linked_amount_redacted?: boolean;
19
+ linked_is_amount_confidential?: boolean;
18
20
  is_origin: boolean;
19
21
  };
20
22
  };
@@ -85,12 +85,6 @@ export function TransactionCard({
85
85
  useFinanceConfidentialVisibility();
86
86
  const isTransfer = !!transaction.transfer;
87
87
 
88
- // Check if transaction is confidential
89
- const isConfidential =
90
- transaction.is_amount_confidential ||
91
- transaction.is_description_confidential ||
92
- transaction.is_category_confidential;
93
-
94
88
  // Get custom icon if available
95
89
  const CategoryIcon = useMemo(() => {
96
90
  if (transaction.category_icon) {
@@ -127,10 +121,17 @@ export function TransactionCard({
127
121
  return wallets.find((w) => w.id === transaction.transfer?.linked_wallet_id);
128
122
  }, [transaction.transfer?.linked_wallet_id, wallets]);
129
123
 
124
+ const linkedAmount = transaction.transfer?.linked_amount_redacted
125
+ ? undefined
126
+ : transaction.transfer?.linked_amount;
127
+ const linkedAmountIsConfidential = Boolean(
128
+ transaction.transfer?.linked_is_amount_confidential
129
+ );
130
130
  const transferDisplay = transaction.transfer
131
131
  ? transaction.transfer.is_origin
132
132
  ? {
133
133
  amount: transaction.amount,
134
+ amountIsConfidential: Boolean(transaction.is_amount_confidential),
134
135
  amountCurrency: transaction.wallet_currency,
135
136
  destinationIcon: linkedWallet?.icon,
136
137
  destinationImageSrc: linkedWallet?.image_src,
@@ -140,11 +141,15 @@ export function TransactionCard({
140
141
  originImageSrc: wallet?.image_src,
141
142
  originWalletId: transaction.wallet_id,
142
143
  originWalletName: transaction.wallet,
143
- secondaryAmount: transaction.transfer.linked_amount,
144
+ secondaryAmount: linkedAmount,
144
145
  secondaryCurrency: transaction.transfer.linked_wallet_currency,
145
146
  }
146
147
  : {
147
- amount: transaction.transfer.linked_amount ?? transaction.amount,
148
+ amount: linkedAmount ?? transaction.amount,
149
+ amountIsConfidential:
150
+ linkedAmount != null
151
+ ? linkedAmountIsConfidential
152
+ : Boolean(transaction.is_amount_confidential),
148
153
  amountCurrency:
149
154
  transaction.transfer.linked_wallet_currency ||
150
155
  transaction.wallet_currency,
@@ -161,9 +166,16 @@ export function TransactionCard({
161
166
  }
162
167
  : null;
163
168
  const displayAmount = transferDisplay?.amount ?? transaction.amount;
169
+ const displayAmountIsConfidential =
170
+ transferDisplay?.amountIsConfidential ??
171
+ Boolean(transaction.is_amount_confidential);
164
172
  const effectiveCurrency =
165
173
  transferDisplay?.amountCurrency || transaction.wallet_currency || currency;
166
174
  const isExpense = (displayAmount || 0) < 0;
175
+ const isConfidential =
176
+ displayAmountIsConfidential ||
177
+ transaction.is_description_confidential ||
178
+ transaction.is_category_confidential;
167
179
 
168
180
  // Currency conversion for foreign-currency transactions
169
181
  const isForeignCurrency =
@@ -434,7 +446,7 @@ export function TransactionCard({
434
446
  <div className="flex flex-col items-end">
435
447
  <ConfidentialAmount
436
448
  amount={displayAmount ?? null}
437
- isConfidential={transaction.is_amount_confidential || false}
449
+ isConfidential={displayAmountIsConfidential}
438
450
  currency={effectiveCurrency}
439
451
  className={cn(
440
452
  'font-bold text-sm tabular-nums transition-all duration-200 sm:text-xl',
@@ -0,0 +1,64 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { MoneyInput } from './money-input';
4
+
5
+ describe('MoneyInput', () => {
6
+ it('renders a minor-unit USD value as major units', () => {
7
+ render(
8
+ <MoneyInput
9
+ aria-label="Price"
10
+ currency="USD"
11
+ hideHelpers
12
+ onChange={vi.fn()}
13
+ value={10000}
14
+ />
15
+ );
16
+
17
+ const input = screen.getByLabelText('Price') as HTMLInputElement;
18
+ expect(input).toHaveValue('100');
19
+ });
20
+
21
+ it('emits minor units (cents) for a USD entry', () => {
22
+ const onChange = vi.fn();
23
+ render(
24
+ <MoneyInput
25
+ aria-label="Price"
26
+ currency="USD"
27
+ hideHelpers
28
+ onChange={onChange}
29
+ value={undefined}
30
+ />
31
+ );
32
+
33
+ const input = screen.getByLabelText('Price') as HTMLInputElement;
34
+ fireEvent.focus(input);
35
+ fireEvent.change(input, {
36
+ target: { selectionStart: 4, value: '9.99' },
37
+ });
38
+
39
+ expect(onChange).toHaveBeenLastCalledWith(999);
40
+ });
41
+
42
+ it('treats zero-decimal currencies 1:1', () => {
43
+ const onChange = vi.fn();
44
+ render(
45
+ <MoneyInput
46
+ aria-label="Price"
47
+ currency="VND"
48
+ hideHelpers
49
+ onChange={onChange}
50
+ value={25000}
51
+ />
52
+ );
53
+
54
+ const input = screen.getByLabelText('Price') as HTMLInputElement;
55
+ // 25000 minor units == 25000 major units for VND (0 fraction digits).
56
+ expect(input.value.replace(/[^\d]/g, '')).toBe('25000');
57
+
58
+ fireEvent.focus(input);
59
+ fireEvent.change(input, {
60
+ target: { selectionStart: 3, value: '500' },
61
+ });
62
+ expect(onChange).toHaveBeenLastCalledWith(500);
63
+ });
64
+ });