536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { useUser } from "@clerk/nextjs";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { toast } from "@/lib/toast";
|
|
import {
|
|
roleSelectionSchema,
|
|
basicInfoSchema,
|
|
clientInfoSchema,
|
|
invitationOptionsSchema,
|
|
mergeUserCreationData,
|
|
type RoleSelectionData,
|
|
type BasicInfoData,
|
|
type ClientInfoData,
|
|
type InvitationOptionsData,
|
|
} from "@/lib/validation/user-schemas";
|
|
import { ChevronLeft, ChevronRight, Check } from "lucide-react";
|
|
import { useGyms } from "@/hooks/use-api";
|
|
import { getInvitableRoles } from "@/lib/auth/permissions";
|
|
import type { UserRole } from "@fitai/shared";
|
|
|
|
interface CreateUserModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
type Step = 1 | 2 | 3 | 4;
|
|
|
|
export function CreateUserModal({
|
|
open,
|
|
onOpenChange,
|
|
onSuccess,
|
|
}: CreateUserModalProps) {
|
|
const [currentStep, setCurrentStep] = useState<Step>(1);
|
|
const [roleData, setRoleData] = useState<RoleSelectionData | null>(null);
|
|
const [basicInfo, setBasicInfo] = useState<BasicInfoData | null>(null);
|
|
const [clientInfo, setClientInfo] = useState<ClientInfoData | null>(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const { data: gyms = [] } = useGyms();
|
|
const { user } = useUser();
|
|
|
|
// Get current user's role and gym from Clerk metadata
|
|
const currentUserRole = (user?.publicMetadata?.role as UserRole) || "client";
|
|
const currentUserGymId = user?.publicMetadata?.gymId as string | null;
|
|
|
|
// Determine which roles the current user can create
|
|
const invitableRoles = getInvitableRoles(currentUserRole);
|
|
|
|
// Step 1: Role Selection Form
|
|
const roleForm = useForm<RoleSelectionData>({
|
|
resolver: zodResolver(roleSelectionSchema),
|
|
defaultValues: { role: "client" },
|
|
});
|
|
|
|
// Step 2: Basic Info Form
|
|
const basicForm = useForm<BasicInfoData>({
|
|
resolver: zodResolver(basicInfoSchema),
|
|
defaultValues: {
|
|
firstName: "",
|
|
lastName: "",
|
|
phone: "",
|
|
email: "",
|
|
},
|
|
});
|
|
|
|
// Step 3: Client Info Form
|
|
const clientForm = useForm<ClientInfoData>({
|
|
resolver: zodResolver(clientInfoSchema),
|
|
defaultValues: {
|
|
membershipType: "basic",
|
|
membershipStatus: "active",
|
|
sendWelcomeEmail: false,
|
|
},
|
|
});
|
|
|
|
// Step 4: Invitation Options Form
|
|
const invitationForm = useForm<InvitationOptionsData>({
|
|
resolver: zodResolver(invitationOptionsSchema),
|
|
defaultValues: {
|
|
sendInvitation: true,
|
|
gymId: "",
|
|
},
|
|
});
|
|
|
|
const handleRoleNext = async () => {
|
|
const isValid = await roleForm.trigger();
|
|
if (isValid) {
|
|
const data = roleForm.getValues();
|
|
setRoleData(data);
|
|
setCurrentStep(2);
|
|
}
|
|
};
|
|
|
|
const handleBasicNext = async () => {
|
|
const isValid = await basicForm.trigger();
|
|
if (isValid) {
|
|
const data = basicForm.getValues();
|
|
setBasicInfo(data);
|
|
|
|
// Determine next step based on role
|
|
if (roleData?.role === "client") {
|
|
setCurrentStep(3);
|
|
} else {
|
|
// For admin/trainer, skip client info
|
|
setCurrentStep(4);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleClientNext = async () => {
|
|
const isValid = await clientForm.trigger();
|
|
if (isValid) {
|
|
const data = clientForm.getValues();
|
|
setClientInfo(data);
|
|
|
|
// If email provided, show invitation options
|
|
if (basicInfo?.email) {
|
|
setCurrentStep(4);
|
|
} else {
|
|
// No email, submit directly
|
|
await handleSubmit();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (invitationData?: InvitationOptionsData) => {
|
|
if (!roleData || !basicInfo) return;
|
|
|
|
try {
|
|
setIsSubmitting(true);
|
|
|
|
const payload = mergeUserCreationData(
|
|
roleData,
|
|
basicInfo,
|
|
clientInfo || undefined,
|
|
invitationData,
|
|
);
|
|
|
|
// Auto-assign gym for non-superAdmins
|
|
// SuperAdmins can select gym manually, others use their own gym
|
|
const finalPayload = {
|
|
...payload,
|
|
gymId:
|
|
currentUserRole === "superAdmin" ? payload.gymId : currentUserGymId,
|
|
};
|
|
|
|
const response = await fetch("/api/users/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(finalPayload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to create user");
|
|
return;
|
|
}
|
|
|
|
const result = await response.json();
|
|
toast.success(result.message || "User created successfully!");
|
|
|
|
// Reset form
|
|
resetModal();
|
|
|
|
// Add delay to allow Clerk API to propagate invitation
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
onSuccess();
|
|
onOpenChange(false);
|
|
} catch (error) {
|
|
console.error("Create user error:", error);
|
|
toast.error("An unexpected error occurred");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleInvitationSubmit = async () => {
|
|
const isValid = await invitationForm.trigger();
|
|
if (isValid) {
|
|
const data = invitationForm.getValues();
|
|
await handleSubmit(data);
|
|
}
|
|
};
|
|
|
|
const resetModal = () => {
|
|
setCurrentStep(1);
|
|
setRoleData(null);
|
|
setBasicInfo(null);
|
|
setClientInfo(null);
|
|
roleForm.reset();
|
|
basicForm.reset();
|
|
clientForm.reset();
|
|
invitationForm.reset();
|
|
};
|
|
|
|
const handleBack = () => {
|
|
if (currentStep === 4 && roleData?.role === "client") {
|
|
setCurrentStep(3);
|
|
} else if (currentStep === 4 || currentStep === 3) {
|
|
setCurrentStep(2);
|
|
} else if (currentStep === 2) {
|
|
setCurrentStep(1);
|
|
}
|
|
};
|
|
|
|
const getStepTitle = () => {
|
|
switch (currentStep) {
|
|
case 1:
|
|
return "Select Role";
|
|
case 2:
|
|
return "Basic Information";
|
|
case 3:
|
|
return "Membership Details";
|
|
case 4:
|
|
return "Invitation Options";
|
|
default:
|
|
return "";
|
|
}
|
|
};
|
|
|
|
const getStepDescription = () => {
|
|
switch (currentStep) {
|
|
case 1:
|
|
return "Choose the role for this user";
|
|
case 2:
|
|
return "Enter user's basic information";
|
|
case 3:
|
|
return "Configure client membership";
|
|
case 4:
|
|
return basicInfo?.email
|
|
? "Choose how to notify the user"
|
|
: "Review and create";
|
|
default:
|
|
return "";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle>{getStepTitle()}</DialogTitle>
|
|
<DialogDescription>{getStepDescription()}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Step Progress Indicator */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
{[1, 2, 3, 4].map((step) => (
|
|
<div
|
|
key={step}
|
|
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
|
|
step < currentStep
|
|
? "bg-green-500 text-white"
|
|
: step === currentStep
|
|
? "bg-blue-500 text-white"
|
|
: "bg-gray-200 text-gray-600"
|
|
}`}
|
|
>
|
|
{step < currentStep ? <Check className="w-4 h-4" /> : step}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Step 1: Role Selection */}
|
|
{currentStep === 1 && (
|
|
<form
|
|
onSubmit={roleForm.handleSubmit(handleRoleNext)}
|
|
className="space-y-4"
|
|
>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Role *</label>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{invitableRoles.map((role) => (
|
|
<label
|
|
key={role}
|
|
className={`flex items-center justify-center p-4 border-2 rounded-lg cursor-pointer transition-colors ${
|
|
roleForm.watch("role") === role
|
|
? "border-blue-500 bg-blue-50"
|
|
: "border-gray-200 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
value={role}
|
|
{...roleForm.register("role")}
|
|
className="sr-only"
|
|
/>
|
|
<span className="text-sm font-medium capitalize">
|
|
{role}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
{roleForm.formState.errors.role && (
|
|
<p className="text-sm text-red-500">
|
|
{roleForm.formState.errors.role.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="submit" className="w-full">
|
|
Next <ChevronRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
)}
|
|
|
|
{/* Step 2: Basic Information */}
|
|
{currentStep === 2 && (
|
|
<form
|
|
onSubmit={basicForm.handleSubmit(handleBasicNext)}
|
|
className="space-y-4"
|
|
>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">First Name *</label>
|
|
<Input {...basicForm.register("firstName")} placeholder="John" />
|
|
{basicForm.formState.errors.firstName && (
|
|
<p className="text-sm text-red-500">
|
|
{basicForm.formState.errors.firstName.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Last Name *</label>
|
|
<Input {...basicForm.register("lastName")} placeholder="Doe" />
|
|
{basicForm.formState.errors.lastName && (
|
|
<p className="text-sm text-red-500">
|
|
{basicForm.formState.errors.lastName.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Phone (Optional)</label>
|
|
<Input
|
|
{...basicForm.register("phone")}
|
|
placeholder="+1234567890"
|
|
type="tel"
|
|
/>
|
|
{basicForm.formState.errors.phone && (
|
|
<p className="text-sm text-red-500">
|
|
{basicForm.formState.errors.phone.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Email *</label>
|
|
<Input
|
|
{...basicForm.register("email")}
|
|
placeholder="john.doe@example.com"
|
|
type="email"
|
|
required
|
|
/>
|
|
<p className="text-xs text-gray-500">
|
|
Email is currently required in the database schema
|
|
</p>
|
|
{basicForm.formState.errors.email && (
|
|
<p className="text-sm text-red-500">
|
|
{basicForm.formState.errors.email.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<DialogFooter className="flex justify-between">
|
|
<Button type="button" variant="outline" onClick={handleBack}>
|
|
<ChevronLeft className="mr-2 h-4 w-4" /> Back
|
|
</Button>
|
|
<Button type="submit">
|
|
Next <ChevronRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
)}
|
|
|
|
{/* Step 3: Client Information (only for clients) */}
|
|
{currentStep === 3 && roleData?.role === "client" && (
|
|
<form
|
|
onSubmit={clientForm.handleSubmit(handleClientNext)}
|
|
className="space-y-4"
|
|
>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Membership Type *</label>
|
|
<select
|
|
{...clientForm.register("membershipType")}
|
|
className="w-full p-2 border border-gray-300 rounded-md"
|
|
>
|
|
<option value="basic">Basic</option>
|
|
<option value="premium">Premium</option>
|
|
<option value="vip">VIP</option>
|
|
</select>
|
|
{clientForm.formState.errors.membershipType && (
|
|
<p className="text-sm text-red-500">
|
|
{clientForm.formState.errors.membershipType.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Status *</label>
|
|
<select
|
|
{...clientForm.register("membershipStatus")}
|
|
className="w-full p-2 border border-gray-300 rounded-md"
|
|
>
|
|
<option value="active">Active</option>
|
|
<option value="inactive">Inactive</option>
|
|
<option value="pending">Pending</option>
|
|
</select>
|
|
</div>
|
|
{basicInfo?.email && (
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
id="sendWelcome"
|
|
{...clientForm.register("sendWelcomeEmail")}
|
|
className="w-4 h-4"
|
|
/>
|
|
<label htmlFor="sendWelcome" className="text-sm">
|
|
Send welcome email to {basicInfo.email}
|
|
</label>
|
|
</div>
|
|
)}
|
|
<DialogFooter className="flex justify-between">
|
|
<Button type="button" variant="outline" onClick={handleBack}>
|
|
<ChevronLeft className="mr-2 h-4 w-4" /> Back
|
|
</Button>
|
|
<Button type="submit" disabled={isSubmitting}>
|
|
{basicInfo?.email ? (
|
|
<>
|
|
Next <ChevronRight className="ml-2 h-4 w-4" />
|
|
</>
|
|
) : (
|
|
<>Create User</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
)}
|
|
|
|
{/* Step 4: Invitation Options */}
|
|
{currentStep === 4 && (
|
|
<form
|
|
onSubmit={invitationForm.handleSubmit(handleInvitationSubmit)}
|
|
className="space-y-4"
|
|
>
|
|
{basicInfo?.email && (
|
|
<>
|
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
|
<p className="text-sm text-blue-900">
|
|
Email provided: <strong>{basicInfo.email}</strong>
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
id="sendInvitation"
|
|
{...invitationForm.register("sendInvitation")}
|
|
className="w-4 h-4"
|
|
/>
|
|
<label htmlFor="sendInvitation" className="text-sm">
|
|
Send Clerk invitation email (user must accept to create
|
|
account)
|
|
</label>
|
|
</div>
|
|
<p className="text-xs text-gray-500">
|
|
{invitationForm.watch("sendInvitation")
|
|
? "User will receive an email to create their account"
|
|
: "User record will be created without a Clerk account"}
|
|
</p>
|
|
</>
|
|
)}
|
|
{!basicInfo?.email && (
|
|
<div className="p-4 bg-amber-50 border border-amber-200 rounded-md">
|
|
<p className="text-sm text-amber-900">
|
|
No email provided. User will be created in the database only.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gym Selector - Only for SuperAdmins */}
|
|
{currentUserRole === "superAdmin" && (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">
|
|
Assign to Gym (Optional)
|
|
</label>
|
|
<select
|
|
{...invitationForm.register("gymId")}
|
|
className="w-full p-2 border border-gray-300 rounded-md"
|
|
>
|
|
<option value="">Select a gym...</option>
|
|
{gyms.map((gym) => (
|
|
<option key={gym.id} value={gym.id}>
|
|
{gym.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="text-xs text-gray-500">
|
|
If not selected, user will not be assigned to any gym
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Info message for non-superAdmins */}
|
|
{currentUserRole !== "superAdmin" && currentUserGymId && (
|
|
<div className="p-4 bg-gray-50 border border-gray-200 rounded-md">
|
|
<p className="text-sm text-gray-700">
|
|
User will be automatically assigned to your gym
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter className="flex justify-between">
|
|
<Button type="button" variant="outline" onClick={handleBack}>
|
|
<ChevronLeft className="mr-2 h-4 w-4" /> Back
|
|
</Button>
|
|
<Button type="submit" disabled={isSubmitting}>
|
|
{isSubmitting ? "Creating..." : "Create User"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|