diff --git a/cypress/e2e/tenders/tender-translation-page.cy.ts b/cypress/e2e/tenders/tender-translation-page.cy.ts new file mode 100644 index 0000000..ec6bcf5 --- /dev/null +++ b/cypress/e2e/tenders/tender-translation-page.cy.ts @@ -0,0 +1,145 @@ +/// + +const tenderId = "507f1f77bcf86cd799439055"; + +const makeTenderDetailsResponse = ( + overrides: Partial> = {}, +) => ({ + 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"); + }); +}); diff --git a/src/app/admins/edit/[id]/page.tsx b/src/app/admins/edit/[id]/page.tsx index 6864d6f..d967b2d 100644 --- a/src/app/admins/edit/[id]/page.tsx +++ b/src/app/admins/edit/[id]/page.tsx @@ -1,10 +1,10 @@ "use client"; -import Loading from "@/components/loading"; import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; import CreateAdminForm from "@/components/forms/admins/CreateAdmin"; +import Loading from "@/components/loading"; import { useAdminDetails } from "@/hooks/queries"; -import React, { use } from "react"; +import { use } from "react"; const AdminEditPage = ({ params }: { params: Promise<{ id: string }> }) => { const { id } = use(params); diff --git a/src/app/tenders/[details]/page.tsx b/src/app/tenders/[details]/page.tsx index 1715592..d50b759 100644 --- a/src/app/tenders/[details]/page.tsx +++ b/src/app/tenders/[details]/page.tsx @@ -1,10 +1,16 @@ "use client"; import { LocationIcon } from "@/assets/icons"; import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; +import { Select } from "@/components/FormElements/select"; import { ShowcaseSection } from "@/components/Layouts/showcase-section"; import Loading from "@/components/loading"; 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 { cn } from "@/lib/utils"; import { @@ -14,6 +20,7 @@ import { } from "@/utils/shared"; import getSymbolFromCurrency from "currency-symbol-map"; import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { use } from "react"; interface IProps { @@ -64,7 +71,15 @@ const SHOWCASE_CENTERED_BODY = { const TenderDetails = ({ params }: IProps) => { 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 = data?.data.country_code?.trim().toUpperCase() ?? ""; const hasValidCountryCodeForDisplay = @@ -130,21 +145,71 @@ const TenderDetails = ({ params }: IProps) => { {tender.title} - - - {tender.status} - - {submissionUrl && ( - - Open submission - - - )} + + + Language & translation + + + { + 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, + ); + }} + /> + { + translateTender( + { id: details, language: selectedLanguage }, + { + onSuccess: async () => { + await refetch(); + }, + }, + ); + }} + /> + + + {tender.status} + + + {submissionUrl && ( + + Open submission + + + )} + } @@ -332,6 +397,20 @@ const TenderDetails = ({ params }: IProps) => { )} + + Translation + + + + Resolved language + + + {tender.language || "original"} + + + + + Description @@ -340,6 +419,15 @@ const TenderDetails = ({ params }: IProps) => { + + + AI summary + + + {tender.overall_summary || "Summary not ready yet"} + + + > diff --git a/src/components/Tables/admins/index.tsx b/src/components/Tables/admins/index.tsx index b1815dc..9d03eae 100644 --- a/src/components/Tables/admins/index.tsx +++ b/src/components/Tables/admins/index.tsx @@ -3,9 +3,11 @@ import { PencilSquareIcon, TrashIcon, UserSettingIcon } from "@/assets/icons"; import EmptyListWrapper from "@/components/HOC/EmptyListWrapper"; import ConfirmationModal from "@/components/ui/ConfirmationModal"; +import IsVisible from "@/components/ui/IsVisible"; import ListHeader from "@/components/ui/ListHeader"; import ListWrapper from "@/components/ui/ListWrapper"; import Modal from "@/components/ui/modal"; +import Pagination from "@/components/ui/pagination"; import Status from "@/components/ui/Status"; import { Table, @@ -48,6 +50,9 @@ const AdminsTable = () => { filterRegister, isMutating, watch, + metadata, + setParams, + shouldShowPagination, } = useAdminsPresenter(); return ( @@ -77,6 +82,7 @@ const AdminsTable = () => { list={allUsers} columns={columns} emptyMessage="No admins were found" + isLoading={isPending} > {allUsers.map((item, index) => ( { data-cy={`admins-table-row-${item?.id ?? index}`} > - {getPaginatedRowNumber({ index })} + {getPaginatedRowNumber({ + index, + page: metadata?.page, + limit: metadata?.limit, + })} {item?.full_name} @@ -200,6 +210,19 @@ const AdminsTable = () => { onCancel={() => setIsModalOpen(false)} /> )} + + { + setParams((currentParams) => ({ + ...currentParams, + offset: e.selected * (metadata?.limit ?? 10), + limit: metadata?.limit ?? 10, + })); + }} + /> + ); }; diff --git a/src/components/Tables/admins/useAdminsPresenter.ts b/src/components/Tables/admins/useAdminsPresenter.ts index bea88fc..2d8e006 100644 --- a/src/components/Tables/admins/useAdminsPresenter.ts +++ b/src/components/Tables/admins/useAdminsPresenter.ts @@ -58,7 +58,9 @@ export const useAdminsPresenter = () => { setParams(newParams); filterFormReset(newParams); }, [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(() => { setIsModalOpen(false); }); @@ -121,5 +123,6 @@ export const useAdminsPresenter = () => { handleFilterSubmit, search, watch, + shouldShowPagination, }; }; diff --git a/src/components/Tables/tenders/TenderListFilters.tsx b/src/components/Tables/tenders/TenderListFilters.tsx index 49c0a9d..fee6a51 100644 --- a/src/components/Tables/tenders/TenderListFilters.tsx +++ b/src/components/Tables/tenders/TenderListFilters.tsx @@ -3,6 +3,7 @@ import { Select } from "@/components/FormElements/select"; import { Button } from "@/components/ui-elements/button"; import { Countries } from "@/constants/countries"; import { TenderStatus } from "@/constants/enums"; +import { Languages } from "@/constants/languages"; import { getEnumAsArray } from "@/utils/shared"; import { FieldValues, @@ -63,6 +64,16 @@ const TenderListFilters = ({ value={watch("country_code")} onClear={() => setValue("country_code", undefined)} /> + setValue("lang", undefined)} + /> { router, pathName, setParams, + params, columns, isFilterModalOpen, setIsFilterModalOpen, @@ -119,7 +120,10 @@ const TendersTable = () => { data-tooltip-content="Details" data-tooltip-place="top" onClick={() => { - router.push(`${pathName}/${item.id}`); + const queryString = params?.lang + ? `?lang=${encodeURIComponent(params.lang)}` + : ""; + router.push(`${pathName}/${item.id}${queryString}`); }} > { 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" onClick={() => { - router.push(`${pathName}/feedback/${item.id}`); + const queryString = params?.lang + ? `?lang=${encodeURIComponent(params.lang)}` + : ""; + router.push(`${pathName}/feedback/${item.id}${queryString}`); }} > diff --git a/src/components/Tables/tenders/useTenderListPresenter.ts b/src/components/Tables/tenders/useTenderListPresenter.ts index db4f671..85859de 100644 --- a/src/components/Tables/tenders/useTenderListPresenter.ts +++ b/src/components/Tables/tenders/useTenderListPresenter.ts @@ -34,6 +34,7 @@ const useTenderListPresenter = () => { ...apiDefaultParams, offset: 0, limit: 10, + lang: "en", }; for (const [key, value] of searchParams.entries()) { newParams[key] = value; @@ -73,6 +74,7 @@ const useTenderListPresenter = () => { error, isPending, setParams, + params, data, pathName, isModalOpen, diff --git a/src/components/forms/admins/useCreateAdminPresenter.ts b/src/components/forms/admins/useCreateAdminPresenter.ts index d05e834..61c8216 100644 --- a/src/components/forms/admins/useCreateAdminPresenter.ts +++ b/src/components/forms/admins/useCreateAdminPresenter.ts @@ -23,8 +23,8 @@ const useCreateAdminPresenter = ({ id, }: IProps) => { const [passwordFieldInputType, setPasswordFieldInputType] = useState< - "password" | "text" ->("password"); + "password" | "text" + >("password"); const isMutating = useIsMutating(); const [selectedFileName, setSelectedFileName] = useState(""); const [selectedFileSize, setSelectedFileSize] = useState(null); diff --git a/src/constants/Api_Params.ts b/src/constants/Api_Params.ts index 7505e1e..5b57a74 100644 --- a/src/constants/Api_Params.ts +++ b/src/constants/Api_Params.ts @@ -1,6 +1,6 @@ export const apiDefaultParams = { sort_by: "created_at", - sort_order: "asc", + sort_order: "dsc", offset: 0, limit: 10, }; diff --git a/src/hooks/queries/useTenders.ts b/src/hooks/queries/useTenders.ts index 4dd775b..bb3efd1 100644 --- a/src/hooks/queries/useTenders.ts +++ b/src/hooks/queries/useTenders.ts @@ -1,6 +1,7 @@ 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 { toast } from "react-toastify"; export const useTendersQuery = (params?: Record) => { const queryKey = useMemo( @@ -14,10 +15,36 @@ export const useTendersQuery = (params?: Record) => { }); }; -export const useTenderDetailQuery = (id: string) => { - const queryKey = useMemo(() => [API_ENDPOINTS.TENDERS.DETAILS(id)], [id]); +export const useTenderDetailQuery = (id: string, params?: Record) => { + const queryKey = useMemo( + () => [API_ENDPOINTS.TENDERS.DETAILS(id), params], + [id, params], + ); return useQuery({ 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.", + ); + }, }); }; diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index 3cfbe99..5b71f03 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -43,6 +43,8 @@ export const API_ENDPOINTS = { UPDATE: (id: string) => `tenders/${id}`, DELETE: (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: { READ_ALL: "customers", diff --git a/src/lib/api/services/tenders-service.ts b/src/lib/api/services/tenders-service.ts index 2383a9c..1bef8e9 100644 --- a/src/lib/api/services/tenders-service.ts +++ b/src/lib/api/services/tenders-service.ts @@ -1,7 +1,11 @@ import api from "../axios"; import { API_ENDPOINTS } from "../endpoints"; import { ApiResponse } from "../types"; -import { TTenderDetails, TTenderResponse } from "../types/Tenders"; +import { + TTenderDetails, + TTenderResponse, + TTenderTranslationResponse, +} from "../types/Tenders"; export const tendersService = { tendersList: async ( @@ -14,9 +18,12 @@ export const tendersService = { throw error; } }, - tenderDetails: async (id: string): Promise> => { + tenderDetails: async ( + id: string, + params?: Record, + ): Promise> => { try { - return (await api.get(API_ENDPOINTS.TENDERS.DETAILS(id))).data; + return (await api.get(API_ENDPOINTS.TENDERS.DETAILS(id), { params })).data; } catch (error) { console.error( "ERROR caught in Tender Service => tender details: ", @@ -25,4 +32,22 @@ export const tendersService = { throw error; } }, + translateTender: async ({ + id, + language, + }: { + id: string; + language: string; + }): Promise> => { + 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; + } + }, }; diff --git a/src/lib/api/types/Tenders.ts b/src/lib/api/types/Tenders.ts index 33e18b6..241cec4 100644 --- a/src/lib/api/types/Tenders.ts +++ b/src/lib/api/types/Tenders.ts @@ -23,9 +23,21 @@ export const TenderSchema = z.object({ created_at: z.number().optional(), tender_id: 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({ tenders: z.array(TenderSchema).or(z.null()), }); export type TTenderResponse = z.infer; export type TTenderDetails = z.infer; +export type TTenderTranslationResponse = z.infer< + typeof TenderTranslationResponseSchema +>;
+ Language & translation +
+ Resolved language +
+ {tender.language || "original"} +
+ {tender.overall_summary || "Summary not ready yet"} +