HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
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 // personnalisation KA ID : posé par le serveur quand le score personnel58 // est net (voir jobka/kaid.py) — affiche « Recommandé pour vous »59 ka_reco?: { score: number; reasons: string[] } | null;60}6162/** Exigences structurées extraites de l'offre (clés présentes si explicites). */63export interface JobRequirements {64 experience_years?: number; // années d'expérience minimales exigées65 education?: string; // plus bas diplôme exigé (DEC, baccalauréat…)66 languages?: string[]; // langues exigées67 [k: string]: unknown; // champs bruts hérités des ATS68}6970/** Salaire de l'offre vs marché de sa catégorie (médiane/quartiles, $/an). */71export interface SalaryContext {72 category: string;73 n: number; // offres avec salaire dans la catégorie74 p25: number;75 median: number;76 p75: number;77 job_mid?: number; // point médian de la fourchette de l'offre78 delta_pct?: number; // écart % vs médiane de la catégorie79}8081export interface SalaryPoint {82 ts: number;83 s_min: number | null;84 s_max: number | null;85 unit: string | null;86}8788export interface JobList {89 total: number;90 count: number;91 jobs: Job[];92}9394export interface Facets {95 cities: string[];96 regions: { region: string; n: number }[];97 languages: { language: string; n: number }[];98 categories: { category: string; n: number }[];99 employers: { employer: string; n: number }[];100 work_modes: string[];101 employment_types: string[];102 seniorities: { seniority: string; n: number }[];103 sources: { source: string; n: number }[];104}105106export interface Source {107 id: string;108 name: string;109 url: string;110 careers_url: string;111 sectors: string[];112 connector: string;113 status: string;114 region: string;115 active_jobs: number;116 last_sync: number | null;117}118119export interface Stats {120 total: number;121 employers: number;122 sources: number;123 with_salary: number;124 avg_salary_year: number | null;125 remote: number;126 geocoded: number;127 direct_jobs?: number;128 direct_employers?: number;129 direct_sources?: number;130 portal_jobs?: number;131 duplicates_hidden?: number;132 top_cities: { city: string; n: number }[];133 categories: { category: string; n: number }[];134 recent_syncs: {135 source: string; ts: number; found: number; added: number;136 updated: number; removed: number; ok: number; message: string;137 }[];138}139140async function get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {141 const url = new URL(path, window.location.origin);142 if (params) {143 for (const [k, v] of Object.entries(params)) {144 if (v !== undefined && v !== "") url.searchParams.set(k, String(v));145 }146 }147 const ctl = new AbortController();148 const timer = setTimeout(() => ctl.abort(), 20000);149 try {150 const resp = await fetch(url.toString(), { signal: ctl.signal });151 if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);152 return (await resp.json()) as T;153 } finally {154 clearTimeout(timer);155 }156}157158export interface JobFilters {159 q?: string;160 city?: string;161 region?: string;162 category?: string;163 work_mode?: string;164 employment_type?: string;165 seniority?: string;166 language?: string;167 salary_min?: number;168 with_salary?: number;169 sort?: string;170}171172export const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>173 get<JobList>("/api/jobs", { ...filters, limit, offset });174175export const fetchJob = (uid: string) => get<Job>(`/api/jobs/${uid}`);176177export const fetchFacets = () => get<Facets>("/api/facets");178179export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");180181export const fetchStats = () => get<Stats>("/api/stats");182183export const geojsonUrl = (filters: JobFilters) => {184 const url = new URL("/api/jobs.geojson", window.location.origin);185 for (const [k, v] of Object.entries(filters)) {186 if (v !== undefined && v !== "") url.searchParams.set(k, String(v));187 }188 return url.toString();189};190191// --- présentation ------------------------------------------------------------192193const UNIT_FR: Record<string, string> = {194 hour: "h", day: "jour", week: "sem.", biweek: "2 sem.", month: "mois", year: "an",195};196197export function formatSalary(job: Pick<Job, "salary_min" | "salary_max" | "salary_unit">): string | null {198 if (job.salary_min == null || !job.salary_unit) return null;199 const fmt = (n: number) =>200 n >= 1000 ? n.toLocaleString("fr-CA", { maximumFractionDigits: 0 })201 : n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });202 const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;203 if (job.salary_max != null && job.salary_max > job.salary_min) {204 return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;205 }206 return `${fmt(job.salary_min)} $ / ${unit}`;207}208209export const MODE_FR: Record<string, string> = {210 presentiel: "Présentiel", hybride: "Hybride", teletravail: "Télétravail",211};212213export const TYPE_FR: Record<string, string> = {214 temps_plein: "Temps plein", temps_partiel: "Temps partiel",215 contractuel: "Contractuel", stage: "Stage", saisonnier: "Saisonnier",216};217218export const LANG_FR: Record<string, string> = {219 fr: "Français", en: "Anglais", bilingue: "Bilingue",220};221222export const SENIORITY_FR: Record<string, string> = {223 stage: "Stage", junior: "Junior", intermediaire: "Intermédiaire",224 senior: "Senior", direction: "Direction",225};226227/** « 62 500 $ » compacté en « 62,5 k$ » (métriques et badges). */228export function compactMoney(n: number): string {229 if (n >= 1000) {230 const k = n / 1000;231 return `${k.toLocaleString("fr-CA", { maximumFractionDigits: k < 100 ? 1 : 0 })} k$`;232 }233 return `${n.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`;234}235236/** Équivalent annuel « ≈ 52 k$ – 60 k$/an » pour un salaire non annuel. */237export function yearlyEquiv(job: Pick<Job, "salary_year_min" | "salary_year_max" | "salary_unit">): string | null {238 if (job.salary_year_min == null || job.salary_unit === "year") return null;239 const lo = compactMoney(job.salary_year_min);240 const hi = job.salary_year_max != null && job.salary_year_max > job.salary_year_min241 ? ` – ${compactMoney(job.salary_year_max)}` : "";242 return `≈ ${lo}${hi}/an`;243}244245/** Jours écoulés depuis une date ISO (null si absente ou invalide). */246export function daysSince(iso: string | null | undefined): number | null {247 if (!iso) return null;248 const d = new Date(`${iso}T12:00:00`).getTime();249 if (Number.isNaN(d)) return null;250 return Math.max(0, Math.round((Date.now() - d) / 86400000));251}252253/** « aujourd'hui », « hier », « il y a N jours ». */254export function agoLabel(days: number): string {255 if (days === 0) return "aujourd'hui";256 if (days === 1) return "hier";257 return `il y a ${days} jours`;258}259260/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */261export const displayTitle = (job: Pick<Job, "title" | "title_clean">) =>262 job.title_clean || job.title;263264export function formatDate(iso: string | null): string {265 if (!iso) return "";266 const d = new Date(`${iso}T12:00:00`);267 return d.toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" });268}269