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())}
|
{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
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
colSpan={columns.length + 1}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
No results.
|
No results.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
|
@ -381,7 +381,7 @@ export function UserDetailSheet({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ban className="h-4 w-4 mr-2" />
|
<Ban className="h-4 w-4 mr-2" />
|
||||||
{user.banned_until ? "Unban user" : "Ban user"}
|
{user.banned_until ? "Unban user" : "Ban user"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -1,80 +1,63 @@
|
||||||
import { FormDescription } from "@/app/_components/ui/form";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import type React from "react"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useState } from "react"
|
||||||
import * as z from "zod";
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns";
|
import { useForm } from "react-hook-form"
|
||||||
import {
|
import type * as z from "zod"
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
import { Loader2 } from "lucide-react"
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
import { UpdateUserParamsSchema, type User, UserSchema } from "@/src/models/users/users.model"
|
||||||
SheetTitle,
|
|
||||||
} from "@/app/_components/ui/sheet";
|
// UI Components
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/app/_components/ui/sheet"
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
} from "@/app/_components/ui/form"
|
||||||
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>;
|
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 {
|
interface UserProfileSheetProps {
|
||||||
open: boolean;
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void
|
||||||
userData?: User; // Replace with your user data type
|
userData?: User
|
||||||
onSave: (data: UserProfileFormValues) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserProfileSheet({
|
export function UserProfileSheet({ open, onOpenChange, userData }: UserProfileSheetProps) {
|
||||||
open,
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
onOpenChange,
|
|
||||||
userData,
|
|
||||||
onSave,
|
|
||||||
}: UserProfileSheetProps) {
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
// Initialize form with user data
|
// Initialize form with user data
|
||||||
const form = useForm<UserProfileFormValues>({
|
const form = useForm<UserProfileFormValues>({
|
||||||
resolver: zodResolver(UserSchema),
|
resolver: zodResolver(UpdateUserParamsSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: userData?.email || "",
|
email: userData?.email || "",
|
||||||
|
password_hash: userData?.password_hash || "",
|
||||||
|
role: (userData?.role as "user" | "staff" | "admin") || "user",
|
||||||
phone: userData?.phone || "",
|
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,
|
is_anonymous: userData?.is_anonymous || false,
|
||||||
|
banned_until: userData?.banned_until ? String(userData.banned_until) : undefined,
|
||||||
profile: {
|
profile: {
|
||||||
|
id: userData?.profile?.id || "",
|
||||||
|
user_id: userData?.profile?.user_id || "",
|
||||||
|
avatar: userData?.profile?.avatar || "",
|
||||||
username: userData?.profile?.username || "",
|
username: userData?.profile?.username || "",
|
||||||
first_name: userData?.profile?.first_name || "",
|
first_name: userData?.profile?.first_name || "",
|
||||||
last_name: userData?.profile?.last_name || "",
|
last_name: userData?.profile?.last_name || "",
|
||||||
bio: userData?.profile?.bio || "",
|
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 || {
|
address: userData?.profile?.address || {
|
||||||
street: "",
|
street: "",
|
||||||
city: "",
|
city: "",
|
||||||
|
@ -82,368 +65,246 @@ export function UserProfileSheet({
|
||||||
country: "",
|
country: "",
|
||||||
postal_code: "",
|
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) {
|
async function onSubmit(data: UserProfileFormValues) {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true)
|
||||||
await onSave(data);
|
await updateUserMutation(data)
|
||||||
onOpenChange(false);
|
onOpenChange(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving user profile:", error);
|
console.error("Error saving user profile:", error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
|
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
||||||
<SheetHeader className="mb-6">
|
<SheetHeader className="mb-6 border-b-2 pb-4">
|
||||||
<SheetTitle>Update User Profile</SheetTitle>
|
<SheetTitle>Update User Profile</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>Make changes to the user profile here. Click save when you're done.</SheetDescription>
|
||||||
Make changes to the user profile here. Click save when you're
|
|
||||||
done.
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
{/* User Information Section */}
|
{/* User Information Section */}
|
||||||
<div className="space-y-4">
|
<FormSection
|
||||||
<h3 className="text-lg font-medium">User Information</h3>
|
title="User Information"
|
||||||
|
description="Update the user information below. Fields marked with an asterisk (*) are required."
|
||||||
<FormField
|
>
|
||||||
control={form.control}
|
<FormFieldWrapper
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
label="Email"
|
||||||
<FormItem>
|
type="string"
|
||||||
<FormLabel>Email</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="email"
|
|
||||||
placeholder="email@example.com"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<FormFieldWrapper
|
||||||
name="phone"
|
name="phone"
|
||||||
render={({ field }) => (
|
label="Phone"
|
||||||
<FormItem>
|
type="tel"
|
||||||
<FormLabel>Phone</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="tel"
|
|
||||||
placeholder="+1234567890"
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
placeholder="+1234567890"
|
||||||
|
/>
|
||||||
|
<FormFieldWrapper
|
||||||
name="role"
|
name="role"
|
||||||
render={({ field }) => (
|
label="Role"
|
||||||
<FormItem>
|
type="string (select)"
|
||||||
<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}
|
control={form.control}
|
||||||
name="is_anonymous"
|
options={[
|
||||||
render={({ field }) => (
|
{ value: "user", label: "User" },
|
||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
{ value: "admin", label: "Admin" },
|
||||||
<div className="space-y-0.5">
|
{ value: "staff", label: "Staff" },
|
||||||
<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>
|
<FormFieldWrapper
|
||||||
|
name="is_anonymous"
|
||||||
|
label="Is Anonymous"
|
||||||
|
type="boolean"
|
||||||
|
control={form.control}
|
||||||
|
isBoolean={true}
|
||||||
|
booleanType="select"
|
||||||
|
/>
|
||||||
|
<FormFieldWrapper
|
||||||
|
name="password_hash"
|
||||||
|
label="Password Hash"
|
||||||
|
type="string"
|
||||||
|
control={form.control}
|
||||||
|
placeholder="Password Hash"
|
||||||
|
/>
|
||||||
|
<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 */}
|
{/* Profile Information Section */}
|
||||||
<div className="space-y-4">
|
<FormSection title="Profile Information" description="Update the user profile information below.">
|
||||||
<h3 className="text-lg font-medium">Profile Information</h3>
|
<FormFieldWrapper
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="profile.username"
|
name="profile.username"
|
||||||
render={({ field }) => (
|
label="Username"
|
||||||
<FormItem>
|
type="string"
|
||||||
<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}
|
control={form.control}
|
||||||
|
placeholder="username"
|
||||||
|
/>
|
||||||
|
<FormFieldWrapper
|
||||||
|
name="profile.first_name"
|
||||||
|
label="First Name"
|
||||||
|
type="string"
|
||||||
|
control={form.control}
|
||||||
|
placeholder="John"
|
||||||
|
/>
|
||||||
|
<FormFieldWrapper
|
||||||
|
name="profile.last_name"
|
||||||
|
label="Last Name"
|
||||||
|
type="string"
|
||||||
|
control={form.control}
|
||||||
|
placeholder="Doe"
|
||||||
|
/>
|
||||||
|
<FormFieldWrapper
|
||||||
name="profile.bio"
|
name="profile.bio"
|
||||||
render={({ field }) => (
|
label="Bio"
|
||||||
<FormItem>
|
type="string"
|
||||||
<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}
|
control={form.control}
|
||||||
|
placeholder="Tell us about yourself"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<FormFieldWrapper
|
||||||
name="profile.birth_date"
|
name="profile.birth_date"
|
||||||
render={({ field }) => (
|
label="Date of birth"
|
||||||
<FormItem className="flex flex-col">
|
type="date"
|
||||||
<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}
|
control={form.control}
|
||||||
name="profile.avatar"
|
isDate={true}
|
||||||
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>
|
<FormFieldWrapper
|
||||||
|
name="profile.avatar"
|
||||||
|
label="Avatar URL"
|
||||||
|
type="string (URL)"
|
||||||
|
control={form.control}
|
||||||
|
placeholder="https://example.com/avatar.jpg"
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
{/* Address Section */}
|
{/* Address Section */}
|
||||||
<div className="space-y-4">
|
<FormSection title="Address" description="Update the user address information below.">
|
||||||
<h3 className="text-lg font-medium">Address</h3>
|
<FormFieldWrapper
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="profile.address.street"
|
name="profile.address.street"
|
||||||
render={({ field }) => (
|
label="Street Address"
|
||||||
<FormItem>
|
type="string"
|
||||||
<FormLabel>Street Address</FormLabel>
|
control={form.control}
|
||||||
<FormControl>
|
placeholder="123 Main St"
|
||||||
<Input {...field} placeholder="123 Main St" />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
<FormFieldWrapper
|
||||||
<div className="grid grid-cols-2 gap-4">
|
name="profile.address.city"
|
||||||
<FormField
|
label="City"
|
||||||
control={form.control}
|
type="string"
|
||||||
name="profile.address.city"
|
control={form.control}
|
||||||
render={({ field }) => (
|
placeholder="City"
|
||||||
<FormItem>
|
/>
|
||||||
<FormLabel>City</FormLabel>
|
<FormFieldWrapper
|
||||||
<FormControl>
|
name="profile.address.state"
|
||||||
<Input {...field} placeholder="City" />
|
label="State"
|
||||||
</FormControl>
|
type="string"
|
||||||
<FormMessage />
|
control={form.control}
|
||||||
</FormItem>
|
placeholder="State"
|
||||||
)}
|
/>
|
||||||
/>
|
<FormFieldWrapper
|
||||||
|
name="profile.address.country"
|
||||||
<FormField
|
label="Country"
|
||||||
control={form.control}
|
type="string"
|
||||||
name="profile.address.state"
|
control={form.control}
|
||||||
render={({ field }) => (
|
placeholder="Country"
|
||||||
<FormItem>
|
/>
|
||||||
<FormLabel>State</FormLabel>
|
<FormFieldWrapper
|
||||||
<FormControl>
|
name="profile.address.postal_code"
|
||||||
<Input {...field} placeholder="State" />
|
label="Postal Code"
|
||||||
</FormControl>
|
type="string"
|
||||||
<FormMessage />
|
control={form.control}
|
||||||
</FormItem>
|
placeholder="Postal Code"
|
||||||
)}
|
/>
|
||||||
/>
|
</FormSection>
|
||||||
</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 */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-end space-x-4 sticky bottom-0 bg-background py-4 border-t">
|
<div className="flex justify-end space-x-4">
|
||||||
<Button
|
<Button type="button" variant="outline" size="xs" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving}>
|
<Button size="xs" type="submit" disabled={isPending}>
|
||||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{isSaving ? "Saving..." : "Save changes"}
|
{isPending ? "Saving..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,26 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useEffect, JSX } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
X,
|
X,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Mail,
|
Mail,
|
||||||
SortAsc,
|
|
||||||
SortDesc,
|
|
||||||
Mail as MailIcon,
|
|
||||||
Phone,
|
|
||||||
Clock,
|
|
||||||
Calendar,
|
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
XCircle,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
UserPen,
|
PenIcon as 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";
|
||||||
|
@ -35,15 +27,14 @@ import {
|
||||||
} from "@/app/_components/ui/dropdown-menu";
|
} from "@/app/_components/ui/dropdown-menu";
|
||||||
import { keepPreviousData, 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 type { User } from "@/src/models/users/users.model";
|
||||||
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 { UserDetailSheet } 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 { ColumnDef, HeaderContext } from "@tanstack/react-table";
|
import type { ColumnDef, HeaderContext } from "@tanstack/react-table";
|
||||||
import { UserProfileSheet } from "./update-user";
|
import { UserProfileSheet } from "./update-user";
|
||||||
|
|
||||||
type UserFilterOptions = {
|
type UserFilterOptions = {
|
||||||
|
@ -58,7 +49,8 @@ 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 [detailUser, setDetailUser] = useState<User | null>(null);
|
||||||
|
const [updateUser, setUpdateUser] = useState<User | null>(null);
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
const [isUpdateOpen, setIsUpdateOpen] = useState(false);
|
const [isUpdateOpen, setIsUpdateOpen] = useState(false);
|
||||||
const [isAddUserOpen, setIsAddUserOpen] = useState(false);
|
const [isAddUserOpen, setIsAddUserOpen] = useState(false);
|
||||||
|
@ -86,17 +78,51 @@ export default function UserManagement() {
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle opening the detail sheet
|
||||||
const handleUserClick = (user: User) => {
|
const handleUserClick = (user: User) => {
|
||||||
setSelectedUser(user);
|
setDetailUser(user);
|
||||||
setIsSheetOpen(true);
|
setIsSheetOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle opening the update sheet
|
||||||
const handleUserUpdate = (user: User) => {
|
const handleUserUpdate = (user: User) => {
|
||||||
setSelectedUser(user);
|
setUpdateUser(user);
|
||||||
setIsSheetOpen(false);
|
|
||||||
setIsUpdateOpen(true);
|
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(() => {
|
const filteredUsers = useMemo(() => {
|
||||||
return users.filter((user) => {
|
return users.filter((user) => {
|
||||||
// Global search
|
// 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">
|
<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 ? (
|
||||||
<Image
|
<Image
|
||||||
src={row.original.profile.avatar}
|
src={row.original.profile.avatar || "/placeholder.svg"}
|
||||||
alt="Avatar"
|
alt="Avatar"
|
||||||
className="w-full h-full rounded-full"
|
className="w-full h-full rounded-full"
|
||||||
width={32}
|
width={32}
|
||||||
|
@ -519,35 +545,38 @@ export default function UserManagement() {
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "",
|
header: "",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DropdownMenu>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<DropdownMenuTrigger asChild>
|
{/* Add this wrapper */}
|
||||||
<Button variant="ghost" size="icon">
|
<DropdownMenu>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<DropdownMenuTrigger asChild>
|
||||||
</Button>
|
<Button variant="ghost" size="icon">
|
||||||
</DropdownMenuTrigger>
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<DropdownMenuContent align="end">
|
</Button>
|
||||||
<DropdownMenuItem onClick={() => handleUserUpdate(row.original)}>
|
</DropdownMenuTrigger>
|
||||||
<UserPen className="h-4 w-4 mr-2 text-blue-500" />
|
<DropdownMenuContent align="end">
|
||||||
Update
|
<DropdownMenuItem onClick={() => handleUserUpdate(row.original)}>
|
||||||
</DropdownMenuItem>
|
<UserPen className="h-4 w-4 mr-2 text-blue-500" />
|
||||||
<DropdownMenuItem
|
Update
|
||||||
onClick={() => {
|
</DropdownMenuItem>
|
||||||
/* handle delete */
|
<DropdownMenuItem
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
/* handle delete */
|
||||||
<Trash2 className="h-4 w-4 mr-2 text-red-500" />
|
}}
|
||||||
Delete
|
>
|
||||||
</DropdownMenuItem>
|
<Trash2 className="h-4 w-4 mr-2 text-red-500" />
|
||||||
<DropdownMenuItem
|
Delete
|
||||||
onClick={() => {
|
</DropdownMenuItem>
|
||||||
/* handle ban */
|
<DropdownMenuItem
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
/* handle ban */
|
||||||
<ShieldAlert className="h-4 w-4 mr-2 text-yellow-500" />
|
}}
|
||||||
{row.original.banned_until != null ? "Unban" : "Ban"}
|
>
|
||||||
</DropdownMenuItem>
|
<ShieldAlert className="h-4 w-4 mr-2 text-yellow-500" />
|
||||||
</DropdownMenuContent>
|
{row.original.banned_until != null ? "Unban" : "Ban"}
|
||||||
</DropdownMenu>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -616,9 +645,9 @@ export default function UserManagement() {
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onRowClick={(user) => handleUserClick(user)}
|
onRowClick={(user) => handleUserClick(user)}
|
||||||
/>
|
/>
|
||||||
{selectedUser && (
|
{detailUser && (
|
||||||
<UserDetailSheet
|
<UserDetailSheet
|
||||||
user={selectedUser}
|
user={detailUser}
|
||||||
open={isSheetOpen}
|
open={isSheetOpen}
|
||||||
onOpenChange={setIsSheetOpen}
|
onOpenChange={setIsSheetOpen}
|
||||||
onUserUpdate={() => {}}
|
onUserUpdate={() => {}}
|
||||||
|
@ -634,12 +663,11 @@ export default function UserManagement() {
|
||||||
onOpenChange={setIsInviteUserOpen}
|
onOpenChange={setIsInviteUserOpen}
|
||||||
onUserInvited={() => refetch()}
|
onUserInvited={() => refetch()}
|
||||||
/>
|
/>
|
||||||
{selectedUser && (
|
{updateUser && (
|
||||||
<UserProfileSheet
|
<UserProfileSheet
|
||||||
open={isUpdateOpen}
|
open={isUpdateOpen}
|
||||||
onOpenChange={setIsUpdateOpen}
|
onOpenChange={setIsUpdateOpen}
|
||||||
userData={selectedUser}
|
userData={updateUser}
|
||||||
onSave={async () => {}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -162,9 +162,15 @@ export async function updateUser(
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
|
const { data, error } = await supabase.auth.admin.updateUserById(userId, {
|
||||||
email: params.email,
|
email: params.email,
|
||||||
phone: params.phone,
|
email_confirm: params.email_confirmed_at,
|
||||||
|
password: params.password_hash,
|
||||||
password_hash: 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) {
|
if (error) {
|
||||||
|
@ -190,16 +196,23 @@ export async function updateUser(
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
data: {
|
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: {
|
profile: {
|
||||||
update: {
|
update: {
|
||||||
avatar: params.profile.avatar || user.profile?.avatar,
|
avatar: params.profile?.avatar || user.profile?.avatar,
|
||||||
username: params.profile.username || user.profile?.username,
|
username: params.profile?.username || user.profile?.username,
|
||||||
first_name: params.profile.first_name || user.profile?.first_name,
|
first_name: params.profile?.first_name || user.profile?.first_name,
|
||||||
last_name: params.profile.last_name || user.profile?.last_name,
|
last_name: params.profile?.last_name || user.profile?.last_name,
|
||||||
bio: params.profile.bio || user.profile?.bio,
|
bio: params.profile?.bio || user.profile?.bio,
|
||||||
address: params.profile.address,
|
address: params.profile?.address || user.profile?.address,
|
||||||
birth_date: params.profile.birth_date || user.profile?.birth_date,
|
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: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: "h-9 rounded-md px-3",
|
||||||
|
xs: "h-7 rounded-md px-2",
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: "h-11 rounded-md px-8",
|
||||||
icon: "h-10 w-10",
|
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",
|
"@tabler/icons-react": "^3.30.0",
|
||||||
"@tanstack/react-query": "^5.66.9",
|
"@tanstack/react-query": "^5.66.9",
|
||||||
"@tanstack/react-table": "^8.21.2",
|
"@tanstack/react-table": "^8.21.2",
|
||||||
|
"@tanstack/react-virtual": "^3.13.2",
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "10.4.20",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
@ -3130,6 +3131,23 @@
|
||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/@tanstack/table-core": {
|
||||||
"version": "8.21.2",
|
"version": "8.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz",
|
||||||
|
@ -3143,6 +3161,16 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@tsconfig/node10": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||||
|
|
|
@ -32,6 +32,7 @@
|
||||||
"@tabler/icons-react": "^3.30.0",
|
"@tabler/icons-react": "^3.30.0",
|
||||||
"@tanstack/react-query": "^5.66.9",
|
"@tanstack/react-query": "^5.66.9",
|
||||||
"@tanstack/react-table": "^8.21.2",
|
"@tanstack/react-table": "^8.21.2",
|
||||||
|
"@tanstack/react-virtual": "^3.13.2",
|
||||||
"autoprefixer": "10.4.20",
|
"autoprefixer": "10.4.20",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
@ -94,19 +94,35 @@ export type CreateUserParams = z.infer<typeof CreateUserParamsSchema>;
|
||||||
|
|
||||||
export const UpdateUserParamsSchema = z.object({
|
export const UpdateUserParamsSchema = z.object({
|
||||||
email: z.string().email().optional(),
|
email: z.string().email().optional(),
|
||||||
phone: z.string().optional(),
|
email_confirmed_at: z.boolean().optional(),
|
||||||
password_hash: z.string().optional(),
|
password_hash: z.string().optional(),
|
||||||
ban_duration: z.string().optional(),
|
|
||||||
role: z.enum(["user", "staff", "admin"]).optional(),
|
role: z.enum(["user", "staff", "admin"]).optional(),
|
||||||
profile: z.object({
|
phone: z.string().optional(),
|
||||||
avatar: z.string().optional(),
|
phone_confirmed_at: z.boolean().optional(),
|
||||||
username: z.string().optional(),
|
invited_at: z.union([z.string(), z.date()]).optional(),
|
||||||
first_name: z.string().optional(),
|
confirmed_at: z.union([z.string(), z.date()]).optional(),
|
||||||
last_name: z.string().optional(),
|
recovery_sent_at: z.union([z.string(), z.date()]).optional(),
|
||||||
bio: z.string().optional(),
|
last_sign_in_at: z.union([z.string(), z.date()]).optional(),
|
||||||
address: z.string().optional(),
|
created_at: z.union([z.string(), z.date()]).optional(),
|
||||||
birth_date: z.string().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.any().optional(),
|
||||||
|
birth_date: z.date().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateUserParams = z.infer<typeof UpdateUserParamsSchema>;
|
export type UpdateUserParams = z.infer<typeof UpdateUserParamsSchema>;
|
||||||
|
|
Loading…
Reference in New Issue