@greatapps/greatauth-ui 0.3.7 → 0.3.9
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.
- package/dist/index.d.ts +102 -1
- package/dist/index.js +940 -5
- package/dist/index.js.map +1 -1
- package/package.json +10 -7
- package/src/components/users/data-table.tsx +185 -0
- package/src/components/users/user-form-dialog.tsx +166 -0
- package/src/components/users/user-profile-badge.tsx +31 -0
- package/src/components/users/users-page.tsx +271 -0
- package/src/hooks/use-users.ts +82 -0
- package/src/index.ts +16 -0
- package/src/types/users.ts +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@greatapps/greatauth-ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -50,16 +50,19 @@
|
|
|
50
50
|
"class-variance-authority": "^0.7"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"react": "^19",
|
|
56
|
-
"react-dom": "^19",
|
|
53
|
+
"@tanstack/react-query": "^5.90.21",
|
|
54
|
+
"@tanstack/react-table": "^8.21.3",
|
|
57
55
|
"@types/react": "latest",
|
|
58
56
|
"@types/react-dom": "latest",
|
|
59
|
-
"next": "latest",
|
|
60
57
|
"better-auth": "latest",
|
|
58
|
+
"cmdk": "^1",
|
|
59
|
+
"date-fns": "^4.1.0",
|
|
60
|
+
"next": "latest",
|
|
61
61
|
"next-themes": "latest",
|
|
62
62
|
"radix-ui": "^1.4",
|
|
63
|
-
"
|
|
63
|
+
"react": "^19",
|
|
64
|
+
"react-dom": "^19",
|
|
65
|
+
"tsup": "latest",
|
|
66
|
+
"typescript": "^5"
|
|
64
67
|
}
|
|
65
68
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ColumnDef,
|
|
5
|
+
type SortingState,
|
|
6
|
+
flexRender,
|
|
7
|
+
getCoreRowModel,
|
|
8
|
+
getSortedRowModel,
|
|
9
|
+
useReactTable,
|
|
10
|
+
} from "@tanstack/react-table";
|
|
11
|
+
import { useState } from "react";
|
|
12
|
+
import {
|
|
13
|
+
Table,
|
|
14
|
+
TableBody,
|
|
15
|
+
TableCell,
|
|
16
|
+
TableHead,
|
|
17
|
+
TableHeader,
|
|
18
|
+
TableRow,
|
|
19
|
+
} from "../ui/table";
|
|
20
|
+
import { Button } from "../ui/button";
|
|
21
|
+
import { Skeleton } from "../ui/skeleton";
|
|
22
|
+
import { cn } from "../../lib/utils";
|
|
23
|
+
import { ChevronLeft, ChevronRight, ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
|
24
|
+
|
|
25
|
+
export interface DataTableProps<TData, TValue> {
|
|
26
|
+
columns: ColumnDef<TData, TValue>[];
|
|
27
|
+
data: TData[];
|
|
28
|
+
isLoading?: boolean;
|
|
29
|
+
emptyMessage?: string;
|
|
30
|
+
total?: number;
|
|
31
|
+
page?: number;
|
|
32
|
+
onPageChange?: (page: number) => void;
|
|
33
|
+
pageSize?: number;
|
|
34
|
+
onRowClick?: (row: TData) => void;
|
|
35
|
+
selectedRowId?: string | number | null;
|
|
36
|
+
getRowId?: (row: TData) => string | number;
|
|
37
|
+
compact?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function DataTable<TData, TValue>({
|
|
41
|
+
columns,
|
|
42
|
+
data,
|
|
43
|
+
isLoading,
|
|
44
|
+
emptyMessage = "Nenhum registro encontrado",
|
|
45
|
+
total,
|
|
46
|
+
page = 1,
|
|
47
|
+
onPageChange,
|
|
48
|
+
pageSize = 15,
|
|
49
|
+
onRowClick,
|
|
50
|
+
selectedRowId,
|
|
51
|
+
getRowId,
|
|
52
|
+
compact,
|
|
53
|
+
}: DataTableProps<TData, TValue>) {
|
|
54
|
+
const [sorting, setSorting] = useState<SortingState>([]);
|
|
55
|
+
|
|
56
|
+
const table = useReactTable({
|
|
57
|
+
data,
|
|
58
|
+
columns,
|
|
59
|
+
getCoreRowModel: getCoreRowModel(),
|
|
60
|
+
getSortedRowModel: getSortedRowModel(),
|
|
61
|
+
onSortingChange: setSorting,
|
|
62
|
+
state: { sorting },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const totalPages = total ? Math.ceil(total / pageSize) : 1;
|
|
66
|
+
const showPagination = onPageChange && total && total > pageSize;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="space-y-4">
|
|
70
|
+
<div className="rounded-md border">
|
|
71
|
+
<Table>
|
|
72
|
+
<TableHeader>
|
|
73
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
74
|
+
<TableRow key={headerGroup.id}>
|
|
75
|
+
{headerGroup.headers.map((header) => (
|
|
76
|
+
<TableHead
|
|
77
|
+
key={header.id}
|
|
78
|
+
className={cn(compact && "py-1.5 text-xs")}
|
|
79
|
+
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
|
|
80
|
+
>
|
|
81
|
+
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="flex items-center gap-1 hover:text-foreground -ml-1 px-1 py-0.5 rounded cursor-pointer select-none"
|
|
85
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
86
|
+
>
|
|
87
|
+
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
88
|
+
{{
|
|
89
|
+
asc: <ArrowUp className="h-3.5 w-3.5" />,
|
|
90
|
+
desc: <ArrowDown className="h-3.5 w-3.5" />,
|
|
91
|
+
}[header.column.getIsSorted() as string] ?? (
|
|
92
|
+
<ArrowUpDown className="h-3.5 w-3.5 text-muted-foreground/50" />
|
|
93
|
+
)}
|
|
94
|
+
</button>
|
|
95
|
+
) : (
|
|
96
|
+
flexRender(header.column.columnDef.header, header.getContext())
|
|
97
|
+
)}
|
|
98
|
+
</TableHead>
|
|
99
|
+
))}
|
|
100
|
+
</TableRow>
|
|
101
|
+
))}
|
|
102
|
+
</TableHeader>
|
|
103
|
+
<TableBody>
|
|
104
|
+
{isLoading ? (
|
|
105
|
+
Array.from({ length: compact ? 3 : 5 }).map((_, i) => (
|
|
106
|
+
<TableRow key={i}>
|
|
107
|
+
{columns.map((_, j) => (
|
|
108
|
+
<TableCell key={j} className={cn(compact && "py-1.5")}>
|
|
109
|
+
<Skeleton className="h-4 w-full max-w-[120px]" />
|
|
110
|
+
</TableCell>
|
|
111
|
+
))}
|
|
112
|
+
</TableRow>
|
|
113
|
+
))
|
|
114
|
+
) : table.getRowModel().rows.length === 0 ? (
|
|
115
|
+
<TableRow>
|
|
116
|
+
<TableCell
|
|
117
|
+
colSpan={columns.length}
|
|
118
|
+
className={cn(
|
|
119
|
+
"text-center text-muted-foreground",
|
|
120
|
+
compact ? "py-4" : "py-8",
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
{emptyMessage}
|
|
124
|
+
</TableCell>
|
|
125
|
+
</TableRow>
|
|
126
|
+
) : (
|
|
127
|
+
table.getRowModel().rows.map((row) => {
|
|
128
|
+
const rowId = getRowId ? getRowId(row.original) : undefined;
|
|
129
|
+
const isSelected = selectedRowId != null && rowId != null && rowId === selectedRowId;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<TableRow
|
|
133
|
+
key={row.id}
|
|
134
|
+
className={cn(
|
|
135
|
+
onRowClick && "cursor-pointer",
|
|
136
|
+
isSelected && "bg-accent",
|
|
137
|
+
)}
|
|
138
|
+
onClick={() => onRowClick?.(row.original)}
|
|
139
|
+
>
|
|
140
|
+
{row.getVisibleCells().map((cell) => (
|
|
141
|
+
<TableCell key={cell.id} className={cn(compact && "py-1.5")}>
|
|
142
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
143
|
+
</TableCell>
|
|
144
|
+
))}
|
|
145
|
+
</TableRow>
|
|
146
|
+
);
|
|
147
|
+
})
|
|
148
|
+
)}
|
|
149
|
+
</TableBody>
|
|
150
|
+
</Table>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{showPagination && (
|
|
154
|
+
<div className="flex items-center justify-between px-2">
|
|
155
|
+
<p className="text-sm text-muted-foreground">
|
|
156
|
+
{total} registro{total !== 1 ? "s" : ""}
|
|
157
|
+
</p>
|
|
158
|
+
<div className="flex items-center gap-2">
|
|
159
|
+
<Button
|
|
160
|
+
variant="outline"
|
|
161
|
+
size="sm"
|
|
162
|
+
onClick={() => onPageChange(page - 1)}
|
|
163
|
+
disabled={page <= 1}
|
|
164
|
+
>
|
|
165
|
+
<ChevronLeft className="h-4 w-4" />
|
|
166
|
+
Anterior
|
|
167
|
+
</Button>
|
|
168
|
+
<span className="text-sm text-muted-foreground">
|
|
169
|
+
{page} de {totalPages}
|
|
170
|
+
</span>
|
|
171
|
+
<Button
|
|
172
|
+
variant="outline"
|
|
173
|
+
size="sm"
|
|
174
|
+
onClick={() => onPageChange(page + 1)}
|
|
175
|
+
disabled={page >= totalPages}
|
|
176
|
+
>
|
|
177
|
+
Próximo
|
|
178
|
+
<ChevronRight className="h-4 w-4" />
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import type { GauthUser, GauthUserHookConfig } from "../../types/users";
|
|
5
|
+
import { useCreateUser, useUpdateUser } from "../../hooks/use-users";
|
|
6
|
+
import {
|
|
7
|
+
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
8
|
+
} from "../ui/dialog";
|
|
9
|
+
import { Button } from "../ui/button";
|
|
10
|
+
import { Input } from "../ui/input";
|
|
11
|
+
import { Label } from "../ui/label";
|
|
12
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
|
13
|
+
import { Loader2 } from "lucide-react";
|
|
14
|
+
import { toast } from "sonner";
|
|
15
|
+
import { ImageCropUpload } from "../image-crop-upload";
|
|
16
|
+
|
|
17
|
+
const PROFILE_OPTIONS = [
|
|
18
|
+
{ value: "owner", label: "Proprietário" },
|
|
19
|
+
{ value: "admin", label: "Administrador" },
|
|
20
|
+
{ value: "collaborator", label: "Colaborador" },
|
|
21
|
+
{ value: "attendant", label: "Atendente" },
|
|
22
|
+
{ value: "viewer", label: "Visualizador" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export interface UserFormDialogProps {
|
|
26
|
+
open: boolean;
|
|
27
|
+
onOpenChange: (open: boolean) => void;
|
|
28
|
+
user?: GauthUser;
|
|
29
|
+
config: GauthUserHookConfig;
|
|
30
|
+
renderPhones?: (idUser: number) => React.ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function UserFormDialog({
|
|
34
|
+
open,
|
|
35
|
+
onOpenChange,
|
|
36
|
+
user,
|
|
37
|
+
config,
|
|
38
|
+
renderPhones,
|
|
39
|
+
}: UserFormDialogProps) {
|
|
40
|
+
const isEditing = !!user;
|
|
41
|
+
const createUser = useCreateUser(config);
|
|
42
|
+
const updateUser = useUpdateUser(config);
|
|
43
|
+
|
|
44
|
+
const [name, setName] = useState("");
|
|
45
|
+
const [lastName, setLastName] = useState("");
|
|
46
|
+
const [email, setEmail] = useState("");
|
|
47
|
+
const [profile, setProfile] = useState("collaborator");
|
|
48
|
+
const [password, setPassword] = useState("");
|
|
49
|
+
const [photo, setPhoto] = useState("");
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (user) {
|
|
53
|
+
setName(user.name || "");
|
|
54
|
+
setLastName(user.last_name || "");
|
|
55
|
+
setEmail(user.email || "");
|
|
56
|
+
setProfile(user.profile || "collaborator");
|
|
57
|
+
setPhoto(user.photo || "");
|
|
58
|
+
} else {
|
|
59
|
+
setName(""); setLastName(""); setEmail(""); setProfile("collaborator"); setPhoto("");
|
|
60
|
+
}
|
|
61
|
+
setPassword("");
|
|
62
|
+
}, [user, open]);
|
|
63
|
+
|
|
64
|
+
const isPending = createUser.isPending || updateUser.isPending;
|
|
65
|
+
|
|
66
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
if (!name.trim() || !email.trim()) return;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
if (isEditing) {
|
|
72
|
+
await updateUser.mutateAsync({
|
|
73
|
+
id: user.id,
|
|
74
|
+
body: {
|
|
75
|
+
name: name.trim(),
|
|
76
|
+
last_name: lastName.trim() || undefined,
|
|
77
|
+
email: email.trim(),
|
|
78
|
+
profile,
|
|
79
|
+
photo: photo || null,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
toast.success("Usuário atualizado");
|
|
83
|
+
} else {
|
|
84
|
+
await createUser.mutateAsync({
|
|
85
|
+
name: name.trim(),
|
|
86
|
+
last_name: lastName.trim() || undefined,
|
|
87
|
+
email: email.trim(),
|
|
88
|
+
profile,
|
|
89
|
+
password: password || undefined,
|
|
90
|
+
photo: photo || null,
|
|
91
|
+
});
|
|
92
|
+
toast.success("Usuário criado");
|
|
93
|
+
}
|
|
94
|
+
onOpenChange(false);
|
|
95
|
+
} catch {
|
|
96
|
+
toast.error(isEditing ? "Erro ao atualizar usuário" : "Erro ao criar usuário");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
102
|
+
<DialogContent>
|
|
103
|
+
<DialogHeader>
|
|
104
|
+
<DialogTitle>{isEditing ? "Editar Usuário" : "Novo Usuário"}</DialogTitle>
|
|
105
|
+
</DialogHeader>
|
|
106
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
107
|
+
<div className="flex justify-center">
|
|
108
|
+
<ImageCropUpload
|
|
109
|
+
value={photo || null}
|
|
110
|
+
onChange={setPhoto}
|
|
111
|
+
onRemove={() => setPhoto("")}
|
|
112
|
+
entityType="users"
|
|
113
|
+
entityId={user?.id}
|
|
114
|
+
idAccount={Number(config.accountId) || 0}
|
|
115
|
+
name={`${name} ${lastName}`.trim() || null}
|
|
116
|
+
disabled={isPending}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="grid grid-cols-2 gap-4">
|
|
120
|
+
<div className="space-y-2">
|
|
121
|
+
<Label htmlFor="user-name">Nome *</Label>
|
|
122
|
+
<Input id="user-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Nome" required disabled={isPending} />
|
|
123
|
+
</div>
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<Label htmlFor="user-lastname">Sobrenome</Label>
|
|
126
|
+
<Input id="user-lastname" value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Sobrenome" disabled={isPending} />
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
<div className="space-y-2">
|
|
130
|
+
<Label htmlFor="user-email">E-mail *</Label>
|
|
131
|
+
<Input id="user-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@exemplo.com" required disabled={isPending} />
|
|
132
|
+
</div>
|
|
133
|
+
{!isEditing && (
|
|
134
|
+
<div className="space-y-2">
|
|
135
|
+
<Label htmlFor="user-password">Senha</Label>
|
|
136
|
+
<Input id="user-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Senha inicial (opcional)" disabled={isPending} />
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
<div className="space-y-2">
|
|
140
|
+
<Label>Perfil de acesso</Label>
|
|
141
|
+
<Select value={profile} onValueChange={setProfile} disabled={isPending}>
|
|
142
|
+
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
|
143
|
+
<SelectContent>
|
|
144
|
+
{PROFILE_OPTIONS.map((opt) => (
|
|
145
|
+
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
146
|
+
))}
|
|
147
|
+
</SelectContent>
|
|
148
|
+
</Select>
|
|
149
|
+
</div>
|
|
150
|
+
{isEditing && renderPhones && (
|
|
151
|
+
<div className="border-t pt-4">
|
|
152
|
+
{renderPhones(user.id)}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
<DialogFooter>
|
|
156
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>Cancelar</Button>
|
|
157
|
+
<Button type="submit" disabled={isPending || !name.trim() || !email.trim()}>
|
|
158
|
+
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
159
|
+
{isEditing ? "Salvar" : "Criar"}
|
|
160
|
+
</Button>
|
|
161
|
+
</DialogFooter>
|
|
162
|
+
</form>
|
|
163
|
+
</DialogContent>
|
|
164
|
+
</Dialog>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "../ui/badge";
|
|
4
|
+
import type { UserProfile } from "../../types/users";
|
|
5
|
+
|
|
6
|
+
const profileConfig: Record<string, { label: string; className: string }> = {
|
|
7
|
+
owner: { label: "Proprietário", className: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" },
|
|
8
|
+
proprietario: { label: "Proprietário", className: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" },
|
|
9
|
+
admin: { label: "Administrador", className: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" },
|
|
10
|
+
administrador: { label: "Administrador", className: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" },
|
|
11
|
+
manager: { label: "Gerente", className: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200" },
|
|
12
|
+
gerente: { label: "Gerente", className: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200" },
|
|
13
|
+
supervisor: { label: "Supervisor", className: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200" },
|
|
14
|
+
collaborator: { label: "Colaborador", className: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" },
|
|
15
|
+
colaborador: { label: "Colaborador", className: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" },
|
|
16
|
+
attendant: { label: "Atendente", className: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200" },
|
|
17
|
+
viewer: { label: "Visualizador", className: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200" },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface UserProfileBadgeProps {
|
|
21
|
+
profile: UserProfile | string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function UserProfileBadge({ profile }: UserProfileBadgeProps) {
|
|
25
|
+
const config = profileConfig[profile] || { label: profile, className: "" };
|
|
26
|
+
return (
|
|
27
|
+
<Badge variant="secondary" className={config.className}>
|
|
28
|
+
{config.label}
|
|
29
|
+
</Badge>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import type { ColumnDef } from "@tanstack/react-table";
|
|
5
|
+
import type { GauthUser, GauthUserHookConfig } from "../../types/users";
|
|
6
|
+
import { useUsers, useDeleteUser, useResetPassword } from "../../hooks/use-users";
|
|
7
|
+
import { UserFormDialog } from "./user-form-dialog";
|
|
8
|
+
import { UserProfileBadge } from "./user-profile-badge";
|
|
9
|
+
import { DataTable } from "./data-table";
|
|
10
|
+
import { EntityAvatar } from "../entity-avatar";
|
|
11
|
+
import { Input } from "../ui/input";
|
|
12
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
|
13
|
+
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
|
|
14
|
+
import {
|
|
15
|
+
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|
16
|
+
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
17
|
+
} from "../ui/alert-dialog";
|
|
18
|
+
import { Button } from "../ui/button";
|
|
19
|
+
import { Pencil, Trash2, Search, Forward, Plus } from "lucide-react";
|
|
20
|
+
import { format } from "date-fns";
|
|
21
|
+
import { ptBR } from "date-fns/locale";
|
|
22
|
+
import { toast } from "sonner";
|
|
23
|
+
|
|
24
|
+
const PAGE_SIZE = 15;
|
|
25
|
+
|
|
26
|
+
const PROFILE_FILTER_OPTIONS = [
|
|
27
|
+
{ value: "all", label: "Todos os perfis" },
|
|
28
|
+
{ value: "owner", label: "Proprietário" },
|
|
29
|
+
{ value: "admin", label: "Administrador" },
|
|
30
|
+
{ value: "collaborator", label: "Colaborador" },
|
|
31
|
+
{ value: "attendant", label: "Atendente" },
|
|
32
|
+
{ value: "viewer", label: "Visualizador" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function useColumns(
|
|
36
|
+
onEdit: (user: GauthUser) => void,
|
|
37
|
+
onDelete: (id: number) => void,
|
|
38
|
+
onResetPassword: (user: GauthUser) => void,
|
|
39
|
+
): ColumnDef<GauthUser>[] {
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
accessorKey: "name",
|
|
43
|
+
header: "Nome",
|
|
44
|
+
cell: ({ row }) => (
|
|
45
|
+
<div className="flex items-center gap-2">
|
|
46
|
+
<EntityAvatar
|
|
47
|
+
photo={row.original.photo}
|
|
48
|
+
name={`${row.original.name} ${row.original.last_name}`.trim()}
|
|
49
|
+
size="sm"
|
|
50
|
+
/>
|
|
51
|
+
<span className="font-medium">
|
|
52
|
+
{row.original.name} {row.original.last_name}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
sortingFn: (rowA, rowB) => {
|
|
57
|
+
const a = `${rowA.original.name} ${rowA.original.last_name}`.toLowerCase();
|
|
58
|
+
const b = `${rowB.original.name} ${rowB.original.last_name}`.toLowerCase();
|
|
59
|
+
return a.localeCompare(b);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
accessorKey: "email",
|
|
64
|
+
header: "E-mail",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
accessorKey: "profile",
|
|
68
|
+
header: "Perfil",
|
|
69
|
+
cell: ({ row }) => <UserProfileBadge profile={row.original.profile} />,
|
|
70
|
+
sortingFn: (rowA, rowB) => rowA.original.profile.localeCompare(rowB.original.profile),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
accessorKey: "last_login",
|
|
74
|
+
header: "Último acesso",
|
|
75
|
+
cell: ({ row }) => (
|
|
76
|
+
<span className="text-muted-foreground text-sm">
|
|
77
|
+
{row.original.last_login
|
|
78
|
+
? format(new Date(row.original.last_login), "dd/MM/yyyy HH:mm", { locale: ptBR })
|
|
79
|
+
: "—"}
|
|
80
|
+
</span>
|
|
81
|
+
),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
accessorKey: "datetime_add",
|
|
85
|
+
header: "Criado em",
|
|
86
|
+
cell: ({ row }) => (
|
|
87
|
+
<span className="text-muted-foreground text-sm">
|
|
88
|
+
{format(new Date(row.original.datetime_add), "dd/MM/yyyy", { locale: ptBR })}
|
|
89
|
+
</span>
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "actions",
|
|
94
|
+
size: 120,
|
|
95
|
+
enableSorting: false,
|
|
96
|
+
cell: ({ row }) => (
|
|
97
|
+
<div className="flex items-center gap-1">
|
|
98
|
+
<Tooltip>
|
|
99
|
+
<TooltipTrigger asChild>
|
|
100
|
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onResetPassword(row.original)}>
|
|
101
|
+
<Forward className="h-4 w-4" />
|
|
102
|
+
</Button>
|
|
103
|
+
</TooltipTrigger>
|
|
104
|
+
<TooltipContent>Resetar senha</TooltipContent>
|
|
105
|
+
</Tooltip>
|
|
106
|
+
<Tooltip>
|
|
107
|
+
<TooltipTrigger asChild>
|
|
108
|
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onEdit(row.original)}>
|
|
109
|
+
<Pencil className="h-4 w-4" />
|
|
110
|
+
</Button>
|
|
111
|
+
</TooltipTrigger>
|
|
112
|
+
<TooltipContent>Editar</TooltipContent>
|
|
113
|
+
</Tooltip>
|
|
114
|
+
<Tooltip>
|
|
115
|
+
<TooltipTrigger asChild>
|
|
116
|
+
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => onDelete(row.original.id)}>
|
|
117
|
+
<Trash2 className="h-4 w-4" />
|
|
118
|
+
</Button>
|
|
119
|
+
</TooltipTrigger>
|
|
120
|
+
<TooltipContent>Excluir</TooltipContent>
|
|
121
|
+
</Tooltip>
|
|
122
|
+
</div>
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface UsersPageProps {
|
|
129
|
+
config: GauthUserHookConfig;
|
|
130
|
+
renderPhones?: (idUser: number) => React.ReactNode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function UsersPage({ config, renderPhones }: UsersPageProps) {
|
|
134
|
+
const [search, setSearch] = useState("");
|
|
135
|
+
const [profileFilter, setProfileFilter] = useState("all");
|
|
136
|
+
const [page, setPage] = useState(1);
|
|
137
|
+
|
|
138
|
+
const queryParams = useMemo(() => {
|
|
139
|
+
const params: Record<string, string> = {
|
|
140
|
+
limit: String(PAGE_SIZE),
|
|
141
|
+
page: String(page),
|
|
142
|
+
};
|
|
143
|
+
if (profileFilter !== "all") params.profile = profileFilter;
|
|
144
|
+
return params;
|
|
145
|
+
}, [profileFilter, page]);
|
|
146
|
+
|
|
147
|
+
const { data, isLoading } = useUsers(config, queryParams);
|
|
148
|
+
const deleteUser = useDeleteUser(config);
|
|
149
|
+
const resetPassword = useResetPassword(config);
|
|
150
|
+
const [editUser, setEditUser] = useState<GauthUser | null>(null);
|
|
151
|
+
const [formOpen, setFormOpen] = useState(false);
|
|
152
|
+
const [deleteId, setDeleteId] = useState<number | null>(null);
|
|
153
|
+
const [resetUser, setResetUser] = useState<GauthUser | null>(null);
|
|
154
|
+
|
|
155
|
+
const users = data?.data || [];
|
|
156
|
+
const total = data?.total || 0;
|
|
157
|
+
|
|
158
|
+
// Client-side search filter
|
|
159
|
+
const filtered = useMemo(() => {
|
|
160
|
+
if (!search) return users;
|
|
161
|
+
const term = search.toLowerCase();
|
|
162
|
+
return users.filter((u) =>
|
|
163
|
+
`${u.name} ${u.last_name}`.toLowerCase().includes(term) ||
|
|
164
|
+
u.email.toLowerCase().includes(term)
|
|
165
|
+
);
|
|
166
|
+
}, [users, search]);
|
|
167
|
+
|
|
168
|
+
const columns = useColumns(
|
|
169
|
+
(u) => { setEditUser(u); setFormOpen(true); },
|
|
170
|
+
(id) => setDeleteId(id),
|
|
171
|
+
(u) => setResetUser(u),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
function handleDelete() {
|
|
175
|
+
if (!deleteId) return;
|
|
176
|
+
deleteUser.mutate(deleteId, {
|
|
177
|
+
onSuccess: () => { toast.success("Usuário excluído"); setDeleteId(null); },
|
|
178
|
+
onError: () => toast.error("Erro ao excluir usuário"),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function handleResetPassword() {
|
|
183
|
+
if (!resetUser) return;
|
|
184
|
+
resetPassword.mutate(resetUser.id, {
|
|
185
|
+
onSuccess: () => { toast.success("Senha resetada e email enviado"); setResetUser(null); },
|
|
186
|
+
onError: () => { toast.error("Erro ao resetar senha"); setResetUser(null); },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className="flex flex-col gap-4 p-4">
|
|
192
|
+
<div className="flex items-center justify-between">
|
|
193
|
+
<div>
|
|
194
|
+
<h1 className="text-xl font-semibold">Usuários</h1>
|
|
195
|
+
<p className="text-sm text-muted-foreground">Gestão de usuários da conta</p>
|
|
196
|
+
</div>
|
|
197
|
+
<Button onClick={() => { setEditUser(null); setFormOpen(true); }} size="sm">
|
|
198
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
199
|
+
Novo Usuário
|
|
200
|
+
</Button>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div className="flex items-center gap-3">
|
|
204
|
+
<div className="relative flex-1 max-w-md">
|
|
205
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
206
|
+
<Input
|
|
207
|
+
placeholder="Buscar por nome ou e-mail..."
|
|
208
|
+
value={search}
|
|
209
|
+
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
|
210
|
+
className="pl-9"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
<Select value={profileFilter} onValueChange={(v) => { setProfileFilter(v); setPage(1); }}>
|
|
214
|
+
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
|
|
215
|
+
<SelectContent>
|
|
216
|
+
{PROFILE_FILTER_OPTIONS.map((opt) => (
|
|
217
|
+
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
218
|
+
))}
|
|
219
|
+
</SelectContent>
|
|
220
|
+
</Select>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<DataTable
|
|
224
|
+
columns={columns}
|
|
225
|
+
data={search ? filtered : users}
|
|
226
|
+
isLoading={isLoading}
|
|
227
|
+
emptyMessage="Nenhum usuário encontrado"
|
|
228
|
+
total={search ? filtered.length : total}
|
|
229
|
+
page={page}
|
|
230
|
+
onPageChange={setPage}
|
|
231
|
+
pageSize={PAGE_SIZE}
|
|
232
|
+
/>
|
|
233
|
+
|
|
234
|
+
<UserFormDialog
|
|
235
|
+
open={formOpen}
|
|
236
|
+
onOpenChange={(open) => { setFormOpen(open); if (!open) setEditUser(null); }}
|
|
237
|
+
user={editUser ?? undefined}
|
|
238
|
+
config={config}
|
|
239
|
+
renderPhones={renderPhones}
|
|
240
|
+
/>
|
|
241
|
+
|
|
242
|
+
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
|
243
|
+
<AlertDialogContent>
|
|
244
|
+
<AlertDialogHeader>
|
|
245
|
+
<AlertDialogTitle>Excluir usuário?</AlertDialogTitle>
|
|
246
|
+
<AlertDialogDescription>Esta ação não pode ser desfeita. O usuário será removido permanentemente.</AlertDialogDescription>
|
|
247
|
+
</AlertDialogHeader>
|
|
248
|
+
<AlertDialogFooter>
|
|
249
|
+
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
|
250
|
+
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">Excluir</AlertDialogAction>
|
|
251
|
+
</AlertDialogFooter>
|
|
252
|
+
</AlertDialogContent>
|
|
253
|
+
</AlertDialog>
|
|
254
|
+
|
|
255
|
+
<AlertDialog open={!!resetUser} onOpenChange={(open) => !open && setResetUser(null)}>
|
|
256
|
+
<AlertDialogContent>
|
|
257
|
+
<AlertDialogHeader>
|
|
258
|
+
<AlertDialogTitle>Resetar senha?</AlertDialogTitle>
|
|
259
|
+
<AlertDialogDescription>
|
|
260
|
+
A senha de <strong>{resetUser?.name} {resetUser?.last_name}</strong> ({resetUser?.email}) será removida e um email com link para definir nova senha será enviado.
|
|
261
|
+
</AlertDialogDescription>
|
|
262
|
+
</AlertDialogHeader>
|
|
263
|
+
<AlertDialogFooter>
|
|
264
|
+
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
|
265
|
+
<AlertDialogAction onClick={handleResetPassword}>Resetar e enviar</AlertDialogAction>
|
|
266
|
+
</AlertDialogFooter>
|
|
267
|
+
</AlertDialogContent>
|
|
268
|
+
</AlertDialog>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|