Skip to content

Commit 47b1f90

Browse files
authored
[PT2025BMHW-119][SuperAdminUI,SharedUI] feat: Implement Global Users Management Page (#147)
1 parent a7ec71f commit 47b1f90

File tree

16 files changed

+2091
-502
lines changed

16 files changed

+2091
-502
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { Button, Icon, Modal, Spinner, features } from '@ngo-platform/shared';
2+
import { type FC, useState } from 'react';
3+
4+
// We'll reimplement a simple Combobox here since the shared one might be more complex
5+
// or require specific props we don't want to deal with right now.
6+
// Ideally we should reuse the shared Combobox.
7+
8+
interface AssignAdminModalProps {
9+
isOpen: boolean;
10+
onClose: () => void;
11+
tenantId: string;
12+
onCreateNew: () => void; // Callback to switch to "Create New" modal
13+
}
14+
15+
export const AssignAdminModal: FC<AssignAdminModalProps> = ({
16+
isOpen,
17+
onClose,
18+
tenantId,
19+
onCreateNew,
20+
}) => {
21+
const [searchTerm, setSearchTerm] = useState('');
22+
const [selectedUser, setSelectedUser] = useState<string | null>(null);
23+
24+
// Queries
25+
const { data: usersData, isLoading: isUsersLoading } = features.useUsers({
26+
pageNumber: 1,
27+
pageSize: 5,
28+
searchTerm: searchTerm,
29+
isActive: true,
30+
});
31+
32+
const assignAdmin = features.useAssignTenantAdmin();
33+
34+
const handleAssign = async () => {
35+
if (!selectedUser) return;
36+
try {
37+
await assignAdmin.mutateAsync({
38+
tenantId,
39+
adminId: selectedUser,
40+
});
41+
onClose();
42+
setSearchTerm('');
43+
setSelectedUser(null);
44+
} catch {
45+
// Error handled by mutation
46+
}
47+
};
48+
49+
const users = usersData?.items || [];
50+
51+
return (
52+
<Modal
53+
isOpen={isOpen}
54+
onClose={onClose}
55+
title="Assign Administrator"
56+
size="md"
57+
footer={
58+
<>
59+
<Button variant="ghost" onClick={onClose} disabled={assignAdmin.isPending}>
60+
Cancel
61+
</Button>
62+
<Button
63+
variant="primary"
64+
onClick={handleAssign}
65+
loading={assignAdmin.isPending}
66+
disabled={!selectedUser}
67+
>
68+
Assign Selected
69+
</Button>
70+
</>
71+
}
72+
>
73+
<div className="space-y-6">
74+
<div className="bg-primary-50 dark:bg-primary-900/10 p-4 rounded-lg flex items-start gap-3">
75+
<Icon name="info" className="text-primary-600 dark:text-primary-400 mt-0.5" size={18} />
76+
<div>
77+
<h4 className="text-sm font-semibold text-primary-800 dark:text-primary-300">
78+
Assign Existing User
79+
</h4>
80+
<p className="text-sm text-primary-700 dark:text-primary-400 mt-1">
81+
Search for an existing user to assign them as an admin for this tenant.
82+
If the user doesn't exist, create a new one instead.
83+
</p>
84+
</div>
85+
</div>
86+
87+
<div className="space-y-2">
88+
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
89+
Search Users
90+
</label>
91+
<div className="relative">
92+
<Icon name="search" className="absolute left-3 top-2.5 text-neutral-400" size={18} />
93+
<input
94+
type="text"
95+
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 rounded-lg focus:ring-2 focus:ring-primary-500 outline-none transition-all"
96+
placeholder="Search by name or email..."
97+
value={searchTerm}
98+
onChange={(e) => setSearchTerm(e.target.value)}
99+
/>
100+
</div>
101+
102+
<div className="mt-2 border border-neutral-200 dark:border-neutral-700 rounded-lg max-h-60 overflow-y-auto">
103+
{isUsersLoading ? (
104+
<div className="p-4 flex justify-center">
105+
<Spinner size="sm" />
106+
</div>
107+
) : users.length === 0 ? (
108+
<div className="p-4 text-center text-sm text-neutral-500">
109+
{searchTerm ? 'No users found.' : 'Type to search users...'}
110+
</div>
111+
) : (
112+
<ul className="divide-y divide-neutral-100 dark:divide-neutral-800">
113+
{users.map((user) => (
114+
<li
115+
key={user.id}
116+
className={`
117+
p-3 cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors flex items-center justify-between
118+
${selectedUser === user.id ? 'bg-primary-50 dark:bg-primary-900/20' : ''}
119+
`}
120+
onClick={() => setSelectedUser(user.id)}
121+
>
122+
<div className="flex items-center gap-3">
123+
<div className="w-8 h-8 rounded-full bg-neutral-200 dark:bg-neutral-700 flex items-center justify-center text-xs font-bold">
124+
{user.firstName.charAt(0)}
125+
</div>
126+
<div>
127+
<p className="text-sm font-medium text-neutral-900 dark:text-white">
128+
{user.fullName}
129+
</p>
130+
<p className="text-xs text-neutral-500">
131+
{user.email}
132+
</p>
133+
</div>
134+
</div>
135+
{selectedUser === user.id && (
136+
<Icon name="check" className="text-primary-600" size={16} />
137+
)}
138+
</li>
139+
))}
140+
</ul>
141+
)}
142+
</div>
143+
</div>
144+
145+
<div className="relative">
146+
<div className="absolute inset-0 flex items-center">
147+
<span className="w-full border-t border-neutral-200 dark:border-neutral-700" />
148+
</div>
149+
<div className="relative flex justify-center text-xs uppercase">
150+
<span className="bg-white dark:bg-neutral-900 px-2 text-neutral-500">
151+
Or
152+
</span>
153+
</div>
154+
</div>
155+
156+
<Button variant="outline" fullWidth onClick={onCreateNew}>
157+
<Icon name="plus" className="mr-2" size={16} />
158+
Create New Admin User
159+
</Button>
160+
</div>
161+
</Modal>
162+
);
163+
};
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Alert, Button, Icon, Input, Modal, Spinner, features, useToast, type Tenant } from '@ngo-platform/shared';
2+
import { type FC, useEffect, useState } from 'react';
3+
4+
interface DeleteTenantDialogProps {
5+
isOpen: boolean;
6+
onClose: () => void;
7+
onSuccess: (tenantId: string) => void;
8+
tenant: Tenant;
9+
}
10+
11+
export const DeleteTenantDialog: FC<DeleteTenantDialogProps> = ({
12+
isOpen,
13+
onClose,
14+
onSuccess,
15+
tenant,
16+
}) => {
17+
const { success, error } = useToast();
18+
const [confirmName, setConfirmName] = useState('');
19+
const [isDeleting, setIsDeleting] = useState(false);
20+
21+
// Reset state when dialog opens/closes
22+
useEffect(() => {
23+
if (isOpen) {
24+
setConfirmName('');
25+
}
26+
}, [isOpen]);
27+
28+
const deleteTenantMutation = features.useDeleteTenant();
29+
const isNameMatch = confirmName === tenant.name;
30+
31+
const handleDelete = async () => {
32+
if (!isNameMatch) return;
33+
34+
setIsDeleting(true);
35+
try {
36+
await deleteTenantMutation.mutateAsync(tenant.id);
37+
success('Tenant deleted', `${tenant.name} has been permanently deleted.`);
38+
onSuccess(tenant.id);
39+
onClose();
40+
} catch (err) {
41+
error('Failed to delete tenant', err instanceof Error ? err.message : 'Unknown error occurred');
42+
} finally {
43+
setIsDeleting(false);
44+
}
45+
};
46+
47+
return (
48+
<Modal
49+
isOpen={isOpen}
50+
onClose={onClose}
51+
title="Delete Tenant"
52+
size="lg"
53+
>
54+
<div className="space-y-6">
55+
<div className="flex items-center gap-2 text-error-600 mb-4">
56+
<Icon name="alertTriangle" size={24} />
57+
<span className="text-lg font-semibold">Warning: Irreversible Action</span>
58+
</div>
59+
<Alert variant="danger" title="Warning: Irreversible Action">
60+
<p>
61+
You are about to permanently delete <strong>{tenant.name}</strong>.
62+
This action cannot be undone. All associated data, including users, settings, and historical records, will be removed.
63+
</p>
64+
</Alert>
65+
66+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
67+
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex flex-col items-center justify-center text-center">
68+
<Icon name="users" size={24} className="text-neutral-400 mb-2" />
69+
<span className="text-2xl font-bold text-neutral-900 dark:text-white">
70+
{tenant.userCount ?? 0}
71+
</span>
72+
<span className="text-xs text-neutral-500 uppercase tracking-wide">Active Users</span>
73+
</div>
74+
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex flex-col items-center justify-center text-center">
75+
{/* Placeholder for tasks until backend supports it */}
76+
<Icon name="checkCircle" size={24} className="text-neutral-400 mb-2" />
77+
<span className="text-2xl font-bold text-neutral-900 dark:text-white">N/A</span>
78+
<span className="text-xs text-neutral-500 uppercase tracking-wide">Pending Tasks</span>
79+
</div>
80+
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 flex flex-col items-center justify-center text-center">
81+
<Icon name="calendar" size={24} className="text-neutral-400 mb-2" />
82+
<span className="text-base font-semibold text-neutral-900 dark:text-white">
83+
{new Date(tenant.createdAt).toLocaleDateString()}
84+
</span>
85+
<span className="text-xs text-neutral-500 uppercase tracking-wide">Created</span>
86+
</div>
87+
</div>
88+
89+
<div className="space-y-3">
90+
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
91+
To confirm, type <span className="font-bold select-all">{tenant.name}</span> below:
92+
</label>
93+
<div className="relative">
94+
<Input
95+
value={confirmName}
96+
onChange={(e) => setConfirmName(e.target.value)}
97+
placeholder={tenant.name}
98+
className={isNameMatch ? 'border-success-500 focus:border-success-500 focus:ring-success-500' : ''}
99+
disabled={isDeleting}
100+
/>
101+
{isNameMatch && (
102+
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-success-500 animate-in fade-in zoom-in duration-200">
103+
<Icon name="check" size={18} />
104+
</div>
105+
)}
106+
</div>
107+
</div>
108+
109+
<div className="flex justify-end gap-3 pt-2">
110+
<Button variant="ghost" onClick={onClose} disabled={isDeleting}>
111+
Cancel
112+
</Button>
113+
<Button
114+
variant="danger"
115+
onClick={handleDelete}
116+
disabled={!isNameMatch || isDeleting}
117+
className="min-w-[120px]"
118+
>
119+
{isDeleting ? <Spinner size="sm" className="mr-2" /> : <Icon name="trash" size={16} className="mr-2" />}
120+
{isDeleting ? 'Deleting...' : 'Delete Tenant'}
121+
</Button>
122+
</div>
123+
</div>
124+
</Modal>
125+
);
126+
};

0 commit comments

Comments
 (0)