Repository Public
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 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}5253export interface JobList {54 total: number;55 count: number;56 jobs: Job[];57}5859export interface Facets {60 cities: string[];61 regions: { region: string; n: number }[];62 languages: { language: string; n: number }[];63 categories: { category: string; n: number }[];64 employers: { employer: string; n: number }[];65 work_modes: string[];66 employment_types: string[];67 sources: { source: string; n: number }[];68}6970export interface Source {71 id: string;72 name: string;73 url: string;74 careers_url: string;75 sectors: string[];76 connector: string;77 status: string;78 region: string;79 active_jobs: number;80 last_sync: number | null;81}8283export interface Stats {84 total: number;85 employers: number;86 sources: number;87 with_salary: number;88 avg_salary_year: number | null;89 remote: number;90 geocoded: number;91 direct_jobs?: number;92 direct_employers?: number;93 direct_sources?: number;94 portal_jobs?: number;95 duplicates_hidden?: number;96 top_cities: { city: string; n: number }[];97 categories: { category: string; n: number }[];98 recent_syncs: {99 source: string; ts: number; found: number; added: number;100 updated: number; removed: number; ok: number; message: string;101 }[];102}103104async function get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {105 const url = new URL(path, window.location.origin);106 if (params) {107 for (const [k, v] of Object.entries(params)) {108 if (v !== undefined && v !== "") url.searchParams.set(k, String(v));109 }110 }111 const ctl = new AbortController();112 const timer = setTimeout(() => ctl.abort(), 20000);113 try {114 const resp = await fetch(url.toString(), { signal: ctl.signal });115 if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`);116 return (await resp.json()) as T;117 } finally {118 clearTimeout(timer);119 }120}121122export interface JobFilters {123 q?: string;124 city?: string;125 region?: string;126 category?: string;127 work_mode?: string;128 employment_type?: string;129 language?: string;130 salary_min?: number;131 with_salary?: number;132 sort?: string;133}134135export const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) =>136 get<JobList>("/api/jobs", { ...filters, limit, offset });137138export const fetchJob = (uid: string) => get<Job>(`/api/jobs/${uid}`);139140export const fetchFacets = () => get<Facets>("/api/facets");141142export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");143144export const fetchStats = () => get<Stats>("/api/stats");145146export const geojsonUrl = (filters: JobFilters) => {147 const url = new URL("/api/jobs.geojson", window.location.origin);148 for (const [k, v] of Object.entries(filters)) {149 if (v !== undefined && v !== "") url.searchParams.set(k, String(v));150 }151 return url.toString();152};153154// --- présentation ------------------------------------------------------------155156const UNIT_FR: Record<string, string> = {157 hour: "h", day: "jour", week: "sem.", biweek: "2 sem.", month: "mois", year: "an",158};159160export function formatSalary(job: Pick<Job, "salary_min" | "salary_max" | "salary_unit">): string | null {161 if (job.salary_min == null || !job.salary_unit) return null;162 const fmt = (n: number) =>163 n >= 1000 ? n.toLocaleString("fr-CA", { maximumFractionDigits: 0 })164 : n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });165 const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit;166 if (job.salary_max != null && job.salary_max > job.salary_min) {167 return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`;168 }169 return `${fmt(job.salary_min)} $ / ${unit}`;170}171172export const MODE_FR: Record<string, string> = {173 presentiel: "Présentiel", hybride: "Hybride", teletravail: "Télétravail",174};175176export const TYPE_FR: Record<string, string> = {177 temps_plein: "Temps plein", temps_partiel: "Temps partiel",178 contractuel: "Contractuel", stage: "Stage", saisonnier: "Saisonnier",179};180181export const LANG_FR: Record<string, string> = {182 fr: "Français", en: "Anglais", bilingue: "Bilingue",183};184185/** Titre d'affichage : version normalisée si disponible, sinon le titre source. */186export const displayTitle = (job: Pick<Job, "title" | "title_clean">) =>187 job.title_clean || job.title;188189export function formatDate(iso: string | null): string {190 if (!iso) return "";191 const d = new Date(`${iso}T12:00:00`);192 return d.toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" });193}194