diff --git a/.env.example b/.env.example index 4570aeb2f..c6285f8a0 100644 --- a/.env.example +++ b/.env.example @@ -131,6 +131,10 @@ GOOGLE_CLIENT_SECRET="" # codebase. # VIGIEPROCURE_API_URL="https://api.vigieproc.fr" # VIGIEPROCURE_API_JWT="" +# +# Same pair also backs the Marches publics page (tenders.listOuverts -> +# GET /api/v1/tenders?status=active) -- one JWT, several read-only VigieProcure +# callers, cf. vigieprocure-tenders.client.ts. # PORT="3001" diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index d71d6153c..0f90d9307 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.17.0" + ".": "1.18.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ddeb02f8..e4bdb4f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.18.0](https://github.com/franckh-stack/crm/compare/v1.17.0...v1.18.0) (2026-09-13) + + +### Features + +* **tenders:** page CRM Marches publics via API VigieProcure scopee ([a140bfb](https://github.com/franckh-stack/crm/commit/a140bfbef39a4d918334c0e27cda2f539c33c4e6)) +* **tenders:** page CRM Marches publics via API VigieProcure scopee ([6c43130](https://github.com/franckh-stack/crm/commit/6c43130e60836d39c57db90e00843183ec53a4a0)) + ## [1.17.0](https://github.com/franckh-stack/crm/compare/v1.16.0...v1.17.0) (2026-09-12) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 82a345df2..ebbb044e5 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -33,6 +33,7 @@ import { SlackModule } from "./slack/slack.module"; import { SsoModule } from "./sso/sso.module"; import { SyncModule } from "./sync/sync.module"; import { TelemetryModule } from "./telemetry/telemetry.module"; +import { TendersModule } from "./tenders/tenders.module"; import { TrackingModule } from "./tracking/tracking.module"; import { TrpcModule } from "./trpc/trpc.module"; import { UsersModule } from "./users/users.module"; @@ -79,6 +80,7 @@ import { WorkspaceModule } from "./workspace/workspace.module"; TrackingModule, ArchiveModule, SavedViewsModule, + TendersModule, ], }) export class AppModule {} diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index 94311df02..c78a1c9ba 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -30,6 +30,7 @@ import { savedViewListInput, savedViewListOutput, savedViewCreateInput, savedVie import { agentModelOutput, modelCatalogOutput, setAgentModelInput, researchKeyOutput, setResearchKeyInput, archiveRetentionOutput, setArchiveRetentionDaysInput } from "../settings/settings.contracts"; import { slackStatusOutput, slackMatchesOutput, slackChannelsInput, slackChannelsOutput, slackJoinChannelInput, slackJoinChannelOutput, slackRefreshPeopleOutput, slackCreateChannelInput, slackCreateChannelOutput, slackDisconnectOutput } from "../slack/slack.contracts"; import { ssoSignInOptionsOutput, ssoSettingsOutput, ssoProviderListInput, ssoProviderListOutput, registerSsoProviderInput, ssoProviderOutput, deleteSsoProviderInput, deleteSsoProviderOutput } from "../sso/sso.contracts"; +import { tendersListOuvertsInput, tendersListOuvertsOutput } from "../tenders/tenders.contracts"; import { trackingSettingsOutput, trackingFlagInput, cookieLifetimeInput, addDomainInput, trackedDomainOutput, removeDomainInput, rotateSiteIdOutput, verifyInput, verifyOutput, sourcesOutput, companyActivityInput, websiteActivityOutput, contactActivityInput } from "../tracking/tracking.contracts"; import { workspaceOutput, memberListInput, memberListOutput, updateWorkspaceInput, setMemberRoleInput, workspaceMemberOutput } from "../workspace/workspace.contracts"; import type { UsersRouter } from "../users/users.router"; @@ -697,6 +698,12 @@ const appRouter = t.router({ .output(deleteSsoProviderOutput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), + tenders: t.router({ + listOuverts: publicProcedure + .input(tendersListOuvertsInput) + .output(tendersListOuvertsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) + }), tracking: t.router({ settings: publicProcedure .output(trackingSettingsOutput) diff --git a/apps/api/src/tenders/tenders.contracts.ts b/apps/api/src/tenders/tenders.contracts.ts new file mode 100644 index 000000000..fa7064bf4 --- /dev/null +++ b/apps/api/src/tenders/tenders.contracts.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +// Bornes calquees sur api_v2/routers/tenders.py::list_tenders -- valider ici +// evite d'expedier une requete que l'API VigieProcure refusera de toute +// facon, et donne une erreur Zod lisible cote CRM plutot qu'un 422 distant. +export const tendersListOuvertsInput = z.object({ + cpv: z.string().trim().min(2).max(400).optional(), + department: z.string().trim().min(1).max(3).optional(), + q: z.string().trim().min(2).max(200).optional(), + page: z.number().int().min(1).max(500).default(1), + limit: z.number().int().min(1).max(100).default(20), + cursor: z.string().optional(), +}); + +export type TendersListOuvertsInput = z.infer; + +const tenderOutput = z.object({ + id: z.string(), + source: z.string().nullable(), + sourceId: z.string().nullable(), + title: z.string().nullable(), + titleDisplay: z.string().nullable(), + cpvCode: z.string().nullable(), + procedureType: z.string().nullable(), + amountEstimated: z.number().nullable(), + department: z.string().nullable(), + publishedAt: z.string().nullable(), + deadlineAt: z.string().nullable(), + status: z.string().nullable(), + buyerId: z.string().nullable(), + buyerName: z.string().nullable(), + updatedAt: z.string().nullable(), +}); + +const tendersPageOutput = z.object({ + items: z.array(tenderOutput), + count: z.number(), + total: z.number(), + totalEstPlafonne: z.boolean(), + page: z.number(), + pageSize: z.number(), + nextCursor: z.string().nullable(), +}); + +export const tendersListOuvertsOutput = z.discriminatedUnion("outcome", [ + z.object({ outcome: z.literal("not-configured") }), + z.object({ outcome: z.literal("unauthorized"), reason: z.string() }), + z.object({ outcome: z.literal("failed"), reason: z.string() }), + z.object({ outcome: z.literal("ok"), page: tendersPageOutput }), +]); + +export type TendersListOuvertsOutput = z.infer; diff --git a/apps/api/src/tenders/tenders.module.ts b/apps/api/src/tenders/tenders.module.ts new file mode 100644 index 000000000..1908685c4 --- /dev/null +++ b/apps/api/src/tenders/tenders.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { TendersRouter } from "./tenders.router"; +import { TendersService } from "./tenders.service"; + +@Module({ + imports: [TrpcModule], + providers: [TendersService, TendersRouter], +}) +export class TendersModule {} diff --git a/apps/api/src/tenders/tenders.router.ts b/apps/api/src/tenders/tenders.router.ts new file mode 100644 index 000000000..ffbc1d03e --- /dev/null +++ b/apps/api/src/tenders/tenders.router.ts @@ -0,0 +1,27 @@ +import { Inject } from "@nestjs/common"; +import { Input, Query, Router, UseMiddlewares } from "nestjs-trpc"; +import type { z } from "zod"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; +import { + tendersListOuvertsInput, + tendersListOuvertsOutput, +} from "./tenders.contracts"; +import { TendersService } from "./tenders.service"; + +@Router({ alias: "tenders" }) +@UseMiddlewares(AuthMiddleware) +export class TendersRouter { + constructor( + @Inject(TendersService) private readonly tenders: TendersService, + ) {} + + @Query({ + input: tendersListOuvertsInput, + output: tendersListOuvertsOutput, + meta: restMeta("GET", "/tenders/ouverts", ["Tenders"]), + }) + async listOuverts(@Input() input: z.infer) { + return this.tenders.listOuverts(input); + } +} diff --git a/apps/api/src/tenders/tenders.service.ts b/apps/api/src/tenders/tenders.service.ts new file mode 100644 index 000000000..12b04a68c --- /dev/null +++ b/apps/api/src/tenders/tenders.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from "@nestjs/common"; +import type { TendersListOuvertsInput } from "./tenders.contracts"; +import { + listOpenTenders, + type TendersResult, +} from "./vigieprocure-tenders.client"; + +@Injectable() +export class TendersService { + /** + * Proxy en lecture seule vers l'API VigieProcure. Ne persiste rien -- + * c'est un simple relai, pas de table Prisma dediee (cf. doctrine du + * chantier "bouton CRM -> API VigieProcure marches ouverts"). + */ + async listOuverts(input: TendersListOuvertsInput): Promise { + return listOpenTenders({ + cpv: input.cpv, + department: input.department, + q: input.q, + page: input.page, + limit: input.limit, + cursor: input.cursor, + }); + } +} diff --git a/apps/api/src/tenders/vigieprocure-tenders.client.ts b/apps/api/src/tenders/vigieprocure-tenders.client.ts new file mode 100644 index 000000000..2b17d37e3 --- /dev/null +++ b/apps/api/src/tenders/vigieprocure-tenders.client.ts @@ -0,0 +1,185 @@ +import { z } from "zod"; + +const TENDERS_TIMEOUT_MS = 15_000; +const TENDERS_PATH = "/api/v1/tenders"; + +export interface VigieProcureTendersApi { + url: URL; + jwt: string; +} + +/** + * `VIGIEPROCURE_API_JWT` unset means there is no way to call VigieProcure, + * not an unauthenticated call to it -- same rule as `vigieProcureApi()` in + * `companies/vigieprocure-companies.client.ts` (resolveSiren/setSiren) and + * `bridge()`/`vigieProcureBridge()` elsewhere. Every caller has to say what + * it does without VigieProcure. + */ +export function vigieProcureTendersApi(): VigieProcureTendersApi | null { + const jwt = process.env.VIGIEPROCURE_API_JWT?.trim(); + const base = process.env.VIGIEPROCURE_API_URL?.trim(); + if (!jwt || !base) return null; + + return { url: new URL(TENDERS_PATH, base), jwt }; +} + +const tenderItem = z + .object({ + id: z.string(), + source: z.string().nullable().catch(null), + source_id: z.string().nullable().catch(null), + title: z.string().nullable().catch(null), + title_display: z.string().nullable().catch(null), + cpv_code: z.string().nullable().catch(null), + procedure_type: z.string().nullable().catch(null), + amount_estimated: z.number().nullable().catch(null), + department: z.string().nullable().catch(null), + published_at: z.string().nullable().catch(null), + deadline_at: z.string().nullable().catch(null), + status: z.string().nullable().catch(null), + buyer_id: z.string().nullable().catch(null), + buyer_name: z.string().nullable().catch(null), + updated_at: z.string().nullable().catch(null), + }) + .transform((raw) => ({ + id: raw.id, + source: raw.source, + sourceId: raw.source_id, + title: raw.title, + // `title_display` retire le prefixe source TED ("France - - ") + // quand il est present ; repli sur `title` sinon (BOAMP, data_gouv, + // TED sans prefixe). Cf. api_v2/routers/tenders.py::list_tenders. + titleDisplay: raw.title_display ?? raw.title, + cpvCode: raw.cpv_code, + procedureType: raw.procedure_type, + amountEstimated: raw.amount_estimated, + department: raw.department, + publishedAt: raw.published_at, + deadlineAt: raw.deadline_at, + status: raw.status, + buyerId: raw.buyer_id, + buyerName: raw.buyer_name, + updatedAt: raw.updated_at, + })); + +export type VigieProcureTender = z.infer; + +const tendersResponse = z.object({ + items: z.array(tenderItem).catch([]), + count: z.number().catch(0), + total: z.number().catch(0), + total_est_plafonne: z.boolean().catch(false), + page: z.number().catch(1), + page_size: z.number().catch(0), + next_cursor: z.string().nullable().catch(null), +}); + +export type TendersPage = { + items: VigieProcureTender[]; + count: number; + total: number; + totalEstPlafonne: boolean; + page: number; + pageSize: number; + nextCursor: string | null; +}; + +export type TendersQuery = { + cpv?: string; + department?: string; + q?: string; + page?: number; + limit?: number; + cursor?: string; +}; + +export type TendersResult = + | { outcome: "ok"; page: TendersPage } + | { outcome: "not-configured" } + | { outcome: "unauthorized"; reason: string } + | { outcome: "failed"; reason: string }; + +/** + * Liste les marches ouverts via l'API VigieProcure + * (`GET /api/v1/tenders?status=active`). `status` est TOUJOURS "active" -- + * ce n'est pas un parametre d'entree cote CRM, c'est le seul statut que + * cette page a vocation a montrer (marches publies et non encore + * echus, cf. api_v2/routers/tenders.py::_STATUTS). + * + * Pas de champ "lien vers l'avis d'origine" ici : `external_url` n'existe + * QUE sur `GET /tenders/{id}` (extrait de `raw_data->>'url_avis'`), pas sur + * ce feed de liste. On ne l'invente pas. + */ +export async function listOpenTenders( + query: TendersQuery, +): Promise { + const api = vigieProcureTendersApi(); + if (!api) return { outcome: "not-configured" }; + + const target = new URL(api.url); + target.searchParams.set("status", "active"); + // Omission volontaire des parametres vides : `q`/`cpv` exigent + // min_length>=2 et `department` min_length>=1 cote api_v2 -- envoyer une + // chaine vide donnerait un 422 sur CHAQUE chargement de page non filtre. + if (query.cpv) target.searchParams.set("cpv", query.cpv); + if (query.department) target.searchParams.set("department", query.department); + if (query.q) target.searchParams.set("q", query.q); + if (query.cursor) { + target.searchParams.set("cursor", query.cursor); + } else if (query.page) { + target.searchParams.set("page", String(query.page)); + } + target.searchParams.set("limit", String(query.limit ?? 20)); + + try { + const response = await fetch(target, { + headers: { authorization: `Bearer ${api.jwt}` }, + signal: AbortSignal.timeout(TENDERS_TIMEOUT_MS), + }); + + if (response.status === 401 || response.status === 403) { + return { + outcome: "unauthorized", + reason: `VigieProcure answered ${response.status} -- the service JWT may be missing or expired.`, + }; + } + + if (!response.ok) { + return { + outcome: "failed", + reason: `VigieProcure answered ${response.status}.`, + }; + } + + const parsed = tendersResponse.safeParse(await response.json()); + if (!parsed.success) { + return { + outcome: "failed", + reason: "VigieProcure's response did not match the expected shape.", + }; + } + + return { + outcome: "ok", + page: { + items: parsed.data.items, + count: parsed.data.count, + total: parsed.data.total, + totalEstPlafonne: parsed.data.total_est_plafonne, + page: parsed.data.page, + pageSize: parsed.data.page_size, + nextCursor: parsed.data.next_cursor, + }, + }; + } catch (cause) { + const aborted = cause instanceof Error && cause.name === "AbortError"; + return { + outcome: "failed", + reason: aborted + ? `Timed out after ${TENDERS_TIMEOUT_MS}ms.` + : cause instanceof Error + ? cause.message + : String(cause), + }; + } +} diff --git a/apps/app/app/(app)/[slug]/marches-publics/marches-publics-list.tsx b/apps/app/app/(app)/[slug]/marches-publics/marches-publics-list.tsx new file mode 100644 index 000000000..c49cf79e6 --- /dev/null +++ b/apps/app/app/(app)/[slug]/marches-publics/marches-publics-list.tsx @@ -0,0 +1,247 @@ +"use client"; + +import ChevronLeft from "@carbon/icons-react/es/ChevronLeft"; +import ChevronRight from "@carbon/icons-react/es/ChevronRight"; +import Search from "@carbon/icons-react/es/Search"; +import WarningAlt from "@carbon/icons-react/es/WarningAlt"; +import { Alert, AlertDescription, AlertTitle } from "@crm/ui/components/alert"; +import { Badge } from "@crm/ui/components/badge"; +import { Button } from "@crm/ui/components/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@crm/ui/components/empty"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@crm/ui/components/input-group"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useQuery } from "@tanstack/react-query"; +import { useQueryStates } from "nuqs"; +import { LocalDay } from "@/components/local-date-time"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; +import { + marchesPublicsParsers, + marchesPublicsSearchParams, +} from "./marches-publics-search-params"; + +type TendersResult = RouterOutputs["tenders"]["listOuverts"]; +type Tender = Extract< + TendersResult, + { outcome: "ok" } +>["page"]["items"][number]; + +const AMOUNT_FORMATTER = new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, +}); + +function formatAmount(amount: number | null): string { + return amount == null ? "—" : AMOUNT_FORMATTER.format(amount); +} + +export function MarchesPublicsList() { + const trpc = useTRPC(); + const [values, setValues] = useQueryStates(marchesPublicsParsers); + const input = marchesPublicsSearchParams.toInput(values); + + const result = useQuery({ + ...trpc.tenders.listOuverts.queryOptions(input), + placeholderData: (previous) => previous, + }); + + if (result.isPending) { + return ( +
+ +
+ ); + } + + if (result.isError) { + return ( + + + Impossible de charger les marches + {result.error.message} + + ); + } + + const data = result.data; + + if (data.outcome === "not-configured") { + return ( + + + + + + VigieProcure n'est pas configure + + VIGIEPROCURE_API_URL ou VIGIEPROCURE_API_JWT est absent de + l'environnement de crm-api. Cette page reste desactivee tant + que ces variables ne sont pas provisionnees. + + + + ); + } + + if (data.outcome === "unauthorized" || data.outcome === "failed") { + return ( + + + VigieProcure indisponible + {data.reason} + + ); + } + + const { page } = data; + + return ( +
+
+ + + + + { + const q = event.target.value; + setValues((prev) => ({ ...prev, q, page: 1 })); + }} + autoComplete="off" + /> + + + { + const cpv = event.target.value; + setValues((prev) => ({ ...prev, cpv, page: 1 })); + }} + autoComplete="off" + /> + + + { + const department = event.target.value; + setValues((prev) => ({ ...prev, department, page: 1 })); + }} + autoComplete="off" + /> + + {result.isFetching && } +
+ + {page.items.length === 0 ? ( + + + Aucun marche ne correspond + + Essayez d'elargir les filtres CPV, departement ou la + recherche texte. + + + + ) : ( +
+ + + + + + + + + + + + {page.items.map((tender) => ( + + ))} + +
ObjetAcheteurCPVMontantDate limite
+
+ )} + +
+ + {page.total} + {page.totalEstPlafonne ? "+" : ""} marche + {page.total > 1 ? "s" : ""} ouvert + {page.total > 1 ? "s" : ""} + +
+ + + Page {page.page} + + +
+
+
+ ); +} + +function TenderRow({ tender }: { tender: Tender }) { + return ( + + + + {tender.titleDisplay ?? tender.title ?? "—"} + + + + {tender.buyerName ?? "—"} + + + {tender.cpvCode ? {tender.cpvCode} : "—"} + + + {formatAmount(tender.amountEstimated)} + + + {tender.deadlineAt ? : "—"} + + + ); +} diff --git a/apps/app/app/(app)/[slug]/marches-publics/marches-publics-search-params.ts b/apps/app/app/(app)/[slug]/marches-publics/marches-publics-search-params.ts new file mode 100644 index 000000000..9567529aa --- /dev/null +++ b/apps/app/app/(app)/[slug]/marches-publics/marches-publics-search-params.ts @@ -0,0 +1,44 @@ +import { createLoader, parseAsInteger, parseAsString } from "nuqs/server"; + +// Parametres propres a cette page -- PAS `createListSearchParams` +// (`@/components/data-table/list-search-params`), qui suppose une liste +// Prisma avec offset/facettes/tri par colonne. Le feed VigieProcure est un +// proxy en lecture vers une API externe avec sa propre pagination +// (page/limit, pas de facettes) : reutiliser cette machinerie aurait force +// un desaccord de forme plutot qu'un raccourci. +export const marchesPublicsParsers = { + q: parseAsString.withDefault(""), + cpv: parseAsString.withDefault(""), + department: parseAsString.withDefault(""), + page: parseAsInteger.withDefault(1).withOptions({ history: "push" }), +}; + +type MarchesPublicsValues = { + q: string; + cpv: string; + department: string; + page: number; +}; + +const DEFAULTS: MarchesPublicsValues = { + q: "", + cpv: "", + department: "", + page: 1, +}; + +function toInput(values: MarchesPublicsValues) { + return { + q: values.q.trim() || undefined, + cpv: values.cpv.trim() || undefined, + department: values.department.trim() || undefined, + page: values.page > 0 ? values.page : 1, + limit: 20, + }; +} + +export const marchesPublicsSearchParams = { + load: createLoader(marchesPublicsParsers), + toInput, + defaultInput: () => toInput(DEFAULTS), +}; diff --git a/apps/app/app/(app)/[slug]/marches-publics/page.tsx b/apps/app/app/(app)/[slug]/marches-publics/page.tsx new file mode 100644 index 000000000..2ac0d4d0c --- /dev/null +++ b/apps/app/app/(app)/[slug]/marches-publics/page.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellLoading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { MarchesPublicsList } from "./marches-publics-list"; +import { marchesPublicsSearchParams } from "./marches-publics-search-params"; + +export const metadata: Metadata = { + title: "Marches publics", +}; + +export default function MarchesPublicsPage({ + searchParams, +}: PageProps<"/[slug]/marches-publics">) { + return ( + + + + Marches publics + + Appels d'offres ouverts, via VigieProcure (BOAMP/TED/DECP). + + + + + + }> + + + + + ); +} + +async function MarchesPublics({ + searchParams, +}: Pick, "searchParams">) { + const [, values] = await Promise.all([ + requireSession(), + marchesPublicsSearchParams.load(searchParams), + ]); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + // Pas de throw en cas d'echec cote VigieProcure -- `listOuverts` rend un + // resultat degrade (`{ outcome: "not-configured" | ... }`), jamais une + // exception. Le prefetch reste donc sans danger pour le SSR meme si + // VIGIEPROCURE_API_JWT est absent ou si l'API distante est en panne. + await queryClient.prefetchQuery( + trpc.tenders.listOuverts.queryOptions( + marchesPublicsSearchParams.toInput(values), + ), + ); + + return ( + + + + ); +} diff --git a/apps/app/components/app-icon-rail.tsx b/apps/app/components/app-icon-rail.tsx index ad3440e00..8ed3f9aaf 100644 --- a/apps/app/components/app-icon-rail.tsx +++ b/apps/app/components/app-icon-rail.tsx @@ -4,6 +4,7 @@ import Building from "@carbon/icons-react/es/Building"; import Close from "@carbon/icons-react/es/Close"; import Dashboard from "@carbon/icons-react/es/Dashboard"; import Partnership from "@carbon/icons-react/es/Partnership"; +import Search from "@carbon/icons-react/es/Search"; import Settings from "@carbon/icons-react/es/Settings"; import UserMultiple from "@carbon/icons-react/es/UserMultiple"; import { Button } from "@crm/ui/components/button"; @@ -57,6 +58,12 @@ const ITEMS: RailItem[] = [ match: "prefix", }, { title: "Deals", href: "/deals", icon: Partnership, match: "prefix" }, + { + title: "Marches publics", + href: "/marches-publics", + icon: Search, + match: "prefix", + }, { title: "Settings", href: "/settings", icon: Settings, match: "prefix" }, ]; diff --git a/apps/app/components/crm/section-prefetch.ts b/apps/app/components/crm/section-prefetch.ts index 8636fa5ff..695683407 100644 --- a/apps/app/components/crm/section-prefetch.ts +++ b/apps/app/components/crm/section-prefetch.ts @@ -5,9 +5,16 @@ import { useCallback } from "react"; import { companiesSearchParams } from "@/app/(app)/[slug]/companies/companies-search-params"; import { contactsSearchParams } from "@/app/(app)/[slug]/contacts/contacts-search-params"; import { dealsSearchParams } from "@/app/(app)/[slug]/deals/deals-search-params"; +import { marchesPublicsSearchParams } from "@/app/(app)/[slug]/marches-publics/marches-publics-search-params"; import { useTRPC } from "@/lib/trpc/client"; -export type Section = "/" | "/companies" | "/contacts" | "/deals" | "/settings"; +export type Section = + | "/" + | "/companies" + | "/contacts" + | "/deals" + | "/marches-publics" + | "/settings"; export function usePrefetchSection(): (section: string) => void { const trpc = useTRPC(); @@ -40,6 +47,13 @@ export function usePrefetchSection(): (section: string) => void { trpc.deals.list.queryOptions(dealsSearchParams.defaultInput()), ); return; + case "/marches-publics": + void queryClient.prefetchQuery( + trpc.tenders.listOuverts.queryOptions( + marchesPublicsSearchParams.defaultInput(), + ), + ); + return; default: return; } diff --git a/package.json b/package.json index d8de6cdfd..a19662641 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "crm", "private": true, "license": "MIT", - "version": "1.17.0", + "version": "1.18.0", "scripts": { "prepare": "git config core.hooksPath .githooks 2>/dev/null || true", "build": "turbo run build",