add custom date time picker
This commit is contained in:
parent
5ee59cbf20
commit
280033b0e1
|
@ -200,38 +200,11 @@ export function DataTable<TData, TValue>({
|
|||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</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>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
|
@ -1,80 +1,63 @@
|
|||
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 type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import type * as z from "zod"
|
||||
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
import { UpdateUserParamsSchema, type User, UserSchema } from "@/src/models/users/users.model"
|
||||
|
||||
// UI Components
|
||||
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";
|
||||
} from "@/app/_components/ui/form"
|
||||
|
||||
type UserProfileFormValues = z.infer<typeof UserSchema>;
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { FormSection } from "@/app/_components/form-section"
|
||||
import { FormFieldWrapper } from "@/app/_components/form-wrapper"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { updateUser } from "../action"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
||||
type UserProfileFormValues = z.infer<typeof UpdateUserParamsSchema>
|
||||
|
||||
interface UserProfileSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
userData?: User; // Replace with your user data type
|
||||
onSave: (data: UserProfileFormValues) => Promise<void>;
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
userData?: User
|
||||
}
|
||||
|
||||
export function UserProfileSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
userData,
|
||||
onSave,
|
||||
}: UserProfileSheetProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
export function UserProfileSheet({ open, onOpenChange, userData }: UserProfileSheetProps) {
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
// Initialize form with user data
|
||||
const form = useForm<UserProfileFormValues>({
|
||||
resolver: zodResolver(UserSchema),
|
||||
resolver: zodResolver(UpdateUserParamsSchema),
|
||||
defaultValues: {
|
||||
email: userData?.email || "",
|
||||
password_hash: userData?.password_hash || "",
|
||||
role: (userData?.role as "user" | "staff" | "admin") || "user",
|
||||
phone: userData?.phone || "",
|
||||
role: userData?.role || "user",
|
||||
invited_at: userData?.invited_at || undefined,
|
||||
confirmed_at: userData?.confirmed_at || undefined,
|
||||
recovery_sent_at: userData?.recovery_sent_at || undefined,
|
||||
last_sign_in_at: userData?.last_sign_in_at || undefined,
|
||||
created_at: userData?.created_at || undefined,
|
||||
updated_at: userData?.updated_at || undefined,
|
||||
is_anonymous: userData?.is_anonymous || false,
|
||||
banned_until: userData?.banned_until ? String(userData.banned_until) : undefined,
|
||||
profile: {
|
||||
id: userData?.profile?.id || "",
|
||||
user_id: userData?.profile?.user_id || "",
|
||||
avatar: userData?.profile?.avatar || "",
|
||||
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: "",
|
||||
|
@ -82,368 +65,246 @@ export function UserProfileSheet({
|
|||
country: "",
|
||||
postal_code: "",
|
||||
},
|
||||
birth_date: userData?.profile?.birth_date ? new Date(userData.profile.birth_date) : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const { mutate: updateUserMutation, isPending } = useMutation({
|
||||
mutationKey: ["updateUser"],
|
||||
mutationFn: (data: UserProfileFormValues) => {
|
||||
if (!userData?.id) {
|
||||
throw new Error("User ID is required");
|
||||
}
|
||||
return updateUser(userData.id, data);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast("Failed to update user");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast("User updated");
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(data: UserProfileFormValues) {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await onSave(data);
|
||||
onOpenChange(false);
|
||||
setIsSaving(true)
|
||||
await updateUserMutation(data)
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error("Error saving user profile:", error);
|
||||
console.error("Error saving user profile:", error)
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
|
||||
<SheetHeader className="mb-6">
|
||||
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
||||
<SheetHeader className="mb-6 border-b-2 pb-4">
|
||||
<SheetTitle>Update User Profile</SheetTitle>
|
||||
<SheetDescription>
|
||||
Make changes to the user profile here. Click save when you're
|
||||
done.
|
||||
</SheetDescription>
|
||||
<SheetDescription>Make changes to the user profile here. Click save when you'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}
|
||||
<FormSection
|
||||
title="User Information"
|
||||
description="Update the user information below. Fields marked with an asterisk (*) are required."
|
||||
>
|
||||
<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
|
||||
<FormFieldWrapper
|
||||
name="email"
|
||||
label="Email"
|
||||
type="string"
|
||||
control={form.control}
|
||||
placeholder="email@example.com"
|
||||
rows={4}
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="phone"
|
||||
label="Phone"
|
||||
type="tel"
|
||||
control={form.control}
|
||||
placeholder="+1234567890"
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="role"
|
||||
label="Role"
|
||||
type="string (select)"
|
||||
control={form.control}
|
||||
options={[
|
||||
{ value: "user", label: "User" },
|
||||
{ value: "admin", label: "Admin" },
|
||||
{ value: "staff", label: "Staff" },
|
||||
]}
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
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}
|
||||
label="Is Anonymous"
|
||||
type="boolean"
|
||||
control={form.control}
|
||||
isBoolean={true}
|
||||
booleanType="select"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormFieldWrapper
|
||||
name="password_hash"
|
||||
label="Password Hash"
|
||||
type="string"
|
||||
control={form.control}
|
||||
placeholder="Password Hash"
|
||||
/>
|
||||
</div>
|
||||
<FormFieldWrapper
|
||||
name="invited_at"
|
||||
label="Invited At"
|
||||
type="date"
|
||||
control={form.control}
|
||||
isDate={true}
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="confirmed_at"
|
||||
label="Confirmed At"
|
||||
type="date"
|
||||
control={form.control}
|
||||
isDate={true}
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="recovery_sent_at"
|
||||
label="Recovery Sent At"
|
||||
type="date"
|
||||
control={form.control}
|
||||
isDate={true}
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="last_sign_in_at"
|
||||
label="Last Sign In At"
|
||||
type="date"
|
||||
control={form.control}
|
||||
isDate={true}
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="created_at"
|
||||
label="Created At"
|
||||
type="date"
|
||||
control={form.control}
|
||||
isDate={true}
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="updated_at"
|
||||
label="Updated At"
|
||||
type="date"
|
||||
control={form.control}
|
||||
isDate={true}
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="banned_until"
|
||||
label="Banned Until"
|
||||
type="date"
|
||||
control={form.control}
|
||||
isDate={true}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Profile Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Profile Information</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
<FormSection title="Profile Information" description="Update the user profile information below.">
|
||||
<FormFieldWrapper
|
||||
name="profile.username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
label="Username"
|
||||
type="string"
|
||||
control={form.control}
|
||||
placeholder="username"
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
<FormFieldWrapper
|
||||
name="profile.first_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
label="First Name"
|
||||
type="string"
|
||||
control={form.control}
|
||||
placeholder="John"
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
<FormFieldWrapper
|
||||
name="profile.last_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
label="Last Name"
|
||||
type="string"
|
||||
control={form.control}
|
||||
placeholder="Doe"
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
<FormFieldWrapper
|
||||
name="profile.bio"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bio</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
label="Bio"
|
||||
type="string"
|
||||
control={form.control}
|
||||
placeholder="Tell us about yourself"
|
||||
className="resize-none"
|
||||
rows={4}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
<FormFieldWrapper
|
||||
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
|
||||
label="Date of birth"
|
||||
type="date"
|
||||
control={form.control}
|
||||
isDate={true}
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="profile.avatar"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Avatar URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="url"
|
||||
label="Avatar URL"
|
||||
type="string (URL)"
|
||||
control={form.control}
|
||||
placeholder="https://example.com/avatar.jpg"
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Address Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Address</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
<FormSection title="Address" description="Update the user address information below.">
|
||||
<FormFieldWrapper
|
||||
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
|
||||
label="Street Address"
|
||||
type="string"
|
||||
control={form.control}
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="profile.address.city"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>City</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="City" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="City"
|
||||
type="string"
|
||||
control={form.control}
|
||||
placeholder="City"
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
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
|
||||
label="State"
|
||||
type="string"
|
||||
control={form.control}
|
||||
placeholder="State"
|
||||
/>
|
||||
<FormFieldWrapper
|
||||
name="profile.address.country"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Country</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Country" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Country"
|
||||
type="string"
|
||||
control={form.control}
|
||||
name="profile.address.postal_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Postal Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Postal Code" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
placeholder="Country"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormFieldWrapper
|
||||
name="profile.address.postal_code"
|
||||
label="Postal Code"
|
||||
type="string"
|
||||
control={form.control}
|
||||
placeholder="Postal Code"
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* 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}
|
||||
>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button type="button" variant="outline" size="xs" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isSaving ? "Saving..." : "Save changes"}
|
||||
<Button size="xs" type="submit" disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,26 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, JSX } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
PlusCircle,
|
||||
Search,
|
||||
Filter,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
ChevronDown,
|
||||
UserPlus,
|
||||
Mail,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
Mail as MailIcon,
|
||||
Phone,
|
||||
Clock,
|
||||
Calendar,
|
||||
ShieldAlert,
|
||||
ListFilter,
|
||||
XCircle,
|
||||
Trash2,
|
||||
UserPen,
|
||||
PenIcon as UserPen,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Input } from "@/app/_components/ui/input";
|
||||
|
@ -35,15 +27,14 @@ import {
|
|||
} from "@/app/_components/ui/dropdown-menu";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { fetchUsers } from "@/app/(protected)/(admin)/dashboard/user-management/action";
|
||||
import { User } from "@/src/models/users/users.model";
|
||||
import { toast } from "sonner";
|
||||
import type { User } from "@/src/models/users/users.model";
|
||||
import { DataTable } from "./data-table";
|
||||
import { InviteUserDialog } from "./invite-user";
|
||||
import { AddUserDialog } from "./add-user-dialog";
|
||||
import { UserDetailSheet } from "./sheet";
|
||||
import { Avatar } from "@radix-ui/react-avatar";
|
||||
import Image from "next/image";
|
||||
import { ColumnDef, HeaderContext } from "@tanstack/react-table";
|
||||
import type { ColumnDef, HeaderContext } from "@tanstack/react-table";
|
||||
import { UserProfileSheet } from "./update-user";
|
||||
|
||||
type UserFilterOptions = {
|
||||
|
@ -58,7 +49,8 @@ type UserTableColumn = ColumnDef<User, User>;
|
|||
|
||||
export default function UserManagement() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [detailUser, setDetailUser] = useState<User | null>(null);
|
||||
const [updateUser, setUpdateUser] = useState<User | null>(null);
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const [isUpdateOpen, setIsUpdateOpen] = useState(false);
|
||||
const [isAddUserOpen, setIsAddUserOpen] = useState(false);
|
||||
|
@ -86,17 +78,51 @@ export default function UserManagement() {
|
|||
throwOnError: true,
|
||||
});
|
||||
|
||||
// Handle opening the detail sheet
|
||||
const handleUserClick = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setDetailUser(user);
|
||||
setIsSheetOpen(true);
|
||||
};
|
||||
|
||||
// Handle opening the update sheet
|
||||
const handleUserUpdate = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setIsSheetOpen(false);
|
||||
setUpdateUser(user);
|
||||
setIsUpdateOpen(true);
|
||||
};
|
||||
|
||||
// Close detail sheet when update sheet opens
|
||||
useEffect(() => {
|
||||
if (isUpdateOpen) {
|
||||
setIsSheetOpen(false);
|
||||
}
|
||||
}, [isUpdateOpen]);
|
||||
|
||||
// Reset detail user when sheet closes
|
||||
useEffect(() => {
|
||||
if (!isSheetOpen) {
|
||||
// Use a small delay to prevent flickering if another sheet is opening
|
||||
const timer = setTimeout(() => {
|
||||
if (!isSheetOpen && !isUpdateOpen) {
|
||||
setDetailUser(null);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isSheetOpen, isUpdateOpen]);
|
||||
|
||||
// Reset update user when update sheet closes
|
||||
useEffect(() => {
|
||||
if (!isUpdateOpen) {
|
||||
// Use a small delay to prevent flickering if another sheet is opening
|
||||
const timer = setTimeout(() => {
|
||||
if (!isUpdateOpen) {
|
||||
setUpdateUser(null);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isUpdateOpen]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
return users.filter((user) => {
|
||||
// Global search
|
||||
|
@ -253,7 +279,7 @@ export default function UserManagement() {
|
|||
<Avatar className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-medium">
|
||||
{row.original.profile?.avatar ? (
|
||||
<Image
|
||||
src={row.original.profile.avatar}
|
||||
src={row.original.profile.avatar || "/placeholder.svg"}
|
||||
alt="Avatar"
|
||||
className="w-full h-full rounded-full"
|
||||
width={32}
|
||||
|
@ -519,6 +545,8 @@ export default function UserManagement() {
|
|||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{/* Add this wrapper */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
|
@ -548,6 +576,7 @@ export default function UserManagement() {
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
@ -616,9 +645,9 @@ export default function UserManagement() {
|
|||
loading={isLoading}
|
||||
onRowClick={(user) => handleUserClick(user)}
|
||||
/>
|
||||
{selectedUser && (
|
||||
{detailUser && (
|
||||
<UserDetailSheet
|
||||
user={selectedUser}
|
||||
user={detailUser}
|
||||
open={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
onUserUpdate={() => {}}
|
||||
|
@ -634,12 +663,11 @@ export default function UserManagement() {
|
|||
onOpenChange={setIsInviteUserOpen}
|
||||
onUserInvited={() => refetch()}
|
||||
/>
|
||||
{selectedUser && (
|
||||
{updateUser && (
|
||||
<UserProfileSheet
|
||||
open={isUpdateOpen}
|
||||
onOpenChange={setIsUpdateOpen}
|
||||
userData={selectedUser}
|
||||
onSave={async () => {}}
|
||||
userData={updateUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -162,9 +162,15 @@ export async function updateUser(
|
|||
|
||||
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
|
||||
email: params.email,
|
||||
phone: params.phone,
|
||||
email_confirm: params.email_confirmed_at,
|
||||
password: params.password_hash,
|
||||
password_hash: params.password_hash,
|
||||
ban_duration: params.ban_duration,
|
||||
phone: params.phone,
|
||||
phone_confirm: params.phone_confirmed_at,
|
||||
role: params.role,
|
||||
ban_duration: params.banned_until,
|
||||
user_metadata: params.user_metadata,
|
||||
app_metadata: params.app_metadata,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
|
@ -190,16 +196,23 @@ export async function updateUser(
|
|||
id: userId,
|
||||
},
|
||||
data: {
|
||||
role: params.role,
|
||||
role: params.role || user.role,
|
||||
invited_at: params.invited_at || user.role,
|
||||
confirmed_at: params.confirmed_at || user.role,
|
||||
recovery_sent_at: params.recovery_sent_at || user.role,
|
||||
last_sign_in_at: params.last_sign_in_at || user.role,
|
||||
is_anonymous: params.is_anonymous || user.is_anonymous,
|
||||
created_at: params.created_at || user.role,
|
||||
updated_at: params.updated_at || user.role,
|
||||
profile: {
|
||||
update: {
|
||||
avatar: params.profile.avatar || user.profile?.avatar,
|
||||
username: params.profile.username || user.profile?.username,
|
||||
first_name: params.profile.first_name || user.profile?.first_name,
|
||||
last_name: params.profile.last_name || user.profile?.last_name,
|
||||
bio: params.profile.bio || user.profile?.bio,
|
||||
address: params.profile.address,
|
||||
birth_date: params.profile.birth_date || user.profile?.birth_date,
|
||||
avatar: params.profile?.avatar || user.profile?.avatar,
|
||||
username: params.profile?.username || user.profile?.username,
|
||||
first_name: params.profile?.first_name || user.profile?.first_name,
|
||||
last_name: params.profile?.last_name || user.profile?.last_name,
|
||||
bio: params.profile?.bio || user.profile?.bio,
|
||||
address: params.profile?.address || user.profile?.address,
|
||||
birth_date: params.profile?.birth_date || user.profile?.birth_date,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -0,0 +1,417 @@
|
|||
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from "react"
|
||||
import { ChevronLeft, ChevronRight, Clock } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
|
||||
// Custom calendar component with enhanced year and month navigation
|
||||
export function DateTimePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
disabled,
|
||||
fromYear = 1900,
|
||||
toYear = new Date().getFullYear() + 10,
|
||||
showTimePicker = true,
|
||||
className,
|
||||
minuteStep = 1,
|
||||
showSeconds = true,
|
||||
}: {
|
||||
selected?: Date
|
||||
onSelect: (date?: Date) => void
|
||||
disabled?: (date: Date) => boolean
|
||||
fromYear?: number
|
||||
toYear?: number
|
||||
showTimePicker?: boolean
|
||||
className?: string
|
||||
minuteStep?: number
|
||||
showSeconds?: boolean
|
||||
}) {
|
||||
// Initialize with selected date or current date
|
||||
const [date, setDate] = useState<Date>(() => {
|
||||
return selected ? new Date(selected) : new Date()
|
||||
})
|
||||
|
||||
const [hours, setHours] = useState<string>(() => {
|
||||
return selected ? String(selected.getHours()).padStart(2, "0") : String(new Date().getHours()).padStart(2, "0")
|
||||
})
|
||||
|
||||
const [minutes, setMinutes] = useState<string>(() => {
|
||||
if (selected) {
|
||||
return String(selected.getMinutes()).padStart(2, "0")
|
||||
}
|
||||
// Round current minutes to nearest step
|
||||
const currentMinutes = new Date().getMinutes()
|
||||
const roundedMinutes = Math.round(currentMinutes / minuteStep) * minuteStep
|
||||
return String(roundedMinutes % 60).padStart(2, "0")
|
||||
})
|
||||
|
||||
const [seconds, setSeconds] = useState<string>(() => {
|
||||
return selected ? String(selected.getSeconds()).padStart(2, "0") : String(0).padStart(2, "0")
|
||||
})
|
||||
|
||||
// Track if we're in the middle of an update to prevent loops
|
||||
const isUpdatingRef = useRef(false)
|
||||
|
||||
// Generate valid minute options based on minuteStep
|
||||
const minuteOptions = useMemo(() => {
|
||||
const options = []
|
||||
for (let i = 0; i < 60; i += minuteStep) {
|
||||
options.push(String(i).padStart(2, "0"))
|
||||
}
|
||||
return options
|
||||
}, [minuteStep])
|
||||
|
||||
// Generate valid hour options
|
||||
const hourOptions = useMemo(() => {
|
||||
return Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"))
|
||||
}, [])
|
||||
|
||||
// Generate valid second options
|
||||
const secondOptions = useMemo(() => {
|
||||
return Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"))
|
||||
}, [])
|
||||
|
||||
// Update the parent component when date or time changes
|
||||
useEffect(() => {
|
||||
if (isUpdatingRef.current) return
|
||||
|
||||
if (date) {
|
||||
const newDate = new Date(date)
|
||||
const newHours = Number.parseInt(hours, 10)
|
||||
const newMinutes = Number.parseInt(minutes, 10)
|
||||
const newSeconds = Number.parseInt(seconds, 10)
|
||||
|
||||
newDate.setHours(newHours, newMinutes, newSeconds, 0)
|
||||
|
||||
// Only call onSelect if the date actually changed
|
||||
if (!selected || Math.abs(newDate.getTime() - (selected?.getTime() || 0)) > 100) {
|
||||
isUpdatingRef.current = true
|
||||
onSelect(newDate)
|
||||
// Use requestAnimationFrame instead of setTimeout for better performance
|
||||
requestAnimationFrame(() => {
|
||||
isUpdatingRef.current = false
|
||||
})
|
||||
}
|
||||
} else {
|
||||
onSelect(undefined)
|
||||
}
|
||||
}, [date, hours, minutes, seconds, onSelect, selected])
|
||||
|
||||
// Update internal state when selected prop changes
|
||||
useEffect(() => {
|
||||
if (isUpdatingRef.current) return
|
||||
|
||||
if (selected) {
|
||||
setDate(new Date(selected))
|
||||
setHours(String(selected.getHours()).padStart(2, "0"))
|
||||
setMinutes(String(selected.getMinutes()).padStart(2, "0"))
|
||||
setSeconds(String(selected.getSeconds()).padStart(2, "0"))
|
||||
}
|
||||
}, [selected])
|
||||
|
||||
// Generate years array from fromYear to toYear
|
||||
const years = useMemo(() => {
|
||||
const yearsArray = []
|
||||
for (let i = toYear; i >= fromYear; i--) {
|
||||
yearsArray.push(i)
|
||||
}
|
||||
return yearsArray
|
||||
}, [fromYear, toYear])
|
||||
|
||||
const months = useMemo(
|
||||
() => [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
// Handle time input changes
|
||||
const handleHoursChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let value = e.target.value.replace(/\D/g, "")
|
||||
if (value === "") {
|
||||
setHours("00")
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = Number.parseInt(value, 10)
|
||||
if (numValue > 23) {
|
||||
value = "23"
|
||||
}
|
||||
setHours(value.padStart(2, "0"))
|
||||
}, [])
|
||||
|
||||
const handleMinutesChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let value = e.target.value.replace(/\D/g, "")
|
||||
if (value === "") {
|
||||
setMinutes("00")
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = Number.parseInt(value, 10)
|
||||
if (numValue > 59) {
|
||||
value = "59"
|
||||
}
|
||||
setMinutes(value.padStart(2, "0"))
|
||||
}, [])
|
||||
|
||||
const handleSecondsChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let value = e.target.value.replace(/\D/g, "")
|
||||
if (value === "") {
|
||||
setSeconds("00")
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = Number.parseInt(value, 10)
|
||||
if (numValue > 59) {
|
||||
value = "59"
|
||||
}
|
||||
setSeconds(value.padStart(2, "0"))
|
||||
}, [])
|
||||
|
||||
// Clear date selection
|
||||
const handleClear = useCallback(() => {
|
||||
const now = new Date()
|
||||
setDate(now)
|
||||
setHours("00")
|
||||
setMinutes("00")
|
||||
setSeconds("00")
|
||||
onSelect(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0))
|
||||
}, [onSelect])
|
||||
|
||||
// Set date to now
|
||||
const handleSetNow = useCallback(() => {
|
||||
const now = new Date()
|
||||
setDate(now)
|
||||
setHours(String(now.getHours()).padStart(2, "0"))
|
||||
const roundedMinutes = Math.round(now.getMinutes() / minuteStep) * minuteStep
|
||||
setMinutes(String(roundedMinutes % 60).padStart(2, "0"))
|
||||
setSeconds(String(now.getSeconds()).padStart(2, "0"))
|
||||
onSelect(now)
|
||||
}, [minuteStep, onSelect])
|
||||
|
||||
// Custom caption component with optimized year selection
|
||||
const CustomCaption = useCallback(
|
||||
({ displayMonth }: { displayMonth: Date }) => {
|
||||
const month = displayMonth.getMonth()
|
||||
const year = displayMonth.getFullYear()
|
||||
const yearListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleMonthChange = useCallback(
|
||||
(newMonth: string) => {
|
||||
const monthIndex = months.findIndex((m) => m === newMonth)
|
||||
const newDate = new Date(date)
|
||||
newDate.setMonth(monthIndex)
|
||||
setDate(newDate)
|
||||
},
|
||||
[date],
|
||||
)
|
||||
|
||||
const handleYearChange = useCallback(
|
||||
(newYear: string) => {
|
||||
const newDate = new Date(date)
|
||||
newDate.setFullYear(Number.parseInt(newYear))
|
||||
setDate(newDate)
|
||||
},
|
||||
[date],
|
||||
)
|
||||
|
||||
// Create a virtualizer for the years list
|
||||
const virtualizer = useVirtualizer({
|
||||
count: years.length,
|
||||
getScrollElement: () => yearListRef.current,
|
||||
estimateSize: () => 36, // Approximate height of each year item
|
||||
overscan: 5, // Reduced overscan for better performance
|
||||
})
|
||||
|
||||
// Pre-scroll to current year when the select opens
|
||||
const handleYearSelectOpen = useCallback(() => {
|
||||
const yearIndex = years.findIndex((y) => y === year)
|
||||
if (yearIndex !== -1 && yearListRef.current) {
|
||||
// Use requestAnimationFrame for smoother scrolling
|
||||
requestAnimationFrame(() => {
|
||||
virtualizer.scrollToIndex(yearIndex, { align: "center" })
|
||||
})
|
||||
}
|
||||
}, [virtualizer, years, year])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-2">
|
||||
<Select value={months[month]} onValueChange={handleMonthChange}>
|
||||
<SelectTrigger className="h-8 w-[110px] text-sm">
|
||||
<SelectValue placeholder={months[month]} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{months.map((monthName) => (
|
||||
<SelectItem key={monthName} value={monthName} className="text-sm">
|
||||
{monthName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={year.toString()}
|
||||
onValueChange={handleYearChange}
|
||||
onOpenChange={(open) => open && handleYearSelectOpen()}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[90px] text-sm">
|
||||
<SelectValue placeholder={year.toString()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
ref={yearListRef}
|
||||
className="h-[200px] overflow-auto"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => (
|
||||
<SelectItem
|
||||
key={years[virtualItem.index]}
|
||||
value={years[virtualItem.index].toString()}
|
||||
className="text-sm absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
height: `${virtualItem.size}px`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{years[virtualItem.index]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[date, months, years],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4 p-2 border rounded-md shadow-sm", className)}>
|
||||
<DayPicker
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(newDate) => newDate && setDate(newDate)}
|
||||
disabled={disabled}
|
||||
month={date}
|
||||
onMonthChange={setDate}
|
||||
components={{
|
||||
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: () => <ChevronRight className="h-4 w-4" />,
|
||||
Caption: CustomCaption,
|
||||
}}
|
||||
classNames={{
|
||||
caption: "flex justify-center relative items-center",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 hover:bg-muted rounded-md transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
),
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside: "text-muted-foreground opacity-50",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
}}
|
||||
/>
|
||||
|
||||
{showTimePicker && (
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-center space-x-1">
|
||||
<Clock className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex items-center">
|
||||
<Select value={hours} onValueChange={setHours}>
|
||||
<SelectTrigger className="h-8 w-16 text-center text-sm">
|
||||
<SelectValue placeholder={hours} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{hourOptions.map((hour) => (
|
||||
<SelectItem key={hour} value={hour}>
|
||||
{hour}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="mx-1 text-sm">:</span>
|
||||
<Select value={minutes} onValueChange={setMinutes}>
|
||||
<SelectTrigger className="h-8 w-16 text-center text-sm">
|
||||
<SelectValue placeholder={minutes} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{minuteOptions.map((minute) => (
|
||||
<SelectItem key={minute} value={minute}>
|
||||
{minute}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showSeconds && (
|
||||
<>
|
||||
<span className="mx-1 text-sm">:</span>
|
||||
<Select value={seconds} onValueChange={setSeconds}>
|
||||
<SelectTrigger className="h-8 w-16 text-center text-sm">
|
||||
<SelectValue placeholder={seconds} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{secondOptions.map((second) => (
|
||||
<SelectItem key={second} value={second}>
|
||||
{second}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between border-t pt-3">
|
||||
<Button variant="outline" size="sm" onClick={handleClear}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleSetNow}>
|
||||
Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { FormDescription } from "./ui/form";
|
||||
|
||||
// Section component to reduce repetition
|
||||
export function FormSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: { title: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-8 border-b-2 pb-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,156 @@
|
|||
"use client"
|
||||
import { format } from "date-fns"
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// UI Components
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } 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 { Popover, PopoverContent, PopoverTrigger } from "@/app/_components/ui/popover"
|
||||
import { Switch } from "@/app/_components/ui/switch"
|
||||
import { Checkbox } from "@/app/_components/ui/checkbox"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { DateTimePicker } from "@/app/_components/date-time-picker"
|
||||
|
||||
// Reusable form field component to reduce repetition
|
||||
interface FormFieldProps {
|
||||
name: string
|
||||
label: string
|
||||
type: string
|
||||
control: any
|
||||
placeholder?: string
|
||||
options?: { value: string; label: string }[]
|
||||
rows?: number
|
||||
isDate?: boolean
|
||||
isBoolean?: boolean
|
||||
description?: string
|
||||
booleanType?: "switch" | "checkbox" | "select"
|
||||
fromYear?: number
|
||||
toYear?: number
|
||||
}
|
||||
|
||||
export function FormFieldWrapper({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
control,
|
||||
placeholder,
|
||||
options,
|
||||
rows = 1,
|
||||
isDate,
|
||||
isBoolean,
|
||||
description,
|
||||
booleanType = "switch",
|
||||
fromYear = 1900,
|
||||
toYear = new Date().getFullYear(),
|
||||
}: FormFieldProps) {
|
||||
// Default boolean options for select
|
||||
const booleanOptions = [
|
||||
{ value: "false", label: "False" },
|
||||
{ value: "true", label: "True" },
|
||||
]
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={
|
||||
isBoolean && booleanType === "switch"
|
||||
? "flex items-start justify-between rounded-lg border p-4"
|
||||
: "flex justify-between items-start gap-4"
|
||||
}
|
||||
>
|
||||
<div className={isBoolean && booleanType === "switch" ? "space-y-1" : "w-1/3"}>
|
||||
<FormLabel className={isBoolean && booleanType === "switch" ? "text-base" : ""}>{label}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">{type}</p>
|
||||
{description && isBoolean && booleanType === "switch" && <FormDescription>{description}</FormDescription>}
|
||||
</div>
|
||||
<div className={isBoolean && booleanType === "switch" ? "" : "w-2/3"}>
|
||||
<FormControl>
|
||||
{isBoolean ? (
|
||||
booleanType === "switch" ? (
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
) : booleanType === "checkbox" ? (
|
||||
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(value === "true")}
|
||||
defaultValue={String(field.value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder || `Select ${label.toLowerCase()}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{booleanOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
) : type.includes("select") && options ? (
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder || `Select ${label.toLowerCase()}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : isDate ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("w-full pl-3 text-left font-normal", !field.value && "text-muted-foreground")}
|
||||
>
|
||||
{field.value ? format(field.value, "MM/dd/yyyy hh:mm:ss a") : <span>Pick a date and time</span>}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<DateTimePicker
|
||||
selected={field.value instanceof Date ? field.value : undefined}
|
||||
onSelect={field.onChange}
|
||||
disabled={(date) => date > new Date() || date < new Date(`${fromYear}-01-01`)}
|
||||
fromYear={fromYear}
|
||||
toYear={toYear}
|
||||
showTimePicker
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : rows > 1 ? (
|
||||
<Textarea
|
||||
{...field}
|
||||
className="resize-none"
|
||||
rows={rows}
|
||||
value={field.value ?? ""}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
{...field}
|
||||
type={type.includes("URL") ? "url" : type === "string" ? "text" : type}
|
||||
placeholder={placeholder}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{!(isBoolean && booleanType === "switch") && <FormMessage />}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ const buttonVariants = cva(
|
|||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
xs: "h-7 rounded-md px-2",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { format, getMonth, getYear, setMonth, setYear } from "date-fns"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { Calendar } from "@/app/_components/ui/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/app/_components/ui/popover"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"
|
||||
|
||||
interface DatePickerProps {
|
||||
startYear?: number;
|
||||
endYear?: number;
|
||||
}
|
||||
export function DatePicker({
|
||||
startYear = getYear(new Date()) - 100,
|
||||
endYear = getYear(new Date()) + 100,
|
||||
}: DatePickerProps) {
|
||||
|
||||
const [date, setDate] = React.useState<Date>(new Date());
|
||||
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
const years = Array.from(
|
||||
{ length: endYear - startYear + 1 },
|
||||
(_, i) => startYear + i
|
||||
);
|
||||
|
||||
const handleMonthChange = (month: string) => {
|
||||
const newDate = setMonth(date, months.indexOf(month));
|
||||
setDate(newDate);
|
||||
}
|
||||
|
||||
const handleYearChange = (year: string) => {
|
||||
const newDate = setYear(date, parseInt(year));
|
||||
setDate(newDate)
|
||||
}
|
||||
|
||||
const handleSelect = (selectedData: Date | undefined) => {
|
||||
if (selectedData) {
|
||||
setDate(selectedData)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-[250px] justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP") : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<div className="flex justify-between p-2">
|
||||
<Select
|
||||
onValueChange={handleMonthChange}
|
||||
value={months[getMonth(date)]}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="Month" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{months.map(month => (
|
||||
<SelectItem key={month} value={month}>{month}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
onValueChange={handleYearChange}
|
||||
value={getYear(date).toString()}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="Year" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(year => (
|
||||
<SelectItem key={year} value={year.toString()}>{year}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={handleSelect}
|
||||
initialFocus
|
||||
month={date}
|
||||
onMonthChange={setDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
|
@ -27,6 +27,7 @@
|
|||
"@tabler/icons-react": "^3.30.0",
|
||||
"@tanstack/react-query": "^5.66.9",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"autoprefixer": "10.4.20",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
@ -3130,6 +3131,23 @@
|
|||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.2.tgz",
|
||||
"integrity": "sha512-LceSUgABBKF6HSsHK2ZqHzQ37IKV/jlaWbHm+NyTa3/WNb/JZVcThDuTainf+PixltOOcFCYXwxbLpOX9sCx+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz",
|
||||
|
@ -3143,6 +3161,16 @@
|
|||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.2.tgz",
|
||||
"integrity": "sha512-Qzz4EgzMbO5gKrmqUondCjiHcuu4B1ftHb0pjCut661lXZdGoHeze9f/M8iwsK1t5LGR6aNuNGU7mxkowaW6RQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||
|
|
|
@ -32,6 +32,7 @@
|
|||
"@tabler/icons-react": "^3.30.0",
|
||||
"@tanstack/react-query": "^5.66.9",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"autoprefixer": "10.4.20",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
|
@ -94,19 +94,35 @@ export type CreateUserParams = z.infer<typeof CreateUserParamsSchema>;
|
|||
|
||||
export const UpdateUserParamsSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().optional(),
|
||||
email_confirmed_at: z.boolean().optional(),
|
||||
password_hash: z.string().optional(),
|
||||
ban_duration: z.string().optional(),
|
||||
role: z.enum(["user", "staff", "admin"]).optional(),
|
||||
profile: z.object({
|
||||
phone: z.string().optional(),
|
||||
phone_confirmed_at: z.boolean().optional(),
|
||||
invited_at: z.union([z.string(), z.date()]).optional(),
|
||||
confirmed_at: z.union([z.string(), z.date()]).optional(),
|
||||
recovery_sent_at: z.union([z.string(), z.date()]).optional(),
|
||||
last_sign_in_at: z.union([z.string(), z.date()]).optional(),
|
||||
created_at: z.union([z.string(), z.date()]).optional(),
|
||||
updated_at: z.union([z.string(), z.date()]).optional(),
|
||||
is_anonymous: z.boolean().optional(),
|
||||
banned_until: z.string().optional(),
|
||||
user_metadata: z.record(z.any()).optional(),
|
||||
app_metadata: z.record(z.any()).optional(),
|
||||
profile: z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
user_id: z.string(),
|
||||
avatar: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
first_name: z.string().optional(),
|
||||
last_name: z.string().optional(),
|
||||
bio: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
birth_date: z.string().optional(),
|
||||
}),
|
||||
address: z.any().optional(),
|
||||
birth_date: z.date().optional(),
|
||||
})
|
||||
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type UpdateUserParams = z.infer<typeof UpdateUserParamsSchema>;
|
||||
|
|
Loading…
Reference in New Issue