1/**2 * =============================================================================3 * Job·Ka — Groupe KA4 * Auteur : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * Fichier : frontend/src/api.ts7 * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py8 * Créé : 2026-08-17 Modifié : 2026-08-259 * =============================================================================10 */1112export interface Job {13 uid: string;14 source: string;15 external_id: string;16 url: string;17 employer: string;18 title: string;19 description?: string;20 address: string;21 city: string;22 region: string;23 postal_code: string;24 location_label: string;25 work_mode: string | null;26 employment_type: string | null;27 salary_min: number | null;28 salary_max: number | null;29 salary_unit: string | null;30 salary_label: string;31 salary_year_min: number | null;32 salary_year_max: number | null;33 salary_hour_min: number | null;34 salary_hour_max: number | null;35 benefits: string[];36 requirements: JobRequirements;37 date_posted: string | null;38 date_deadline: string | null;39 category: string;40 ats: string;41 details: Record<string, unknown>;42 lat: number | null;43 lng: number | null;44 active: number;45 dup_sources?: string[];46 is_direct?: boolean;47 company_logo?: string | null;48 language?: string | null; // fr | en | bilingue49 apply_url?: string | null; // candidature directe (≠ url de la fiche)50 title_clean?: string | null; // titre d'affichage normalisé51 seniority?: string | null; // stage | junior | intermediaire | senior | direction52 first_seen?: number | null; // epoch : première collecte par Job·Ka53 // fiche seulement (GET /api/jobs/{uid}) :54 salary_context?: SalaryContext | null;55 employer_jobs?: number; // offres actives du même employeur56 salary_history?: SalaryPoint[];57}5859/** Exigences structurées extraites de l'offre (clés présentes si explicites). */60export interface JobRequirements {61 experience_years?: number; // années d'expérience minimales exigées62 education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)63 languages?: string[]; // langues exigées64 [k: string]: unknown; // champs bruts hérités des ATS65}6667/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */68export interface SalaryContext {69 category: string;70 n: number; // offres avec salaire dans la catégorie71 p25: number;72 median: number;73 p75: number;74 job_mid?: number; // point médian de la fourchette de l'offre75 delta_pct?: number; // écart % vs médiane de la catégorie76}7778export interface SalaryPoint {79 ts: number;80 s_min: number | null;81 s_max: number | null;82 unit: string | null;83}8485export interface JobList {86 total: number;87 count: number;88 jobs: Job[];89}9091export interface Facets {92 cities: string[];93 regions: { region: string; n: number }[];94 languages: { language: string; n: number }[];95 categories: { category: string; n: number }[];96 employers: { employer: string; n: number }[];97 work_modes: string[];98 employment_types: string[];99 seniorities: { seniority: string; n: number }[];100 sources: { source: string; n: number }[];101}102103export interface Source {104 id: string;105 name: string;106 url: string;107 careers_url: string;108 sectors: string[];109 connector: string;110 status: string;111 region: string;112 active_jobs: number;113 last_sync: number | null;114}115116export interface Stats {117 total: number;118 employers: number;119 sources: number;120 with_salary: number;121 avg_salary_year: number | null;122 remote: number;123 geocoded: number;124 direct_jobs?: number;125 direct_employers?: number;126 direct_sources?: number;127 portal_jobs?: number;128 duplicates_hidden?: number;129 top_cities: { city: string; n: number }[];130 categories: { category: string; n: number }[];131 recent_syncs: {132 source: string; ts: number; found: number; added: number;133 updated: number; removed: number; ok: number; message: string;134 }[];135}136137async function get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {138 const url = new URL(path, window.location.origin);139 if (params) {140 for (const [k, v] of Object.entries(params)) {141 if (v !== undefined && v !== "") url.searchParams.set(k, String(v));142 }143 }144 const ctl = new AbortController();145 const timer = setTimeout(() => ctl.abort(), 20000);146 try {147 const resp = await fetch(url.toString(), { signal: ctl.signal });148 if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);149 return (await resp.json()) as T;150 } finally {151 clearTimeout(timer);152 }153}154155export interface JobFilters {156 q?: string;157 city?: string;158 region?: string;159 category?: string;160 work_mode?: string;161 employment_type?: string;162 seniority?: string;163 language?: string;164 salary_min?: number;165 with_salary?: number;166 sort?: string;167}168169export const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>170 get<JobList>("/api/jobs", { ...filters, limit, offset });171172export const fetchJob = (uid: string) => get<Job>(`/api/jobs/${uid}`);173174export const fetchFacets = () => get<Facets>("/api/facets");175176export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");177178export const fetchStats = () => get<Stats>("/api/stats");179180export const geojsonUrl = (filters: JobFilters) => {181 const url = new URL("/api/jobs.geojson", window.location.origin);182 for (const [k, v] of Object.entries(filters)) {183 if (v !== undefined && v !== "") url.searchParams.set(k, String(v));184 }185 return url.toString();186};187188// --- présentation ------------------------------------------------------------189190const UNIT_FR: Record<string, string> = {191 hour: "h", day: "jour", week: "sem.", biweek: "2 sem.", month: "mois", year: "an",192};193194export function formatSalary(job: Pick<Job, "salary_min" | "salary_max" | "salary_unit">): string | null {195 if (job.salary_min == null || !job.salary_unit) return null;196 const fmt = (n: number) =>197 n >= 1000 ? n.toLocaleString("fr-CA", { maximumFractionDigits: 0 })198 : n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });199 const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;200 if (job.salary_max != null && job.salary_max > job.salary_min) {201 return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;202 }203 return `${fmt(job.salary_min)} $ / ${unit}`;204}205206export const MODE_FR: Record<string, string> = {207 presentiel: "Présentiel", hybride: "Hybride", teletravail: "Télétravail",208};209210export const TYPE_FR: Record<string, string> = {211 temps_plein: "Temps plein", temps_partiel: "Temps partiel",212 contractuel: "Contractuel", stage: "Stage", saisonnier: "Saisonnier",213};214215export const LANG_FR: Record<string, string> = {216 fr: "Français", en: "Anglais", bilingue: "Bilingue",217};218219export const SENIORITY_FR: Record<string, string> = {220 stage: "Stage", junior: "Junior", intermediaire: "Intermédiaire",221 senior: "Senior", direction: "Direction",222};223224/** « 62 500 $ » compacté en « 62,5 k$ » (métriques et badges). */225export function compactMoney(n: number): string {226 if (n >= 1000) {227 const k = n / 1000;228 return `${k.toLocaleString("fr-CA", { maximumFractionDigits: k < 100 ? 1 : 0 })} k$`;229 }230 return `${n.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`;231}232233/** Équivalent annuel « ≈ 52 k$ – 60 k$/an » pour un salaire non annuel. */234export function yearlyEquiv(job: Pick<Job, "salary_year_min" | "salary_year_max" | "salary_unit">): string | null {235 if (job.salary_year_min == null || job.salary_unit === "year") return null;236 const lo = compactMoney(job.salary_year_min);237 const hi = job.salary_year_max != null && job.salary_year_max > job.salary_year_min238 ? ` – ${compactMoney(job.salary_year_max)}` : "";239 return `≈ ${lo}${hi}/an`;240}241242/** Jours écoulés depuis une date ISO (null si absente ou invalide). */243export function daysSince(iso: string | null | undefined): number | null {244 if (!iso) return null;245 const d = new Date(`${iso}T12:00:00`).getTime();246 if (Number.isNaN(d)) return null;247 return Math.max(0, Math.round((Date.now() - d) / 86400000));248}249250/** « aujourd'hui », « hier », « il y a N jours ». */251export function agoLabel(days: number): string {252 if (days === 0) return "aujourd'hui";253 if (days === 1) return "hier";254 return `il y a ${days} jours`;255}256257/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */258export const displayTitle = (job: Pick<Job, "title" | "title_clean">) =>259 job.title_clean || job.title;260261export function formatDate(iso: string | null): string {262 if (!iso) return "";263 const d = new Date(`${iso}T12:00:00`);264 return d.toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" });265}266