feat(forms): implement dynamic form builder for admin and company creation
continuous-integration/drone/push Build is passing
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:
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user