refactor(tender-details): enhance translation and summary features
- Integrated language selection and translation functionality in TenderDetails, allowing users to choose display language and translate tender metadata. - Added AI summary section to display overall summary of the tender. - Updated API service to support translation requests and handle responses effectively. - Improved pagination handling in AdminsTable for better user experience. - Refactored various components to ensure consistent data flow and state management.
This commit is contained in:
@@ -0,0 +1,145 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
const tenderId = "507f1f77bcf86cd799439055";
|
||||||
|
|
||||||
|
const makeTenderDetailsResponse = (
|
||||||
|
overrides: Partial<Record<string, any>> = {},
|
||||||
|
) => ({
|
||||||
|
success: true,
|
||||||
|
message: "ok",
|
||||||
|
data: {
|
||||||
|
id: tenderId,
|
||||||
|
buyer_organization: { name: "ACME Procurement" },
|
||||||
|
application_deadline: 1710001000,
|
||||||
|
country_code: "IR",
|
||||||
|
currency: "USD",
|
||||||
|
description: "Original description",
|
||||||
|
duration: "12",
|
||||||
|
duration_unit: "month",
|
||||||
|
estimated_value: 100000,
|
||||||
|
notice_publication_id: "NP-123",
|
||||||
|
procedure_code: "OPEN",
|
||||||
|
procurement_type_code: "GOODS",
|
||||||
|
publication_date: 1710000000,
|
||||||
|
status: "open",
|
||||||
|
submission_deadline: 1710002000,
|
||||||
|
submission_url: "https://example.com/submit",
|
||||||
|
tender_deadline: 1710003000,
|
||||||
|
created_at: 1710000000,
|
||||||
|
tender_id: "TDR-9001",
|
||||||
|
title: "Original title",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tender details - translation and summary", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.setAuthCookies();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders details with summary fallback and translated metadata", () => {
|
||||||
|
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, (req) => {
|
||||||
|
expect(req.query.lang).to.eq("en");
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: makeTenderDetailsResponse({
|
||||||
|
title: "Translated title EN",
|
||||||
|
description: "Translated description EN",
|
||||||
|
language: "en",
|
||||||
|
overall_summary: undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}).as("getTenderDetailsEn");
|
||||||
|
|
||||||
|
cy.visit(`/tenders/${tenderId}?lang=en`);
|
||||||
|
cy.wait("@getTenderDetailsEn");
|
||||||
|
|
||||||
|
cy.contains("h1", "Translated title EN").should("be.visible");
|
||||||
|
cy.contains("Resolved language").should("be.visible");
|
||||||
|
cy.contains("en").should("be.visible");
|
||||||
|
cy.contains("AI summary").should("be.visible");
|
||||||
|
cy.contains("Summary not ready yet").should("be.visible");
|
||||||
|
cy.contains("Translated description EN").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches language from selector and reloads details with new lang query", () => {
|
||||||
|
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, (req) => {
|
||||||
|
const lang = String(req.query.lang ?? "en");
|
||||||
|
req.alias = lang === "fr" ? "getTenderDetailsFr" : "getTenderDetailsEn";
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: makeTenderDetailsResponse({
|
||||||
|
title: lang === "fr" ? "Titre FR" : "Title EN",
|
||||||
|
description: lang === "fr" ? "Description FR" : "Description EN",
|
||||||
|
language: lang,
|
||||||
|
overall_summary: lang === "fr" ? "Resume FR" : "Summary EN",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.visit(`/tenders/${tenderId}?lang=en`);
|
||||||
|
cy.wait("@getTenderDetailsEn");
|
||||||
|
|
||||||
|
cy.get('select[name="lang"]').select("fr");
|
||||||
|
cy.location("search").should("include", "lang=fr");
|
||||||
|
cy.wait("@getTenderDetailsFr");
|
||||||
|
cy.contains("h1", "Titre FR").should("be.visible");
|
||||||
|
cy.contains("Resume FR").should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers translate endpoint with selected language", () => {
|
||||||
|
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: makeTenderDetailsResponse({
|
||||||
|
title: "Before translate",
|
||||||
|
description: "Before translate description",
|
||||||
|
language: "de",
|
||||||
|
}),
|
||||||
|
}).as("getTenderDetails");
|
||||||
|
|
||||||
|
cy.intercept(
|
||||||
|
"POST",
|
||||||
|
`**/api/proxy/tenders/${tenderId}/ai-translate`,
|
||||||
|
(req) => {
|
||||||
|
expect(req.body).to.deep.equal({ language: "de" });
|
||||||
|
req.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
success: true,
|
||||||
|
message: "ok",
|
||||||
|
data: {
|
||||||
|
notice_id: "NP-123",
|
||||||
|
language: "de",
|
||||||
|
translated_title: "Ubersetzter Titel",
|
||||||
|
translated_description: "Ubersetzte Beschreibung",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
).as("translateTender");
|
||||||
|
|
||||||
|
cy.visit(`/tenders/${tenderId}?lang=de`);
|
||||||
|
cy.wait("@getTenderDetails");
|
||||||
|
cy.contains("button", "Translate metadata").click();
|
||||||
|
cy.wait("@translateTender");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects legacy /translation URL to tender details with lang preserved", () => {
|
||||||
|
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: makeTenderDetailsResponse({
|
||||||
|
title: "Redirect check",
|
||||||
|
language: "fa",
|
||||||
|
}),
|
||||||
|
}).as("getTenderAfterRedirect");
|
||||||
|
|
||||||
|
cy.visit(`/tenders/${tenderId}/translation?lang=fa`);
|
||||||
|
cy.location("pathname", { timeout: 15000 }).should(
|
||||||
|
"eq",
|
||||||
|
`/tenders/${tenderId}`,
|
||||||
|
);
|
||||||
|
cy.location("search").should("include", "lang=fa");
|
||||||
|
cy.wait("@getTenderAfterRedirect");
|
||||||
|
cy.contains("h1", "Redirect check").should("be.visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Loading from "@/components/loading";
|
|
||||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||||
import CreateAdminForm from "@/components/forms/admins/CreateAdmin";
|
import CreateAdminForm from "@/components/forms/admins/CreateAdmin";
|
||||||
|
import Loading from "@/components/loading";
|
||||||
import { useAdminDetails } from "@/hooks/queries";
|
import { useAdminDetails } from "@/hooks/queries";
|
||||||
import React, { use } from "react";
|
import { use } from "react";
|
||||||
|
|
||||||
const AdminEditPage = ({ params }: { params: Promise<{ id: string }> }) => {
|
const AdminEditPage = ({ params }: { params: Promise<{ id: string }> }) => {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { LocationIcon } from "@/assets/icons";
|
import { LocationIcon } from "@/assets/icons";
|
||||||
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
|
||||||
|
import { Select } from "@/components/FormElements/select";
|
||||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||||
import Loading from "@/components/loading";
|
import Loading from "@/components/loading";
|
||||||
import Status from "@/components/ui/Status";
|
import Status from "@/components/ui/Status";
|
||||||
import { useTenderDetailQuery } from "@/hooks/queries";
|
import { Button } from "@/components/ui-elements/button";
|
||||||
|
import { Languages } from "@/constants/languages";
|
||||||
|
import {
|
||||||
|
useTenderDetailQuery,
|
||||||
|
useTranslateTenderMutation,
|
||||||
|
} from "@/hooks/queries";
|
||||||
import { useGetFlagQuery } from "@/hooks/queries/useFlagsQueries";
|
import { useGetFlagQuery } from "@/hooks/queries/useFlagsQueries";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +20,7 @@ import {
|
|||||||
} from "@/utils/shared";
|
} from "@/utils/shared";
|
||||||
import getSymbolFromCurrency from "currency-symbol-map";
|
import getSymbolFromCurrency from "currency-symbol-map";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { use } from "react";
|
import { use } from "react";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -64,7 +71,15 @@ const SHOWCASE_CENTERED_BODY = {
|
|||||||
|
|
||||||
const TenderDetails = ({ params }: IProps) => {
|
const TenderDetails = ({ params }: IProps) => {
|
||||||
const { details } = use(params);
|
const { details } = use(params);
|
||||||
const { data, isLoading, isError } = useTenderDetailQuery(details);
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const selectedLanguage = searchParams.get("lang") || "en";
|
||||||
|
const { mutate: translateTender, isPending: isTranslating } =
|
||||||
|
useTranslateTenderMutation();
|
||||||
|
const { data, isLoading, isError, refetch } = useTenderDetailQuery(details, {
|
||||||
|
lang: selectedLanguage,
|
||||||
|
});
|
||||||
const countryCodeNormalized =
|
const countryCodeNormalized =
|
||||||
data?.data.country_code?.trim().toUpperCase() ?? "";
|
data?.data.country_code?.trim().toUpperCase() ?? "";
|
||||||
const hasValidCountryCodeForDisplay =
|
const hasValidCountryCodeForDisplay =
|
||||||
@@ -130,21 +145,71 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
{tender.title}
|
{tender.title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
|
<div className="flex w-full min-w-0 shrink-0 flex-col gap-3 lg:max-w-2xl lg:items-end">
|
||||||
<Status status={tender.status ?? "Unknown"}>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-dark-5 dark:text-dark-6 lg:w-full lg:text-end">
|
||||||
{tender.status}
|
Language & translation
|
||||||
</Status>
|
</p>
|
||||||
{submissionUrl && (
|
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
||||||
<Link
|
<Select
|
||||||
href={submissionUrl}
|
name="lang"
|
||||||
target="_blank"
|
label="Display language"
|
||||||
rel="noopener noreferrer"
|
items={Languages}
|
||||||
className="shadow-theme-xs inline-flex items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:bg-primary/90 hover:shadow-md"
|
className={cn(
|
||||||
>
|
"w-full shrink-0 space-y-0 sm:w-[min(100%,11.5rem)]",
|
||||||
<span>Open submission</span>
|
"[&_label]:sr-only",
|
||||||
<ExternalLinkIcon className="opacity-90" />
|
)}
|
||||||
</Link>
|
value={selectedLanguage}
|
||||||
)}
|
onChange={async (event) => {
|
||||||
|
const nextParams = new URLSearchParams(
|
||||||
|
searchParams.toString(),
|
||||||
|
);
|
||||||
|
const nextLanguage = event.target.value;
|
||||||
|
if (nextLanguage) {
|
||||||
|
nextParams.set("lang", nextLanguage);
|
||||||
|
} else {
|
||||||
|
nextParams.delete("lang");
|
||||||
|
}
|
||||||
|
const queryString = nextParams.toString();
|
||||||
|
router.replace(
|
||||||
|
queryString ? `${pathname}?${queryString}` : pathname,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
shape="rounded"
|
||||||
|
className="w-full shrink-0 whitespace-nowrap sm:w-auto sm:min-h-[3.375rem]"
|
||||||
|
label={isTranslating ? "Translating..." : "Translate metadata"}
|
||||||
|
disabled={isTranslating}
|
||||||
|
onClick={() => {
|
||||||
|
translateTender(
|
||||||
|
{ id: details, language: selectedLanguage },
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex shrink-0 items-center sm:min-h-[3.375rem]">
|
||||||
|
<Status status={tender.status ?? "Unknown"}>
|
||||||
|
{tender.status}
|
||||||
|
</Status>
|
||||||
|
</div>
|
||||||
|
{submissionUrl && (
|
||||||
|
<Link
|
||||||
|
href={submissionUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="shadow-theme-xs inline-flex min-h-[3.375rem] w-full shrink-0 items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:bg-primary/90 hover:shadow-md sm:w-auto"
|
||||||
|
>
|
||||||
|
<span>Open submission</span>
|
||||||
|
<ExternalLinkIcon className="opacity-90" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -332,6 +397,20 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionHeading>Translation</SectionHeading>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 p-6 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-dark-5 dark:text-dark-6">
|
||||||
|
Resolved language
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-dark dark:text-white">
|
||||||
|
{tender.language || "original"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading>Description</SectionHeading>
|
<SectionHeading>Description</SectionHeading>
|
||||||
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 p-6 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
|
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 p-6 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
|
||||||
@@ -340,6 +419,15 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionHeading>AI summary</SectionHeading>
|
||||||
|
<div className="rounded-2xl border border-stroke/60 bg-gray-2/40 p-6 shadow-inner dark:border-dark-3 dark:bg-dark-3/30">
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-dark dark:text-white/90">
|
||||||
|
{tender.overall_summary || "Summary not ready yet"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { PencilSquareIcon, TrashIcon, UserSettingIcon } from "@/assets/icons";
|
import { PencilSquareIcon, TrashIcon, UserSettingIcon } from "@/assets/icons";
|
||||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||||
|
import IsVisible from "@/components/ui/IsVisible";
|
||||||
import ListHeader from "@/components/ui/ListHeader";
|
import ListHeader from "@/components/ui/ListHeader";
|
||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import Modal from "@/components/ui/modal";
|
import Modal from "@/components/ui/modal";
|
||||||
|
import Pagination from "@/components/ui/pagination";
|
||||||
import Status from "@/components/ui/Status";
|
import Status from "@/components/ui/Status";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -48,6 +50,9 @@ const AdminsTable = () => {
|
|||||||
filterRegister,
|
filterRegister,
|
||||||
isMutating,
|
isMutating,
|
||||||
watch,
|
watch,
|
||||||
|
metadata,
|
||||||
|
setParams,
|
||||||
|
shouldShowPagination,
|
||||||
} = useAdminsPresenter();
|
} = useAdminsPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -77,6 +82,7 @@ const AdminsTable = () => {
|
|||||||
list={allUsers}
|
list={allUsers}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No admins were found"
|
emptyMessage="No admins were found"
|
||||||
|
isLoading={isPending}
|
||||||
>
|
>
|
||||||
{allUsers.map((item, index) => (
|
{allUsers.map((item, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -85,7 +91,11 @@ const AdminsTable = () => {
|
|||||||
data-cy={`admins-table-row-${item?.id ?? index}`}
|
data-cy={`admins-table-row-${item?.id ?? index}`}
|
||||||
>
|
>
|
||||||
<TableCell className="text-start" colSpan={100} data-cy="admins-row-number">
|
<TableCell className="text-start" colSpan={100} data-cy="admins-row-number">
|
||||||
{getPaginatedRowNumber({ index })}
|
{getPaginatedRowNumber({
|
||||||
|
index,
|
||||||
|
page: metadata?.page,
|
||||||
|
limit: metadata?.limit,
|
||||||
|
})}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100} data-cy="admins-row-full-name">
|
<TableCell className="text-start" colSpan={100} data-cy="admins-row-full-name">
|
||||||
{item?.full_name}
|
{item?.full_name}
|
||||||
@@ -200,6 +210,19 @@ const AdminsTable = () => {
|
|||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<IsVisible condition={shouldShowPagination}>
|
||||||
|
<Pagination
|
||||||
|
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
||||||
|
totalPages={metadata?.pages ?? 1}
|
||||||
|
onPageChange={(e) => {
|
||||||
|
setParams((currentParams) => ({
|
||||||
|
...currentParams,
|
||||||
|
offset: e.selected * (metadata?.limit ?? 10),
|
||||||
|
limit: metadata?.limit ?? 10,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</IsVisible>
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export const useAdminsPresenter = () => {
|
|||||||
setParams(newParams);
|
setParams(newParams);
|
||||||
filterFormReset(newParams);
|
filterFormReset(newParams);
|
||||||
}, [searchParams, filterFormReset]);
|
}, [searchParams, filterFormReset]);
|
||||||
const { data, isPending } = useGetAdminListQuery(params);
|
const { data, isPending } = useGetAdminListQuery({ ...params, ...apiDefaultParams });
|
||||||
|
const shouldShowPagination =
|
||||||
|
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||||
const { mutate: deleteAdmin } = useDeleteAdmin(() => {
|
const { mutate: deleteAdmin } = useDeleteAdmin(() => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
});
|
});
|
||||||
@@ -121,5 +123,6 @@ export const useAdminsPresenter = () => {
|
|||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
|
shouldShowPagination,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Select } from "@/components/FormElements/select";
|
|||||||
import { Button } from "@/components/ui-elements/button";
|
import { Button } from "@/components/ui-elements/button";
|
||||||
import { Countries } from "@/constants/countries";
|
import { Countries } from "@/constants/countries";
|
||||||
import { TenderStatus } from "@/constants/enums";
|
import { TenderStatus } from "@/constants/enums";
|
||||||
|
import { Languages } from "@/constants/languages";
|
||||||
import { getEnumAsArray } from "@/utils/shared";
|
import { getEnumAsArray } from "@/utils/shared";
|
||||||
import {
|
import {
|
||||||
FieldValues,
|
FieldValues,
|
||||||
@@ -63,6 +64,16 @@ const TenderListFilters = ({
|
|||||||
value={watch("country_code")}
|
value={watch("country_code")}
|
||||||
onClear={() => setValue("country_code", undefined)}
|
onClear={() => setValue("country_code", undefined)}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
{...filterRegister("lang")}
|
||||||
|
items={Languages}
|
||||||
|
label="language"
|
||||||
|
name="lang"
|
||||||
|
placeholder="Language"
|
||||||
|
clearable
|
||||||
|
value={watch("lang")}
|
||||||
|
onClear={() => setValue("lang", undefined)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="col-span-2 flex gap-4">
|
<div className="col-span-2 flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const TendersTable = () => {
|
|||||||
router,
|
router,
|
||||||
pathName,
|
pathName,
|
||||||
setParams,
|
setParams,
|
||||||
|
params,
|
||||||
columns,
|
columns,
|
||||||
isFilterModalOpen,
|
isFilterModalOpen,
|
||||||
setIsFilterModalOpen,
|
setIsFilterModalOpen,
|
||||||
@@ -119,7 +120,10 @@ const TendersTable = () => {
|
|||||||
data-tooltip-content="Details"
|
data-tooltip-content="Details"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`${pathName}/${item.id}`);
|
const queryString = params?.lang
|
||||||
|
? `?lang=${encodeURIComponent(params.lang)}`
|
||||||
|
: "";
|
||||||
|
router.push(`${pathName}/${item.id}${queryString}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -135,7 +139,10 @@ const TendersTable = () => {
|
|||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
|
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`${pathName}/feedback/${item.id}`);
|
const queryString = params?.lang
|
||||||
|
? `?lang=${encodeURIComponent(params.lang)}`
|
||||||
|
: "";
|
||||||
|
router.push(`${pathName}/feedback/${item.id}${queryString}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const useTenderListPresenter = () => {
|
|||||||
...apiDefaultParams,
|
...apiDefaultParams,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
lang: "en",
|
||||||
};
|
};
|
||||||
for (const [key, value] of searchParams.entries()) {
|
for (const [key, value] of searchParams.entries()) {
|
||||||
newParams[key] = value;
|
newParams[key] = value;
|
||||||
@@ -73,6 +74,7 @@ const useTenderListPresenter = () => {
|
|||||||
error,
|
error,
|
||||||
isPending,
|
isPending,
|
||||||
setParams,
|
setParams,
|
||||||
|
params,
|
||||||
data,
|
data,
|
||||||
pathName,
|
pathName,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ const useCreateAdminPresenter = ({
|
|||||||
id,
|
id,
|
||||||
}: IProps) => {
|
}: IProps) => {
|
||||||
const [passwordFieldInputType, setPasswordFieldInputType] = useState<
|
const [passwordFieldInputType, setPasswordFieldInputType] = useState<
|
||||||
"password" | "text"
|
"password" | "text"
|
||||||
>("password");
|
>("password");
|
||||||
const isMutating = useIsMutating();
|
const isMutating = useIsMutating();
|
||||||
const [selectedFileName, setSelectedFileName] = useState<string>("");
|
const [selectedFileName, setSelectedFileName] = useState<string>("");
|
||||||
const [selectedFileSize, setSelectedFileSize] = useState<number | null>(null);
|
const [selectedFileSize, setSelectedFileSize] = useState<number | null>(null);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const apiDefaultParams = {
|
export const apiDefaultParams = {
|
||||||
sort_by: "created_at",
|
sort_by: "created_at",
|
||||||
sort_order: "asc",
|
sort_order: "dsc",
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { API_ENDPOINTS, tendersService } from "@/lib/api";
|
import { API_ENDPOINTS, tendersService } from "@/lib/api";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const useTendersQuery = (params?: Record<string, any>) => {
|
export const useTendersQuery = (params?: Record<string, any>) => {
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
@@ -14,10 +15,36 @@ export const useTendersQuery = (params?: Record<string, any>) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useTenderDetailQuery = (id: string) => {
|
export const useTenderDetailQuery = (id: string, params?: Record<string, any>) => {
|
||||||
const queryKey = useMemo(() => [API_ENDPOINTS.TENDERS.DETAILS(id)], [id]);
|
const queryKey = useMemo(
|
||||||
|
() => [API_ENDPOINTS.TENDERS.DETAILS(id), params],
|
||||||
|
[id, params],
|
||||||
|
);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: () => tendersService.tenderDetails(id),
|
queryFn: () => tendersService.tenderDetails(id, params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTranslateTenderMutation = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationKey: [API_ENDPOINTS.TENDERS.AI_TRANSLATE("translate")],
|
||||||
|
mutationFn: tendersService.translateTender,
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
toast.success(`Translation for "${variables.language}" is ready`);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [API_ENDPOINTS.TENDERS.READ_ALL],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [API_ENDPOINTS.TENDERS.DETAILS(variables.id)],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(
|
||||||
|
"Translation temporarily unavailable. Showing original content.",
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export const API_ENDPOINTS = {
|
|||||||
UPDATE: (id: string) => `tenders/${id}`,
|
UPDATE: (id: string) => `tenders/${id}`,
|
||||||
DELETE: (id: string) => `tenders/${id}`,
|
DELETE: (id: string) => `tenders/${id}`,
|
||||||
DETAILS: (id: string) => `tenders/${id}`,
|
DETAILS: (id: string) => `tenders/${id}`,
|
||||||
|
AI_TRANSLATE: (id: string) => `tenders/${id}/ai-translate`,
|
||||||
|
AI_SUMMARY: (id: string) => `tenders/${id}/ai-summary`,
|
||||||
},
|
},
|
||||||
CUSTOMERS: {
|
CUSTOMERS: {
|
||||||
READ_ALL: "customers",
|
READ_ALL: "customers",
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import api from "../axios";
|
import api from "../axios";
|
||||||
import { API_ENDPOINTS } from "../endpoints";
|
import { API_ENDPOINTS } from "../endpoints";
|
||||||
import { ApiResponse } from "../types";
|
import { ApiResponse } from "../types";
|
||||||
import { TTenderDetails, TTenderResponse } from "../types/Tenders";
|
import {
|
||||||
|
TTenderDetails,
|
||||||
|
TTenderResponse,
|
||||||
|
TTenderTranslationResponse,
|
||||||
|
} from "../types/Tenders";
|
||||||
|
|
||||||
export const tendersService = {
|
export const tendersService = {
|
||||||
tendersList: async (
|
tendersList: async (
|
||||||
@@ -14,9 +18,12 @@ export const tendersService = {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tenderDetails: async (id: string): Promise<ApiResponse<TTenderDetails>> => {
|
tenderDetails: async (
|
||||||
|
id: string,
|
||||||
|
params?: Record<string, any>,
|
||||||
|
): Promise<ApiResponse<TTenderDetails>> => {
|
||||||
try {
|
try {
|
||||||
return (await api.get(API_ENDPOINTS.TENDERS.DETAILS(id))).data;
|
return (await api.get(API_ENDPOINTS.TENDERS.DETAILS(id), { params })).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
"ERROR caught in Tender Service => tender details: ",
|
"ERROR caught in Tender Service => tender details: ",
|
||||||
@@ -25,4 +32,22 @@ export const tendersService = {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
translateTender: async ({
|
||||||
|
id,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
language: string;
|
||||||
|
}): Promise<ApiResponse<TTenderTranslationResponse>> => {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
await api.post(API_ENDPOINTS.TENDERS.AI_TRANSLATE(id), {
|
||||||
|
language,
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ERROR caught in Tender Service => translate tender: ", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,9 +23,21 @@ export const TenderSchema = z.object({
|
|||||||
created_at: z.number().optional(),
|
created_at: z.number().optional(),
|
||||||
tender_id: z.string(),
|
tender_id: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
|
language: z.string().optional(),
|
||||||
|
overall_summary: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TenderTranslationResponseSchema = z.object({
|
||||||
|
notice_id: z.string(),
|
||||||
|
language: z.string(),
|
||||||
|
translated_title: z.string(),
|
||||||
|
translated_description: z.string(),
|
||||||
});
|
});
|
||||||
export const TendersResponse = z.object({
|
export const TendersResponse = z.object({
|
||||||
tenders: z.array(TenderSchema).or(z.null()),
|
tenders: z.array(TenderSchema).or(z.null()),
|
||||||
});
|
});
|
||||||
export type TTenderResponse = z.infer<typeof TendersResponse>;
|
export type TTenderResponse = z.infer<typeof TendersResponse>;
|
||||||
export type TTenderDetails = z.infer<typeof TenderSchema>;
|
export type TTenderDetails = z.infer<typeof TenderSchema>;
|
||||||
|
export type TTenderTranslationResponse = z.infer<
|
||||||
|
typeof TenderTranslationResponseSchema
|
||||||
|
>;
|
||||||
|
|||||||
Reference in New Issue
Block a user