feat(feedback): add feedback button to tenders table

This commit introduces the ability for users to navigate to a feedback page directly from the tenders list.

A new feedback icon and a corresponding button have been added to the actions column of the tenders table. Clicking this button redirects the user to the feedback page associated with that specific tender.

To support this feature, the following changes were made:
- Added a new `FeedbackIcon` SVG component.
- Defined API endpoints and created query hooks for the feedback module.
- Fixed a query key inconsistency for company details to ensure data is correctly refetched after updates.
This commit is contained in:
AmirReza Jamali
2025-09-23 11:16:02 +03:30
parent 0abfdde285
commit 1d944739bc
15 changed files with 237 additions and 7 deletions
+28
View File
@@ -0,0 +1,28 @@
"use client";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import TenderFeedbackTable from "@/components/Tables/tenders/feedback";
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: "/tenders", name: "Tenders" },
{ href: `/tenders/feedback/${id}`, name: "Tender feedback" },
];
return (
<>
<Breadcrumb items={breadcrumbItems} />
<TenderFeedbackTable id={id} />
</>
);
};
export default TenderFeedbackPage;
+14 -1
View File
@@ -297,7 +297,20 @@ 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
@@ -0,0 +1,83 @@
"use client";
import { ExclamationIcon } from "@/assets/icons";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
import { unixToDate } from "@/utils/shared";
import { TableRow } from "../../../ui/table";
import useTenderFeedbackPresenter from "./useTenderFeedbackPresenter";
import { cn } from "@/lib/utils";
interface IProps {
id: string;
}
const TenderFeedbackTable = ({ id }: IProps) => {
const { feedback, isLoading, columns, pathName, router } =
useTenderFeedbackPresenter({ id });
return (
<div 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">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead className="capitalize" key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isLoading && <TableSkeleton column={columns.length} />}
<TableBody>
{feedback?.map((item, index) =>
!item ? (
<h6 key={index}>No feedback is submitted for this tender</h6>
) : (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{index + 1}
</TableCell>
<TableCell className="uppercase" colSpan={100}>
{item.feedback_type}
</TableCell>
<TableCell className="uppercase" colSpan={100}>
{unixToDate(item.updated_at)}
</TableCell>
<TableCell className="uppercase" colSpan={100}>
{item?.tender?.title ?? "-"}
</TableCell>
<TableCell className="uppercase" colSpan={100}>
{unixToDate(item.created_at)}
</TableCell>
<TableCell
className={cn(`text flex gap-2 capitalize`)}
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 />
</button>
</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
</div>
);
};
export default TenderFeedbackTable;
@@ -0,0 +1,20 @@
"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",
"tender name",
"created at",
"actions",
];
return { feedback: data?.data, isLoading, columns, pathName, router };
};
export default useTenderFeedbackPresenter;
+12 -2
View File
@@ -11,7 +11,7 @@ import {
} from "@/components/ui/table";
import useTenderListPresenter from "./useTenderListPresenter";
import { ExclamationIcon } from "@/assets/icons";
import { ExclamationIcon, FeedbackIcon } from "@/assets/icons";
import Pagination from "@/components/ui/pagination";
import TableSkeleton from "@/components/ui/TableSkeleton";
import { cn } from "@/lib/utils";
@@ -65,7 +65,10 @@ const TendersTable = () => {
<TableCell className={cn(`text capitalize`)} colSpan={100}>
<Status status={item.status}>{item.status}</Status>
</TableCell>
<TableCell className={cn(`text capitalize`)} colSpan={100}>
<TableCell
className={cn(`text flex gap-2 capitalize`)}
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={() => {
@@ -74,6 +77,13 @@ const TendersTable = () => {
>
<ExclamationIcon />
</button>
<button
onClick={() => {
router.push(`${pathName}/feedback/${item.id}`);
}}
>
<FeedbackIcon />
</button>
</TableCell>
</TableRow>
),
@@ -528,7 +528,6 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
</div>
<FormFooter />
</form>
);
};
+1
View File
@@ -3,3 +3,4 @@ export * from "./useCompaniesQueries";
export * from "./useTenders";
export * from "./useCustomerQueries";
export * from "./useProfileQueries";
export * from "./useFeedbackQueries"
+4 -1
View File
@@ -45,6 +45,9 @@ export const useUpdateCompany = (id: string) => {
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL],
});
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.COMPANIES.READ_ALL, "details"],
});
router.push("/companies");
},
});
@@ -52,7 +55,7 @@ export const useUpdateCompany = (id: string) => {
export const useCompanyDetails = (id: string) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.COMPANIES.DETAILS(id), "details"],
() => [API_ENDPOINTS.COMPANIES.READ_ALL, "details"],
[id],
);
return useQuery({
+22
View File
@@ -0,0 +1,22 @@
import { API_ENDPOINTS } from "@/lib/api";
import { feedbackService } from "@/lib/api/services/feedback-service";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
export const useGetFeedSingleFeedback = (id: string) => {
const queryKey = useMemo(() => [API_ENDPOINTS.FEEDBACK.DETAILS(id)], [id]);
return useQuery({
queryKey,
queryFn: () => feedbackService.getSingleFeedback(id),
});
};
export const useGetFeedback = (params: Record<string, any>) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.FEEDBACK.READ_ALL, params],
[params],
);
return useQuery({
queryKey,
queryFn: () => feedbackService.getFeedbacks(params),
});
};
+5
View File
@@ -45,4 +45,9 @@ export const API_ENDPOINTS = {
ASSIGN_COMPANY_TO_CUSTOMER: (id: string) =>
`customers/${id}/companies/assign`,
},
FEEDBACK: {
READ_ALL: "feedback",
DETAILS: (id: string) => `feedback/${id}`,
DELETE: (id: string) => `feedback/${id}`,
},
} as const;
+30
View File
@@ -0,0 +1,30 @@
import api from "../axios";
import { API_ENDPOINTS } from "../endpoints";
import { ApiResponse } from "../types";
import { TFeedbackResponse } from "../types/Feedback";
export const feedbackService = {
getFeedbacks: async (
params: Record<string, any>,
): Promise<TFeedbackResponse> => {
try {
return (await api.get(API_ENDPOINTS.FEEDBACK.READ_ALL, { params })).data;
} catch (error) {
console.error("ERROR Caught in feedback service => getFeedbacks", error);
throw error;
}
},
getSingleFeedback: async (
id: string,
): Promise<ApiResponse<TFeedbackResponse>> => {
try {
return (await api.get(API_ENDPOINTS.FEEDBACK.DETAILS(id))).data;
} catch (error) {
console.error(
"ERROR Caught in feedback service => getSingleFeedback",
error,
);
throw error;
}
},
};
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod";
import { TenderSchema } from "./Tenders";
export const feedbackSchema = z.object({
company_id: z.string(),
created_at: z.number(),
customer_id: z.string(),
feedback_type: z.string(),
id: z.string(),
tender: TenderSchema.or(z.null()),
updated_at: z.number(),
});
export const feedbackResponseSchema = z.object({
data: z.array(feedbackSchema),
});
export type TFeedback = z.infer<typeof feedbackSchema>;
export type TFeedbackResponse = z.infer<typeof feedbackResponseSchema>;
+1 -1
View File
@@ -52,7 +52,7 @@ export interface ICreateCompanyCredentials {
export const companySchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email().optional(),
email: z.string().optional(),
phone: z.string().optional(),
website: z.string(),
type: z.string(),
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
const TenderSchema = z.object({
export const TenderSchema = z.object({
id: z.string(),
buyer_organization: z.object({
name: z.string(),