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-179 * =============================================================================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: Record<string, unknown>;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}4748export interface JobList {49 total: number;50 count: number;51 jobs: Job[];52}5354export interface Facets {55 cities: string[];56 categories: { category: string; n: number }[];57 employers: { employer: string; n: number }[];58 work_modes: string[];59 employment_types: string[];60 sources: { source: string; n: number }[];61}6263export interface Source {64 id: string;65 name: string;66 url: string;67 careers_url: string;68 sectors: string[];69 connector: string;70 status: string;71 region: string;72 active_jobs: number;73 last_sync: number | null;74}7576export interface Stats {77 total: number;78 employers: number;79 sources: number;80 with_salary: number;81 avg_salary_year: number | null;82 remote: number;83 geocoded: number;84 top_cities: { city: string; n: number }[];85 categories: { category: string; n: number }[];86 recent_syncs: {87 source: string; ts: number; found: number; added: number;88 updated: number; removed: number; ok: number; message: string;89 }[];90}9192async function get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {93 const url = new URL(path, window.location.origin);94 if (params) {95 for (const [k, v] of Object.entries(params)) {96 if (v !== undefined && v !== "") url.searchParams.set(k, String(v));97 }98 }99 const ctl = new AbortController();100 const timer = setTimeout(() => ctl.abort(), 20000);101 try {102 const resp = await fetch(url.toString(), { signal: ctl.signal });103 if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);104 return (await resp.json()) as T;105 } finally {106 clearTimeout(timer);107 }108}109110export interface JobFilters {111 q?: string;112 city?: string;113 category?: string;114 work_mode?: string;115 employment_type?: string;116 salary_min?: number;117 with_salary?: number;118 sort?: string;119}120121export const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>122 get<JobList>("/api/jobs", { ...filters, limit, offset });123124export const fetchJob = (uid: string) => get<Job>(`/api/jobs/${uid}`);125126export const fetchFacets = () => get<Facets>("/api/facets");127128export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");129130export const fetchStats = () => get<Stats>("/api/stats");131132export const geojsonUrl = (filters: JobFilters) => {133 const url = new URL("/api/jobs.geojson", window.location.origin);134 for (const [k, v] of Object.entries(filters)) {135 if (v !== undefined && v !== "") url.searchParams.set(k, String(v));136 }137 return url.toString();138};139140// --- présentation ------------------------------------------------------------141142const UNIT_FR: Record<string, string> = {143 hour: "h", day: "jour", week: "sem.", biweek: "2 sem.", month: "mois", year: "an",144};145146export function formatSalary(job: Pick<Job, "salary_min" | "salary_max" | "salary_unit">): string | null {147 if (job.salary_min == null || !job.salary_unit) return null;148 const fmt = (n: number) =>149 n >= 1000 ? n.toLocaleString("fr-CA", { maximumFractionDigits: 0 })150 : n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });151 const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;152 if (job.salary_max != null && job.salary_max > job.salary_min) {153 return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;154 }155 return `${fmt(job.salary_min)} $ / ${unit}`;156}157158export const MODE_FR: Record<string, string> = {159 presentiel: "Présentiel", hybride: "Hybride", teletravail: "Télétravail",160};161162export const TYPE_FR: Record<string, string> = {163 temps_plein: "Temps plein", temps_partiel: "Temps partiel",164 contractuel: "Contractuel", stage: "Stage", saisonnier: "Saisonnier",165};166167export function formatDate(iso: string | null): string {168 if (!iso) return "";169 const d = new Date(`${iso}T12:00:00`);170 return d.toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" });171}172