From 90a42b9fc5879c7d5c23cca776d23b633bcea4ff Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sun, 21 Jun 2026 09:03:51 +0330 Subject: [PATCH] feat(forms): implement dynamic form builder for admin and company creation - 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. --- AGENTS.md | 16 + .../FormElements/InputGroup/index.tsx | 3 +- src/components/forms/DynamicFormBuilder.tsx | 173 ++++++ src/components/forms/admins/CreateAdmin.tsx | 291 +-------- .../forms/admins/createAdminForm.types.ts | 8 + .../forms/admins/createAdminFormSections.tsx | 188 ++++++ .../forms/admins/useCreateAdminPresenter.ts | 39 +- .../forms/companies/CreateCompany.tsx | 553 ++---------------- .../CreateCompanyCategory.tsx | 85 +-- .../companyCategoryFormSections.tsx | 62 ++ .../useCreateCompanyCategoryPresenter.ts | 2 + .../forms/companies/companyFormSections.tsx | 499 ++++++++++++++++ .../companies/useCreateCompanyPresenter.ts | 7 +- .../forms/customers/CreateCustomer.tsx | 234 +------- .../forms/customers/customerFormSections.tsx | 185 ++++++ .../notifications/CreateNotification.tsx | 184 +----- .../notificationFormSections.tsx | 200 +++++++ src/components/ui/index.ts | 46 ++ src/hooks/queries/index.ts | 2 +- src/hooks/queries/useAdminListQuery.ts | 11 - src/hooks/queries/useAdminQueries.ts | 163 ++++++ src/hooks/queries/useUsersQueries.ts | 158 +---- 22 files changed, 1717 insertions(+), 1392 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/components/forms/DynamicFormBuilder.tsx create mode 100644 src/components/forms/admins/createAdminForm.types.ts create mode 100644 src/components/forms/admins/createAdminFormSections.tsx create mode 100644 src/components/forms/companies/company-categories/companyCategoryFormSections.tsx create mode 100644 src/components/forms/companies/companyFormSections.tsx create mode 100644 src/components/forms/customers/customerFormSections.tsx create mode 100644 src/components/forms/notifications/notificationFormSections.tsx create mode 100644 src/components/ui/index.ts delete mode 100644 src/hooks/queries/useAdminListQuery.ts create mode 100644 src/hooks/queries/useAdminQueries.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..95b1faf --- /dev/null +++ b/AGENTS.md @@ -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". diff --git a/src/components/FormElements/InputGroup/index.tsx b/src/components/FormElements/InputGroup/index.tsx index 85e9f5c..32e986d 100644 --- a/src/components/FormElements/InputGroup/index.tsx +++ b/src/components/FormElements/InputGroup/index.tsx @@ -11,6 +11,7 @@ import { type UseFormRegister, type UseFormRegisterReturn, } from "react-hook-form"; +import { get } from "react-hook-form"; type InputGroupProps = { name: Path; @@ -58,7 +59,7 @@ const InputGroup = ({ }: InputGroupProps): React.ReactElement => { const id = useId(); - const error = errors && errors[name]; + const error = errors && get(errors, name); return (
diff --git a/src/components/forms/DynamicFormBuilder.tsx b/src/components/forms/DynamicFormBuilder.tsx new file mode 100644 index 0000000..9a6792f --- /dev/null +++ b/src/components/forms/DynamicFormBuilder.tsx @@ -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 { + control: Control; + errors: FieldErrors; + register: UseFormRegister; + error?: string; +} + +interface DynamicFieldBase { + name: Path; + className?: string; + hidden?: boolean; +} + +export interface DynamicInputField< + TValues extends FieldValues, +> extends DynamicFieldBase { + kind: "input"; + label: string; + type: HTMLInputTypeAttribute; + placeholder?: string; + required?: boolean; + disabled?: boolean; + autoComplete?: HTMLInputAutoCompleteAttribute; + dataCy?: string; + icon?: ReactNode; + iconPosition?: "left" | "right"; + rules?: RegisterOptions>; +} + +export interface DynamicCustomField< + TValues extends FieldValues, +> extends DynamicFieldBase { + kind: "custom"; + render: (context: DynamicFieldContext) => ReactNode; +} + +export type DynamicFormField = + | DynamicInputField + | DynamicCustomField; + +export interface DynamicFormSection { + title?: ReactNode; + className?: string; + rootClassName?: string; + fields: DynamicFormField[]; +} + +interface DynamicFormBuilderProps { + sections: DynamicFormSection[]; + register: UseFormRegister; + control: Control; + errors: FieldErrors; + handleSubmit: UseFormHandleSubmit; + onSubmit: SubmitHandler; + dataCy?: string; + className?: string; + header?: ReactNode; + sectionsClassName?: string; + footer?: ReactNode; + footerPlacement?: "last-section" | "form"; +} + +const getErrorMessage = ( + errors: FieldErrors, + name: Path, +) => { + const message = get(errors, name)?.message; + return typeof message === "string" ? message : undefined; +}; + +const DynamicFormBuilder = ({ + sections, + register, + control, + errors, + handleSubmit, + onSubmit, + dataCy, + className, + header, + sectionsClassName, + footer, + footerPlacement = "last-section", +}: DynamicFormBuilderProps) => ( +
+ {header} +
+ {sections.map((section, sectionIndex) => ( + + {section.fields.map((field) => { + if (field.hidden) return null; + + if (field.kind === "custom") { + return ( +
+ {field.render({ + control, + errors, + register, + error: getErrorMessage(errors, field.name), + })} +
+ ); + } + + return ( + + 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 ? ( +
{footer}
+ ) : null} +
+ ))} +
+ {footerPlacement === "form" ? footer : null} +
+); + +export default DynamicFormBuilder; diff --git a/src/components/forms/admins/CreateAdmin.tsx b/src/components/forms/admins/CreateAdmin.tsx index 5791ae2..56b26cd 100644 --- a/src/components/forms/admins/CreateAdmin.tsx +++ b/src/components/forms/admins/CreateAdmin.tsx @@ -1,284 +1,35 @@ "use client"; -import { PasswordIcon } from "@/assets/icons"; -import InputGroup from "@/components/FormElements/InputGroup"; -import { - createPhoneFieldRules, - PhoneNumberInput, -} from "@/components/FormElements/PhoneNumberInput"; -import { ShowcaseSection } from "@/components/Layouts/showcase-section"; -import FileImageUploadCard from "@/components/ui/FileImageUploadCard"; + +import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder"; import FormFooter from "@/components/ui/FormFooter"; -import { FormErrorMessages } from "@/constants/Texts"; import { ICreateAdminCredentials } from "@/lib/api"; -import { FC } from "react"; -import { Controller } from "react-hook-form"; -import useCreateAdminPresenter from "./useCreateAdminPresenter"; +import type { FC } from "react"; +import useCreateAdminPresenter, { + type CreateAdminFormValues, +} from "./useCreateAdminPresenter"; + interface IProps { editMode?: boolean; defaultValues?: ICreateAdminCredentials; id?: string; } + const CreateAdminForm: FC = ({ defaultValues, editMode, id }) => { - const { - errors, - handleSubmit, - register, - control, - onSubmit, - isUploadingImage, - uploadProgress, - selectedFileName, - selectedFileSize, - previewUrl, - clearSelectedProfileImage, - profileImageRegister, - passwordFieldInputType, - setPasswordFieldInputType, - } = useCreateAdminPresenter({ - editMode, - defaultValues, - id, - }); + const presenter = useCreateAdminPresenter({ editMode, defaultValues, id }); + const { errors, handleSubmit, register, control, onSubmit, sections } = + presenter; return ( -
{ - onSubmit(data); - })} - data-cy={editMode ? "admin-edit-form" : "admin-create-form"} - > -
- -
- - {errors.full_name && ( -

- {errors.full_name.message} -

- )} -
- -
- - {errors.username && ( -

- {errors.username.message} -

- )} -
- -
- - {errors.email && ( -

- {errors.email.message} -

- )} -
- {!editMode && ( -
- { - 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" - } - > - - - } - 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 && ( -

- {errors.password.message} -

- )} -
- )} - -
- ( - - )} - /> -
- -
- - {errors.department && ( -

- {errors.department.message} -

- )} -
- -
- - {errors.position && ( -

- {errors.position.message} -

- )} -
- - - -
- -
-
-
-
+ + sections={sections} + register={register} + control={control} + errors={errors} + handleSubmit={handleSubmit} + onSubmit={onSubmit} + dataCy={editMode ? "admin-edit-form" : "admin-create-form"} + footer={} + /> ); }; diff --git a/src/components/forms/admins/createAdminForm.types.ts b/src/components/forms/admins/createAdminForm.types.ts new file mode 100644 index 0000000..b73af78 --- /dev/null +++ b/src/components/forms/admins/createAdminForm.types.ts @@ -0,0 +1,8 @@ +import { ICreateAdminCredentials } from "@/lib/api"; + +export type CreateAdminFormValues = Omit< + ICreateAdminCredentials, + "profile_image" +> & { + profile_image?: FileList; +}; diff --git a/src/components/forms/admins/createAdminFormSections.tsx b/src/components/forms/admins/createAdminFormSections.tsx new file mode 100644 index 0000000..3104e17 --- /dev/null +++ b/src/components/forms/admins/createAdminFormSections.tsx @@ -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>; + 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[] => [ + { + 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: ( + + ), + 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 }) => ( + ( + + )} + /> + ), + }, + { + 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 }) => ( + + ), + }, + ], + }, +]; diff --git a/src/components/forms/admins/useCreateAdminPresenter.ts b/src/components/forms/admins/useCreateAdminPresenter.ts index 531333f..14f13ae 100644 --- a/src/components/forms/admins/useCreateAdminPresenter.ts +++ b/src/components/forms/admins/useCreateAdminPresenter.ts @@ -5,9 +5,13 @@ import { normalizePhoneFieldValue } from "@/components/FormElements/PhoneNumberI import { validateImageFile } from "@/utils/shared"; import { useIsMutating } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { ChangeEvent, useEffect, useRef, useState } from "react"; +import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import { toast } from "react-toastify"; +import { createAdminFormSections } from "./createAdminFormSections"; +import type { CreateAdminFormValues } from "./createAdminForm.types"; + +export type { CreateAdminFormValues } from "./createAdminForm.types"; interface IProps { editMode?: boolean; @@ -15,10 +19,6 @@ interface IProps { id?: string; } -type CreateAdminFormValues = Omit & { - profile_image?: FileList; -}; - const useCreateAdminPresenter = ({ editMode = false, defaultValues, @@ -244,6 +244,34 @@ const useCreateAdminPresenter = ({ } }; const router = useRouter(); + + const sections = useMemo( + () => + createAdminFormSections({ + editMode, + passwordFieldInputType, + setPasswordFieldInputType, + profileImageRegister: profileImageFileRegister, + isUploadingImage, + uploadProgress, + previewUrl, + selectedFileName, + selectedFileSize, + clearSelectedProfileImage, + }), + [ + editMode, + passwordFieldInputType, + profileImageFileRegister, + isUploadingImage, + uploadProgress, + previewUrl, + selectedFileName, + selectedFileSize, + clearSelectedProfileImage, + ], + ); + return { errors, register, @@ -262,6 +290,7 @@ const useCreateAdminPresenter = ({ profileImageRegister: profileImageFileRegister, passwordFieldInputType, setPasswordFieldInputType, + sections, }; }; diff --git a/src/components/forms/companies/CreateCompany.tsx b/src/components/forms/companies/CreateCompany.tsx index e38e238..5a90a81 100644 --- a/src/components/forms/companies/CreateCompany.tsx +++ b/src/components/forms/companies/CreateCompany.tsx @@ -1,16 +1,9 @@ "use client"; -import { GlobeIcon, MoneyIcon } from "@/assets/icons"; -import InputGroup from "@/components/FormElements/InputGroup"; -import TagInput from "@/components/FormElements/InputGroup/tag-input"; -import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area"; -import MultiSelect from "@/components/FormElements/MultiSelect"; -import { Select } from "@/components/FormElements/select"; -import { ShowcaseSection } from "@/components/Layouts/showcase-section"; + +import DynamicFormBuilder from "@/components/forms/DynamicFormBuilder"; import FormFooter from "@/components/ui/FormFooter"; -import { REGEX } from "@/constants/regex"; -import { FormErrorMessages } from "@/constants/Texts"; -import { ICreateCompanyCredentials } from "@/lib/api/types/Company"; -import CompanyDocuments from "./documents/CompanyDocuments"; +import type { ICreateCompanyCredentials } from "@/lib/api"; +import { createCompanyFormSections } from "./companyFormSections"; import useCreateCompanyPresenter from "./useCreateCompanyPresenter"; interface IProps { @@ -19,519 +12,53 @@ interface IProps { id?: string; } -/** Inline error for nested fields whose dotted name can't use the inputs' built-in lookup. */ -const FieldError = ({ message }: { message?: string }) => - message ? ( -

{message}

- ) : null; - -const optionalNumber = (value: unknown) => { - if (value === "" || value === null || value === undefined) return undefined; - return Number(value); -}; - -const optionalNumberRange = ({ - min, - max, - integer = false, -}: { - min: number; - max: number; - integer?: boolean; -}) => { - return (value: unknown) => { - const numberValue = optionalNumber(value); - - if (numberValue === undefined) return true; - if (!Number.isFinite(numberValue)) return "Enter a valid number"; - if (integer && !Number.isInteger(numberValue)) { - return "This field must be a whole number"; - } - if (numberValue < min) return FormErrorMessages.min(min); - if (numberValue > max) return FormErrorMessages.max(max); - - return true; - }; -}; - const CreateCompany = ({ defaultValues, editMode, id }: IProps) => { const { errors, handleSubmit, onSubmit, register, + control, watch, setValue, companyCategories, - } = useCreateCompanyPresenter({ - editMode, - defaultValues, - id, - }); + } = useCreateCompanyPresenter({ editMode, defaultValues, id }); + + const categoryItems = + companyCategories?.data?.categories?.map((category) => ({ + label: category.name, + value: category.id, + })) ?? []; return ( -
-
-

- {editMode ? "Edit company" : "New company"} -

-

- Profile, business details, address, tags, and supporting documents. -

-
- -
- - - - - } - /> - -
- {!editMode && ( -
- { - setShowPassword(!showPassword); - }} - > - - - } - iconPosition="right" - label="Password" - type={showPassword ? "text" : "password"} - placeholder="At least 8 characters" - required={!editMode} - /> - {errors.password && ( -

- {errors.password.message} -

- )} -
- )} - -
- - {errors.full_name && ( -

- {errors.full_name.message} -

- )} -
-
- ( - - )} - /> -
- -
- - {errors.target && ( -

{errors.target.message}

- )} -
-
- 0 ? "Choose recipients" : "" - } - name="recipient" - onSearch={recipient ? onSearchRecipient : undefined} - onLoadMore={onLoadMoreRecipients} - hasMore={hasMoreRecipients} - isLoading={isLoadingRecipients} - isLoadingMore={isLoadingMoreRecipients} - /> -
-
- -
-
- - {errors.link && ( -

{errors.link.message}

- )} -
-
- - {errors.type && ( -

{errors.type.message}

- )} -
-
- -
-
- -
- -
+ return ( + + sections={createNotificationFormSections({ + recipient, + onSelectTarget, + onSearchRecipient, + onLoadMoreRecipients, + hasMoreRecipients, + isLoadingMoreRecipients, + isLoadingRecipients, + priorities, + targets, + types, + channels, + })} + register={register} + control={control} + errors={errors} + handleSubmit={handleSubmit} + onSubmit={onSubmit} + footer={} + /> ); }; diff --git a/src/components/forms/notifications/notificationFormSections.tsx b/src/components/forms/notifications/notificationFormSections.tsx new file mode 100644 index 0000000..a3fa131 --- /dev/null +++ b/src/components/forms/notifications/notificationFormSections.tsx @@ -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) => 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[] => [ + { + 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 }) => ( + + ), + }, + { + kind: "custom", + name: "target", + render: ({ register, errors }) => ( + + 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 }) => ( + + 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 }) => ( + + 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 }) => ( + + 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 }) => ( + + 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 }) => ( + + required + {...register("description", { + required: { + value: true, + message: FormErrorMessages.required, + }, + })} + errors={errors} + label="Description" + name="description" + placeholder="Main message — include context and any action needed" + /> + ), + }, + ], + }, +]; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..9d1d367 --- /dev/null +++ b/src/components/ui/index.ts @@ -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"; diff --git a/src/hooks/queries/index.ts b/src/hooks/queries/index.ts index c09b184..036162a 100644 --- a/src/hooks/queries/index.ts +++ b/src/hooks/queries/index.ts @@ -13,7 +13,7 @@ export * from "./useNotificationQueries"; export * from "./useProfileQueries"; export * from "./useTenders"; export * from "./useUsersQueries"; -export * from "./useAdminListQuery"; +export * from "./useAdminQueries"; export * from "./useSelectSearchQuery"; export * from "./useDashboardQueries"; export * from "./useFilesQueries"; diff --git a/src/hooks/queries/useAdminListQuery.ts b/src/hooks/queries/useAdminListQuery.ts deleted file mode 100644 index 2b4a073..0000000 --- a/src/hooks/queries/useAdminListQuery.ts +++ /dev/null @@ -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) => { - const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]); - return useQuery({ - queryKey, - queryFn: () => userService.adminsList(params), - }); -}; \ No newline at end of file diff --git a/src/hooks/queries/useAdminQueries.ts b/src/hooks/queries/useAdminQueries.ts new file mode 100644 index 0000000..11ae454 --- /dev/null +++ b/src/hooks/queries/useAdminQueries.ts @@ -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, + 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) => { + const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]); + return useQuery({ + queryKey, + queryFn: () => userService.adminsList(params), + }); +}; + +export const useGetUsersQuery = (params?: Record) => { + 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], + }); + }, + }); +}; diff --git a/src/hooks/queries/useUsersQueries.ts b/src/hooks/queries/useUsersQueries.ts index 9aae141..13482bc 100644 --- a/src/hooks/queries/useUsersQueries.ts +++ b/src/hooks/queries/useUsersQueries.ts @@ -1,19 +1,11 @@ import { useUser } from "@/contexts"; import { API_ENDPOINTS, userService } from "@/lib/api"; -import { - useInfiniteQuery, - useMutation, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; -import { useRouter } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; import { useMemo } from "react"; -import { toast } from "react-toastify"; export const useLoginQuery = (callback?: () => void) => { const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []); const { setAuthState } = useUser(); - const router = useRouter(); return useMutation({ mutationKey, mutationFn: userService.login, @@ -28,153 +20,15 @@ export const useLoginQuery = (callback?: () => void) => { }); }; -export const useDeleteAdmin = (onSuccessCallback: () => void) => { - const queryClient = useQueryClient(); - const mutationKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, "delete"], []); - return useMutation({ - mutationKey, - mutationFn: userService.deleteAdmin, - onSuccess: (response) => { - toast.info(response.data.message); - onSuccessCallback(); - queryClient.invalidateQueries({ - queryKey: [API_ENDPOINTS.USER.ADMINS], - }); - }, - }); -}; export const useLogoutQuery = () => { - const queryKey = useMemo(() => [API_ENDPOINTS.USER.LOGOUT], []); + const { logout } = useUser(); + const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGOUT], []); - return useQuery({ - queryKey, - queryFn: userService.logout, - }); -}; - -export const useCreateAdmin = () => { - const queryClient = useQueryClient(); - const router = useRouter(); - const mutationKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, "create"], []); return useMutation({ mutationKey, - mutationFn: userService.createAdmin, - onSuccess: (response) => { - toast.success(response); - queryClient.invalidateQueries({ - queryKey: [API_ENDPOINTS.USER.ADMINS], - }); - router.push("/admins"); - }, - }); -}; - -export const useUpdateAdmin = (id: string) => { - const queryClient = useQueryClient(); - const router = useRouter(); - const mutationKey = useMemo( - () => [API_ENDPOINTS.USER.ADMINS, "update", id], - [id], - ); - return useMutation({ - mutationKey, - mutationFn: userService.updateAdmin, - onSuccess: (response) => { - toast.success(response); - queryClient.invalidateQueries({ - queryKey: [API_ENDPOINTS.USER.ADMINS], - }); - router.push("/admins"); - }, - }); -}; - -export const useAdminDetails = (id: string) => { - const queryKey = useMemo( - () => [API_ENDPOINTS.USER.ADMINS, "details", id], - [id], - ); - return useQuery({ - queryKey, - queryFn: () => userService.adminDetails(id), - }); -}; - -export const useAdminListInfiniteQuery = ( - params?: Record, - 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) => { - const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]); - return useQuery({ - queryKey, - queryFn: () => userService.adminsList(params), - }); -}; -export const useResetAdminPassword = ({ - successCallback, -}: { - successCallback?: (password: string) => void; -} = {}) => { - const mutationKey = useMemo( - () => [API_ENDPOINTS.USER.ADMINS, "reset-password"], - [], - ); - return useMutation({ - mutationKey, - mutationFn: userService.resetAdminPassword, - onSuccess: (response) => { - toast.success(response.message); - successCallback?.(response.data.password); - }, - }); -}; - -export const useChangeAdminStatus = ({ - id, - successCallback, -}: { - id: string; - successCallback: () => void; -}) => { - const queryClient = useQueryClient(); - const mutationKey = useMemo( - () => [API_ENDPOINTS.USER.CHANGE_ADMIN_STATUS(id)], - [id], - ); - return useMutation({ - mutationKey, - mutationFn: userService.changeAdminStatus, - onSuccess: ({ message }: { message: string }) => { - successCallback(); - toast.success(message); - queryClient.invalidateQueries({ - queryKey: [API_ENDPOINTS.USER.ADMINS], - }); + mutationFn: () => userService.logout(), + onSuccess: () => { + logout(); }, }); };