Merge pull request 'feat(feedback): Add feedback view for companies and customers' (#36) from feedbacks into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_panel/pulls/36
This commit is contained in:
Nima Nakhsotin
2025-09-24 11:57:15 +03:30
21 changed files with 255 additions and 121 deletions
@@ -0,0 +1,29 @@
"use client";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import FeedbackTable from "@/components/Tables/feedback-table";
import { BreadcrumbItem } from "@/types/shared";
import { use } from "react";
interface IProps {
params: Promise<{
id: string;
}>;
}
const TenderFeedbackPage = ({ params }: IProps) => {
const { id } = use(params);
const breadcrumbItems: BreadcrumbItem[] = [
{ href: "/", name: "Dashboard" },
{ href: "/companies", name: "Companies" },
{ href: `/companies/feedback/${id}`, name: "Companies feedback" },
];
return (
<>
<Breadcrumb items={breadcrumbItems} />
<FeedbackTable paramKey="company_id" id={id} />
</>
);
};
export default TenderFeedbackPage;
+29
View File
@@ -0,0 +1,29 @@
"use client";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import FeedbackTable from "@/components/Tables/feedback-table";
import { BreadcrumbItem } from "@/types/shared";
import { use } from "react";
interface IProps {
params: Promise<{
id: string;
}>;
}
const TenderFeedbackPage = ({ params }: IProps) => {
const { id } = use(params);
const breadcrumbItems: BreadcrumbItem[] = [
{ href: "/", name: "Dashboard" },
{ href: "/customers", name: "Customers" },
{ href: `/customers/feedback/${id}`, name: "Customers feedback" },
];
return (
<>
<Breadcrumb items={breadcrumbItems} />
<FeedbackTable paramKey="customer_id" id={id} />
</>
);
};
export default TenderFeedbackPage;
+3 -2
View File
@@ -1,6 +1,7 @@
"use client";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import TenderFeedbackTable from "@/components/Tables/tenders/feedback";
import FeedbackTable from "@/components/Tables/feedback-table";
import { BreadcrumbItem } from "@/types/shared";
import { use } from "react";
interface IProps {
@@ -20,7 +21,7 @@ const TenderFeedbackPage = ({ params }: IProps) => {
return (
<>
<Breadcrumb items={breadcrumbItems} />
<TenderFeedbackTable id={id} />
<FeedbackTable id={id} paramKey="tender_id" />
</>
);
};
+15 -15
View File
@@ -214,7 +214,20 @@ export function GlobeIcon(props: IconProps) {
</svg>
);
}
export function EyeIcon(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="M480-320q75 0 127.5-52.5T660-500q0-75-52.5-127.5T480-680q-75 0-127.5 52.5T300-500q0 75 52.5 127.5T480-320Zm0-72q-45 0-76.5-31.5T372-500q0-45 31.5-76.5T480-608q45 0 76.5 31.5T588-500q0 45-31.5 76.5T480-392Zm0 192q-146 0-266-81.5T40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200Zm0-300Zm0 220q113 0 207.5-59.5T832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280Z" />
</svg>
);
}
export function UserSettingIcon(props: IconProps) {
return (
<svg
@@ -297,20 +310,7 @@ export function EmailIcon(props: IconProps) {
</svg>
);
}
export function FeedbackIcon(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="M480-360q17 0 28.5-11.5T520-400q0-17-11.5-28.5T480-440q-17 0-28.5 11.5T440-400q0 17 11.5 28.5T480-360Zm-40-160h80v-240h-80v240ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H240L80-80Zm126-240h594v-480H160v525l46-45Zm-46 0v-480 480Z" />
</svg>
);
}
export function PasswordIcon(props: IconProps) {
return (
<svg
+14 -7
View File
@@ -1,30 +1,32 @@
"use client";
import { PasswordIcon, UserIcon } from "@/assets/icons";
import InputGroup from "../FormElements/InputGroup";
import { SubmitHandler, useForm } from "react-hook-form";
import { ILoginCredentials } from "@/lib/api";
import { useLoginQuery } from "@/hooks/queries";
import { useRef, useState } from "react";
import { ILoginCredentials } from "@/lib/api";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import InputGroup from "../FormElements/InputGroup";
import { FormErrorMessages } from "@/constants/Texts";
import { useIsMutating } from "@tanstack/react-query";
export default function SigninWithPassword() {
const isMutating = useIsMutating();
const [passwordFieldInputType, setPasswordFieldInputType] = useState<
"password" | "text"
>("password");
const { handleSubmit, control, reset, register } =
useForm<ILoginCredentials>();
const { mutate, isPending } = useLoginQuery();
const { mutate } = useLoginQuery(reset);
const onSubmit: SubmitHandler<ILoginCredentials> = (data) => {
mutate(data);
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<InputGroup
type="username"
disabled={!!isMutating}
{...register("username", {
required: {
value: true,
@@ -40,6 +42,7 @@ export default function SigninWithPassword() {
<InputGroup
type={passwordFieldInputType}
disabled={!!isMutating}
{...register("password", {
required: {
value: true,
@@ -67,7 +70,11 @@ export default function SigninWithPassword() {
type="submit"
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-primary p-4 font-medium text-white transition hover:bg-opacity-90"
>
Sign In
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
"Sign in"
)}
</button>
</div>
</form>
@@ -28,6 +28,7 @@ type TagInputProps<T extends FieldValues> = {
watch?: UseFormWatch<T>;
errors?: FieldErrors<T>;
maxTags?: number;
shouldAddWithSpace?: boolean;
allowDuplicates?: boolean;
tagValidator?: (tag: string) => boolean;
} & Partial<UseFormRegisterReturn>;
@@ -43,6 +44,7 @@ const TagInput = <T extends FieldValues>({
name,
register,
setValue,
shouldAddWithSpace = false,
watch,
errors,
maxTags,
@@ -87,7 +89,11 @@ const TagInput = <T extends FieldValues>({
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
if (
e.key === "Enter" ||
e.key === "," ||
(shouldAddWithSpace && e.key === " ")
) {
e.preventDefault();
addTag(inputValue);
} else if (e.key === "Backspace" && !inputValue && tags.length > 0) {
@@ -167,7 +173,7 @@ const TagInput = <T extends FieldValues>({
<input
type="hidden"
{...(register ? register(name) : {})}
value={tags.length > 0 ? JSON.stringify(tags) : ''}
value={tags.length > 0 ? JSON.stringify(tags) : ""}
ref={ref}
/>
</div>
+1 -1
View File
@@ -24,7 +24,7 @@ export function Header() {
{isMobile && (
<Link href={"/"} className="ml-2 max-[430px]:hidden min-[375px]:ml-4">
<Image
src={"/images/logo/logo-icon.svg"}
src={"/images/fav-icon.svg"}
width={32}
height={32}
alt=""
+27 -25
View File
@@ -1,5 +1,5 @@
"use client";
import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
import { ExclamationIcon, PencilSquareIcon, TrashIcon } from "@/assets/icons";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import {
Table,
@@ -58,39 +58,33 @@ const CompaniesTable = ({}: IProps) => {
key={company.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableHead className="text-start" colSpan={100}>
<TableCell className="text-start" colSpan={100}>
{index + 1}
</TableHead>
<TableHead className="text-start" colSpan={100}>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.name}
</TableHead>
<TableHead className="text-start" colSpan={100}>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.email}
</TableHead>
<TableHead className="w-full text-start" colSpan={100}>
</TableCell>
<TableCell className="w-full text-start" colSpan={100}>
{formatPhoneNumber(company.phone)}
</TableHead>
<TableHead className="text-start" colSpan={100}>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.address.country}
</TableHead>
<TableHead className="text-start" colSpan={100}>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.address.state}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.address.city}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.address.postal_code}
</TableHead>
<TableHead className="text-start" colSpan={100}>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.language}
</TableHead>
<TableHead className="text-start" colSpan={100}>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.currency}
</TableHead>
<TableHead className="text-start" colSpan={100}>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.employee_count}
</TableHead>
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
@@ -112,6 +106,14 @@ const CompaniesTable = ({}: IProps) => {
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
<button
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
onClick={() => {
router.push(`${pathName}/feedback/${company.id}`);
}}
>
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
@@ -31,8 +31,7 @@ export const useCompanyListPresenter = () => {
"phone",
"country",
"state",
"city",
"postal code",
"language",
"currency",
"employee count",
@@ -1,31 +1,31 @@
import {
Dispatch,
FC,
RefObject,
SetStateAction,
useEffect,
useState,
} from "react";
import { Dispatch, FC, SetStateAction, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { TAssignCustomerToCompanyCredentials } from "@/lib/api";
import { useCompanyFullList } from "@/hooks/queries";
import { ILabelValue } from "@/types/shared";
import { Select } from "@/components/FormElements/select";
import { Building } from "@/components/Layouts/sidebar/icons";
import { FormErrorMessages } from "@/constants/Texts";
import { useCompanyFullList } from "@/hooks/queries";
import { TAssignCustomerToCompanyCredentials } from "@/lib/api";
import { ILabelValue } from "@/types/shared";
import { useForm } from "react-hook-form";
interface IProps {
setCompanies: Dispatch<
SetStateAction<TAssignCustomerToCompanyCredentials | null>
SetStateAction<TAssignCustomerToCompanyCredentials | undefined>
>;
defaultValues?: TAssignCustomerToCompanyCredentials;
}
const AssignToCompanyModalContent: FC<IProps> = ({ setCompanies }) => {
const AssignToCompanyModalContent: FC<IProps> = ({
setCompanies,
defaultValues,
}) => {
const [options, setOptions] = useState<ILabelValue[]>([]);
const { data, status, isSuccess } = useCompanyFullList();
const { handleSubmit, register } =
useForm<TAssignCustomerToCompanyCredentials>();
useForm<TAssignCustomerToCompanyCredentials>({
mode: "onChange",
defaultValues,
});
useEffect(() => {
if (isSuccess) {
+16 -2
View File
@@ -1,5 +1,5 @@
"use client";
import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
import { ExclamationIcon, PencilSquareIcon, TrashIcon } from "@/assets/icons";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import {
Table,
@@ -112,11 +112,22 @@ const CustomersTable = () => {
onClick={() => {
setCurrentCustomer(customer);
setIsAssignCompanyModalOpen(true);
setSelectedCompanies({
company_ids: [customer?.companies?.[0]?.id ?? ""],
});
}}
>
<span className="sr-only">Assign To Company </span>
<Building />
</button>
<button
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
onClick={() => {
router.push(`${pathName}/feedback/${customer.id}`);
}}
>
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
@@ -137,7 +148,10 @@ const CustomersTable = () => {
isOpen={isAssignCompanyModalOpen}
onClose={() => setIsAssignCompanyModalOpen(false)}
>
<AssignToCompanyModalContent setCompanies={setSelectedCompanies} />
<AssignToCompanyModalContent
setCompanies={setSelectedCompanies}
defaultValues={selectedCompanies}
/>
</Modal>
{isModalOpen && (
<ConfirmationModal
@@ -12,8 +12,9 @@ import { useState } from "react";
const useCustomerListPresenter = () => {
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
useState(false);
const [selectedCompanies, setSelectedCompanies] =
useState<TAssignCustomerToCompanyCredentials | null>(null);
const [selectedCompanies, setSelectedCompanies] = useState<
TAssignCustomerToCompanyCredentials | undefined
>();
const [currentCustomer, setCurrentCustomer] = useState<TCustomer | null>(
null,
);
@@ -50,6 +51,7 @@ const useCustomerListPresenter = () => {
deleteCustomer(currentCustomer.id);
}
};
const customers = data?.pages.flatMap((page) => page.data.customers) ?? [];
return {
allCustomers: customers,
@@ -1,31 +1,39 @@
"use client";
import { ExclamationIcon } from "@/assets/icons";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
import { cn } from "@/lib/utils";
import { unixToDate } from "@/utils/shared";
import { TableRow } from "../../../ui/table";
import useTenderFeedbackPresenter from "./useTenderFeedbackPresenter";
import { capitalize, unixToDate } from "@/utils/shared";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import useFeedbackTablePresenter from "./useFeedbackTablePresenter";
interface IProps {
id: string;
paramKey: "tender_id" | "company_id" | "customer_id";
}
const FeedbackTable = ({ id, paramKey }: IProps) => {
const {
columns,
feedback,
isLoading,
pathName,
router,
getShowcaseSectionTitle,
} = useFeedbackTablePresenter({ id, paramKey });
const TenderFeedbackTable = ({ id }: IProps) => {
const { feedback, isLoading, columns, pathName, router } =
useTenderFeedbackPresenter({ id });
return (
<ShowcaseSection
title={!isLoading && `Tender: ${feedback?.[0].tender?.title}`}
className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card"
title={
!isLoading &&
`${capitalize(paramKey.slice(0, -3))}${getShowcaseSectionTitle()}`
}
className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 capitalize shadow-1 dark:bg-gray-dark dark:shadow-card"
>
<Table>
<TableHeader>
@@ -38,11 +46,16 @@ const TenderFeedbackTable = ({ id }: IProps) => {
</TableRow>
</TableHeader>
{isLoading && <TableSkeleton column={columns.length} />}
<TableBody>
{feedback?.map((item, index) =>
!item ? (
<h6 key={index}>No feedback is submitted for this tender</h6>
{!feedback?.length ? (
<TableRow className="w-full">
<TableCell colSpan={500} className="text-center">
<h2>No feedback</h2>
</TableCell>
</TableRow>
) : (
feedback?.map((item, index) => (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
@@ -74,7 +87,7 @@ const TenderFeedbackTable = ({ id }: IProps) => {
</button>
</TableCell>
</TableRow>
),
))
)}
</TableBody>
</Table>
@@ -82,4 +95,4 @@ const TenderFeedbackTable = ({ id }: IProps) => {
);
};
export default TenderFeedbackTable;
export default FeedbackTable;
@@ -0,0 +1,36 @@
import { useGetFeedback } from "@/hooks/queries";
import { usePathname, useRouter } from "next/navigation";
interface IProps {
id: string;
paramKey: "tender_id" | "company_id" | "customer_id";
}
const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
const { data, isLoading } = useGetFeedback({ [paramKey]: id });
const router = useRouter();
const pathName = usePathname();
const columns = [
"row",
"feedback type",
"updated at",
"created at",
"actions",
];
const getShowcaseSectionTitle = () => {
if (paramKey === "tender_id") {
return `: ${data?.data?.[0]?.tender?.title}`;
} else {
return "";
}
};
return {
isLoading,
feedback: data?.data,
columns,
pathName,
router,
getShowcaseSectionTitle,
};
};
export default useFeedbackTablePresenter;
@@ -1,20 +0,0 @@
"use client";
import { useGetFeedback } from "@/hooks/queries";
import { usePathname, useRouter } from "next/navigation";
const useTenderFeedbackPresenter = ({ id }: { id: string }) => {
const { data, isLoading } = useGetFeedback({ tender_id: id });
const router = useRouter();
const pathName = usePathname();
const columns = [
"row",
"feedback type",
"updated at",
"created at",
"actions",
];
return { feedback: data?.data, isLoading, columns, pathName, router };
};
export default useTenderFeedbackPresenter;
+4 -4
View File
@@ -11,7 +11,7 @@ import {
} from "@/components/ui/table";
import useTenderListPresenter from "./useTenderListPresenter";
import { ExclamationIcon, FeedbackIcon } from "@/assets/icons";
import { ExclamationIcon, EyeIcon } from "@/assets/icons";
import Pagination from "@/components/ui/pagination";
import TableSkeleton from "@/components/ui/TableSkeleton";
import { cn } from "@/lib/utils";
@@ -70,19 +70,19 @@ const TendersTable = () => {
colSpan={100}
>
<button
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
onClick={() => {
router.push(`${pathName}/${item.id}`);
}}
>
<ExclamationIcon />
<EyeIcon />
</button>
<button
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
onClick={() => {
router.push(`${pathName}/feedback/${item.id}`);
}}
>
<FeedbackIcon />
<ExclamationIcon />
</button>
</TableCell>
</TableRow>
@@ -497,6 +497,7 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
{...register("tags.cpv_codes")}
label="CPV codes"
name="tags.cpv_codes"
shouldAddWithSpace
watch={watch}
setValue={setValue}
placeholder="Enter Tag CPV codes"
@@ -6,6 +6,7 @@ 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 { USERNAME_REGEX } from "@/constants/regex";
import { FormErrorMessages } from "@/constants/Texts";
import { CreateCustomerCredentials } from "@/lib/api";
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
@@ -17,7 +18,7 @@ interface IProps {
}
const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
const { errors, handleSubmit, register, onSubmit, companies, router } =
const { errors, handleSubmit, register, watch, onSubmit, companies, router } =
useCreateCustomerPresenter({ defaultValues, editMode, id });
return (
@@ -36,7 +37,7 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
message: FormErrorMessages.maxLength(30),
},
pattern: {
value: /^[a-zA-Z0-9](?:[a-zA-Z0-9._]*[a-zA-Z0-9])?$/,
value: USERNAME_REGEX,
message: FormErrorMessages.invalidPattern,
},
})}
@@ -54,6 +55,7 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
<div className="flex flex-col gap-2">
<MultiSelect
label="Select companies"
value={watch("companies")}
items={
companies
? companies?.map((item) => ({
@@ -7,6 +7,7 @@ import {
} from "@/hooks/queries";
import { CreateCustomerCredentials } from "@/lib/api";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
interface IProps {
@@ -24,6 +25,8 @@ const useCreateCustomerPresenter = ({
register,
handleSubmit,
formState: { errors },
setValue,
watch,
} = useForm<CreateCustomerCredentials>({
mode: "onChange",
defaultValues,
@@ -34,6 +37,14 @@ const useCreateCustomerPresenter = ({
id as string,
);
const { data: companies } = useCompanyFullList();
useEffect(() => {
if (defaultValues) {
const companies = defaultValues?.companies?.map(
(company: any) => company.id,
);
setValue("companies", companies);
}
}, [defaultValues]);
const onSubmit: SubmitHandler<CreateCustomerCredentials> = (data) => {
if (editMode) {
updateCustomer({
@@ -41,7 +52,6 @@ const useCreateCustomerPresenter = ({
credentials: data,
});
} else {
createCustomer({
...data,
employee_count: data.employee_count ? +data.employee_count : undefined,
@@ -52,6 +62,7 @@ const useCreateCustomerPresenter = ({
};
return {
handleSubmit,
watch,
register,
errors,
onSubmit,
+1
View File
@@ -1 +1,2 @@
export const PHONE_REGEX = /^[+\d]+$/;
export const USERNAME_REGEX = /^[a-zA-Z0-9](?:[a-zA-Z0-9._]*[a-zA-Z0-9])?$/;
+3 -2
View File
@@ -10,7 +10,7 @@ import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { toast } from "react-toastify";
export const useLoginQuery = () => {
export const useLoginQuery = (callback?: () => void) => {
const mutationKey = useMemo(() => [API_ENDPOINTS.USER.LOGIN], []);
const { setAuthState } = useUser();
const router = useRouter();
@@ -18,9 +18,10 @@ export const useLoginQuery = () => {
mutationKey,
mutationFn: userService.login,
onSuccess: (response) => {
if (response.success) {
setAuthState(response.data);
router.replace("/charts");
if (callback) {
callback();
}
},
});