diff --git a/src/app/admins/page.tsx b/src/app/admins/page.tsx index a16d9fa..acd5ea3 100644 --- a/src/app/admins/page.tsx +++ b/src/app/admins/page.tsx @@ -1,8 +1,7 @@ import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; import AdminsTable from "@/components/Tables/admins"; -import { AdminsSkeleton } from "@/components/Tables/admins/Skeleton"; + import { Metadata } from "next"; -import { Suspense } from "react"; export const metadata: Metadata = { title: "Admins Page", @@ -15,9 +14,7 @@ const AdminsPage = () => { return ( <> - }> - - + ); }; 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/FormElements/switch.tsx b/src/components/FormElements/switch.tsx index 4054f06..358ad2b 100644 --- a/src/components/FormElements/switch.tsx +++ b/src/components/FormElements/switch.tsx @@ -50,14 +50,14 @@ export function Switch({ "absolute left-1 top-1 flex size-6 items-center justify-center rounded-full bg-white shadow-switch-1 transition peer-checked:right-1 peer-checked:translate-x-full peer-checked:[&_.check-icon]:block peer-checked:[&_.x-icon]:hidden", { "-top-1 left-0 size-7 shadow-switch-2": backgroundSize === "sm", - "peer-checked:bg-primary peer-checked:dark:bg-white": + "peer-checked:bg-primary peer-checked:dark:bg-primary": background !== "dark", }, )} > {withIcon && ( <> - + )} diff --git a/src/components/Tables/admins/Skeleton.tsx b/src/components/Tables/admins/Skeleton.tsx deleted file mode 100644 index 506c0bb..0000000 --- a/src/components/Tables/admins/Skeleton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; - -export function AdminsSkeleton() { - return ( -
-

- Admins -

- - - - - Full name - Email - Role - Username - Actions - - - - - {Array.from({ length: 5 }).map((_, i) => ( - - - - - - ))} - -
-
- ); -} diff --git a/src/components/Tables/admins/index.tsx b/src/components/Tables/admins/index.tsx index 4fc8f13..1442972 100644 --- a/src/components/Tables/admins/index.tsx +++ b/src/components/Tables/admins/index.tsx @@ -1,5 +1,7 @@ "use client"; +import { PencilSquareIcon, TrashIcon } from "@/assets/icons"; +import ConfirmationModal from "@/components/ui/ConfirmationModal"; import { Table, TableBody, @@ -8,11 +10,9 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { useAdminsPresenter } from "./useAdminsPresenter"; -import { PencilSquareIcon, TrashIcon } from "@/assets/icons"; -import ConfirmationModal from "@/components/ui/ConfirmationModal"; -import { AdminsSkeleton } from "./Skeleton"; +import TableSkeleton from "@/components/ui/TableSkeleton"; import Link from "next/link"; +import { useAdminsPresenter } from "./useAdminsPresenter"; const AdminsTable = () => { const { @@ -27,7 +27,6 @@ const AdminsTable = () => { pathName, } = useAdminsPresenter(); - if (isPending) return ; return (
-
- - -
+
diff --git a/src/components/forms/companies/CreateCompany.tsx b/src/components/forms/companies/CreateCompany.tsx index 69ac8e1..d4d9793 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 && ( @@ -498,25 +528,7 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
- {/*
- - -
*/} + ); }; diff --git a/src/components/forms/companies/useCreateCompanyPresenter.ts b/src/components/forms/companies/useCreateCompanyPresenter.ts index 3de3a0b..0a51258 100644 --- a/src/components/forms/companies/useCreateCompanyPresenter.ts +++ b/src/components/forms/companies/useCreateCompanyPresenter.ts @@ -1,8 +1,13 @@ "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"; +import { useEffect } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; interface IProps { @@ -17,9 +22,26 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => { register, formState: { errors }, handleSubmit, + watch, + setValue, reset, - } = useForm({ mode: "onChange", defaultValues }); + } = useForm({ + mode: "onChange", + defaultValues: { + ...defaultValues, + }, + }); + + useEffect(() => { + if (defaultValues) { + const categories = defaultValues?.tags.categories.map( + (item: any) => item.id, + ); + setValue("tags.categories", categories); + } + }, [defaultValues]); const router = useRouter(); + const { data: companyCategories } = useCompanyCategoriesQuery(); const { mutate: createCompany, isPending: isCreating } = useCreateCompany(); const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany( id as string, @@ -30,33 +52,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 +75,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..51dc122 100644 --- a/src/components/forms/customers/CreateCustomer.tsx +++ b/src/components/forms/customers/CreateCustomer.tsx @@ -1,13 +1,14 @@ "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 FormFooter from "@/components/ui/FormFooter"; +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; @@ -170,27 +171,6 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {

{errors.phone.message}

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

{errors.mobile.message}

- )} -