feat(forms): implement dynamic form builder for admin and company creation
continuous-integration/drone/push Build is passing

- Introduced DynamicFormBuilder component to streamline form creation for admin and company categories.
- Refactored CreateAdmin and CreateCompany components to utilize the new dynamic form structure.
- Added createAdminFormSections and createCompanyFormSections functions to define form fields and validation rules.
- Enhanced form handling with improved error management and dynamic field rendering.

This update enhances maintainability and scalability of forms across the application.
This commit is contained in:
AmirReza Jamali
2026-06-21 09:03:51 +03:30
parent 5a6a5346c6
commit 90a42b9fc5
22 changed files with 1717 additions and 1392 deletions
+16
View File
@@ -0,0 +1,16 @@
# Agent Instructions
## Dual Origin Commit and Push
When the user asks to "commit and push" (or equivalent), always execute both targets unless the user explicitly says otherwise:
- Primary origin push:
- `git push origin dev`
- Old origin push:
- `git push https://repo.ravanertebat.com/TM/tm_panel.git dev:develop`
Behavior requirements:
- If there are uncommitted changes, create a commit first and then push to both targets.
- If there is nothing to commit, still push the current `dev` branch to both targets.
- Do not ask for confirmation for the second push when the user request is "commit and push".
@@ -11,6 +11,7 @@ import {
type UseFormRegister,
type UseFormRegisterReturn,
} from "react-hook-form";
import { get } from "react-hook-form";
type InputGroupProps<T extends FieldValues> = {
name: Path<T>;
@@ -58,7 +59,7 @@ const InputGroup = <T extends FieldValues>({
}: InputGroupProps<T>): React.ReactElement => {
const id = useId();
const error = errors && errors[name];
const error = errors && get(errors, name);
return (
<div className={className}>
+173
View File
@@ -0,0 +1,173 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import { cn } from "@/lib/utils";
import type {
Control,
FieldErrors,
FieldValues,
Path,
RegisterOptions,
SubmitHandler,
UseFormHandleSubmit,
UseFormRegister,
} from "react-hook-form";
import { get } from "react-hook-form";
import type {
HTMLInputAutoCompleteAttribute,
HTMLInputTypeAttribute,
ReactNode,
} from "react";
export interface DynamicFieldContext<TValues extends FieldValues> {
control: Control<TValues>;
errors: FieldErrors<TValues>;
register: UseFormRegister<TValues>;
error?: string;
}
interface DynamicFieldBase<TValues extends FieldValues> {
name: Path<TValues>;
className?: string;
hidden?: boolean;
}
export interface DynamicInputField<
TValues extends FieldValues,
> extends DynamicFieldBase<TValues> {
kind: "input";
label: string;
type: HTMLInputTypeAttribute;
placeholder?: string;
required?: boolean;
disabled?: boolean;
autoComplete?: HTMLInputAutoCompleteAttribute;
dataCy?: string;
icon?: ReactNode;
iconPosition?: "left" | "right";
rules?: RegisterOptions<TValues, Path<TValues>>;
}
export interface DynamicCustomField<
TValues extends FieldValues,
> extends DynamicFieldBase<TValues> {
kind: "custom";
render: (context: DynamicFieldContext<TValues>) => ReactNode;
}
export type DynamicFormField<TValues extends FieldValues> =
| DynamicInputField<TValues>
| DynamicCustomField<TValues>;
export interface DynamicFormSection<TValues extends FieldValues> {
title?: ReactNode;
className?: string;
rootClassName?: string;
fields: DynamicFormField<TValues>[];
}
interface DynamicFormBuilderProps<TValues extends FieldValues> {
sections: DynamicFormSection<TValues>[];
register: UseFormRegister<TValues>;
control: Control<TValues>;
errors: FieldErrors<TValues>;
handleSubmit: UseFormHandleSubmit<TValues>;
onSubmit: SubmitHandler<TValues>;
dataCy?: string;
className?: string;
header?: ReactNode;
sectionsClassName?: string;
footer?: ReactNode;
footerPlacement?: "last-section" | "form";
}
const getErrorMessage = <TValues extends FieldValues>(
errors: FieldErrors<TValues>,
name: Path<TValues>,
) => {
const message = get(errors, name)?.message;
return typeof message === "string" ? message : undefined;
};
const DynamicFormBuilder = <TValues extends FieldValues>({
sections,
register,
control,
errors,
handleSubmit,
onSubmit,
dataCy,
className,
header,
sectionsClassName,
footer,
footerPlacement = "last-section",
}: DynamicFormBuilderProps<TValues>) => (
<form
className={cn("grid grid-cols-1 gap-9", className)}
onSubmit={handleSubmit(onSubmit)}
data-cy={dataCy}
noValidate
>
{header}
<div className={sectionsClassName}>
{sections.map((section, sectionIndex) => (
<ShowcaseSection
key={sectionIndex}
title={section.title}
rootClassName={section.rootClassName}
className={cn(
"grid grid-cols-1 gap-3 md:grid-cols-2",
section.className,
)}
>
{section.fields.map((field) => {
if (field.hidden) return null;
if (field.kind === "custom") {
return (
<div key={field.name} className={field.className}>
{field.render({
control,
errors,
register,
error: getErrorMessage(errors, field.name),
})}
</div>
);
}
return (
<InputGroup<TValues>
key={field.name}
{...register(field.name, field.rules)}
name={field.name}
label={field.label}
type={field.type}
placeholder={field.placeholder}
required={field.required}
disabled={field.disabled}
autoComplete={field.autoComplete}
dataCy={field.dataCy}
icon={field.icon}
iconPosition={field.iconPosition}
className={field.className}
errors={errors}
/>
);
})}
{footer &&
footerPlacement === "last-section" &&
sectionIndex === sections.length - 1 ? (
<div className="md:col-span-2">{footer}</div>
) : null}
</ShowcaseSection>
))}
</div>
{footerPlacement === "form" ? footer : null}
</form>
);
export default DynamicFormBuilder;
+19 -268
View File
@@ -1,284 +1,35 @@
"use client";
import { PasswordIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup";
import {
createPhoneFieldRules,
PhoneNumberInput,
} from "@/components/FormElements/PhoneNumberInput";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
import FormFooter from "@/components/ui/FormFooter";
import { FormErrorMessages } from "@/constants/Texts";
import { ICreateAdminCredentials } from "@/lib/api";
import { FC } from "react";
import { Controller } from "react-hook-form";
import useCreateAdminPresenter from "./useCreateAdminPresenter";
import type { FC } from "react";
import useCreateAdminPresenter, {
type CreateAdminFormValues,
} from "./useCreateAdminPresenter";
interface IProps {
editMode?: boolean;
defaultValues?: ICreateAdminCredentials;
id?: string;
}
const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
const {
errors,
handleSubmit,
register,
control,
onSubmit,
isUploadingImage,
uploadProgress,
selectedFileName,
selectedFileSize,
previewUrl,
clearSelectedProfileImage,
profileImageRegister,
passwordFieldInputType,
setPasswordFieldInputType,
} = useCreateAdminPresenter({
editMode,
defaultValues,
id,
});
const presenter = useCreateAdminPresenter({ editMode, defaultValues, id });
const { errors, handleSubmit, register, control, onSubmit, sections } =
presenter;
return (
<form
className="grid grid-cols-1 gap-9"
onSubmit={handleSubmit((data) => {
onSubmit(data);
})}
data-cy={editMode ? "admin-edit-form" : "admin-create-form"}
>
<div className="flex flex-col gap-9">
<ShowcaseSection
title="Input Fields"
className="grid grid-cols-1 gap-3 md:grid-cols-2"
>
<div>
<InputGroup
{...register("full_name", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 5,
message: FormErrorMessages.minLength(5),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Full name"
placeholder="Enter full name (5-100 characters)"
required
type="text"
name="full_name"
dataCy="admin-form-full-name-input"
/>
{errors.full_name && (
<p className="mt-1 text-sm text-red-500">
{errors.full_name.message}
</p>
)}
</div>
<div>
<InputGroup
label="Username"
placeholder="Letters and numbers only (3-30)"
autoComplete="off"
required
type="text"
{...register("username", {
required: {
value: true,
message: FormErrorMessages.required,
},
pattern: {
value: /^[a-zA-Z0-9]+$/,
message: FormErrorMessages.alphaNumeric,
},
minLength: {
value: 3,
message: FormErrorMessages.minLength(3),
},
maxLength: {
value: 30,
message: FormErrorMessages.maxLength(30),
},
})}
name="username"
dataCy="admin-form-username-input"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-500">
{errors.username.message}
</p>
)}
</div>
<div>
<InputGroup
label="Email"
autoComplete="off"
placeholder="you@example.com"
type="email"
required
{...register("email", {
required: {
value: true,
message: FormErrorMessages.required,
},
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
})}
name="email"
dataCy="admin-form-email-input"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">
{errors.email.message}
</p>
)}
</div>
{!editMode && (
<div>
<InputGroup
label="Password"
autoComplete="new-password"
placeholder="At least 8 characters"
required
icon={
<button
type="button"
onClick={() => {
setPasswordFieldInputType(
passwordFieldInputType === "password"
? "text"
: "password",
);
}}
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
aria-label={
passwordFieldInputType === "password"
? "Show password"
: "Hide password"
}
>
<PasswordIcon className="pointer-events-none" />
</button>
}
type={passwordFieldInputType}
{...register("password", {
required: {
value: true,
message: FormErrorMessages.required,
},
minLength: {
value: 8,
message: FormErrorMessages.minLength(8),
},
maxLength: {
value: 128,
message: FormErrorMessages.maxLength(128),
},
})}
name="password"
dataCy="admin-form-password-input"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-500">
{errors.password.message}
</p>
)}
</div>
)}
<div>
<Controller
name="phone"
<DynamicFormBuilder<CreateAdminFormValues>
sections={sections}
register={register}
control={control}
rules={createPhoneFieldRules({ required: false })}
render={({ field }) => (
<PhoneNumberInput
label="Phone"
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.phone?.message as string | undefined}
dataCy="admin-form-phone-input"
errors={errors}
handleSubmit={handleSubmit}
onSubmit={onSubmit}
dataCy={editMode ? "admin-edit-form" : "admin-create-form"}
footer={<FormFooter />}
/>
)}
/>
</div>
<div>
<InputGroup
label="Department"
type="text"
{...register("department", {
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
name="department"
dataCy="admin-form-department-input"
/>
{errors.department && (
<p className="mt-1 text-sm text-red-500">
{errors.department.message}
</p>
)}
</div>
<div>
<InputGroup
label="Position"
type="text"
{...register("position", {
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
name="position"
dataCy="admin-form-position-input"
/>
{errors.position && (
<p className="mt-1 text-sm text-red-500">
{errors.position.message}
</p>
)}
</div>
<FileImageUploadCard
label="Profile image"
inputId="profile-image-upload"
fileRegister={profileImageRegister}
isLoading={isUploadingImage}
uploadProgress={uploadProgress}
previewUrl={previewUrl}
selectedFileName={selectedFileName}
selectedFileSize={selectedFileSize}
onClear={clearSelectedProfileImage}
errorMessage={errors.profile_image?.message as string | undefined}
/>
<div className="md:col-span-2">
<FormFooter />
</div>
</ShowcaseSection>
</div>
</form>
);
};
@@ -0,0 +1,8 @@
import { ICreateAdminCredentials } from "@/lib/api";
export type CreateAdminFormValues = Omit<
ICreateAdminCredentials,
"profile_image"
> & {
profile_image?: FileList;
};
@@ -0,0 +1,188 @@
import { PasswordIcon } from "@/assets/icons";
import {
createPhoneFieldRules,
PhoneNumberInput,
} from "@/components/FormElements/PhoneNumberInput";
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
import { FormErrorMessages } from "@/constants/Texts";
import type { Dispatch, SetStateAction } from "react";
import { Controller, type UseFormRegisterReturn } from "react-hook-form";
import type { CreateAdminFormValues } from "./createAdminForm.types";
interface CreateAdminFormSectionsOptions {
editMode?: boolean;
passwordFieldInputType: "password" | "text";
setPasswordFieldInputType: Dispatch<SetStateAction<"password" | "text">>;
profileImageRegister: UseFormRegisterReturn;
isUploadingImage: boolean;
uploadProgress: number;
previewUrl: string | null;
selectedFileName: string;
selectedFileSize: number | null;
clearSelectedProfileImage: () => void;
}
export const createAdminFormSections = ({
editMode,
passwordFieldInputType,
setPasswordFieldInputType,
profileImageRegister,
isUploadingImage,
uploadProgress,
previewUrl,
selectedFileName,
selectedFileSize,
clearSelectedProfileImage,
}: CreateAdminFormSectionsOptions): DynamicFormSection<CreateAdminFormValues>[] => [
{
title: "Input Fields",
fields: [
{
kind: "input",
name: "full_name",
label: "Full name",
type: "text",
placeholder: "Enter full name (5-100 characters)",
required: true,
dataCy: "admin-form-full-name-input",
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "input",
name: "username",
label: "Username",
type: "text",
placeholder: "Letters and numbers only (3-30)",
autoComplete: "off",
required: true,
dataCy: "admin-form-username-input",
rules: {
required: { value: true, message: FormErrorMessages.required },
pattern: {
value: /^[a-zA-Z0-9]+$/,
message: FormErrorMessages.alphaNumeric,
},
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: { value: 30, message: FormErrorMessages.maxLength(30) },
},
},
{
kind: "input",
name: "email",
label: "Email",
type: "email",
placeholder: "you@example.com",
autoComplete: "off",
required: true,
dataCy: "admin-form-email-input",
rules: {
required: { value: true, message: FormErrorMessages.required },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
},
},
{
kind: "input",
name: "password",
label: "Password",
type: passwordFieldInputType,
placeholder: "At least 8 characters",
autoComplete: "new-password",
required: true,
hidden: editMode,
dataCy: "admin-form-password-input",
icon: (
<button
type="button"
onClick={() =>
setPasswordFieldInputType(
passwordFieldInputType === "password" ? "text" : "password",
)
}
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
aria-label={
passwordFieldInputType === "password"
? "Show password"
: "Hide password"
}
>
<PasswordIcon className="pointer-events-none" />
</button>
),
rules: {
required: { value: true, message: FormErrorMessages.required },
minLength: { value: 8, message: FormErrorMessages.minLength(8) },
maxLength: { value: 128, message: FormErrorMessages.maxLength(128) },
},
},
{
kind: "custom",
name: "phone",
render: ({ control, errors }) => (
<Controller
name="phone"
control={control}
rules={createPhoneFieldRules({ required: false })}
render={({ field }) => (
<PhoneNumberInput
label="Phone"
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.phone?.message as string | undefined}
dataCy="admin-form-phone-input"
/>
)}
/>
),
},
{
kind: "input",
name: "department",
label: "Department",
type: "text",
dataCy: "admin-form-department-input",
rules: {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "input",
name: "position",
label: "Position",
type: "text",
dataCy: "admin-form-position-input",
rules: {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "custom",
name: "profile_image",
render: ({ error }) => (
<FileImageUploadCard
label="Profile image"
inputId="profile-image-upload"
fileRegister={profileImageRegister}
isLoading={isUploadingImage}
uploadProgress={uploadProgress}
previewUrl={previewUrl}
selectedFileName={selectedFileName}
selectedFileSize={selectedFileSize}
onClear={clearSelectedProfileImage}
errorMessage={error}
/>
),
},
],
},
];
@@ -5,9 +5,13 @@ import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberI
import { validateImageFile } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-toastify";
import { createAdminFormSections } from "./createAdminFormSections";
import type { CreateAdminFormValues } from "./createAdminForm.types";
export type { CreateAdminFormValues } from "./createAdminForm.types";
interface IProps {
editMode?: boolean;
@@ -15,10 +19,6 @@ interface IProps {
id?: string;
}
type CreateAdminFormValues = Omit<ICreateAdminCredentials, "profile_image"> & {
profile_image?: FileList;
};
const useCreateAdminPresenter = ({
editMode = false,
defaultValues,
@@ -244,6 +244,34 @@ const useCreateAdminPresenter = ({
}
};
const router = useRouter();
const sections = useMemo(
() =>
createAdminFormSections({
editMode,
passwordFieldInputType,
setPasswordFieldInputType,
profileImageRegister: profileImageFileRegister,
isUploadingImage,
uploadProgress,
previewUrl,
selectedFileName,
selectedFileSize,
clearSelectedProfileImage,
}),
[
editMode,
passwordFieldInputType,
profileImageFileRegister,
isUploadingImage,
uploadProgress,
previewUrl,
selectedFileName,
selectedFileSize,
clearSelectedProfileImage,
],
);
return {
errors,
register,
@@ -262,6 +290,7 @@ const useCreateAdminPresenter = ({
profileImageRegister: profileImageFileRegister,
passwordFieldInputType,
setPasswordFieldInputType,
sections,
};
};
+30 -503
View File
@@ -1,16 +1,9 @@
"use client";
import { GlobeIcon, MoneyIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup";
import TagInput from "@/components/FormElements/InputGroup/tag-input";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
import FormFooter from "@/components/ui/FormFooter";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import { ICreateCompanyCredentials } from "@/lib/api/types/Company";
import CompanyDocuments from "./documents/CompanyDocuments";
import type { ICreateCompanyCredentials } from "@/lib/api";
import { createCompanyFormSections } from "./companyFormSections";
import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
interface IProps {
@@ -19,58 +12,41 @@ interface IProps {
id?: string;
}
/** Inline error for nested fields whose dotted name can't use the inputs' built-in lookup. */
const FieldError = ({ message }: { message?: string }) =>
message ? (
<p className="mt-1.5 text-sm font-medium text-error">{message}</p>
) : null;
const optionalNumber = (value: unknown) => {
if (value === "" || value === null || value === undefined) return undefined;
return Number(value);
};
const optionalNumberRange = ({
min,
max,
integer = false,
}: {
min: number;
max: number;
integer?: boolean;
}) => {
return (value: unknown) => {
const numberValue = optionalNumber(value);
if (numberValue === undefined) return true;
if (!Number.isFinite(numberValue)) return "Enter a valid number";
if (integer && !Number.isInteger(numberValue)) {
return "This field must be a whole number";
}
if (numberValue < min) return FormErrorMessages.min(min);
if (numberValue > max) return FormErrorMessages.max(max);
return true;
};
};
const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
const {
errors,
handleSubmit,
onSubmit,
register,
control,
watch,
setValue,
companyCategories,
} = useCreateCompanyPresenter({
editMode,
defaultValues,
id,
});
} = useCreateCompanyPresenter({ editMode, defaultValues, id });
const categoryItems =
companyCategories?.data?.categories?.map((category) => ({
label: category.name,
value: category.id,
})) ?? [];
return (
<form className="flex flex-col gap-6" onSubmit={handleSubmit(onSubmit)}>
<DynamicFormBuilder<ICreateCompanyCredentials>
sections={createCompanyFormSections({
editMode,
id,
categoryItems,
watch,
setValue,
})}
register={register}
control={control}
errors={errors}
handleSubmit={handleSubmit}
onSubmit={onSubmit}
className="flex flex-col gap-6"
sectionsClassName="grid grid-cols-1 items-start gap-6 lg:grid-cols-2"
header={
<header className="flex flex-col gap-1">
<h1 className="text-xl font-bold text-dark dark:text-white">
{editMode ? "Edit company" : "New company"}
@@ -79,459 +55,10 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
Profile, business details, address, tags, and supporting documents.
</p>
</header>
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-2">
<ShowcaseSection
title="Company information"
rootClassName="lg:col-span-2"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<InputGroup
{...register("name", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: {
value: 200,
message: FormErrorMessages.maxLength(200),
},
})}
errors={errors}
label="Name"
name="name"
required
type="text"
placeholder="Legal name as registered"
className="sm:col-span-2"
/>
<InputGroup
{...register("email", {
required: { message: FormErrorMessages.required, value: true },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
})}
errors={errors}
label="Email"
name="email"
required
type="email"
placeholder="name@company.com"
/>
<InputGroup
{...register("phone", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 20,
message: FormErrorMessages.maxLength(20),
},
pattern: {
value: REGEX.PHONE,
message: FormErrorMessages.invalidPattern,
},
})}
errors={errors}
label="Phone"
name="phone"
required
type="tel"
placeholder="+971 50 123 4567"
/>
<Select
{...register("type", {
required: { message: FormErrorMessages.required, value: true },
})}
errors={errors}
name="type"
label="Company type"
required
items={[
{ value: "private", label: "Private" },
{ value: "public", label: "Public" },
{ value: "government", label: "Government" },
{ value: "ngo", label: "NGO" },
{ value: "startup", label: "Startup" },
]}
defaultValue="Private"
prefixIcon={<GlobeIcon />}
/>
<InputGroup
{...register("industry", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
errors={errors}
label="Industry"
name="industry"
type="text"
required
placeholder="e.g. construction, software, logistics"
/>
<InputGroup
{...register("registration_number", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: {
value: 50,
message: FormErrorMessages.maxLength(50),
},
})}
errors={errors}
label="Registration number"
name="registration_number"
type="text"
required
placeholder="Registry or license number"
/>
<InputGroup
{...register("tax_id", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: {
value: 50,
message: FormErrorMessages.maxLength(50),
},
})}
errors={errors}
label="Tax id"
name="tax_id"
type="text"
required
placeholder="National tax identification number"
/>
<InputGroup
{...register("website", {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
pattern: {
value: REGEX.URL,
message: FormErrorMessages.invalidPattern,
},
})}
errors={errors}
label="Website"
required
name="website"
type="text"
placeholder="https://www.example.com"
className="sm:col-span-2"
/>
<TextAreaGroup
label="Description"
{...register("description", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 1000,
message: FormErrorMessages.maxLength(1000),
},
})}
errors={errors}
name="description"
className="sm:col-span-2"
/>
</ShowcaseSection>
<ShowcaseSection
title="Business information"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<InputGroup
{...register("employee_count", {
setValueAs: optionalNumber,
validate: optionalNumberRange({
min: 1,
max: 100000,
integer: true,
}),
})}
errors={errors}
label="Employee count"
name="employee_count"
type="number"
/>
<InputGroup
{...register("annual_revenue", {
setValueAs: optionalNumber,
validate: optionalNumberRange({
min: 0,
max: 999999999999,
}),
})}
errors={errors}
label="Annual revenue"
name="annual_revenue"
type="number"
/>
<InputGroup
{...register("founded_year", {
setValueAs: optionalNumber,
validate: optionalNumberRange({
min: 1800,
max: 2100,
integer: true,
}),
})}
errors={errors}
label="Founded year"
name="founded_year"
type="number"
className="sm:col-span-2"
/>
</ShowcaseSection>
<ShowcaseSection
title="Settings"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<Select
{...register("language")}
errors={errors}
name="language"
label="Language"
items={[
{ value: "en", label: "EN" },
{ value: "ar", label: "AR" },
{ value: "fr", label: "FR" },
{ value: "es", label: "ES" },
{ value: "de", label: "DE" },
{ value: "zh", label: "ZH" },
{ value: "jEna", label: "jEna" },
{ value: "ko", label: "KO" },
]}
defaultValue="en"
prefixIcon={<GlobeIcon />}
/>
<Select
{...register("currency")}
errors={errors}
name="currency"
label="Currency"
items={[
{ label: "USD", value: "USD" },
{ label: "EUR", value: "EUR" },
{ label: "GBP", value: "GBP" },
{ label: "JPY", value: "JPY" },
{ label: "CAD", value: "CAD" },
{ label: "AUD", value: "AUD" },
{ label: "CHF", value: "CHF" },
{ label: "CNY", value: "CNY" },
{ label: "INR", value: "INR" },
{ label: "BRL", value: "BRL" },
]}
defaultValue="en"
prefixIcon={<MoneyIcon />}
/>
<InputGroup
{...register("timezone", {
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: {
value: 50,
message: FormErrorMessages.maxLength(50),
},
})}
errors={errors}
label="Timezone"
name="timezone"
type="text"
className="sm:col-span-2"
/>
</ShowcaseSection>
<ShowcaseSection
title="Address"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<div className="sm:col-span-2">
<InputGroup
{...register("address.street", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 5,
message: FormErrorMessages.minLength(5),
},
maxLength: {
value: 200,
message: FormErrorMessages.maxLength(200),
},
})}
label="Street"
name="address.street"
required
type="text"
placeholder="Building, street, suite or unit"
/>
<FieldError message={errors.address?.street?.message} />
</div>
<div>
<InputGroup
{...register("address.city", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="City"
required
name="address.city"
type="text"
placeholder="City or town"
/>
<FieldError message={errors.address?.city?.message} />
</div>
<div>
<InputGroup
{...register("address.state", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="State"
name="address.state"
type="text"
required
placeholder="State, province, or region"
/>
<FieldError message={errors.address?.state?.message} />
</div>
<div>
<InputGroup
{...register("address.postal_code", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 3,
message: FormErrorMessages.minLength(3),
},
maxLength: {
value: 20,
message: FormErrorMessages.maxLength(20),
},
})}
label="Postal code"
name="address.postal_code"
type="text"
required
placeholder="Postal or ZIP code"
/>
<FieldError message={errors.address?.postal_code?.message} />
</div>
<div>
<InputGroup
{...register("address.country", {
required: { message: FormErrorMessages.required, value: true },
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
label="Country"
name="address.country"
type="text"
required
placeholder="Full country name"
/>
<FieldError message={errors.address?.country?.message} />
</div>
</ShowcaseSection>
<ShowcaseSection
title="Tags"
className="grid grid-cols-1 gap-x-5 gap-y-5 sm:grid-cols-2"
>
<TagInput
{...register("tags.keywords")}
name="tags.keywords"
label="Keywords"
placeholder=""
register={register}
setValue={setValue}
watch={watch}
/>
<MultiSelect
{...register("tags.categories")}
value={watch("tags.categories")}
name="tags.categories"
label="Categories"
items={
companyCategories
? companyCategories?.data?.categories?.map((c) => ({
label: c.name,
value: c.id,
}))
: []
}
placeholder=""
footer={<FormFooter />}
footerPlacement="form"
/>
<TagInput
{...register("tags.cpv_codes")}
label="CPV codes"
name="tags.cpv_codes"
shouldAddWithSpace
watch={watch}
setValue={setValue}
placeholder=""
/>
<TagInput
{...register("tags.certifications")}
label="Certifications"
name="tags.certifications"
watch={watch}
setValue={setValue}
placeholder=""
/>
<div className="sm:col-span-2">
<TagInput
{...register("tags.specializations")}
label="Specializations"
name="tags.specializations"
watch={watch}
setValue={setValue}
placeholder=""
/>
<FieldError message={errors.tags?.specializations?.message} />
</div>
</ShowcaseSection>
<ShowcaseSection title="Documents" rootClassName="lg:col-span-2">
<CompanyDocuments
companyId={editMode ? id : undefined}
value={watch("document_file_ids") ?? []}
onChange={(ids) => setValue("document_file_ids", ids)}
/>
</ShowcaseSection>
</div>
<FormFooter />
</form>
);
};
@@ -1,10 +1,9 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
import FormFooter from "@/components/ui/FormFooter";
import { FormErrorMessages } from "@/constants/Texts";
import { TCreateCompanyCategoryCredentials } from "@/lib/api";
import type { TCreateCompanyCategoryCredentials } from "@/lib/api";
import { createCompanyCategoryFormSections } from "./companyCategoryFormSections";
import useCreateCompanyCategoryPresenter from "./useCreateCompanyCategoryPresenter";
interface IProps {
@@ -14,72 +13,20 @@ interface IProps {
}
const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
const { errors, handleSubmit, onSubmit, register } =
const { errors, handleSubmit, onSubmit, register, control } =
useCreateCompanyCategoryPresenter({ defaultValues, editMode, id });
return (
<form
className="grid grid-cols-1 gap-9"
data-cy="company-category-form"
onSubmit={handleSubmit(onSubmit)}
>
<ShowcaseSection
title={editMode ? "Edit Company Category" : "Create Company Category"}
className="grid grid-cols-1 gap-5 md:grid-cols-2"
>
<div className="flex flex-col gap-2 md:col-span-1">
<InputGroup
label="Name"
required
dataCy="company-category-form-name-input"
{...register("name", {
required: {
value: true,
message: FormErrorMessages.required,
},
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
type="text"
name="name"
placeholder="e.g. Technology"
<DynamicFormBuilder<TCreateCompanyCategoryCredentials>
sections={createCompanyCategoryFormSections(editMode)}
register={register}
control={control}
errors={errors}
handleSubmit={handleSubmit}
onSubmit={onSubmit}
dataCy="company-category-form"
footer={<FormFooter />}
/>
{errors.name && (
<p className="mt-1 text-sm text-red-500">{errors.name.message}</p>
)}
</div>
<div className="flex flex-col gap-2 md:col-span-1">
<TextAreaGroup
label="Description"
dataCy="company-category-form-description-input"
{...register("description", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 1000,
message: FormErrorMessages.maxLength(1000),
},
})}
name="description"
/>
{errors.description && (
<p className="mt-1 text-sm text-red-500">
{errors.description.message}
</p>
)}
</div>
<div className="md:col-span-2">
<FormFooter />
</div>
</ShowcaseSection>
</form>
);
};
@@ -0,0 +1,62 @@
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
import { FormErrorMessages } from "@/constants/Texts";
import type { TCreateCompanyCategoryCredentials } from "@/lib/api";
export const createCompanyCategoryFormSections = (
editMode?: boolean,
): DynamicFormSection<TCreateCompanyCategoryCredentials>[] => [
{
title: editMode ? "Edit Company Category" : "Create Company Category",
className: "gap-5",
fields: [
{
kind: "input",
name: "name",
label: "Name",
type: "text",
placeholder: "e.g. Technology",
required: true,
dataCy: "company-category-form-name-input",
className: "md:col-span-1",
rules: {
required: {
value: true,
message: FormErrorMessages.required,
},
minLength: {
value: 2,
message: FormErrorMessages.minLength(2),
},
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
},
},
{
kind: "custom",
name: "description",
className: "md:col-span-1",
render: ({ register, errors }) => (
<TextAreaGroup<TCreateCompanyCategoryCredentials>
label="Description"
dataCy="company-category-form-description-input"
{...register("description", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 1000,
message: FormErrorMessages.maxLength(1000),
},
})}
name="description"
errors={errors}
/>
),
},
],
},
];
@@ -20,6 +20,7 @@ const useCreateCompanyCategoryPresenter = ({
}: IProps) => {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<TCreateCompanyCategoryCredentials>({
@@ -38,6 +39,7 @@ const useCreateCompanyCategoryPresenter = ({
};
return {
register,
control,
onSubmit,
handleSubmit,
errors,
@@ -0,0 +1,499 @@
import { GlobeIcon, MoneyIcon } from "@/assets/icons";
import TagInput from "@/components/FormElements/InputGroup/tag-input";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import type { ICreateCompanyCredentials } from "@/lib/api";
import type { ILabelValue } from "@/types/shared";
import type { UseFormSetValue, UseFormWatch } from "react-hook-form";
import CompanyDocuments from "./documents/CompanyDocuments";
interface CompanyFormSectionsOptions {
editMode?: boolean;
id?: string;
categoryItems: ILabelValue[];
watch: UseFormWatch<ICreateCompanyCredentials>;
setValue: UseFormSetValue<ICreateCompanyCredentials>;
}
const optionalNumber = (value: unknown) => {
if (value === "" || value === null || value === undefined) return undefined;
return Number(value);
};
const optionalNumberRange = ({
min,
max,
integer = false,
}: {
min: number;
max: number;
integer?: boolean;
}) => {
return (value: unknown) => {
const numberValue = optionalNumber(value);
if (numberValue === undefined) return true;
if (!Number.isFinite(numberValue)) return "Enter a valid number";
if (integer && !Number.isInteger(numberValue)) {
return "This field must be a whole number";
}
if (numberValue < min) return FormErrorMessages.min(min);
if (numberValue > max) return FormErrorMessages.max(max);
return true;
};
};
const languageItems: ILabelValue[] = [
{ value: "en", label: "EN" },
{ value: "ar", label: "AR" },
{ value: "fr", label: "FR" },
{ value: "es", label: "ES" },
{ value: "de", label: "DE" },
{ value: "zh", label: "ZH" },
{ value: "jEna", label: "jEna" },
{ value: "ko", label: "KO" },
];
const currencyItems: ILabelValue[] = [
{ label: "USD", value: "USD" },
{ label: "EUR", value: "EUR" },
{ label: "GBP", value: "GBP" },
{ label: "JPY", value: "JPY" },
{ label: "CAD", value: "CAD" },
{ label: "AUD", value: "AUD" },
{ label: "CHF", value: "CHF" },
{ label: "CNY", value: "CNY" },
{ label: "INR", value: "INR" },
{ label: "BRL", value: "BRL" },
];
export const createCompanyFormSections = ({
editMode,
id,
categoryItems,
watch,
setValue,
}: CompanyFormSectionsOptions): DynamicFormSection<ICreateCompanyCredentials>[] => [
{
title: "Company information",
rootClassName: "lg:col-span-2",
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
fields: [
{
kind: "input",
name: "name",
label: "Name",
type: "text",
placeholder: "Legal name as registered",
required: true,
className: "sm:col-span-2",
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 200, message: FormErrorMessages.maxLength(200) },
},
},
{
kind: "input",
name: "email",
label: "Email",
type: "email",
placeholder: "name@company.com",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
},
},
{
kind: "input",
name: "phone",
label: "Phone",
type: "tel",
placeholder: "+971 50 123 4567",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 10, message: FormErrorMessages.minLength(10) },
maxLength: { value: 20, message: FormErrorMessages.maxLength(20) },
pattern: {
value: REGEX.PHONE,
message: FormErrorMessages.invalidPattern,
},
},
},
{
kind: "custom",
name: "type",
render: ({ register, errors }) => (
<Select<ICreateCompanyCredentials>
{...register("type", {
required: { message: FormErrorMessages.required, value: true },
})}
errors={errors}
name="type"
label="Company type"
required
items={[
{ value: "private", label: "Private" },
{ value: "public", label: "Public" },
{ value: "government", label: "Government" },
{ value: "ngo", label: "NGO" },
{ value: "startup", label: "Startup" },
]}
defaultValue="Private"
prefixIcon={<GlobeIcon />}
/>
),
},
{
kind: "input",
name: "industry",
label: "Industry",
type: "text",
placeholder: "e.g. construction, software, logistics",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "input",
name: "registration_number",
label: "Registration number",
type: "text",
placeholder: "Registry or license number",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: { value: 50, message: FormErrorMessages.maxLength(50) },
},
},
{
kind: "input",
name: "tax_id",
label: "Tax id",
type: "text",
placeholder: "National tax identification number",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: { value: 50, message: FormErrorMessages.maxLength(50) },
},
},
{
kind: "input",
name: "website",
label: "Website",
type: "text",
placeholder: "https://www.example.com",
required: true,
className: "sm:col-span-2",
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
pattern: {
value: REGEX.URL,
message: FormErrorMessages.invalidPattern,
},
},
},
{
kind: "custom",
name: "description",
className: "sm:col-span-2",
render: ({ register, errors }) => (
<TextAreaGroup<ICreateCompanyCredentials>
label="Description"
{...register("description", {
minLength: {
value: 10,
message: FormErrorMessages.minLength(10),
},
maxLength: {
value: 1000,
message: FormErrorMessages.maxLength(1000),
},
})}
errors={errors}
name="description"
/>
),
},
],
},
{
title: "Business information",
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
fields: [
{
kind: "input",
name: "employee_count",
label: "Employee count",
type: "number",
rules: {
setValueAs: optionalNumber,
validate: optionalNumberRange({ min: 1, max: 100000, integer: true }),
},
},
{
kind: "input",
name: "annual_revenue",
label: "Annual revenue",
type: "number",
rules: {
setValueAs: optionalNumber,
validate: optionalNumberRange({ min: 0, max: 999999999999 }),
},
},
{
kind: "input",
name: "founded_year",
label: "Founded year",
type: "number",
className: "sm:col-span-2",
rules: {
setValueAs: optionalNumber,
validate: optionalNumberRange({
min: 1800,
max: 2100,
integer: true,
}),
},
},
],
},
{
title: "Settings",
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
fields: [
{
kind: "custom",
name: "language",
render: ({ register, errors }) => (
<Select<ICreateCompanyCredentials>
{...register("language")}
errors={errors}
name="language"
label="Language"
items={languageItems}
defaultValue="en"
prefixIcon={<GlobeIcon />}
/>
),
},
{
kind: "custom",
name: "currency",
render: ({ register, errors }) => (
<Select<ICreateCompanyCredentials>
{...register("currency")}
errors={errors}
name="currency"
label="Currency"
items={currencyItems}
defaultValue="en"
prefixIcon={<MoneyIcon />}
/>
),
},
{
kind: "input",
name: "timezone",
label: "Timezone",
type: "text",
className: "sm:col-span-2",
rules: {
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: { value: 50, message: FormErrorMessages.maxLength(50) },
},
},
],
},
{
title: "Address",
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
fields: [
{
kind: "input",
name: "address.street",
label: "Street",
type: "text",
placeholder: "Building, street, suite or unit",
required: true,
className: "sm:col-span-2",
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 5, message: FormErrorMessages.minLength(5) },
maxLength: { value: 200, message: FormErrorMessages.maxLength(200) },
},
},
{
kind: "input",
name: "address.city",
label: "City",
type: "text",
placeholder: "City or town",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "input",
name: "address.state",
label: "State",
type: "text",
placeholder: "State, province, or region",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "input",
name: "address.postal_code",
label: "Postal code",
type: "text",
placeholder: "Postal or ZIP code",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: { value: 20, message: FormErrorMessages.maxLength(20) },
},
},
{
kind: "input",
name: "address.country",
label: "Country",
type: "text",
placeholder: "Full country name",
required: true,
rules: {
required: { message: FormErrorMessages.required, value: true },
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
],
},
{
title: "Tags",
className: "gap-x-5 gap-y-5 sm:grid-cols-2 md:grid-cols-2",
fields: [
{
kind: "custom",
name: "tags.keywords",
render: ({ register }) => (
<TagInput<ICreateCompanyCredentials>
{...register("tags.keywords")}
name="tags.keywords"
label="Keywords"
placeholder=""
register={register}
setValue={setValue}
watch={watch}
/>
),
},
{
kind: "custom",
name: "tags.categories",
render: ({ register }) => (
<MultiSelect<ICreateCompanyCredentials>
{...register("tags.categories")}
value={watch("tags.categories")}
name="tags.categories"
label="Categories"
items={categoryItems}
placeholder=""
/>
),
},
{
kind: "custom",
name: "tags.cpv_codes",
render: ({ register }) => (
<TagInput<ICreateCompanyCredentials>
{...register("tags.cpv_codes")}
label="CPV codes"
name="tags.cpv_codes"
shouldAddWithSpace
watch={watch}
setValue={setValue}
placeholder=""
/>
),
},
{
kind: "custom",
name: "tags.certifications",
render: ({ register }) => (
<TagInput<ICreateCompanyCredentials>
{...register("tags.certifications")}
label="Certifications"
name="tags.certifications"
watch={watch}
setValue={setValue}
placeholder=""
/>
),
},
{
kind: "custom",
name: "tags.specializations",
className: "sm:col-span-2",
render: ({ register, error }) => (
<>
<TagInput<ICreateCompanyCredentials>
{...register("tags.specializations")}
label="Specializations"
name="tags.specializations"
watch={watch}
setValue={setValue}
placeholder=""
/>
{error ? (
<p className="mt-1.5 text-sm font-medium text-error">{error}</p>
) : null}
</>
),
},
],
},
{
title: "Documents",
rootClassName: "lg:col-span-2",
className: "md:grid-cols-1",
fields: [
{
kind: "custom",
name: "document_file_ids",
render: () => (
<CompanyDocuments
companyId={editMode ? id : undefined}
value={watch("document_file_ids") ?? []}
onChange={(ids) => setValue("document_file_ids", ids)}
/>
),
},
],
},
];
@@ -20,6 +20,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
const isMutating = useIsMutating();
const {
register,
control,
formState: { errors },
handleSubmit,
watch,
@@ -41,7 +42,9 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
}
}, [defaultValues, setValue]);
const router = useRouter();
const { data: companyCategories } = useCompanyCategoriesQuery({ published: true });
const { data: companyCategories } = useCompanyCategoriesQuery({
published: true,
});
const { mutate: createCompany, isPending: isCreating } = useCreateCompany();
const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany(
id as string,
@@ -82,7 +85,6 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
} else {
createCompany(formattedData as ICreateCompanyCredentials);
}
};
const handleCancel = () => {
@@ -92,6 +94,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
return {
errors,
register,
control,
handleSubmit,
onSubmit,
handleCancel,
+21 -207
View File
@@ -1,21 +1,9 @@
"use client";
import { EyeIcon, GlobeIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup";
import MultiSelect from "@/components/FormElements/MultiSelect";
import {
createPhoneFieldRules,
PhoneNumberInput,
} from "@/components/FormElements/PhoneNumberInput";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
import FormFooter from "@/components/ui/FormFooter";
import { AdminRoles } from "@/constants/enums";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import { CreateCustomerCredentials } from "@/lib/api";
import { getEnumAsArray } from "@/utils/shared";
import { Controller } from "react-hook-form";
import type { CreateCustomerCredentials } from "@/lib/api";
import { createCustomerFormSections } from "./customerFormSections";
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
interface IProps {
@@ -37,202 +25,28 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
setShowPassword,
} = useCreateCustomerPresenter({ defaultValues, editMode, id });
const companyItems =
companies?.map((company) => ({
value: company.id,
label: company.name,
})) ?? [];
return (
<form className="grid grid-cols-1 gap-9" onSubmit={handleSubmit(onSubmit)}>
<ShowcaseSection
title="Create Customer"
className="grid grid-cols-1 gap-5 md:grid-cols-2"
>
<div className="flex flex-col gap-2">
<InputGroup
{...register("username", {
required: { value: true, message: FormErrorMessages.required },
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: {
value: 30,
message: FormErrorMessages.maxLength(30),
},
pattern: {
value: REGEX.USERNAME,
message: FormErrorMessages.invalidPattern,
},
<DynamicFormBuilder<CreateCustomerCredentials>
sections={createCustomerFormSections({
editMode,
companyItems,
watch,
showPassword,
setShowPassword,
})}
name="username"
label="Username"
type="text"
placeholder="Unique login, letters and numbers only"
required
/>
{errors.username && (
<p className="mt-1 text-sm text-red-500">
{errors.username.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<MultiSelect
label="Select companies"
value={watch("companies")}
items={
companies
? companies?.map((item) => ({
value: item.id,
label: item.name,
}))
: []
}
{...register("companies")}
errors={errors}
placeholder=""
/>
</div>
<div className="flex flex-col gap-2">
<InputGroup
{...register("email", {
required: { value: true, message: FormErrorMessages.required },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
})}
name="email"
label="Email"
type="email"
required
placeholder="name@company.com"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<div className="flex flex-col">
<Select
label="Role"
{...register("role", {
required: { message: FormErrorMessages.required, value: true },
})}
name="role"
value={watch("role")}
items={getEnumAsArray(AdminRoles)}
placeholder="Choose a role"
required
/>
</div>
{!editMode && (
<div className="flex flex-col gap-2">
<InputGroup
{...register("password", {
required: { value: true, message: FormErrorMessages.required },
minLength: {
value: 8,
message: FormErrorMessages.minLength(8),
},
maxLength: {
value: 128,
message: FormErrorMessages.maxLength(128),
},
})}
disabled={editMode}
name="password"
icon={
<button
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
onClick={() => {
setShowPassword(!showPassword);
}}
>
<EyeIcon />
</button>
}
iconPosition="right"
label="Password"
type={showPassword ? "text" : "password"}
placeholder="At least 8 characters"
required={!editMode}
/>
{errors.password && (
<p className="mt-1 text-sm text-red-500">
{errors.password.message}
</p>
)}
</div>
)}
<div className="flex flex-col gap-2">
<InputGroup
{...register("full_name", {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: {
value: 100,
message: FormErrorMessages.maxLength(100),
},
})}
name="full_name"
label="Full name"
type="text"
/>
{errors.full_name && (
<p className="mt-1 text-sm text-red-500">
{errors.full_name.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Controller
name="phone"
register={register}
control={control}
rules={createPhoneFieldRules({ required: false })}
render={({ field }) => (
<PhoneNumberInput
label="Phone"
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.phone?.message as string | undefined}
errors={errors}
handleSubmit={handleSubmit}
onSubmit={onSubmit}
footer={<FormFooter />}
/>
)}
/>
</div>
<div className="flex flex-col gap-2">
<Select
{...register("type", {
required: {
message: FormErrorMessages.required,
value: true,
},
})}
name="type"
label="Type"
required
value={watch("type")}
placeholder="Choose customer type"
prefixIcon={<GlobeIcon />}
items={[
{
label: "Individual",
value: "individual",
},
{
label: "Company",
value: "company",
},
{
label: "Government",
value: "government",
},
]}
/>
{errors.type && (
<p className="mt-1 text-sm text-red-500">{errors.type.message}</p>
)}
</div>
<div className="md:col-span-2">
<FormFooter />
</div>
</ShowcaseSection>
</form>
);
};
@@ -0,0 +1,185 @@
import { EyeIcon, GlobeIcon } from "@/assets/icons";
import MultiSelect from "@/components/FormElements/MultiSelect";
import {
createPhoneFieldRules,
PhoneNumberInput,
} from "@/components/FormElements/PhoneNumberInput";
import { Select } from "@/components/FormElements/select";
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
import { AdminRoles } from "@/constants/enums";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import type { CreateCustomerCredentials } from "@/lib/api";
import type { ILabelValue } from "@/types/shared";
import { getEnumAsArray } from "@/utils/shared";
import type { Dispatch, SetStateAction } from "react";
import { Controller, type UseFormWatch } from "react-hook-form";
interface CustomerFormSectionsOptions {
editMode?: boolean;
companyItems: ILabelValue[];
watch: UseFormWatch<CreateCustomerCredentials>;
showPassword: boolean;
setShowPassword: Dispatch<SetStateAction<boolean>>;
}
export const createCustomerFormSections = ({
editMode,
companyItems,
watch,
showPassword,
setShowPassword,
}: CustomerFormSectionsOptions): DynamicFormSection<CreateCustomerCredentials>[] => [
{
title: "Create Customer",
className: "gap-5",
fields: [
{
kind: "input",
name: "username",
label: "Username",
type: "text",
placeholder: "Unique login, letters and numbers only",
required: true,
rules: {
required: { value: true, message: FormErrorMessages.required },
minLength: { value: 3, message: FormErrorMessages.minLength(3) },
maxLength: { value: 30, message: FormErrorMessages.maxLength(30) },
pattern: {
value: REGEX.USERNAME,
message: FormErrorMessages.invalidPattern,
},
},
},
{
kind: "custom",
name: "companies",
render: ({ register, errors }) => (
<MultiSelect<CreateCustomerCredentials>
label="Select companies"
value={watch("companies")}
items={companyItems}
{...register("companies")}
errors={errors}
placeholder=""
/>
),
},
{
kind: "input",
name: "email",
label: "Email",
type: "email",
placeholder: "name@company.com",
required: true,
rules: {
required: { value: true, message: FormErrorMessages.required },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: FormErrorMessages.invalidEmail,
},
},
},
{
kind: "custom",
name: "role",
render: ({ register, errors }) => (
<Select<CreateCustomerCredentials>
label="Role"
{...register("role", {
required: { message: FormErrorMessages.required, value: true },
})}
errors={errors}
name="role"
value={watch("role")}
items={getEnumAsArray(AdminRoles)}
placeholder="Choose a role"
required
/>
),
},
{
kind: "input",
name: "password",
label: "Password",
type: showPassword ? "text" : "password",
placeholder: "At least 8 characters",
required: !editMode,
disabled: editMode,
hidden: editMode,
iconPosition: "right",
icon: (
<button
type="button"
className="absolute right-4.5 top-1/2 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-dark-5 transition hover:text-primary dark:text-white/75 [&>svg]:!static [&>svg]:!size-5 [&>svg]:!translate-y-0"
onClick={() => setShowPassword((visible) => !visible)}
aria-label={showPassword ? "Hide password" : "Show password"}
>
<EyeIcon />
</button>
),
rules: {
required: { value: true, message: FormErrorMessages.required },
minLength: { value: 8, message: FormErrorMessages.minLength(8) },
maxLength: { value: 128, message: FormErrorMessages.maxLength(128) },
},
},
{
kind: "input",
name: "full_name",
label: "Full name",
type: "text",
rules: {
minLength: { value: 2, message: FormErrorMessages.minLength(2) },
maxLength: { value: 100, message: FormErrorMessages.maxLength(100) },
},
},
{
kind: "custom",
name: "phone",
render: ({ control, errors }) => (
<Controller
name="phone"
control={control}
rules={createPhoneFieldRules({ required: false })}
render={({ field }) => (
<PhoneNumberInput
label="Phone"
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.phone?.message as string | undefined}
/>
)}
/>
),
},
{
kind: "custom",
name: "type",
render: ({ register, errors }) => (
<Select<CreateCustomerCredentials>
{...register("type", {
required: {
message: FormErrorMessages.required,
value: true,
},
})}
errors={errors}
name="type"
label="Type"
required
value={watch("type")}
placeholder="Choose customer type"
prefixIcon={<GlobeIcon />}
items={[
{ label: "Individual", value: "individual" },
{ label: "Company", value: "company" },
{ label: "Government", value: "government" },
]}
/>
),
},
],
},
];
@@ -1,13 +1,9 @@
"use client";
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
import InputGroup from "@/components/FormElements/InputGroup";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
import FormFooter from "@/components/ui/FormFooter";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import type { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
import { createNotificationFormSections } from "./notificationFormSections";
import useCreateNotificationFormPresenter from "./useCreateNotificationFormPresenter";
const CreateNotificationForm = () => {
@@ -29,157 +25,29 @@ const CreateNotificationForm = () => {
types,
channels,
} = useCreateNotificationFormPresenter();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<ShowcaseSection
title="Create Notification"
className="grid grid-cols-1 gap-5 md:grid-cols-2"
>
<div className="flex flex-col gap-2">
<InputGroup
required
{...register("title", {
required: { value: true, message: FormErrorMessages.required },
})}
name="title"
label="Title"
type="text"
placeholder="Short headline shown to recipients"
/>
{errors.title && (
<p className="mt-1 text-sm text-red-500">{errors.title.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<DatePicker
control={control}
name="schedule_at"
label="schedule at"
range={false}
timePicker
/>
</div>
<div className="flex flex-col gap-2">
<Select
required
{...register("target", {
required: {
value: true,
message: FormErrorMessages.required,
},
onChange: onSelectTarget,
})}
items={targets}
label="target"
placeholder="Choose who this applies to"
name="target"
/>
{errors.target && (
<p className="mt-1 text-sm text-red-500">{errors.target.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<MultiSelect
disabled={!recipient}
required={!!recipient}
{...register("recipient")}
label="recipient"
items={recipient ?? []}
placeholder={
recipient && recipient.length > 0 ? "Choose recipients" : ""
}
name="recipient"
onSearch={recipient ? onSearchRecipient : undefined}
onLoadMore={onLoadMoreRecipients}
hasMore={hasMoreRecipients}
isLoading={isLoadingRecipients}
isLoadingMore={isLoadingMoreRecipients}
/>
</div>
<div className="flex flex-col gap-2">
<MultiSelect
required
{...register("channels")}
label="channels"
items={channels ?? []}
placeholder="Email, push, or both"
name="channels"
/>
</div>
<div className="flex flex-col gap-2">
<InputGroup
{...register("link", {
pattern: {
value: REGEX.URL,
message: FormErrorMessages.invalidPattern,
},
return (
<DynamicFormBuilder<TCreateNotificationCredentials>
sections={createNotificationFormSections({
recipient,
onSelectTarget,
onSearchRecipient,
onLoadMoreRecipients,
hasMoreRecipients,
isLoadingMoreRecipients,
isLoadingRecipients,
priorities,
targets,
types,
channels,
})}
name="link"
label="Link"
type="url"
register={register}
control={control}
errors={errors}
handleSubmit={handleSubmit}
onSubmit={onSubmit}
footer={<FormFooter />}
/>
{errors.link && (
<p className="mt-1 text-sm text-red-500">{errors.link.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Select
required
{...register("priority", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
items={priorities}
label="Priority"
placeholder="Choose urgency"
name="priority"
/>
{errors.priority && (
<p className="mt-1 text-sm text-red-500">
{errors.priority.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Select
required
{...register("type", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
items={types}
label="type"
placeholder="Info, warning, alert, or reject"
name="type"
/>
{errors.type && (
<p className="mt-1 text-sm text-red-500">{errors.type.message}</p>
)}
</div>
<div className="flex flex-col gap-2 md:col-span-2">
<TextAreaGroup
required
{...register("description", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
label="Description"
name="description"
placeholder="Main message — include context and any action needed"
/>
</div>
<div className="md:col-span-2">
<FormFooter />
</div>
</ShowcaseSection>
</form>
);
};
@@ -0,0 +1,200 @@
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import type { DynamicFormSection } from "@/components/forms/DynamicFormBuilder";
import { REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import type { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
import type { ILabelValue } from "@/types/shared";
import type { ChangeEvent } from "react";
interface NotificationFormSectionsOptions {
recipient: ILabelValue[] | null;
onSelectTarget: (event: ChangeEvent<HTMLSelectElement>) => void;
onSearchRecipient: (query: string) => void;
onLoadMoreRecipients: () => void;
hasMoreRecipients: boolean;
isLoadingMoreRecipients: boolean;
isLoadingRecipients: boolean;
priorities: ILabelValue[];
targets: ILabelValue[];
types: ILabelValue[];
channels: ILabelValue[];
}
export const createNotificationFormSections = ({
recipient,
onSelectTarget,
onSearchRecipient,
onLoadMoreRecipients,
hasMoreRecipients,
isLoadingMoreRecipients,
isLoadingRecipients,
priorities,
targets,
types,
channels,
}: NotificationFormSectionsOptions): DynamicFormSection<TCreateNotificationCredentials>[] => [
{
title: "Create Notification",
className: "gap-5",
fields: [
{
kind: "input",
name: "title",
label: "Title",
type: "text",
placeholder: "Short headline shown to recipients",
required: true,
rules: {
required: { value: true, message: FormErrorMessages.required },
},
},
{
kind: "custom",
name: "schedule_at",
render: ({ control }) => (
<DatePicker
control={control}
name="schedule_at"
label="schedule at"
range={false}
timePicker
/>
),
},
{
kind: "custom",
name: "target",
render: ({ register, errors }) => (
<Select<TCreateNotificationCredentials>
required
{...register("target", {
required: {
value: true,
message: FormErrorMessages.required,
},
onChange: onSelectTarget,
})}
errors={errors}
items={targets}
label="target"
placeholder="Choose who this applies to"
name="target"
/>
),
},
{
kind: "custom",
name: "recipient",
render: ({ register, errors }) => (
<MultiSelect<TCreateNotificationCredentials>
disabled={!recipient}
required={!!recipient}
{...register("recipient")}
errors={errors}
label="recipient"
items={recipient ?? []}
placeholder={
recipient && recipient.length > 0 ? "Choose recipients" : ""
}
name="recipient"
onSearch={recipient ? onSearchRecipient : undefined}
onLoadMore={onLoadMoreRecipients}
hasMore={hasMoreRecipients}
isLoading={isLoadingRecipients}
isLoadingMore={isLoadingMoreRecipients}
/>
),
},
{
kind: "custom",
name: "channels",
render: ({ register, errors }) => (
<MultiSelect<TCreateNotificationCredentials>
required
{...register("channels")}
errors={errors}
label="channels"
items={channels ?? []}
placeholder="Email, push, or both"
name="channels"
/>
),
},
{
kind: "input",
name: "link",
label: "Link",
type: "url",
rules: {
pattern: {
value: REGEX.URL,
message: FormErrorMessages.invalidPattern,
},
},
},
{
kind: "custom",
name: "priority",
render: ({ register, errors }) => (
<Select<TCreateNotificationCredentials>
required
{...register("priority", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
errors={errors}
items={priorities}
label="Priority"
placeholder="Choose urgency"
name="priority"
/>
),
},
{
kind: "custom",
name: "type",
render: ({ register, errors }) => (
<Select<TCreateNotificationCredentials>
required
{...register("type", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
errors={errors}
items={types}
label="type"
placeholder="Info, warning, alert, or reject"
name="type"
/>
),
},
{
kind: "custom",
name: "description",
className: "md:col-span-2",
render: ({ register, errors }) => (
<TextAreaGroup<TCreateNotificationCredentials>
required
{...register("description", {
required: {
value: true,
message: FormErrorMessages.required,
},
})}
errors={errors}
label="Description"
name="description"
placeholder="Main message — include context and any action needed"
/>
),
},
],
},
];
+46
View File
@@ -0,0 +1,46 @@
export { default as Boolean } from "./Boolean";
export { default as CollapsibleFilterSection } from "./CollapsibleFilterSection";
export {
default as ConfirmationModal,
type ConfirmationModalProps,
} from "./ConfirmationModal";
export {
Dropdown,
DropdownClose,
DropdownContent,
DropdownTrigger,
} from "./dropdown";
export { default as FileImageUploadCard } from "./FileImageUploadCard";
export { default as FormFooter } from "./FormFooter";
export { default as ImageUploadField } from "./ImageUploadField";
export {
default as InlineListFiltersPanel,
type InlineListFiltersPanelProps,
} from "./InlineListFiltersPanel";
export { default as IsVisible } from "./IsVisible";
export { default as ListHeader } from "./ListHeader";
export { default as ListWrapper } from "./ListWrapper";
export { default as Modal } from "./modal";
export { NotificationDetailsCard } from "./notification-details-card";
export { default as Pagination } from "./pagination";
export { default as ResetPasswordModalContent } from "./ResetPasswordModalContent";
export { default as ShadowBox } from "./shadow-box";
export { Skeleton } from "./skeleton";
export { default as Status } from "./Status";
export { default as Stepper, type Step } from "./Stepper";
export {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TableSortProvider,
} from "./table";
export { default as TableSkeleton } from "./TableSkeleton";
export {
TableSortContext,
type SortDirection,
type TableSortContextValue,
} from "./tableSortContext";
+1 -1
View File
@@ -13,7 +13,7 @@ export * from "./useNotificationQueries";
export * from "./useProfileQueries";
export * from "./useTenders";
export * from "./useUsersQueries";
export * from "./useAdminListQuery";
export * from "./useAdminQueries";
export * from "./useSelectSearchQuery";
export * from "./useDashboardQueries";
export * from "./useFilesQueries";
-11
View File
@@ -1,11 +0,0 @@
import { API_ENDPOINTS, userService } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
export const useGetAdminListQuery = (params?: Record<string, any>) => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useQuery({
queryKey,
queryFn: () => userService.adminsList(params),
});
};
+163
View File
@@ -0,0 +1,163 @@
import { API_ENDPOINTS, userService } from "@/lib/api";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { toast } from "react-toastify";
export const useDeleteAdmin = (onSuccessCallback: () => void) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, "delete"], []);
return useMutation({
mutationKey,
mutationFn: userService.deleteAdmin,
onSuccess: (response) => {
toast.info(response.data.message);
onSuccessCallback();
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
},
});
};
export const useCreateAdmin = () => {
const queryClient = useQueryClient();
const router = useRouter();
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, "create"], []);
return useMutation({
mutationKey,
mutationFn: userService.createAdmin,
onSuccess: (response) => {
toast.success(response);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
router.push("/admins");
},
});
};
export const useUpdateAdmin = (id: string) => {
const queryClient = useQueryClient();
const router = useRouter();
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "update", id],
[id],
);
return useMutation({
mutationKey,
mutationFn: userService.updateAdmin,
onSuccess: (response) => {
toast.success(response);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
router.push("/admins");
},
});
};
export const useAdminDetails = (id: string) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "details", id],
[id],
);
return useQuery({
queryKey,
queryFn: () => userService.adminDetails(id),
});
};
export const useAdminListInfiniteQuery = (
params?: Record<string, any>,
options?: { enabled?: boolean },
) => {
const enabled = options?.enabled ?? true;
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useInfiniteQuery({
queryKey,
queryFn: ({ pageParam = 0 }) =>
userService.adminsList({ ...params, offset: pageParam }),
initialPageParam: 0,
enabled,
getNextPageParam: (lastPage) => {
if (!lastPage.meta) {
return undefined;
}
const { offset, limit, total } = lastPage.meta;
const nextOffset = offset + limit;
return nextOffset < total ? nextOffset : undefined;
},
});
};
export const useGetAdminListQuery = (params?: Record<string, any>) => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useQuery({
queryKey,
queryFn: () => userService.adminsList(params),
});
};
export const useGetUsersQuery = (params?: Record<string, any>) => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useQuery({
queryKey,
queryFn: () => userService.adminsList(params),
});
};
export const useResetAdminPassword = ({
successCallback,
}: {
successCallback?: (password: string) => void;
} = {}) => {
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "reset-password"],
[],
);
return useMutation({
mutationKey,
mutationFn: userService.resetAdminPassword,
onSuccess: (response) => {
toast.success(response.message);
successCallback?.(response.data.password);
},
});
};
export const useChangeAdminStatus = ({
id,
successCallback,
}: {
id: string;
successCallback: () => void;
}) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.CHANGE_ADMIN_STATUS(id)],
[id],
);
return useMutation({
mutationKey,
mutationFn: userService.changeAdminStatus,
onSuccess: ({ message }: { message: string }) => {
successCallback();
toast.success(message);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
},
});
};
+6 -152
View File
@@ -1,19 +1,11 @@
import { useUser } from "@/contexts";
import { API_ENDPOINTS, userService } from "@/lib/api";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { useMemo } from "react";
import { toast } from "react-toastify";
export const useLoginQuery = (callback?: () => void) => {
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []);
const { setAuthState } = useUser();
const router = useRouter();
return useMutation({
mutationKey,
mutationFn: userService.login,
@@ -28,153 +20,15 @@ export const useLoginQuery = (callback?: () => void) => {
});
};
export const useDeleteAdmin = (onSuccessCallback: () => void) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, "delete"], []);
return useMutation({
mutationKey,
mutationFn: userService.deleteAdmin,
onSuccess: (response) => {
toast.info(response.data.message);
onSuccessCallback();
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
},
});
};
export const useLogoutQuery = () => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.LOGOUT], []);
const { logout } = useUser();
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGOUT], []);
return useQuery({
queryKey,
queryFn: userService.logout,
});
};
export const useCreateAdmin = () => {
const queryClient = useQueryClient();
const router = useRouter();
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, "create"], []);
return useMutation({
mutationKey,
mutationFn: userService.createAdmin,
onSuccess: (response) => {
toast.success(response);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
router.push("/admins");
},
});
};
export const useUpdateAdmin = (id: string) => {
const queryClient = useQueryClient();
const router = useRouter();
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "update", id],
[id],
);
return useMutation({
mutationKey,
mutationFn: userService.updateAdmin,
onSuccess: (response) => {
toast.success(response);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
router.push("/admins");
},
});
};
export const useAdminDetails = (id: string) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "details", id],
[id],
);
return useQuery({
queryKey,
queryFn: () => userService.adminDetails(id),
});
};
export const useAdminListInfiniteQuery = (
params?: Record<string, any>,
options?: { enabled?: boolean },
) => {
const enabled = options?.enabled ?? true;
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useInfiniteQuery({
queryKey,
queryFn: ({ pageParam = 0 }) =>
userService.adminsList({ ...params, offset: pageParam }),
initialPageParam: 0,
enabled,
getNextPageParam: (lastPage) => {
if (!lastPage.meta) {
return undefined;
}
const { offset, limit, total } = lastPage.meta;
const nextOffset = offset + limit;
return nextOffset < total ? nextOffset : undefined;
},
});
};
export const useGetUsersQuery = (params?: Record<string, any>) => {
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useQuery({
queryKey,
queryFn: () => userService.adminsList(params),
});
};
export const useResetAdminPassword = ({
successCallback,
}: {
successCallback?: (password: string) => void;
} = {}) => {
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.ADMINS, "reset-password"],
[],
);
return useMutation({
mutationKey,
mutationFn: userService.resetAdminPassword,
onSuccess: (response) => {
toast.success(response.message);
successCallback?.(response.data.password);
},
});
};
export const useChangeAdminStatus = ({
id,
successCallback,
}: {
id: string;
successCallback: () => void;
}) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.CHANGE_ADMIN_STATUS(id)],
[id],
);
return useMutation({
mutationKey,
mutationFn: userService.changeAdminStatus,
onSuccess: ({ message }: { message: string }) => {
successCallback();
toast.success(message);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
mutationFn: () => userService.logout(),
onSuccess: () => {
logout();
},
});
};