SPB Git

spb/job-ka

Public
8.7 KB · 266 lines typescript
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
Files · c48d737f451ba771d4e4dcbff95959a5334d6c1b
Menu

Theme