SPB Git forge

spb/job-ka

Public
226commits 1branches 0releases
37.5 MBsize
maindefault branch
10 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
8.9 KB · 269 lines typescript
Raw Blame History
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