Files
fitaiProto/apps/admin/src/components/users/CreateUserModal.tsx
T
2026-03-18 23:58:14 +01:00

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>
);
}