diff --git a/src/app/forms/form-elements/page.tsx b/src/app/forms/form-elements/page.tsx index d147f83..9e3f959 100644 --- a/src/app/forms/form-elements/page.tsx +++ b/src/app/forms/form-elements/page.tsx @@ -1,15 +1,10 @@ import type { Metadata } from "next"; -import { GlobeIcon } from "@/assets/icons"; import DatePickerOne from "@/components/FormElements/DatePicker/DatePickerOne"; import DatePickerTwo from "@/components/FormElements/DatePicker/DatePickerTwo"; -import InputGroup from "@/components/FormElements/InputGroup"; -import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area"; -import MultiSelect from "@/components/FormElements/MultiSelect"; import { Checkbox } from "@/components/FormElements/checkbox"; import { RadioInput } from "@/components/FormElements/radio"; -import { Select } from "@/components/FormElements/select"; import { Switch } from "@/components/FormElements/switch"; import { ShowcaseSection } from "@/components/Layouts/showcase-section"; diff --git a/src/components/FormElements/InputGroup/tag-input.tsx b/src/components/FormElements/InputGroup/tag-input.tsx new file mode 100644 index 0000000..1da2cff --- /dev/null +++ b/src/components/FormElements/InputGroup/tag-input.tsx @@ -0,0 +1,188 @@ +import { cn } from "@/lib/utils"; +import { KeyboardEvent, useId, useState } from "react"; +import { + type FieldErrors, + type FieldValues, + type Path, + type UseFormRegister, + type UseFormRegisterReturn, + type UseFormSetValue, + type UseFormWatch, +} from "react-hook-form"; +import { toast } from "react-toastify"; + +type TagInputProps = { + name: Path; + label: string; + placeholder: string; + className?: string; + required?: boolean; + disabled?: boolean; + active?: boolean; + value?: string[]; + icon?: React.ReactNode; + iconPosition?: "left" | "right"; + height?: "sm" | "default"; + register?: UseFormRegister; + setValue?: UseFormSetValue; + watch?: UseFormWatch; + errors?: FieldErrors; + maxTags?: number; + allowDuplicates?: boolean; + tagValidator?: (tag: string) => boolean; +} & Partial; + +const TagInput = ({ + className, + label, + placeholder, + required, + disabled, + active, + icon, + name, + register, + setValue, + watch, + errors, + maxTags, + allowDuplicates = false, + tagValidator, + onChange, + onBlur, + ref, + ...props +}: TagInputProps): React.ReactElement => { + const id = useId(); + const [inputValue, setInputValue] = useState(""); + + const watchedValue = watch ? watch(name) : props.value || []; + const tags: string[] = Array.isArray(watchedValue) ? watchedValue : []; + + const error = errors && errors[name]; + + const addTag = (tag: string) => { + const trimmedTag = tag.trim(); + if (!trimmedTag) return; + + if (maxTags && tags.length >= maxTags) return; + if (!allowDuplicates && tags.includes(trimmedTag)) { + toast.error("Item already exists"); + return; + } + if (tagValidator && !tagValidator(trimmedTag)) return; + + const newTags = [...tags, trimmedTag]; + if (setValue) { + setValue(name, newTags as any, { shouldValidate: true }); + } + setInputValue(""); + }; + + const removeTag = (indexToRemove: number) => { + const newTags = tags.filter((_, index) => index !== indexToRemove); + if (setValue) { + setValue(name, newTags as any, { shouldValidate: true }); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addTag(inputValue); + } else if (e.key === "Backspace" && !inputValue && tags.length > 0) { + removeTag(tags.length - 1); + } + }; + + const handleInputBlur = (e: React.FocusEvent) => { + if (inputValue) { + addTag(inputValue); + } + onBlur?.(e); + }; + + return ( +
+ + +
+
+ {tags.map((tag, index) => ( + + {tag} + {!disabled && ( + + )} + + ))} + + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleInputBlur} + disabled={disabled || (maxTags ? tags.length >= maxTags : false)} + className="min-w-[120px] flex-1 bg-transparent text-dark outline-none placeholder:text-dark-6 dark:text-white" + /> +
+ + {icon} + + +
+ + {error && ( +

{error.message as string}

+ )} + + {maxTags && ( +

+ {tags.length}/{maxTags} tags +

+ )} +
+ ); +}; + +export default TagInput; diff --git a/src/components/FormElements/MultiSelect.tsx b/src/components/FormElements/MultiSelect.tsx index 830d010..87f36c5 100644 --- a/src/components/FormElements/MultiSelect.tsx +++ b/src/components/FormElements/MultiSelect.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { useEffect, useRef, useState, useId } from "react"; +import { cn } from "@/lib/utils"; +import React, { useEffect, useId, useRef, useState } from "react"; import { type FieldErrors, type FieldValues, @@ -17,10 +18,14 @@ interface Option { type MultiSelectProps = { label: string; items: { value: string; text: string }[]; + value?: string[]; errors?: FieldErrors; required?: boolean; placeholder?: string; className?: string; + disabled?: boolean; + active?: boolean; + height?: "sm" | "default"; } & UseFormRegisterReturn>; function MultiSelect({ @@ -28,12 +33,16 @@ function MultiSelect({ label, items, errors, - required, placeholder = "Select an option", className, + disabled, + active, + height, onChange, onBlur, ref, + value, + required, }: MultiSelectProps) { const id = useId(); const error = errors && errors[name]; @@ -45,16 +54,22 @@ function MultiSelect({ const trigger = useRef(null); useEffect(() => { - setOptions( - items.map((item) => ({ - ...item, - selected: false, - })), - ); - }, [items]); + const newOptions = items.map((item) => ({ + ...item, + selected: value?.includes(item.value) ?? false, + })); + setOptions(newOptions); + + const newSelectedIndices = items + .map((item, index) => (value?.includes(item.value) ? index : -1)) + .filter((index) => index !== -1); + setSelected(newSelectedIndices); + }, [items, value]); const open = () => { - setShow(true); + if (!disabled) { + setShow(true); + } }; const isOpen = () => { @@ -69,6 +84,8 @@ function MultiSelect({ }; const handleSelect = (index: number) => { + if (disabled) return; + const newOptions = [...options]; let newSelected = [...selected]; @@ -83,11 +100,13 @@ function MultiSelect({ setOptions(newOptions); setSelected(newSelected); - const selectedValues = newSelected.map((i) => options[i].value); + const selectedValues = newSelected.map((i) => newOptions[i].value); onChange?.({ target: { name, value: selectedValues } }); }; const remove = (index: number, event: React.MouseEvent) => { + if (disabled) return; + event.stopPropagation(); const newOptions = [...options]; const newSelected = selected.filter((i) => i !== index); @@ -96,7 +115,7 @@ function MultiSelect({ setOptions(newOptions); setSelected(newSelected); - const selectedValues = newSelected.map((i) => options[i].value); + const selectedValues = newSelected.map((i) => newOptions[i].value); onChange?.({ target: { name, value: selectedValues } }); }; @@ -116,126 +135,128 @@ function MultiSelect({ }, [show]); return ( -
+
-
+
-
+
{selected.map((index) => ( -
-
- {options[index]?.text} -
-
-
remove(index, e)} - className="hover:text-red cursor-pointer pl-2" + className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-xs hover:bg-primary/20" > - - - -
-
-
+ × + + )} + ))} {selected.length === 0 && ( -
- - {placeholder} - -
+ + {placeholder} + )}
-
- + +
-
+
{options.map((option, index) => ( -
+
handleSelect(index)} + >
handleSelect(index)} + className={cn( + "mr-3 h-4 w-4 rounded border-2", + option.selected + ? "border-primary bg-primary" + : "border-stroke dark:border-dark-3", + )} > -
-
-
{option.text}
-
-
+ {option.selected && ( + + + + )}
+ {option.text}
))}
+ {error && (

{error.message as string}

)}
); } + export default MultiSelect; diff --git a/src/components/forms/companies/CreateCompany.tsx b/src/components/forms/companies/CreateCompany.tsx index 69ac8e1..7972d85 100644 --- a/src/components/forms/companies/CreateCompany.tsx +++ b/src/components/forms/companies/CreateCompany.tsx @@ -1,7 +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 FormFooter from "@/components/ui/FormFooter"; @@ -17,12 +19,19 @@ interface IProps { } const CreateCompany = ({ defaultValues, editMode, id }: IProps) => { - const { errors, handleSubmit, onSubmit, register } = - useCreateCompanyPresenter({ - editMode, - defaultValues, - id, - }); + const { + errors, + handleSubmit, + onSubmit, + register, + watch, + setValue, + companyCategories, + } = useCreateCompanyPresenter({ + editMode, + defaultValues, + id, + }); return (
{ )} - - {errors.tags?.keywords && ( -

- {errors.tags.keywords.message} -

- )} - { + return { + text: c.name, + value: c.id, + }; + }) + : [] + } + placeholder="Please select categories" /> - {errors.tags?.categories && ( -

- {errors.tags.categories.message} -

- )} - + + + + {errors.tags?.specializations && ( diff --git a/src/components/forms/companies/useCreateCompanyPresenter.ts b/src/components/forms/companies/useCreateCompanyPresenter.ts index 3de3a0b..bac32b4 100644 --- a/src/components/forms/companies/useCreateCompanyPresenter.ts +++ b/src/components/forms/companies/useCreateCompanyPresenter.ts @@ -1,5 +1,9 @@ "use client"; -import { useCreateCompany, useUpdateCompany } from "@/hooks/queries"; +import { + useCompanyCategoriesQuery, + useCreateCompany, + useUpdateCompany, +} from "@/hooks/queries"; import { ICreateCompanyCredentials } from "@/lib/api"; import { useIsMutating } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; @@ -17,9 +21,24 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => { register, formState: { errors }, handleSubmit, + watch, + setValue, reset, - } = useForm({ mode: "onChange", defaultValues }); + } = useForm({ + mode: "onChange", + defaultValues: { + ...defaultValues, + tags: { + ...defaultValues?.tags, + categories: defaultValues?.tags.categories.map((item) => ({ + value: item.id, + text: item.name, + })), + }, + }, + }); const router = useRouter(); + const { data: companyCategories } = useCompanyCategoriesQuery(); const { mutate: createCompany, isPending: isCreating } = useCreateCompany(); const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany( id as string, @@ -30,33 +49,6 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => { founded_year: data.founded_year ? +data.founded_year : 0, employee_count: data.employee_count ? +data.employee_count : 0, annual_revenue: data.annual_revenue ? +data.annual_revenue : 0, - tags: { - keywords: data.tags?.keywords - ? (data.tags.keywords as unknown as string) - .split(",") - .map((s) => s.trim()) - : [], - categories: data.tags?.categories - ? (data.tags.categories as unknown as string) - .split(",") - .map((s) => s.trim()) - : [], - specializations: data.tags?.specializations - ? (data.tags.specializations as unknown as string) - .split(",") - .map((s) => s.trim()) - : [], - certifications: data.tags?.certifications - ? (data.tags.certifications as unknown as string) - .split(",") - .map((s) => s.trim()) - : [], - cpv_codes: data.tags?.cpv_codes - ? (data.tags.cpv_codes as unknown as string) - .split(",") - .map((s) => s.trim()) - : [], - }, }; if (editMode) { updateCompany({ @@ -80,7 +72,10 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => { isMutating, isCreating, isUpdating, - router + router, + watch, + setValue, + companyCategories, }; }; diff --git a/src/components/forms/customers/CreateCustomer.tsx b/src/components/forms/customers/CreateCustomer.tsx index e962cf0..6509ad5 100644 --- a/src/components/forms/customers/CreateCustomer.tsx +++ b/src/components/forms/customers/CreateCustomer.tsx @@ -1,13 +1,13 @@ "use client"; +import { GlobeIcon } from "@/assets/icons"; +import InputGroup from "@/components/FormElements/InputGroup"; +import MultiSelect from "@/components/FormElements/MultiSelect"; +import { Select } from "@/components/FormElements/select"; +import { ShowcaseSection } from "@/components/Layouts/showcase-section"; +import { FormErrorMessages } from "@/constants/Texts"; import { CreateCustomerCredentials } from "@/lib/api"; import useCreateCustomerPresenter from "./useCreateCustomerPresenter"; -import { ShowcaseSection } from "@/components/Layouts/showcase-section"; -import InputGroup from "@/components/FormElements/InputGroup"; -import { FormErrorMessages } from "@/constants/Texts"; -import { Select } from "@/components/FormElements/select"; -import MultiSelect from "@/components/FormElements/MultiSelect"; -import { GlobeIcon, UserIcon } from "@/assets/icons"; interface IProps { editMode?: boolean; diff --git a/src/lib/api/types/TCompany.ts b/src/lib/api/types/TCompany.ts index bdfb7a2..b171b3d 100644 --- a/src/lib/api/types/TCompany.ts +++ b/src/lib/api/types/TCompany.ts @@ -22,7 +22,7 @@ export interface IContactPerson { export interface ITags { keywords: string[]; - categories: string[]; + categories: any[]; specializations: string[]; certifications: string[]; cpv_codes: string[]; @@ -87,7 +87,7 @@ export const companySchema = z.object({ .optional(), tags: z.object({ keywords: z.array(z.string()).or(z.null()).optional(), - categories: z.array(z.string()).or(z.null()).optional(), + categories: z.array(z.any()).or(z.null()).optional(), specializations: z.array(z.string()).or(z.null()).optional(), certifications: z.array(z.string()).or(z.null()).optional(), cpv_codes: z.array(z.string()).or(z.null()).optional(),