From 6c43130e60836d39c57db90e00843183ec53a4a0 Mon Sep 17 00:00:00 2001 From: Franck H Date: Sun, 13 Sep 2026 09:02:23 +0200 Subject: [PATCH 1/2] feat(tenders): page CRM Marches publics via API VigieProcure scopee Ajoute une page CRM listant les marches publics ouverts (source, CPV, departement, texte, pagination) en interrogeant GET /api/v1/tenders?status=active sur l'API VigieProcure avec le JWT de service scope tenders:read (voir chantier auth.py/tenders.py cote api_v2 -- meme session). Client vigieprocure-tenders.client.ts : doctrine "absent = degrade, jamais d'appel non authentifie" -- VIGIEPROCURE_API_URL/ VIGIEPROCURE_API_JWT absents renvoient un etat explicite, pas un crash ni un appel silencieusement anonyme. Procedure tRPC dediee (tenders.listOuverts) avec validation Zod alignee sur les bornes reelles de la route Python (cpv 2-400, department 1-3, q 2-200). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01P6yNGEKWRMLZrVyjCrD61G --- .env.example | 4 + apps/api/src/app.module.ts | 2 + apps/api/src/generated/server.ts | 7 + apps/api/src/tenders/tenders.contracts.ts | 52 ++++ apps/api/src/tenders/tenders.module.ts | 10 + apps/api/src/tenders/tenders.router.ts | 27 ++ apps/api/src/tenders/tenders.service.ts | 25 ++ .../tenders/vigieprocure-tenders.client.ts | 185 +++++++++++++ .../marches-publics/marches-publics-list.tsx | 247 ++++++++++++++++++ .../marches-publics-search-params.ts | 44 ++++ .../app/(app)/[slug]/marches-publics/page.tsx | 70 +++++ apps/app/components/app-icon-rail.tsx | 7 + apps/app/components/crm/section-prefetch.ts | 16 +- 13 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/tenders/tenders.contracts.ts create mode 100644 apps/api/src/tenders/tenders.module.ts create mode 100644 apps/api/src/tenders/tenders.router.ts create mode 100644 apps/api/src/tenders/tenders.service.ts create mode 100644 apps/api/src/tenders/vigieprocure-tenders.client.ts create mode 100644 apps/app/app/(app)/[slug]/marches-publics/marches-publics-list.tsx create mode 100644 apps/app/app/(app)/[slug]/marches-publics/marches-publics-search-params.ts create mode 100644 apps/app/app/(app)/[slug]/marches-publics/page.tsx 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/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; } From 4e2ed99c5ff20df647a0a3ad2fb382a88e351757 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 07:27:38 +0000 Subject: [PATCH 2/2] chore(main): release 1.18.0 --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) 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/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",