@tuturuuu/ui 0.26.1 → 0.27.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 (26) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/biome.json +1 -1
  3. package/package.json +48 -48
  4. package/src/components/ui/custom/combobox.tsx +4 -0
  5. package/src/components/ui/custom/workspace-access/adapters.test.ts +89 -1
  6. package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
  7. package/src/components/ui/custom/workspace-access/types.ts +13 -0
  8. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
  9. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
  10. package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
  11. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
  12. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
  13. package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
  14. package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
  15. package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
  16. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
  17. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
  18. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
  19. package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
  20. package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
  21. package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
  22. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
  23. package/src/hooks/use-board-actions.test.ts +23 -0
  24. package/src/hooks/use-board-actions.ts +48 -35
  25. package/src/hooks/use-calendar-sync.tsx +12 -12
  26. package/src/hooks/use-notifications.ts +4 -8
@@ -0,0 +1,125 @@
1
+ 'use client';
2
+
3
+ import { ChevronDown, Plus, ShieldUser, X } from '@tuturuuu/icons';
4
+ import { Badge } from '@tuturuuu/ui/badge';
5
+ import { Button } from '@tuturuuu/ui/button';
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuCheckboxItem,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ } from '@tuturuuu/ui/dropdown-menu';
15
+ import { useTranslations } from 'next-intl';
16
+ import type { WorkspaceAccessRole } from './types';
17
+
18
+ type Props = {
19
+ email?: null | string;
20
+ isMutating: boolean;
21
+ onUpdate: (payload: {
22
+ email?: null | string;
23
+ roleIds: string[];
24
+ userId?: null | string;
25
+ }) => void;
26
+ assignedRoles: Array<Pick<WorkspaceAccessRole, 'id' | 'name'>>;
27
+ roles: Array<Pick<WorkspaceAccessRole, 'id' | 'name'>>;
28
+ userId?: null | string;
29
+ };
30
+
31
+ export function WorkspaceAccessInvitationRoleMenu({
32
+ email,
33
+ isMutating,
34
+ onUpdate,
35
+ assignedRoles,
36
+ roles,
37
+ userId,
38
+ }: Props) {
39
+ const t = useTranslations();
40
+ const assignedRoleIds = new Set(assignedRoles.map((role) => role.id));
41
+ const updateRole = (roleId: string) => {
42
+ const roleIds = assignedRoleIds.has(roleId)
43
+ ? assignedRoles
44
+ .filter((role) => role.id !== roleId)
45
+ .map((role) => role.id)
46
+ : [...assignedRoles.map((role) => role.id), roleId];
47
+ onUpdate({ email, roleIds, userId });
48
+ };
49
+
50
+ return (
51
+ <div className="space-y-2">
52
+ {assignedRoles.length > 0 ? (
53
+ <div className="flex flex-wrap gap-1.5">
54
+ {assignedRoles.map((role) => (
55
+ <Badge
56
+ key={role.id}
57
+ className="h-6 gap-1 border-dynamic-purple/35 bg-dynamic-purple/10 px-2 text-dynamic-purple text-xs"
58
+ >
59
+ <ShieldUser className="size-3" />
60
+ {role.name}
61
+ </Badge>
62
+ ))}
63
+ </div>
64
+ ) : null}
65
+ <DropdownMenu>
66
+ <DropdownMenuTrigger asChild>
67
+ <Button
68
+ variant="outline"
69
+ size="sm"
70
+ className={`h-7 max-w-full rounded-full px-2.5 text-xs active:scale-[0.98] ${assignedRoles.length > 0 ? 'border-dynamic-purple/40 bg-dynamic-purple/10 text-dynamic-purple hover:bg-dynamic-purple/15 hover:text-dynamic-purple' : 'border-dashed text-muted-foreground hover:text-foreground'}`}
71
+ disabled={isMutating}
72
+ >
73
+ {assignedRoles.length > 0 ? (
74
+ <ShieldUser className="size-3.5 shrink-0" />
75
+ ) : (
76
+ <Plus className="size-3.5 shrink-0" />
77
+ )}
78
+ <span className="truncate">
79
+ {assignedRoles.length > 0
80
+ ? t('ws-members.roles_selected', {
81
+ count: assignedRoles.length,
82
+ })
83
+ : t('ws-members.assign_invitation_role')}
84
+ </span>
85
+ <ChevronDown className="size-3 shrink-0 opacity-60" />
86
+ </Button>
87
+ </DropdownMenuTrigger>
88
+ <DropdownMenuContent align="start" className="w-60">
89
+ <DropdownMenuLabel className="flex items-center gap-2">
90
+ <ShieldUser className="size-4 text-muted-foreground" />
91
+ {t('ws-members.invitation_role')}
92
+ </DropdownMenuLabel>
93
+ <DropdownMenuSeparator />
94
+ {roles.map((option) => (
95
+ <DropdownMenuCheckboxItem
96
+ checked={assignedRoleIds.has(option.id)}
97
+ key={option.id}
98
+ onSelect={(event) => {
99
+ event.preventDefault();
100
+ updateRole(option.id);
101
+ }}
102
+ >
103
+ <ShieldUser className="size-4" />
104
+ <span className="flex-1 truncate">{option.name}</span>
105
+ </DropdownMenuCheckboxItem>
106
+ ))}
107
+ {assignedRoles.length > 0 ? (
108
+ <>
109
+ <DropdownMenuSeparator />
110
+ <DropdownMenuItem
111
+ onSelect={() => onUpdate({ email, roleIds: [], userId })}
112
+ >
113
+ <X className="size-4" />
114
+ {t('ws-members.clear_all_roles')}
115
+ </DropdownMenuItem>
116
+ </>
117
+ ) : null}
118
+ </DropdownMenuContent>
119
+ </DropdownMenu>
120
+ <p className="text-muted-foreground text-xs leading-4">
121
+ {t('ws-members.invitation_role_helper')}
122
+ </p>
123
+ </div>
124
+ );
125
+ }
@@ -0,0 +1,108 @@
1
+ 'use client';
2
+
3
+ import { CreditCard, ShieldUser, UserPlus } from '@tuturuuu/icons';
4
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
5
+ import { useTranslations } from 'next-intl';
6
+
7
+ type AccessPreset = 'guest' | 'member' | 'pos_operator';
8
+
9
+ const OPTIONS: Array<{
10
+ descriptionKey: string;
11
+ icon: typeof UserPlus;
12
+ labelKey: string;
13
+ shortLabelKey: string;
14
+ value: AccessPreset;
15
+ }> = [
16
+ {
17
+ descriptionKey: 'ws-members.invite_membership_member_description',
18
+ icon: UserPlus,
19
+ labelKey: 'ws-members.invite_membership_member',
20
+ shortLabelKey: 'ws-members.invite_access_member_tab',
21
+ value: 'member',
22
+ },
23
+ {
24
+ descriptionKey: 'ws-members.invite_membership_guest_description',
25
+ icon: ShieldUser,
26
+ labelKey: 'ws-members.invite_membership_guest',
27
+ shortLabelKey: 'ws-members.invite_access_guest_tab',
28
+ value: 'guest',
29
+ },
30
+ {
31
+ descriptionKey: 'ws-members.pos_operator_description',
32
+ icon: CreditCard,
33
+ labelKey: 'ws-members.invite_membership_pos_operator',
34
+ shortLabelKey: 'ws-members.invite_access_pos_tab',
35
+ value: 'pos_operator',
36
+ },
37
+ ];
38
+
39
+ export function WorkspaceAccessInviteAccessPicker({
40
+ canManageRoles,
41
+ onChange,
42
+ value,
43
+ }: {
44
+ canManageRoles: boolean;
45
+ onChange: (value: AccessPreset) => void;
46
+ value: AccessPreset;
47
+ }) {
48
+ const t = useTranslations() as (key: string) => string;
49
+ const options = canManageRoles
50
+ ? OPTIONS
51
+ : OPTIONS.filter((option) => option.value !== 'pos_operator');
52
+
53
+ return (
54
+ <div className="space-y-2">
55
+ <p className="font-medium text-sm">
56
+ {t('ws-members.invite_membership_label')}
57
+ </p>
58
+ <Tabs
59
+ value={value}
60
+ onValueChange={(nextValue) => onChange(nextValue as AccessPreset)}
61
+ >
62
+ <TabsList
63
+ aria-label={t('ws-members.invite_membership_label')}
64
+ className={`grid h-auto w-full gap-1 rounded-xl bg-muted/60 p-1 ${canManageRoles ? 'grid-cols-3' : 'grid-cols-2'}`}
65
+ >
66
+ {options.map((option) => {
67
+ const Icon = option.icon;
68
+
69
+ return (
70
+ <TabsTrigger
71
+ key={option.value}
72
+ value={option.value}
73
+ className="min-h-10 min-w-0 rounded-lg px-2.5 py-2 data-[state=active]:text-dynamic-blue"
74
+ >
75
+ <Icon className="size-4" />
76
+ <span className="truncate">{t(option.shortLabelKey)}</span>
77
+ </TabsTrigger>
78
+ );
79
+ })}
80
+ </TabsList>
81
+
82
+ {options.map((option) => {
83
+ const Icon = option.icon;
84
+
85
+ return (
86
+ <TabsContent
87
+ key={option.value}
88
+ value={option.value}
89
+ className="mt-1 rounded-xl border bg-background px-3.5 py-3"
90
+ >
91
+ <div className="flex items-start gap-3">
92
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/30 text-dynamic-blue">
93
+ <Icon className="size-4" />
94
+ </span>
95
+ <div className="min-w-0">
96
+ <p className="font-medium text-sm">{t(option.labelKey)}</p>
97
+ <p className="mt-0.5 text-muted-foreground text-xs leading-5">
98
+ {t(option.descriptionKey)}
99
+ </p>
100
+ </div>
101
+ </div>
102
+ </TabsContent>
103
+ );
104
+ })}
105
+ </Tabs>
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,134 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import type { ComponentProps } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { WorkspaceAccessInviteDialog } from './workspace-access-invite-dialog';
5
+
6
+ vi.mock('next-intl', () => ({
7
+ useTranslations: () => (key: string, values?: Record<string, number>) =>
8
+ values?.count === undefined ? key : `${values.count} ${key}`,
9
+ }));
10
+
11
+ describe('WorkspaceAccessInviteDialog', () => {
12
+ it('selects multiple workspace roles before the invitation is sent', () => {
13
+ const props = {
14
+ accessPreset: 'member',
15
+ canManageRoles: true,
16
+ confirmDefaultAdminMigration: false,
17
+ defaultAdminEnabled: false,
18
+ emails: 'editor@example.com',
19
+ isSubmitting: false,
20
+ joinedMemberCount: 1,
21
+ noRoleLabel: 'No assigned roles',
22
+ onAccessPresetChange: vi.fn(),
23
+ onConfirmDefaultAdminMigrationChange: vi.fn(),
24
+ onEmailsChange: vi.fn(),
25
+ onOpenChange: vi.fn(),
26
+ onRoleIdsChange: vi.fn(),
27
+ onSubmit: vi.fn(),
28
+ open: true,
29
+ roleIds: [],
30
+ roles: [
31
+ { id: 'role-editor', name: 'Editor' },
32
+ { id: 'role-reviewer', name: 'Reviewer' },
33
+ ],
34
+ } as ComponentProps<typeof WorkspaceAccessInviteDialog> & {
35
+ noRoleLabel: string;
36
+ onRoleIdsChange: (roleIds: string[]) => void;
37
+ roleIds: string[];
38
+ roles: Array<{ id: string; name: string }>;
39
+ };
40
+
41
+ const { rerender } = render(<WorkspaceAccessInviteDialog {...props} />);
42
+
43
+ expect(
44
+ screen.getByRole('tab', {
45
+ name: 'ws-members.invite_access_member_tab',
46
+ })
47
+ ).toHaveAttribute('data-state', 'active');
48
+ expect(screen.getByText('ws-members.role-placeholder')).toBeDefined();
49
+ expect(
50
+ screen.getByPlaceholderText('ws-members.invite_roles_search')
51
+ ).toBeDefined();
52
+
53
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Editor' }));
54
+ expect(props.onRoleIdsChange).toHaveBeenLastCalledWith(['role-editor']);
55
+
56
+ rerender(
57
+ <WorkspaceAccessInviteDialog {...props} roleIds={['role-editor']} />
58
+ );
59
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Reviewer' }));
60
+ expect(props.onRoleIdsChange).toHaveBeenLastCalledWith([
61
+ 'role-editor',
62
+ 'role-reviewer',
63
+ ]);
64
+
65
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Editor' }));
66
+ expect(props.onRoleIdsChange).toHaveBeenLastCalledWith([]);
67
+
68
+ fireEvent.mouseDown(
69
+ screen.getByRole('tab', {
70
+ name: 'ws-members.invite_access_guest_tab',
71
+ })
72
+ );
73
+ expect(props.onAccessPresetChange).toHaveBeenLastCalledWith('guest');
74
+
75
+ rerender(
76
+ <WorkspaceAccessInviteDialog
77
+ {...props}
78
+ accessPreset="guest"
79
+ roleIds={[]}
80
+ />
81
+ );
82
+ expect(screen.queryByRole('checkbox', { name: 'Editor' })).toBeNull();
83
+ });
84
+
85
+ it('filters roles, clears the selection, and lets the role section collapse', () => {
86
+ const onRoleIdsChange = vi.fn();
87
+
88
+ render(
89
+ <WorkspaceAccessInviteDialog
90
+ accessPreset="member"
91
+ canManageRoles
92
+ confirmDefaultAdminMigration={false}
93
+ defaultAdminEnabled={false}
94
+ emails="editor@example.com"
95
+ isSubmitting={false}
96
+ joinedMemberCount={1}
97
+ noRoleLabel="No assigned roles"
98
+ onAccessPresetChange={vi.fn()}
99
+ onConfirmDefaultAdminMigrationChange={vi.fn()}
100
+ onEmailsChange={vi.fn()}
101
+ onOpenChange={vi.fn()}
102
+ onRoleIdsChange={onRoleIdsChange}
103
+ onSubmit={vi.fn()}
104
+ open
105
+ roleIds={['role-editor', 'role-reviewer']}
106
+ roles={[
107
+ { id: 'role-editor', name: 'Editor' },
108
+ { id: 'role-reviewer', name: 'Reviewer' },
109
+ ]}
110
+ />
111
+ );
112
+
113
+ fireEvent.change(
114
+ screen.getByPlaceholderText('ws-members.invite_roles_search'),
115
+ { target: { value: 'review' } }
116
+ );
117
+ expect(screen.queryByRole('checkbox', { name: 'Editor' })).toBeNull();
118
+ expect(screen.getByRole('checkbox', { name: 'Reviewer' })).toBeDefined();
119
+
120
+ fireEvent.click(
121
+ screen.getByRole('button', { name: 'ws-members.clear_all_roles' })
122
+ );
123
+ expect(onRoleIdsChange).toHaveBeenLastCalledWith([]);
124
+
125
+ fireEvent.click(
126
+ screen.getByRole('button', {
127
+ name: /ws-members\.role-placeholder/,
128
+ })
129
+ );
130
+ expect(
131
+ screen.queryByPlaceholderText('ws-members.invite_roles_search')
132
+ ).toBeNull();
133
+ });
134
+ });
@@ -1,15 +1,8 @@
1
1
  'use client';
2
2
 
3
- import {
4
- CreditCard,
5
- MailPlus,
6
- ShieldCheck,
7
- TriangleAlert,
8
- UserPlus,
9
- } from '@tuturuuu/icons';
3
+ import { MailPlus, UserPlus } from '@tuturuuu/icons';
10
4
  import { Badge } from '@tuturuuu/ui/badge';
11
5
  import { Button } from '@tuturuuu/ui/button';
12
- import { Checkbox } from '@tuturuuu/ui/checkbox';
13
6
  import {
14
7
  Dialog,
15
8
  DialogContent,
@@ -19,16 +12,12 @@ import {
19
12
  DialogTitle,
20
13
  } from '@tuturuuu/ui/dialog';
21
14
  import { Label } from '@tuturuuu/ui/label';
22
- import {
23
- Select,
24
- SelectContent,
25
- SelectItem,
26
- SelectTrigger,
27
- SelectValue,
28
- } from '@tuturuuu/ui/select';
29
15
  import { Textarea } from '@tuturuuu/ui/textarea';
30
16
  import { useTranslations } from 'next-intl';
31
17
  import { parseInviteEmails } from './member-filter-utils';
18
+ import { WorkspaceAccessInviteAccessPicker } from './workspace-access-invite-access-picker';
19
+ import { WorkspaceAccessInvitePosPanel } from './workspace-access-invite-pos-panel';
20
+ import { WorkspaceAccessInviteRolePicker } from './workspace-access-invite-role-picker';
32
21
 
33
22
  type Props = {
34
23
  accessPreset: 'guest' | 'member' | 'pos_operator';
@@ -38,12 +27,16 @@ type Props = {
38
27
  emails: string;
39
28
  isSubmitting: boolean;
40
29
  joinedMemberCount: number;
30
+ noRoleLabel: string;
41
31
  onAccessPresetChange: (value: 'guest' | 'member' | 'pos_operator') => void;
42
32
  onConfirmDefaultAdminMigrationChange: (value: boolean) => void;
43
33
  onEmailsChange: (value: string) => void;
44
34
  onOpenChange: (open: boolean) => void;
35
+ onRoleIdsChange: (roleIds: string[]) => void;
45
36
  onSubmit: () => void;
46
37
  open: boolean;
38
+ roleIds: string[];
39
+ roles: Array<{ id: string; name: string }>;
47
40
  };
48
41
 
49
42
  export function WorkspaceAccessInviteDialog({
@@ -54,19 +47,32 @@ export function WorkspaceAccessInviteDialog({
54
47
  emails,
55
48
  isSubmitting,
56
49
  joinedMemberCount,
50
+ noRoleLabel,
57
51
  onAccessPresetChange,
58
52
  onConfirmDefaultAdminMigrationChange,
59
53
  onEmailsChange,
60
54
  onOpenChange,
55
+ onRoleIdsChange,
61
56
  onSubmit,
62
57
  open,
58
+ roleIds,
59
+ roles,
63
60
  }: Props) {
64
- const t = useTranslations() as (key: string) => string;
61
+ const t = useTranslations();
65
62
  const count = parseInviteEmails(emails).length;
63
+ const selectedRoleNames = roles
64
+ .filter((role) => roleIds.includes(role.id))
65
+ .map((role) => role.name);
66
+ const accessSummary =
67
+ accessPreset === 'member'
68
+ ? selectedRoleNames.length > 0
69
+ ? `${t('ws-members.invite_access_member_tab')} · ${selectedRoleNames.join(', ')}`
70
+ : `${t('ws-members.invite_access_member_tab')} · ${t('ws-members.no_roles_assigned')}`
71
+ : t(`ws-members.invite_membership_${accessPreset}`);
66
72
 
67
73
  return (
68
74
  <Dialog open={open} onOpenChange={onOpenChange}>
69
- <DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-none flex-col gap-0 overflow-hidden rounded-b-none p-0 max-sm:top-auto max-sm:bottom-0 max-sm:left-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-h-[min(88dvh,52rem)] sm:max-w-xl sm:rounded-lg">
75
+ <DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-none flex-col gap-0 overflow-hidden rounded-b-none p-0 max-sm:top-auto max-sm:bottom-0 max-sm:left-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-h-[min(90dvh,54rem)] sm:max-w-4xl sm:rounded-2xl">
70
76
  <DialogHeader className="shrink-0 gap-0 border-b p-4 pr-12 text-left sm:p-6 sm:pr-12">
71
77
  <div className="flex items-start gap-3">
72
78
  <div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-dynamic-blue/25 bg-dynamic-blue/10 text-dynamic-blue">
@@ -81,114 +87,89 @@ export function WorkspaceAccessInviteDialog({
81
87
  </div>
82
88
  </DialogHeader>
83
89
 
84
- <div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain p-4 sm:p-6">
85
- <div className="grid gap-2">
86
- <Label htmlFor="workspace-access-invite-emails">
87
- {t('ws-members.invite_member')}
88
- </Label>
89
- <Textarea
90
- id="workspace-access-invite-emails"
91
- rows={5}
92
- value={emails}
93
- onChange={(event) => onEmailsChange(event.target.value)}
94
- placeholder={'one@example.com\ntwo@example.com'}
95
- className="min-h-28 resize-y"
96
- />
97
- </div>
98
-
99
- <div className="grid gap-2">
100
- <Label htmlFor="workspace-access-invite-membership">
101
- {t('ws-members.invite_membership_label')}
102
- </Label>
103
- <Select
104
- value={accessPreset}
105
- onValueChange={(value) =>
106
- onAccessPresetChange(
107
- value as 'guest' | 'member' | 'pos_operator'
108
- )
109
- }
110
- >
111
- <SelectTrigger
112
- id="workspace-access-invite-membership"
113
- className="w-full"
114
- >
115
- <SelectValue />
116
- </SelectTrigger>
117
- <SelectContent>
118
- <SelectItem value="member">
119
- {t('ws-members.invite_membership_member')}
120
- </SelectItem>
121
- <SelectItem value="guest">
122
- {t('ws-members.invite_membership_guest')}
123
- </SelectItem>
124
- <SelectItem value="pos_operator" disabled={!canManageRoles}>
125
- {t('ws-members.invite_membership_pos_operator')}
126
- </SelectItem>
127
- </SelectContent>
128
- </Select>
129
- </div>
130
-
131
- {accessPreset === 'pos_operator' ? (
132
- <div className="space-y-3 rounded-xl border border-dynamic-blue/25 bg-dynamic-blue/5 p-3.5 sm:p-4">
133
- <div className="flex items-start gap-3">
134
- <div className="mt-0.5 rounded-lg border border-dynamic-blue/20 bg-background p-2 text-dynamic-blue">
135
- <CreditCard className="h-4 w-4" />
136
- </div>
137
- <div className="min-w-0 space-y-1">
138
- <p className="font-medium text-sm">
139
- {t('ws-members.pos_operator_title')}
140
- </p>
141
- <p className="text-muted-foreground text-sm leading-5">
142
- {t('ws-members.pos_operator_description')}
143
- </p>
90
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
91
+ <div className="grid lg:grid-cols-[minmax(0,1fr)_minmax(22rem,1fr)] lg:divide-x">
92
+ <section className="space-y-4 p-4 sm:p-6">
93
+ <div className="flex items-center gap-2">
94
+ <span className="flex size-6 items-center justify-center rounded-full bg-foreground font-semibold text-background text-xs">
95
+ 1
96
+ </span>
97
+ <h3 className="font-semibold text-sm">
98
+ {t('ws-members.invite_recipients_title')}
99
+ </h3>
100
+ </div>
101
+ <div className="grid gap-2">
102
+ <Label htmlFor="workspace-access-invite-emails">
103
+ {t('ws-members.invite_emails_label')}
104
+ </Label>
105
+ <Textarea
106
+ id="workspace-access-invite-emails"
107
+ rows={7}
108
+ value={emails}
109
+ onChange={(event) => onEmailsChange(event.target.value)}
110
+ placeholder={'one@example.com\ntwo@example.com'}
111
+ className="min-h-40 resize-y rounded-xl bg-muted/20 font-mono text-sm leading-6"
112
+ />
113
+ <div className="flex items-center justify-between gap-3 text-muted-foreground text-xs">
114
+ <span>{t('ws-members.invite_emails_helper')}</span>
115
+ <Badge variant="outline" className="rounded-full">
116
+ {t('ws-members.recipients_count', { count })}
117
+ </Badge>
144
118
  </div>
145
119
  </div>
120
+ </section>
146
121
 
147
- <div className="grid gap-2 sm:grid-cols-2">
148
- {defaultAdminEnabled ? (
149
- <div className="flex items-center gap-2 rounded-lg border bg-background/70 p-3 text-sm">
150
- <ShieldCheck className="h-4 w-4 text-dynamic-green" />
151
- <span>
152
- {joinedMemberCount}{' '}
153
- {t('ws-members.pos_operator_existing_members')}
154
- </span>
155
- </div>
156
- ) : null}
157
- <div className="flex items-center gap-2 rounded-lg border bg-background/70 p-3 text-sm">
158
- <CreditCard className="h-4 w-4 text-dynamic-blue" />
159
- <span>{t('ws-members.pos_operator_only_permission')}</span>
160
- </div>
122
+ <section className="space-y-5 bg-muted/10 p-4 sm:p-6">
123
+ <div className="flex items-center gap-2">
124
+ <span className="flex size-6 items-center justify-center rounded-full bg-foreground font-semibold text-background text-xs">
125
+ 2
126
+ </span>
127
+ <h3 className="font-semibold text-sm">
128
+ {t('ws-members.invite_access_title')}
129
+ </h3>
161
130
  </div>
162
131
 
163
- {defaultAdminEnabled ? (
164
- <div className="flex items-start gap-2 rounded-lg border border-dynamic-orange/25 bg-dynamic-orange/5 p-3 text-sm">
165
- <TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-dynamic-orange" />
166
- <p className="text-muted-foreground leading-5">
167
- {t('ws-members.pos_operator_admin_migration_note')}
168
- </p>
169
- </div>
132
+ <WorkspaceAccessInviteAccessPicker
133
+ canManageRoles={canManageRoles}
134
+ onChange={onAccessPresetChange}
135
+ value={accessPreset}
136
+ />
137
+
138
+ {canManageRoles && accessPreset === 'member' ? (
139
+ <WorkspaceAccessInviteRolePicker
140
+ noRoleLabel={noRoleLabel}
141
+ onChange={onRoleIdsChange}
142
+ roleIds={roleIds}
143
+ roles={roles}
144
+ />
170
145
  ) : null}
171
146
 
172
- <label className="flex cursor-pointer items-start gap-3 rounded-lg border bg-background p-3">
173
- <Checkbox
174
- checked={confirmDefaultAdminMigration}
175
- className="mt-0.5"
176
- onCheckedChange={(checked) =>
177
- onConfirmDefaultAdminMigrationChange(checked === true)
147
+ {accessPreset === 'pos_operator' ? (
148
+ <WorkspaceAccessInvitePosPanel
149
+ confirmDefaultAdminMigration={confirmDefaultAdminMigration}
150
+ defaultAdminEnabled={defaultAdminEnabled}
151
+ joinedMemberCount={joinedMemberCount}
152
+ onConfirmDefaultAdminMigrationChange={
153
+ onConfirmDefaultAdminMigrationChange
178
154
  }
179
155
  />
180
- <span className="text-sm leading-5">
181
- {t('ws-members.pos_operator_confirmation')}
182
- </span>
183
- </label>
184
- </div>
185
- ) : null}
156
+ ) : null}
157
+ </section>
158
+ </div>
186
159
  </div>
187
160
 
188
161
  <DialogFooter className="grid shrink-0 grid-cols-[auto_1fr] items-center gap-3 border-t bg-muted/20 p-3 sm:flex sm:p-4">
189
- <Badge variant="secondary" className="rounded-full">
190
- {count} {t('ws-members.pending_invitations').toLowerCase()}
191
- </Badge>
162
+ <div className="min-w-0">
163
+ <p className="font-medium text-sm">
164
+ {t('ws-members.recipients_count', { count })}
165
+ </p>
166
+ <p
167
+ className="truncate text-muted-foreground text-xs"
168
+ title={accessSummary}
169
+ >
170
+ {accessSummary}
171
+ </p>
172
+ </div>
192
173
  <Button
193
174
  className="min-w-0"
194
175
  disabled={