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 UseFormRegister,
|
||||||
type UseFormRegisterReturn,
|
type UseFormRegisterReturn,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
import { get } from "react-hook-form";
|
||||||
|
|
||||||
type InputGroupProps<T extends FieldValues> = {
|
type InputGroupProps<T extends FieldValues> = {
|
||||||
name: Path<T>;
|
name: Path<T>;
|
||||||
@@ -58,7 +59,7 @@ const InputGroup = <T extends FieldValues>({
|
|||||||
}: InputGroupProps<T>): React.ReactElement => {
|
}: InputGroupProps<T>): React.ReactElement => {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
|
|
||||||
const error = errors && errors[name];
|
const error = errors && get(errors, name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<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";
|
"use client";
|
||||||
import { PasswordIcon } from "@/assets/icons";
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||||
import {
|
|
||||||
createPhoneFieldRules,
|
|
||||||
PhoneNumberInput,
|
|
||||||
} from "@/components/FormElements/PhoneNumberInput";
|
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
|
||||||
import FileImageUploadCard from "@/components/ui/FileImageUploadCard";
|
|
||||||
import FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
|
||||||
import { ICreateAdminCredentials } from "@/lib/api";
|
import { ICreateAdminCredentials } from "@/lib/api";
|
||||||
import { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Controller } from "react-hook-form";
|
import useCreateAdminPresenter, {
|
||||||
import useCreateAdminPresenter from "./useCreateAdminPresenter";
|
type CreateAdminFormValues,
|
||||||
|
} from "./useCreateAdminPresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
defaultValues?: ICreateAdminCredentials;
|
defaultValues?: ICreateAdminCredentials;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
|
const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
|
||||||
const {
|
const presenter = useCreateAdminPresenter({ editMode, defaultValues, id });
|
||||||
errors,
|
const { errors, handleSubmit, register, control, onSubmit, sections } =
|
||||||
handleSubmit,
|
presenter;
|
||||||
register,
|
|
||||||
control,
|
|
||||||
onSubmit,
|
|
||||||
isUploadingImage,
|
|
||||||
uploadProgress,
|
|
||||||
selectedFileName,
|
|
||||||
selectedFileSize,
|
|
||||||
previewUrl,
|
|
||||||
clearSelectedProfileImage,
|
|
||||||
profileImageRegister,
|
|
||||||
passwordFieldInputType,
|
|
||||||
setPasswordFieldInputType,
|
|
||||||
} = useCreateAdminPresenter({
|
|
||||||
editMode,
|
|
||||||
defaultValues,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<DynamicFormBuilder<CreateAdminFormValues>
|
||||||
className="grid grid-cols-1 gap-9"
|
sections={sections}
|
||||||
onSubmit={handleSubmit((data) => {
|
register={register}
|
||||||
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"
|
|
||||||
control={control}
|
control={control}
|
||||||
rules={createPhoneFieldRules({ required: false })}
|
errors={errors}
|
||||||
render={({ field }) => (
|
handleSubmit={handleSubmit}
|
||||||
<PhoneNumberInput
|
onSubmit={onSubmit}
|
||||||
label="Phone"
|
dataCy={editMode ? "admin-edit-form" : "admin-create-form"}
|
||||||
value={field.value ?? ""}
|
footer={<FormFooter />}
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
error={errors.phone?.message as string | undefined}
|
|
||||||
dataCy="admin-form-phone-input"
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</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 { validateImageFile } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { useRouter } from "next/navigation";
|
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 { SubmitHandler, useForm } from "react-hook-form";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import { createAdminFormSections } from "./createAdminFormSections";
|
||||||
|
import type { CreateAdminFormValues } from "./createAdminForm.types";
|
||||||
|
|
||||||
|
export type { CreateAdminFormValues } from "./createAdminForm.types";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
editMode?: boolean;
|
editMode?: boolean;
|
||||||
@@ -15,10 +19,6 @@ interface IProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateAdminFormValues = Omit<ICreateAdminCredentials, "profile_image"> & {
|
|
||||||
profile_image?: FileList;
|
|
||||||
};
|
|
||||||
|
|
||||||
const useCreateAdminPresenter = ({
|
const useCreateAdminPresenter = ({
|
||||||
editMode = false,
|
editMode = false,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
@@ -244,6 +244,34 @@ const useCreateAdminPresenter = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const router = useRouter();
|
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 {
|
return {
|
||||||
errors,
|
errors,
|
||||||
register,
|
register,
|
||||||
@@ -262,6 +290,7 @@ const useCreateAdminPresenter = ({
|
|||||||
profileImageRegister: profileImageFileRegister,
|
profileImageRegister: profileImageFileRegister,
|
||||||
passwordFieldInputType,
|
passwordFieldInputType,
|
||||||
setPasswordFieldInputType,
|
setPasswordFieldInputType,
|
||||||
|
sections,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { GlobeIcon, MoneyIcon } from "@/assets/icons";
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||||
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 FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { REGEX } from "@/constants/regex";
|
import type { ICreateCompanyCredentials } from "@/lib/api";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import { createCompanyFormSections } from "./companyFormSections";
|
||||||
import { ICreateCompanyCredentials } from "@/lib/api/types/Company";
|
|
||||||
import CompanyDocuments from "./documents/CompanyDocuments";
|
|
||||||
import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
|
import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -19,58 +12,41 @@ interface IProps {
|
|||||||
id?: string;
|
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 CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
||||||
const {
|
const {
|
||||||
errors,
|
errors,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
companyCategories,
|
companyCategories,
|
||||||
} = useCreateCompanyPresenter({
|
} = useCreateCompanyPresenter({ editMode, defaultValues, id });
|
||||||
editMode,
|
|
||||||
defaultValues,
|
const categoryItems =
|
||||||
id,
|
companyCategories?.data?.categories?.map((category) => ({
|
||||||
});
|
label: category.name,
|
||||||
|
value: category.id,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
return (
|
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">
|
<header className="flex flex-col gap-1">
|
||||||
<h1 className="text-xl font-bold text-dark dark:text-white">
|
<h1 className="text-xl font-bold text-dark dark:text-white">
|
||||||
{editMode ? "Edit company" : "New company"}
|
{editMode ? "Edit company" : "New company"}
|
||||||
@@ -79,459 +55,10 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
Profile, business details, address, tags, and supporting documents.
|
Profile, business details, address, tags, and supporting documents.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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";
|
"use client";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
|
||||||
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
|
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
|
||||||
import FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import type { TCreateCompanyCategoryCredentials } from "@/lib/api";
|
||||||
import { TCreateCompanyCategoryCredentials } from "@/lib/api";
|
import { createCompanyCategoryFormSections } from "./companyCategoryFormSections";
|
||||||
import useCreateCompanyCategoryPresenter from "./useCreateCompanyCategoryPresenter";
|
import useCreateCompanyCategoryPresenter from "./useCreateCompanyCategoryPresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -14,72 +13,20 @@ interface IProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
|
const CreateCompanyCategoryForm = ({ defaultValues, editMode, id }: IProps) => {
|
||||||
const { errors, handleSubmit, onSubmit, register } =
|
const { errors, handleSubmit, onSubmit, register, control } =
|
||||||
useCreateCompanyCategoryPresenter({ defaultValues, editMode, id });
|
useCreateCompanyCategoryPresenter({ defaultValues, editMode, id });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<DynamicFormBuilder<TCreateCompanyCategoryCredentials>
|
||||||
className="grid grid-cols-1 gap-9"
|
sections={createCompanyCategoryFormSections(editMode)}
|
||||||
data-cy="company-category-form"
|
register={register}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
control={control}
|
||||||
>
|
errors={errors}
|
||||||
<ShowcaseSection
|
handleSubmit={handleSubmit}
|
||||||
title={editMode ? "Edit Company Category" : "Create Company Category"}
|
onSubmit={onSubmit}
|
||||||
className="grid grid-cols-1 gap-5 md:grid-cols-2"
|
dataCy="company-category-form"
|
||||||
>
|
footer={<FormFooter />}
|
||||||
<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"
|
|
||||||
/>
|
/>
|
||||||
{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) => {
|
}: IProps) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<TCreateCompanyCategoryCredentials>({
|
} = useForm<TCreateCompanyCategoryCredentials>({
|
||||||
@@ -38,6 +39,7 @@ const useCreateCompanyCategoryPresenter = ({
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
errors,
|
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 isMutating = useIsMutating();
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
@@ -41,7 +42,9 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
}
|
}
|
||||||
}, [defaultValues, setValue]);
|
}, [defaultValues, setValue]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: companyCategories } = useCompanyCategoriesQuery({ published: true });
|
const { data: companyCategories } = useCompanyCategoriesQuery({
|
||||||
|
published: true,
|
||||||
|
});
|
||||||
const { mutate: createCompany, isPending: isCreating } = useCreateCompany();
|
const { mutate: createCompany, isPending: isCreating } = useCreateCompany();
|
||||||
const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany(
|
const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany(
|
||||||
id as string,
|
id as string,
|
||||||
@@ -82,7 +85,6 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
} else {
|
} else {
|
||||||
createCompany(formattedData as ICreateCompanyCredentials);
|
createCompany(formattedData as ICreateCompanyCredentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@@ -92,6 +94,7 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
return {
|
return {
|
||||||
errors,
|
errors,
|
||||||
register,
|
register,
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { EyeIcon, GlobeIcon } from "@/assets/icons";
|
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||||
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 FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { AdminRoles } from "@/constants/enums";
|
import type { CreateCustomerCredentials } from "@/lib/api";
|
||||||
import { REGEX } from "@/constants/regex";
|
import { createCustomerFormSections } from "./customerFormSections";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
|
||||||
import { CreateCustomerCredentials } from "@/lib/api";
|
|
||||||
import { getEnumAsArray } from "@/utils/shared";
|
|
||||||
import { Controller } from "react-hook-form";
|
|
||||||
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -37,202 +25,28 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
|||||||
setShowPassword,
|
setShowPassword,
|
||||||
} = useCreateCustomerPresenter({ defaultValues, editMode, id });
|
} = useCreateCustomerPresenter({ defaultValues, editMode, id });
|
||||||
|
|
||||||
|
const companyItems =
|
||||||
|
companies?.map((company) => ({
|
||||||
|
value: company.id,
|
||||||
|
label: company.name,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="grid grid-cols-1 gap-9" onSubmit={handleSubmit(onSubmit)}>
|
<DynamicFormBuilder<CreateCustomerCredentials>
|
||||||
<ShowcaseSection
|
sections={createCustomerFormSections({
|
||||||
title="Create Customer"
|
editMode,
|
||||||
className="grid grid-cols-1 gap-5 md:grid-cols-2"
|
companyItems,
|
||||||
>
|
watch,
|
||||||
<div className="flex flex-col gap-2">
|
showPassword,
|
||||||
<InputGroup
|
setShowPassword,
|
||||||
{...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,
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
name="username"
|
register={register}
|
||||||
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"
|
|
||||||
control={control}
|
control={control}
|
||||||
rules={createPhoneFieldRules({ required: false })}
|
errors={errors}
|
||||||
render={({ field }) => (
|
handleSubmit={handleSubmit}
|
||||||
<PhoneNumberInput
|
onSubmit={onSubmit}
|
||||||
label="Phone"
|
footer={<FormFooter />}
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
error={errors.phone?.message as string | undefined}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</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";
|
"use client";
|
||||||
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder";
|
||||||
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 FormFooter from "@/components/ui/FormFooter";
|
import FormFooter from "@/components/ui/FormFooter";
|
||||||
import { REGEX } from "@/constants/regex";
|
import type { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
|
||||||
import { FormErrorMessages } from "@/constants/Texts";
|
import { createNotificationFormSections } from "./notificationFormSections";
|
||||||
import useCreateNotificationFormPresenter from "./useCreateNotificationFormPresenter";
|
import useCreateNotificationFormPresenter from "./useCreateNotificationFormPresenter";
|
||||||
|
|
||||||
const CreateNotificationForm = () => {
|
const CreateNotificationForm = () => {
|
||||||
@@ -29,157 +25,29 @@ const CreateNotificationForm = () => {
|
|||||||
types,
|
types,
|
||||||
channels,
|
channels,
|
||||||
} = useCreateNotificationFormPresenter();
|
} = 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">
|
return (
|
||||||
<MultiSelect
|
<DynamicFormBuilder<TCreateNotificationCredentials>
|
||||||
disabled={!recipient}
|
sections={createNotificationFormSections({
|
||||||
required={!!recipient}
|
recipient,
|
||||||
{...register("recipient")}
|
onSelectTarget,
|
||||||
label="recipient"
|
onSearchRecipient,
|
||||||
items={recipient ?? []}
|
onLoadMoreRecipients,
|
||||||
placeholder={
|
hasMoreRecipients,
|
||||||
recipient && recipient.length > 0 ? "Choose recipients" : ""
|
isLoadingMoreRecipients,
|
||||||
}
|
isLoadingRecipients,
|
||||||
name="recipient"
|
priorities,
|
||||||
onSearch={recipient ? onSearchRecipient : undefined}
|
targets,
|
||||||
onLoadMore={onLoadMoreRecipients}
|
types,
|
||||||
hasMore={hasMoreRecipients}
|
channels,
|
||||||
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,
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
name="link"
|
register={register}
|
||||||
label="Link"
|
control={control}
|
||||||
type="url"
|
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 "./useProfileQueries";
|
||||||
export * from "./useTenders";
|
export * from "./useTenders";
|
||||||
export * from "./useUsersQueries";
|
export * from "./useUsersQueries";
|
||||||
export * from "./useAdminListQuery";
|
export * from "./useAdminQueries";
|
||||||
export * from "./useSelectSearchQuery";
|
export * from "./useSelectSearchQuery";
|
||||||
export * from "./useDashboardQueries";
|
export * from "./useDashboardQueries";
|
||||||
export * from "./useFilesQueries";
|
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 { useUser } from "@/contexts";
|
||||||
import { API_ENDPOINTS, userService } from "@/lib/api";
|
import { API_ENDPOINTS, userService } from "@/lib/api";
|
||||||
import {
|
import { useMutation } from "@tanstack/react-query";
|
||||||
useInfiniteQuery,
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { toast } from "react-toastify";
|
|
||||||
|
|
||||||
export const useLoginQuery = (callback?: () => void) => {
|
export const useLoginQuery = (callback?: () => void) => {
|
||||||
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []);
|
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []);
|
||||||
const { setAuthState } = useUser();
|
const { setAuthState } = useUser();
|
||||||
const router = useRouter();
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey,
|
mutationKey,
|
||||||
mutationFn: userService.login,
|
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 = () => {
|
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({
|
return useMutation({
|
||||||
mutationKey,
|
mutationKey,
|
||||||
mutationFn: userService.createAdmin,
|
mutationFn: () => userService.logout(),
|
||||||
onSuccess: (response) => {
|
onSuccess: () => {
|
||||||
toast.success(response);
|
logout();
|
||||||
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],
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user