@greatapps/greatauth-ui 0.3.6 → 0.3.8
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 +137 -1
- package/dist/index.js +1430 -5
- package/dist/index.js.map +1 -1
- package/package.json +11 -7
- package/src/components/entity-avatar.tsx +72 -0
- package/src/components/image-crop-upload.tsx +253 -0
- package/src/components/index.ts +4 -0
- package/src/components/users/data-table.tsx +185 -0
- package/src/components/users/user-form-dialog.tsx +169 -0
- package/src/components/users/user-profile-badge.tsx +31 -0
- package/src/components/users/users-page.tsx +273 -0
- package/src/hooks/use-users.ts +82 -0
- package/src/index.ts +20 -0
- package/src/lib/image-utils.ts +82 -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.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"next-themes": "*",
|
|
40
40
|
"radix-ui": "^1.4",
|
|
41
41
|
"cmdk": "^1",
|
|
42
|
+
"react-easy-crop": "^5",
|
|
42
43
|
"sonner": "^2.0.7",
|
|
43
44
|
"vaul": "^1.1.2",
|
|
44
45
|
"input-otp": "^1.4.2"
|
|
@@ -49,16 +50,19 @@
|
|
|
49
50
|
"class-variance-authority": "^0.7"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"react": "^19",
|
|
55
|
-
"react-dom": "^19",
|
|
53
|
+
"@tanstack/react-query": "^5.90.21",
|
|
54
|
+
"@tanstack/react-table": "^8.21.3",
|
|
56
55
|
"@types/react": "latest",
|
|
57
56
|
"@types/react-dom": "latest",
|
|
58
|
-
"next": "latest",
|
|
59
57
|
"better-auth": "latest",
|
|
58
|
+
"cmdk": "^1",
|
|
59
|
+
"date-fns": "^4.1.0",
|
|
60
|
+
"next": "latest",
|
|
60
61
|
"next-themes": "latest",
|
|
61
62
|
"radix-ui": "^1.4",
|
|
62
|
-
"
|
|
63
|
+
"react": "^19",
|
|
64
|
+
"react-dom": "^19",
|
|
65
|
+
"tsup": "latest",
|
|
66
|
+
"typescript": "^5"
|
|
63
67
|
}
|
|
64
68
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
const COLORS = [
|
|
7
|
+
"bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
|
8
|
+
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
|
9
|
+
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
|
10
|
+
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
|
11
|
+
"bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
|
|
12
|
+
"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
|
13
|
+
"bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
|
|
14
|
+
"bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const SIZE_MAP = {
|
|
18
|
+
xs: "h-5 w-5 text-[9px]",
|
|
19
|
+
sm: "h-6 w-6 text-[10px]",
|
|
20
|
+
md: "h-8 w-8 text-xs",
|
|
21
|
+
lg: "h-10 w-10 text-sm",
|
|
22
|
+
xl: "h-16 w-16 text-lg",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
function hashCode(str: string): number {
|
|
26
|
+
let hash = 0;
|
|
27
|
+
for (let i = 0; i < str.length; i++) {
|
|
28
|
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
29
|
+
}
|
|
30
|
+
return Math.abs(hash);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getInitials(name: string): string {
|
|
34
|
+
const parts = name.trim().split(/\s+/);
|
|
35
|
+
if (parts.length >= 2) {
|
|
36
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
37
|
+
}
|
|
38
|
+
return (name[0] || "?").toUpperCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EntityAvatarProps {
|
|
42
|
+
photo?: string | null;
|
|
43
|
+
name: string | null;
|
|
44
|
+
size?: keyof typeof SIZE_MAP;
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function EntityAvatar({
|
|
49
|
+
photo,
|
|
50
|
+
name,
|
|
51
|
+
size = "md",
|
|
52
|
+
className,
|
|
53
|
+
}: EntityAvatarProps) {
|
|
54
|
+
const displayName = name || "?";
|
|
55
|
+
const color = COLORS[hashCode(displayName) % COLORS.length];
|
|
56
|
+
const initials = getInitials(displayName);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Avatar className={cn(SIZE_MAP[size], className)}>
|
|
60
|
+
{photo && (
|
|
61
|
+
<AvatarImage
|
|
62
|
+
src={photo}
|
|
63
|
+
alt={displayName}
|
|
64
|
+
className="object-cover"
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
<AvatarFallback className={cn("font-medium", color)}>
|
|
68
|
+
{initials}
|
|
69
|
+
</AvatarFallback>
|
|
70
|
+
</Avatar>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
|
+
import Cropper from "react-easy-crop";
|
|
5
|
+
import type { Area } from "react-easy-crop";
|
|
6
|
+
import { Button } from "./ui/button";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "./ui/dialog";
|
|
14
|
+
import { Slider } from "./ui/slider";
|
|
15
|
+
import { toast } from "sonner";
|
|
16
|
+
import { Camera, Trash2 } from "lucide-react";
|
|
17
|
+
import { EntityAvatar } from "./entity-avatar";
|
|
18
|
+
import {
|
|
19
|
+
getCroppedImage,
|
|
20
|
+
validateImageFile,
|
|
21
|
+
fileToDataUrl,
|
|
22
|
+
} from "../lib/image-utils";
|
|
23
|
+
|
|
24
|
+
const STORAGE_URL_MAP: Record<string, string> = {
|
|
25
|
+
users: "https://gauth-r2-storage.greatlabs.workers.dev",
|
|
26
|
+
agents: "https://gagents-r2-storage.greatlabs.workers.dev",
|
|
27
|
+
contacts: "https://gchat-r2-storage.greatlabs.workers.dev",
|
|
28
|
+
professionals: "https://gclinic-r2-storage.greatlabs.workers.dev",
|
|
29
|
+
patients: "https://gclinic-r2-storage.greatlabs.workers.dev",
|
|
30
|
+
clinics: "https://gclinic-r2-storage.greatlabs.workers.dev",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const BUCKET_MAP: Record<string, string> = {
|
|
34
|
+
users: "gauth-r1-storage",
|
|
35
|
+
agents: "gagents-r1-storage",
|
|
36
|
+
contacts: "gchat-r1-storage",
|
|
37
|
+
professionals: "gclinic-r1-storage",
|
|
38
|
+
patients: "gclinic-r1-storage",
|
|
39
|
+
clinics: "gclinic-r1-storage",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const DEFAULT_STORAGE_URL = "https://gauth-r2-storage.greatlabs.workers.dev";
|
|
43
|
+
const DEFAULT_BUCKET = "gauth-r1-storage";
|
|
44
|
+
|
|
45
|
+
export interface ImageCropUploadProps {
|
|
46
|
+
value?: string | null;
|
|
47
|
+
onChange: (url: string) => void;
|
|
48
|
+
onRemove?: () => void;
|
|
49
|
+
entityType: string;
|
|
50
|
+
entityId?: number | string;
|
|
51
|
+
idAccount: number;
|
|
52
|
+
name?: string | null;
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ImageCropUpload({
|
|
57
|
+
value,
|
|
58
|
+
onChange,
|
|
59
|
+
onRemove,
|
|
60
|
+
entityType,
|
|
61
|
+
entityId,
|
|
62
|
+
idAccount,
|
|
63
|
+
name,
|
|
64
|
+
disabled = false,
|
|
65
|
+
}: ImageCropUploadProps) {
|
|
66
|
+
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
|
67
|
+
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
|
68
|
+
const [zoom, setZoom] = useState(1);
|
|
69
|
+
const [croppedArea, setCroppedArea] = useState<Area | null>(null);
|
|
70
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
71
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
72
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
73
|
+
|
|
74
|
+
const onCropComplete = useCallback((_: Area, croppedAreaPixels: Area) => {
|
|
75
|
+
setCroppedArea(croppedAreaPixels);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const handleFileSelect = useCallback(
|
|
79
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
80
|
+
const file = e.target.files?.[0];
|
|
81
|
+
if (!file) return;
|
|
82
|
+
|
|
83
|
+
const error = validateImageFile(file);
|
|
84
|
+
if (error) {
|
|
85
|
+
toast.error(error);
|
|
86
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dataUrl = await fileToDataUrl(file);
|
|
91
|
+
setImageSrc(dataUrl);
|
|
92
|
+
setCrop({ x: 0, y: 0 });
|
|
93
|
+
setZoom(1);
|
|
94
|
+
setIsOpen(true);
|
|
95
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
96
|
+
},
|
|
97
|
+
[],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const handleConfirmCrop = useCallback(async () => {
|
|
101
|
+
if (!imageSrc || !croppedArea) return;
|
|
102
|
+
|
|
103
|
+
setIsUploading(true);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const base64 = await getCroppedImage(imageSrc, croppedArea);
|
|
107
|
+
const path = `${entityType}/${idAccount}/${entityId || "temp"}.webp`;
|
|
108
|
+
const storageUrl = STORAGE_URL_MAP[entityType] || DEFAULT_STORAGE_URL;
|
|
109
|
+
const bucket = BUCKET_MAP[entityType] || DEFAULT_BUCKET;
|
|
110
|
+
|
|
111
|
+
const res = await fetch(storageUrl, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify({ file: base64, bucket, path }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const data = await res.json();
|
|
118
|
+
|
|
119
|
+
if (data.status !== 1) {
|
|
120
|
+
throw new Error(data.message || "Erro ao enviar imagem");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const publicUrl = `${storageUrl}/${path}?t=${Date.now()}`;
|
|
124
|
+
onChange(publicUrl);
|
|
125
|
+
toast.success("Imagem atualizada");
|
|
126
|
+
} catch (err) {
|
|
127
|
+
toast.error(
|
|
128
|
+
err instanceof Error ? err.message : "Erro ao enviar imagem",
|
|
129
|
+
);
|
|
130
|
+
} finally {
|
|
131
|
+
setIsUploading(false);
|
|
132
|
+
setIsOpen(false);
|
|
133
|
+
setImageSrc(null);
|
|
134
|
+
}
|
|
135
|
+
}, [imageSrc, croppedArea, entityType, entityId, idAccount, onChange]);
|
|
136
|
+
|
|
137
|
+
const handleRemove = useCallback(async () => {
|
|
138
|
+
if (!value) return;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const path = `${entityType}/${idAccount}/${entityId || "temp"}.webp`;
|
|
142
|
+
const storageUrl = STORAGE_URL_MAP[entityType] || DEFAULT_STORAGE_URL;
|
|
143
|
+
const bucket = BUCKET_MAP[entityType] || DEFAULT_BUCKET;
|
|
144
|
+
|
|
145
|
+
await fetch(storageUrl, {
|
|
146
|
+
method: "DELETE",
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
body: JSON.stringify({ bucket, path }),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
onRemove?.();
|
|
152
|
+
toast.success("Imagem removida");
|
|
153
|
+
} catch {
|
|
154
|
+
toast.error("Erro ao remover imagem");
|
|
155
|
+
}
|
|
156
|
+
}, [value, entityType, entityId, idAccount, onRemove]);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div className="flex flex-col items-center gap-2">
|
|
160
|
+
<div className="relative group">
|
|
161
|
+
<EntityAvatar photo={value} name={name ?? null} size="xl" />
|
|
162
|
+
{!disabled && (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={() => inputRef.current?.click()}
|
|
166
|
+
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
|
167
|
+
>
|
|
168
|
+
<Camera className="h-5 w-5 text-white" />
|
|
169
|
+
</button>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<input
|
|
174
|
+
ref={inputRef}
|
|
175
|
+
type="file"
|
|
176
|
+
accept="image/png,image/jpeg,image/webp"
|
|
177
|
+
onChange={handleFileSelect}
|
|
178
|
+
className="hidden"
|
|
179
|
+
disabled={disabled}
|
|
180
|
+
/>
|
|
181
|
+
|
|
182
|
+
{!disabled && value && (
|
|
183
|
+
<Button
|
|
184
|
+
type="button"
|
|
185
|
+
variant="ghost"
|
|
186
|
+
size="sm"
|
|
187
|
+
onClick={handleRemove}
|
|
188
|
+
className="text-destructive hover:text-destructive"
|
|
189
|
+
>
|
|
190
|
+
<Trash2 className="h-4 w-4 mr-1" />
|
|
191
|
+
Remover
|
|
192
|
+
</Button>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
196
|
+
<DialogContent className="sm:max-w-md">
|
|
197
|
+
<DialogHeader>
|
|
198
|
+
<DialogTitle>Recortar imagem</DialogTitle>
|
|
199
|
+
</DialogHeader>
|
|
200
|
+
|
|
201
|
+
<div className="relative w-full h-72 bg-muted rounded-md overflow-hidden">
|
|
202
|
+
{imageSrc && (
|
|
203
|
+
<Cropper
|
|
204
|
+
image={imageSrc}
|
|
205
|
+
crop={crop}
|
|
206
|
+
zoom={zoom}
|
|
207
|
+
aspect={1}
|
|
208
|
+
onCropChange={setCrop}
|
|
209
|
+
onZoomChange={setZoom}
|
|
210
|
+
onCropComplete={onCropComplete}
|
|
211
|
+
cropShape="round"
|
|
212
|
+
showGrid={false}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div className="flex items-center gap-3 px-1">
|
|
218
|
+
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
|
219
|
+
Zoom
|
|
220
|
+
</span>
|
|
221
|
+
<Slider
|
|
222
|
+
value={[zoom]}
|
|
223
|
+
onValueChange={(v: number[]) => setZoom(v[0])}
|
|
224
|
+
min={1}
|
|
225
|
+
max={3}
|
|
226
|
+
step={0.1}
|
|
227
|
+
className="flex-1"
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<DialogFooter>
|
|
232
|
+
<Button
|
|
233
|
+
variant="outline"
|
|
234
|
+
onClick={() => {
|
|
235
|
+
setIsOpen(false);
|
|
236
|
+
setImageSrc(null);
|
|
237
|
+
}}
|
|
238
|
+
disabled={isUploading}
|
|
239
|
+
>
|
|
240
|
+
Cancelar
|
|
241
|
+
</Button>
|
|
242
|
+
<Button
|
|
243
|
+
onClick={handleConfirmCrop}
|
|
244
|
+
disabled={isUploading || !croppedArea}
|
|
245
|
+
>
|
|
246
|
+
{isUploading ? "Enviando..." : "Confirmar"}
|
|
247
|
+
</Button>
|
|
248
|
+
</DialogFooter>
|
|
249
|
+
</DialogContent>
|
|
250
|
+
</Dialog>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -3,3 +3,7 @@ export { AppSidebar } from "./app-sidebar";
|
|
|
3
3
|
export { AppHeader } from "./app-header";
|
|
4
4
|
export { LoginForm } from "./login-form";
|
|
5
5
|
export { ThemeToggle } from "./theme-toggle";
|
|
6
|
+
export { EntityAvatar } from "./entity-avatar";
|
|
7
|
+
export type { EntityAvatarProps } from "./entity-avatar";
|
|
8
|
+
export { ImageCropUpload } from "./image-crop-upload";
|
|
9
|
+
export type { ImageCropUploadProps } from "./image-crop-upload";
|
|
@@ -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
|
+
}
|