Menggunakan rreact query untuk menangani CRUD

This commit is contained in:
vergiLgood1 2025-03-09 23:48:59 +07:00
parent 84490a1c70
commit 5ee59cbf20
30 changed files with 1905 additions and 247 deletions

View File

@ -2,9 +2,9 @@
import * as React from "react"; import * as React from "react";
import { NavMain } from "@/app/_components/admin/navigations/nav-main"; import { NavMain } from "@/app/(protected)/(admin)/_components/admin/navigations/nav-main";
import { NavReports } from "@/app/_components/admin/navigations/nav-report"; import { NavReports } from "@/app/(protected)/(admin)/_components/admin/navigations/nav-report";
import { NavUser } from "@/app/_components/admin/navigations/nav-user"; import { NavUser } from "@/app/(protected)/(admin)/_components/admin/navigations/nav-user";
import { import {
Sidebar, Sidebar,
@ -15,7 +15,7 @@ import {
} from "@/app/_components/ui/sidebar"; } from "@/app/_components/ui/sidebar";
import { NavPreMain } from "./navigations/nav-pre-main"; import { NavPreMain } from "./navigations/nav-pre-main";
import { navData } from "@/prisma/data/nav"; import { navData } from "@/prisma/data/nav";
import { TeamSwitcher } from "../team-switcher"; import { TeamSwitcher } from "../../../../_components/team-switcher";
import { Profile, User } from "@/src/models/users/users.model"; import { Profile, User } from "@/src/models/users/users.model";
import { getCurrentUser } from "@/app/(protected)/(admin)/dashboard/user-management/action"; import { getCurrentUser } from "@/app/(protected)/(admin)/dashboard/user-management/action";

View File

@ -4,7 +4,7 @@ import { Card, CardContent } from "@/app/_components/ui/card";
import { ScrollArea } from "@/app/_components/ui/scroll-area"; import { ScrollArea } from "@/app/_components/ui/scroll-area";
import { Separator } from "@/app/_components/ui/separator"; import { Separator } from "@/app/_components/ui/separator";
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";
import { Badge } from "../../ui/badge"; import { Badge } from "../../../../../_components/ui/badge";
import { import {
IconBrandGoogleAnalytics, IconBrandGoogleAnalytics,
IconCsv, IconCsv,

View File

@ -5,8 +5,8 @@ import { ChevronDown } from "lucide-react";
import { Switch } from "@/app/_components/ui/switch"; import { Switch } from "@/app/_components/ui/switch";
import { Separator } from "@/app/_components/ui/separator"; import { Separator } from "@/app/_components/ui/separator";
import { ScrollArea } from "@/app/_components/ui/scroll-area"; import { ScrollArea } from "@/app/_components/ui/scroll-area";
import { ThemeSwitcher } from "../../theme-switcher"; import { ThemeSwitcher } from "../../../../../_components/theme-switcher";
import DropdownSwitcher from "../../custom-dropdown-switcher"; import DropdownSwitcher from "../../../../../_components/custom-dropdown-switcher";
import { import {
type CookiePreferences, type CookiePreferences,
defaultCookiePreferences, defaultCookiePreferences,

View File

@ -1,5 +1,3 @@
"use client";
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useState } from "react";
@ -15,6 +13,7 @@ import { Checkbox } from "@/app/_components/ui/checkbox";
import { createUser } from "@/app/(protected)/(admin)/dashboard/user-management/action"; import { createUser } from "@/app/(protected)/(admin)/dashboard/user-management/action";
import { toast } from "sonner"; import { toast } from "sonner";
import { Mail, Lock, Loader2, X } from "lucide-react"; import { Mail, Lock, Loader2, X } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
interface AddUserDialogProps { interface AddUserDialogProps {
open: boolean; open: boolean;
@ -27,7 +26,6 @@ export function AddUserDialog({
onOpenChange, onOpenChange,
onUserAdded, onUserAdded,
}: AddUserDialogProps) { }: AddUserDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: "", email: "",
password: "", password: "",
@ -39,17 +37,10 @@ export function AddUserDialog({
setFormData((prev) => ({ ...prev, [name]: value })); setFormData((prev) => ({ ...prev, [name]: value }));
}; };
const handleSubmit = async (e: React.FormEvent) => { const { mutate: createUserMutation, isPending } = useMutation({
e.preventDefault(); mutationKey: ["createUser"],
setIsLoading(true); mutationFn: createUser,
onSuccess: () => {
try {
await createUser({
email: formData.email,
password: formData.password,
email_confirm: formData.emailConfirm,
});
toast.success("User created successfully."); toast.success("User created successfully.");
onUserAdded(); onUserAdded();
onOpenChange(false); onOpenChange(false);
@ -58,10 +49,24 @@ export function AddUserDialog({
password: "", password: "",
emailConfirm: true, emailConfirm: true,
}); });
},
onError: () => {
toast.error("Failed to create user.");
},
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createUserMutation({
email: formData.email,
password: formData.password,
email_confirm: formData.emailConfirm,
});
} catch (error) { } catch (error) {
toast.error("Failed to create user."); toast.error("Failed to create user.");
} finally { return;
setIsLoading(false);
} }
}; };
@ -144,8 +149,8 @@ export function AddUserDialog({
</p> </p>
</div> </div>
<Button type="submit" disabled={isLoading} className="w-full "> <Button type="submit" disabled={isPending} className="w-full ">
{isLoading ? ( {isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating... Creating...

View File

@ -1,5 +1,3 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/app/_components/ui/badge"; import { Badge } from "@/app/_components/ui/badge";
import { Checkbox } from "@/app/_components/ui/checkbox"; import { Checkbox } from "@/app/_components/ui/checkbox";

View File

@ -48,6 +48,7 @@ interface DataTableProps<TData, TValue> {
data: TData[]; data: TData[];
loading?: boolean; loading?: boolean;
onRowClick?: (row: TData) => void; onRowClick?: (row: TData) => void;
onActionClick?: (row: TData, action: string) => void;
pageSize?: number; pageSize?: number;
} }
@ -56,6 +57,7 @@ export function DataTable<TData, TValue>({
data, data,
loading = false, loading = false,
onRowClick, onRowClick,
onActionClick,
pageSize = 5, pageSize = 5,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@ -198,11 +200,38 @@ export function DataTable<TData, TValue>({
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell> </TableCell>
))} ))}
{/* {onActionClick && (
<TableCell>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onActionClick(row.original, "update");
}}
>
Update
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onActionClick(row.original, "delete");
}}
>
Delete
</Button>
</TableCell>
)} */}
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center"> <TableCell
colSpan={columns.length + 1}
className="h-24 text-center"
>
No results. No results.
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@ -1,5 +1,3 @@
"use client";
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useState } from "react";
@ -35,8 +33,6 @@ export function InviteUserDialog({
metadata: "{}", metadata: "{}",
}); });
const [isLoading, setIsLoading] = useState(false);
const handleInputChange = ( const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => { ) => {
@ -44,23 +40,10 @@ export function InviteUserDialog({
setFormData((prev) => ({ ...prev, [name]: value })); setFormData((prev) => ({ ...prev, [name]: value }));
}; };
const handleSubmit = async (e: React.FormEvent) => { const { mutate: inviteUserMutation, isPending } = useMutation({
e.preventDefault(); mutationKey: ["inviteUser"],
setIsLoading(true); mutationFn: inviteUser,
onSuccess: () => {
let metadata = {};
try {
metadata = JSON.parse(formData.metadata);
} catch (error) {
toast.error("Invalid JSON. Please check your metadata format.");
setIsLoading(false);
return;
}
try {
await inviteUser({
email: formData.email,
});
toast.success("Invitation sent"); toast.success("Invitation sent");
onUserInvited(); onUserInvited();
onOpenChange(false); onOpenChange(false);
@ -68,10 +51,24 @@ export function InviteUserDialog({
email: "", email: "",
metadata: "{}", metadata: "{}",
}); });
} catch (error) { },
onError: () => {
toast.error("Failed to send invitation"); toast.error("Failed to send invitation");
} finally { },
setIsLoading(false); });
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
let metadata = {};
try {
metadata = JSON.parse(formData.metadata);
inviteUserMutation({
email: formData.email,
});
} catch (error) {
toast.error("Invalid JSON. Please check your metadata format.");
return;
} }
}; };
@ -106,8 +103,8 @@ export function InviteUserDialog({
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isLoading}> <Button type="submit" disabled={isPending}>
{isLoading ? "Sending..." : "Send Invitation"} {isPending ? "Sending..." : "Send Invitation"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@ -1,5 +1,3 @@
"use client";
import { useState } from "react"; import { useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
@ -43,19 +41,19 @@ import {
} from "@/app/(protected)/(admin)/dashboard/user-management/action"; } from "@/app/(protected)/(admin)/dashboard/user-management/action";
import { format } from "date-fns"; import { format } from "date-fns";
interface UserDetailsSheetProps { interface UserDetailSheetProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
user: any; user: any;
onUserUpdate: () => void; onUserUpdate: () => void;
} }
export function UserDetailsSheet({ export function UserDetailSheet({
open, open,
onOpenChange, onOpenChange,
user, user,
onUserUpdate, onUserUpdate,
}: UserDetailsSheetProps) { }: UserDetailSheetProps) {
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isLoading, setIsLoading] = useState({ const [isLoading, setIsLoading] = useState({
deleteUser: false, deleteUser: false,
@ -64,67 +62,105 @@ export function UserDetailsSheet({
toggleBan: false, toggleBan: false,
}); });
const handleDeleteUser = async () => { const deleteUserMutation = useMutation({
setIsLoading((prev) => ({ ...prev, deleteUser: true })); mutationFn: () => deleteUser(user.id),
setIsDeleting(true); onMutate: () => {
try { setIsLoading((prev) => ({ ...prev, deleteUser: true }));
await deleteUser(user.id); setIsDeleting(true);
},
onSuccess: () => {
toast.success("User deleted successfully"); toast.success("User deleted successfully");
onUserUpdate(); onUserUpdate();
onOpenChange(false); onOpenChange(false);
} catch { },
onError: () => {
toast.error("Failed to delete user"); toast.error("Failed to delete user");
} finally { },
onSettled: () => {
setIsLoading((prev) => ({ ...prev, deleteUser: false })); setIsLoading((prev) => ({ ...prev, deleteUser: false }));
setIsDeleting(false); setIsDeleting(false);
} },
}; });
const handleSendPasswordRecovery = async () => { const sendPasswordRecoveryMutation = useMutation({
setIsLoading((prev) => ({ ...prev, sendPasswordRecovery: true })); mutationFn: () => {
try {
if (!user.email) { if (!user.email) {
throw new Error("User does not have an email address"); throw new Error("User does not have an email address");
} }
await sendPasswordRecovery(user.email); return sendPasswordRecovery(user.email);
},
onMutate: () => {
setIsLoading((prev) => ({ ...prev, sendPasswordRecovery: true }));
},
onSuccess: () => {
toast.success("Password recovery email sent"); toast.success("Password recovery email sent");
} catch { },
onError: () => {
toast.error("Failed to send password recovery email"); toast.error("Failed to send password recovery email");
} finally { },
onSettled: () => {
setIsLoading((prev) => ({ ...prev, sendPasswordRecovery: false })); setIsLoading((prev) => ({ ...prev, sendPasswordRecovery: false }));
} },
}; });
const handleSendMagicLink = async () => { const sendMagicLinkMutation = useMutation({
setIsLoading((prev) => ({ ...prev, sendMagicLink: true })); mutationFn: () => {
try {
if (!user.email) { if (!user.email) {
throw new Error("User does not have an email address"); throw new Error("User does not have an email address");
} }
await sendMagicLink(user.email); return sendMagicLink(user.email);
},
onMutate: () => {
setIsLoading((prev) => ({ ...prev, sendMagicLink: true }));
},
onSuccess: () => {
toast.success("Magic link sent successfully"); toast.success("Magic link sent successfully");
} catch { },
onError: () => {
toast.error("Failed to send magic link"); toast.error("Failed to send magic link");
} finally { },
onSettled: () => {
setIsLoading((prev) => ({ ...prev, sendMagicLink: false })); setIsLoading((prev) => ({ ...prev, sendMagicLink: false }));
} },
}; });
const handleToggleBan = async () => { const toggleBanMutation = useMutation({
setIsLoading((prev) => ({ ...prev, toggleBan: true })); mutationFn: () => {
try {
if (user.banned_until) { if (user.banned_until) {
await unbanUser(user.id); return unbanUser(user.id);
} else { } else {
await banUser(user.id); return banUser(user.id);
} }
},
onMutate: () => {
setIsLoading((prev) => ({ ...prev, toggleBan: true }));
},
onSuccess: () => {
toast.success("User ban status updated"); toast.success("User ban status updated");
onUserUpdate(); onUserUpdate();
} catch { },
onError: () => {
toast.error("Failed to update user ban status"); toast.error("Failed to update user ban status");
} finally { },
onSettled: () => {
setIsLoading((prev) => ({ ...prev, toggleBan: false })); setIsLoading((prev) => ({ ...prev, toggleBan: false }));
} },
});
const handleDeleteUser = () => {
deleteUserMutation.mutate();
};
const handleSendPasswordRecovery = () => {
sendPasswordRecoveryMutation.mutate();
};
const handleSendMagicLink = () => {
sendMagicLinkMutation.mutate();
};
const handleToggleBan = () => {
toggleBanMutation.mutate();
}; };
const handleCopyItem = (item: string) => { const handleCopyItem = (item: string) => {

View File

@ -0,0 +1,449 @@
import { FormDescription } from "@/app/_components/ui/form";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { format } from "date-fns";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/app/_components/ui/sheet";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/app/_components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/_components/ui/select";
import { Input } from "@/app/_components/ui/input";
import { Button } from "@/app/_components/ui/button";
import { Textarea } from "@/app/_components/ui/textarea";
import { Calendar } from "@/app/_components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/app/_components/ui/popover";
import { cn } from "@/lib/utils";
import { CalendarIcon, Loader2 } from "lucide-react";
import { Switch } from "@/app/_components/ui/switch";
import { User, UserSchema } from "@/src/models/users/users.model";
type UserProfileFormValues = z.infer<typeof UserSchema>;
interface UserProfileSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userData?: User; // Replace with your user data type
onSave: (data: UserProfileFormValues) => Promise<void>;
}
export function UserProfileSheet({
open,
onOpenChange,
userData,
onSave,
}: UserProfileSheetProps) {
const [isSaving, setIsSaving] = useState(false);
// Initialize form with user data
const form = useForm<UserProfileFormValues>({
resolver: zodResolver(UserSchema),
defaultValues: {
email: userData?.email || "",
phone: userData?.phone || "",
role: userData?.role || "user",
is_anonymous: userData?.is_anonymous || false,
profile: {
username: userData?.profile?.username || "",
first_name: userData?.profile?.first_name || "",
last_name: userData?.profile?.last_name || "",
bio: userData?.profile?.bio || "",
birth_date: userData?.profile?.birth_date
? new Date(userData.profile.birth_date)
: null,
avatar: userData?.profile?.avatar || "",
address: userData?.profile?.address || {
street: "",
city: "",
state: "",
country: "",
postal_code: "",
},
},
},
});
async function onSubmit(data: UserProfileFormValues) {
try {
setIsSaving(true);
await onSave(data);
onOpenChange(false);
} catch (error) {
console.error("Error saving user profile:", error);
} finally {
setIsSaving(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
<SheetHeader className="mb-6">
<SheetTitle>Update User Profile</SheetTitle>
<SheetDescription>
Make changes to the user profile here. Click save when you&apos;re
done.
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* User Information Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium">User Information</h3>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
type="email"
placeholder="email@example.com"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl>
<Input
{...field}
type="tel"
placeholder="+1234567890"
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="is_anonymous"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Anonymous User
</FormLabel>
<FormDescription>
Make this user anonymous in the system
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Profile Information Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Profile Information</h3>
<FormField
control={form.control}
name="profile.username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
{...field}
placeholder="username"
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="profile.first_name"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="John"
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="profile.last_name"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Doe"
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="profile.bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Tell us about yourself"
className="resize-none"
rows={4}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="profile.birth_date"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Date of birth</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-full pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={
field.value instanceof Date
? field.value
: undefined
}
onSelect={field.onChange}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="profile.avatar"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL</FormLabel>
<FormControl>
<Input
{...field}
type="url"
placeholder="https://example.com/avatar.jpg"
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Address Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Address</h3>
<FormField
control={form.control}
name="profile.address.street"
render={({ field }) => (
<FormItem>
<FormLabel>Street Address</FormLabel>
<FormControl>
<Input {...field} placeholder="123 Main St" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="profile.address.city"
render={({ field }) => (
<FormItem>
<FormLabel>City</FormLabel>
<FormControl>
<Input {...field} placeholder="City" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="profile.address.state"
render={({ field }) => (
<FormItem>
<FormLabel>State</FormLabel>
<FormControl>
<Input {...field} placeholder="State" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="profile.address.country"
render={({ field }) => (
<FormItem>
<FormLabel>Country</FormLabel>
<FormControl>
<Input {...field} placeholder="Country" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="profile.address.postal_code"
render={({ field }) => (
<FormItem>
<FormLabel>Postal Code</FormLabel>
<FormControl>
<Input {...field} placeholder="Postal Code" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-4 sticky bottom-0 bg-background py-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSaving ? "Saving..." : "Save changes"}
</Button>
</div>
</form>
</Form>
</SheetContent>
</Sheet>
);
}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect, JSX } from "react";
import { import {
PlusCircle, PlusCircle,
Search, Search,
@ -18,6 +18,9 @@ import {
Calendar, Calendar,
ShieldAlert, ShieldAlert,
ListFilter, ListFilter,
XCircle,
Trash2,
UserPen,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/app/_components/ui/button"; import { Button } from "@/app/_components/ui/button";
import { Input } from "@/app/_components/ui/input"; import { Input } from "@/app/_components/ui/input";
@ -30,33 +33,39 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
} from "@/app/_components/ui/dropdown-menu"; } from "@/app/_components/ui/dropdown-menu";
import { useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { fetchUsers } from "@/app/(protected)/(admin)/dashboard/user-management/action"; import { fetchUsers } from "@/app/(protected)/(admin)/dashboard/user-management/action";
import { User } from "@/src/models/users/users.model"; import { User } from "@/src/models/users/users.model";
import { toast } from "sonner"; import { toast } from "sonner";
import { DataTable } from "./data-table"; import { DataTable } from "./data-table";
import { InviteUserDialog } from "./invite-user"; import { InviteUserDialog } from "./invite-user";
import { AddUserDialog } from "./add-user-dialog"; import { AddUserDialog } from "./add-user-dialog";
import { UserDetailsSheet } from "./sheet"; import { UserDetailSheet } from "./sheet";
import { Avatar } from "@radix-ui/react-avatar"; import { Avatar } from "@radix-ui/react-avatar";
import Image from "next/image"; import Image from "next/image";
import { useNavigations } from "@/app/_hooks/use-navigations"; import { ColumnDef, HeaderContext } from "@tanstack/react-table";
import { UserProfileSheet } from "./update-user";
type UserFilterOptions = {
email: string;
phone: string;
lastSignIn: string;
createdAt: string;
status: string[];
};
type UserTableColumn = ColumnDef<User, User>;
export default function UserManagement() { export default function UserManagement() {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
const [isUpdateOpen, setIsUpdateOpen] = useState(false);
const [isAddUserOpen, setIsAddUserOpen] = useState(false); const [isAddUserOpen, setIsAddUserOpen] = useState(false);
const [isInviteUserOpen, setIsInviteUserOpen] = useState(false); const [isInviteUserOpen, setIsInviteUserOpen] = useState(false);
// Filter states // Filter states
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<UserFilterOptions>({
email: string;
phone: string;
lastSignIn: string;
createdAt: string;
status: string[];
}>({
email: "", email: "",
phone: "", phone: "",
lastSignIn: "", lastSignIn: "",
@ -65,45 +74,27 @@ export default function UserManagement() {
}); });
// Use React Query to fetch users // Use React Query to fetch users
const [users, setUsers] = useState<User[]>([]); const {
const { isLoading, setIsLoading } = useNavigations(); data: users = [],
isLoading,
useEffect(() => { refetch,
const fetchData = async () => { isPlaceholderData,
try { } = useQuery<User[]>({
setIsLoading(true); queryKey: ["users"],
const fetchedUsers = await fetchUsers(); queryFn: fetchUsers,
setUsers(fetchedUsers); placeholderData: keepPreviousData,
} catch (error) { throwOnError: true,
toast.error("Failed to fetch users"); });
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
const refetch = async () => {
setIsLoading(true);
try {
const fetchedUsers = await fetchUsers();
setUsers(fetchedUsers);
} catch (error) {
toast.error("Failed to fetch users");
} finally {
setIsLoading(false);
}
};
const handleUserClick = (user: User) => { const handleUserClick = (user: User) => {
setSelectedUser(user); setSelectedUser(user);
setIsSheetOpen(true); setIsSheetOpen(true);
}; };
const handleUserUpdate = () => { const handleUserUpdate = (user: User) => {
refetch(); setSelectedUser(user);
setIsSheetOpen(false); setIsSheetOpen(false);
setIsUpdateOpen(true);
}; };
const filteredUsers = useMemo(() => { const filteredUsers = useMemo(() => {
@ -224,10 +215,10 @@ export default function UserManagement() {
(Array.isArray(value) && value.length > 0) (Array.isArray(value) && value.length > 0)
).length; ).length;
const columns = [ const columns: UserTableColumn[] = [
{ {
id: "email", id: "email",
header: ({ column }: any) => ( header: ({ column }: HeaderContext<User, User>) => (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span>Email</span> <span>Email</span>
<DropdownMenu> <DropdownMenu>
@ -257,7 +248,7 @@ export default function UserManagement() {
</DropdownMenu> </DropdownMenu>
</div> </div>
), ),
cell: ({ row }: { row: { original: User } }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-medium"> <Avatar className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-medium">
{row.original.profile?.avatar ? ( {row.original.profile?.avatar ? (
@ -285,7 +276,7 @@ export default function UserManagement() {
}, },
{ {
id: "phone", id: "phone",
header: ({ column }: any) => ( header: ({ column }: HeaderContext<User, User>) => (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span>Phone</span> <span>Phone</span>
<DropdownMenu> <DropdownMenu>
@ -315,11 +306,11 @@ export default function UserManagement() {
</DropdownMenu> </DropdownMenu>
</div> </div>
), ),
cell: ({ row }: { row: { original: User } }) => row.original.phone || "-", cell: ({ row }) => row.original.phone || "-",
}, },
{ {
id: "lastSignIn", id: "lastSignIn",
header: ({ column }: any) => ( header: ({ column }: HeaderContext<User, User>) => (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span>Last Sign In</span> <span>Last Sign In</span>
<DropdownMenu> <DropdownMenu>
@ -383,7 +374,7 @@ export default function UserManagement() {
</DropdownMenu> </DropdownMenu>
</div> </div>
), ),
cell: ({ row }: { row: { original: User } }) => { cell: ({ row }) => {
return row.original.last_sign_in_at return row.original.last_sign_in_at
? new Date(row.original.last_sign_in_at).toLocaleString() ? new Date(row.original.last_sign_in_at).toLocaleString()
: "Never"; : "Never";
@ -391,7 +382,7 @@ export default function UserManagement() {
}, },
{ {
id: "createdAt", id: "createdAt",
header: ({ column }: any) => ( header: ({ column }: HeaderContext<User, User>) => (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span>Created At</span> <span>Created At</span>
<DropdownMenu> <DropdownMenu>
@ -444,7 +435,7 @@ export default function UserManagement() {
</DropdownMenu> </DropdownMenu>
</div> </div>
), ),
cell: ({ row }: { row: { original: User } }) => { cell: ({ row }) => {
return row.original.created_at return row.original.created_at
? new Date(row.original.created_at).toLocaleString() ? new Date(row.original.created_at).toLocaleString()
: "N/A"; : "N/A";
@ -452,7 +443,7 @@ export default function UserManagement() {
}, },
{ {
id: "status", id: "status",
header: ({ column }: any) => ( header: ({ column }: HeaderContext<User, User>) => (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span>Status</span> <span>Status</span>
<DropdownMenu> <DropdownMenu>
@ -514,7 +505,7 @@ export default function UserManagement() {
</DropdownMenu> </DropdownMenu>
</div> </div>
), ),
cell: ({ row }: { row: { original: User } }) => { cell: ({ row }) => {
if (row.original.banned_until) { if (row.original.banned_until) {
return <Badge variant="destructive">Banned</Badge>; return <Badge variant="destructive">Banned</Badge>;
} }
@ -527,17 +518,36 @@ export default function UserManagement() {
{ {
id: "actions", id: "actions",
header: "", header: "",
cell: ({ row }: { row: { original: User } }) => ( cell: ({ row }) => (
<Button <DropdownMenu>
variant="ghost" <DropdownMenuTrigger asChild>
size="icon" <Button variant="ghost" size="icon">
onClick={(e) => { <MoreHorizontal className="h-4 w-4" />
e.stopPropagation(); </Button>
handleUserClick(row.original); </DropdownMenuTrigger>
}} <DropdownMenuContent align="end">
> <DropdownMenuItem onClick={() => handleUserUpdate(row.original)}>
<MoreHorizontal className="h-4 w-4" /> <UserPen className="h-4 w-4 mr-2 text-blue-500" />
</Button> Update
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
/* handle delete */
}}
>
<Trash2 className="h-4 w-4 mr-2 text-red-500" />
Delete
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
/* handle ban */
}}
>
<ShieldAlert className="h-4 w-4 mr-2 text-yellow-500" />
{row.original.banned_until != null ? "Unban" : "Ban"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
), ),
}, },
]; ];
@ -600,34 +610,38 @@ export default function UserManagement() {
</Button> </Button>
</div> </div>
</div> </div>
<DataTable <DataTable
columns={columns} columns={columns}
data={filteredUsers} data={filteredUsers}
loading={isLoading} loading={isLoading}
onRowClick={(user) => handleUserClick(user)} onRowClick={(user) => handleUserClick(user)}
/> />
{selectedUser && ( {selectedUser && (
<UserDetailsSheet <UserDetailSheet
user={selectedUser} user={selectedUser}
open={isSheetOpen} open={isSheetOpen}
onOpenChange={setIsSheetOpen} onOpenChange={setIsSheetOpen}
onUserUpdate={handleUserUpdate} onUserUpdate={() => {}}
/> />
)} )}
<AddUserDialog <AddUserDialog
open={isAddUserOpen} open={isAddUserOpen}
onOpenChange={setIsAddUserOpen} onOpenChange={setIsAddUserOpen}
onUserAdded={() => refetch()} onUserAdded={() => refetch()}
/> />
<InviteUserDialog <InviteUserDialog
open={isInviteUserOpen} open={isInviteUserOpen}
onOpenChange={setIsInviteUserOpen} onOpenChange={setIsInviteUserOpen}
onUserInvited={() => refetch()} onUserInvited={() => refetch()}
/> />
{selectedUser && (
<UserProfileSheet
open={isUpdateOpen}
onOpenChange={setIsUpdateOpen}
userData={selectedUser}
onSave={async () => {}}
/>
)}
</div> </div>
); );
} }

View File

@ -28,23 +28,10 @@ function calculateUserStats(users: User[]) {
} }
export function UserStats() { export function UserStats() {
const { isLoading, setIsLoading } = useNavigations(); const { data: users = [], isLoading } = useQuery({
const [users, setUsers] = useState<User[]>([]); queryKey: ["users"],
queryFn: fetchUsers,
useEffect(() => { });
const fetchUserData = async () => {
try {
setIsLoading(true);
const fetchedUsers = await fetchUsers();
setUsers(fetchedUsers);
} catch (error) {
toast.error("Failed to fetch users");
} finally {
setIsLoading(false);
}
};
fetchUserData();
}, [setIsLoading]);
const stats = calculateUserStats(users); const stats = calculateUserStats(users);

View File

@ -36,6 +36,8 @@ export async function fetchUsers(): Promise<User[]> {
throw new Error("Users not found"); throw new Error("Users not found");
} }
console.log("fetchedUsers");
return users; return users;
} }

View File

@ -1,5 +1,5 @@
import UserManagement from "@/app/_components/admin/users/user-management"; import UserManagement from "@/app/(protected)/(admin)/dashboard/user-management/_components/user-management";
import { UserStats } from "@/app/_components/admin/users/user-stats"; import { UserStats } from "@/app/(protected)/(admin)/dashboard/user-management/_components/user-stats";
export default function UsersPage() { export default function UsersPage() {
return ( return (

View File

@ -26,7 +26,7 @@ import { ThemeSwitcher } from "@/app/_components/theme-switcher";
import { Separator } from "@/app/_components/ui/separator"; import { Separator } from "@/app/_components/ui/separator";
import { InboxDrawer } from "@/app/_components/inbox-drawer"; import { InboxDrawer } from "@/app/_components/inbox-drawer";
import FloatingActionSearchBar from "@/app/_components/floating-action-search-bar"; import FloatingActionSearchBar from "@/app/_components/floating-action-search-bar";
import { AppSidebar } from "@/app/_components/admin/app-sidebar"; import { AppSidebar } from "@/app/(protected)/(admin)/_components/admin/app-sidebar";
import { createClient } from "@/utils/supabase/server"; import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";

View File

@ -1,36 +0,0 @@
// "use client"
// import { useState } from "react"
// import { DataTable } from "./data-table"
// import { columns, User } from "./column"
// import { useQuery } from "react-query"
// import { getUsers } from "../../user-management/action"
// import { UserDetailSheet } from "./sheet"
// export function UsersTable() {
// const [selectedUser, setSelectedUser] = useState<User | null>(null)
// const [sheetOpen, setSheetOpen] = useState(false)
// const { data: users = [], isLoading } = useQuery({
// queryKey: ["users"],
// queryFn: getUsers,
// })
// const handleRowClick = (user: User) => {
// setSelectedUser(user)
// setSheetOpen(true)
// }
// if (isLoading) {
// return <div>Loading...</div>
// }
// return (
// <div className="w-full">
// <DataTable columns={columns} data={users} onRowClick={handleRowClick} />
// {selectedUser && <UserDetailSheet user={selectedUser} open={sheetOpen} onOpenChange={setSheetOpen} />}
// </div>
// )
// }

View File

@ -33,15 +33,16 @@ export default function RootLayout({
return ( return (
<html lang="en" className={geistSans.className} suppressHydrationWarning> <html lang="en" className={geistSans.className} suppressHydrationWarning>
<body className="bg-background text-foreground"> <body className="bg-background text-foreground">
<ThemeProvider <ReactQueryProvider>
attribute="class" <ThemeProvider
defaultTheme="system" attribute="class"
enableSystem defaultTheme="system"
disableTransitionOnChange enableSystem
> disableTransitionOnChange
<main className="min-h-screen flex flex-col items-center"> >
<div className="flex-1 w-full gap-20 items-center"> <main className="min-h-screen flex flex-col items-center">
{/* <nav className="w-full flex justify-center border-b border-b-foreground/10 h-16"> <div className="flex-1 w-full gap-20 items-center">
{/* <nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
<div className="w-full max-w-5xl flex justify-between items-center p-3 px-5 text-sm"> <div className="w-full max-w-5xl flex justify-between items-center p-3 px-5 text-sm">
<div className="flex gap-5 items-center font-semibold"> <div className="flex gap-5 items-center font-semibold">
<Link href={"/"}> <Link href={"/"}>
@ -57,12 +58,12 @@ export default function RootLayout({
</div> </div>
</div> </div>
</nav> */} </nav> */}
<div className="flex flex-col max-w-full p-0"> <div className="flex flex-col max-w-full p-0">
{children} {children}
<Toaster theme="system" richColors position="top-right" /> <Toaster theme="system" richColors position="top-right" />
</div> </div>
{/* <footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16 mt-auto"> {/* <footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16 mt-auto">
<p> <p>
Powered by{" " Powered by{" "
<a <a
@ -75,9 +76,10 @@ export default function RootLayout({
</a> </a>
</p> </p>
</footer> */} </footer> */}
</div> </div>
</main> </main>
</ThemeProvider> </ThemeProvider>
</ReactQueryProvider>
</body> </body>
</html> </html>
); );

File diff suppressed because it is too large Load Diff

View File

@ -52,6 +52,8 @@
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/eslint-plugin-query": "^5.67.2",
"@tanstack/react-query-devtools": "^5.67.2",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@types/react-dom": "19.0.2", "@types/react-dom": "19.0.2",

View File

@ -2,12 +2,31 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const ReactQueryProvider = ({ children }: { children: React.ReactNode }) => { const ReactQueryProvider = ({ children }: { children: React.ReactNode }) => {
const [queryClient] = useState(() => new QueryClient()); const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// Pengaturan caching global
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
},
},
})
);
return ( return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> <QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === "development" && <ReactQueryDevtools />}
</QueryClientProvider>
); );
}; };