diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9dcfff0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# AGENTS.md — ordinarthur-os + +Avant toute action, lire dans cet ordre : + +1. [`README.md`](./README.md) +2. [`PLAN.md`](./PLAN.md) +3. [`ARCHITECTURE.md`](./ARCHITECTURE.md) + +## Règles non-négociables + +- **Pas de Next.js, pas de Vercel.** Stack = Vite/React + NestJS, déploiement k3s. +- **L'IA ne mute jamais la DB sans clic de confirmation utilisateur.** Le backend renvoie des `ProposedAction[]`, la PWA confirme via modal, puis `/ai/command/confirm` exécute. +- **Single-user.** Bearer token statique, pas de multi-tenant. +- **Design = portfolio arthurbarre.fr** (cream `#F5F1EA`, ink `#0F0F0F`, accent orange `#FF4A1C`, mono labels, bordures, italique = orange). PAS le violet/cyan du HTML jobs. +- **FR only** côté IA (prompts + STT lang=fr). +- **Schéma Postgres dédié `ordinarthur_os`** dans Supabase self-hosted (`supabase.arthurbarre.fr`). + +## Phases + +Voir `PLAN.md`. Implémentation séquentielle Phase 0 → 7. Phase 8 (finance) reportée. + +## Conventions repo + +- Monorepo pnpm + Turborepo +- `apps/pwa` Vite + React + TanStack Router/Query + Tailwind + shadcn +- `apps/api` NestJS (modules par domaine), `@supabase/supabase-js` (pas d'ORM) +- `packages/shared` types + zod DTOs partagés PWA ↔ API +- `packages/db/migrations` SQL versionné, appliqué manuellement sur Supabase pour l'instant +- Pas de fichier `.env` commité, juste `.env.example` diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 181ac56..a11b2af 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,9 +4,10 @@ import { DbModule } from "./db/db.module"; import { HealthModule } from "./modules/health/health.module"; import { AuthModule } from "./modules/auth/auth.module"; import { BearerMiddleware } from "./modules/auth/bearer.middleware"; +import { JobsModule } from "./modules/jobs/jobs.module"; @Module({ - imports: [ConfigModule, DbModule, HealthModule, AuthModule], + imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index eb09da5..886ac3c 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; import { z } from "zod"; const EnvSchema = z.object({ @@ -10,7 +12,6 @@ const EnvSchema = z.object({ SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), SUPABASE_SCHEMA: z.string().default("ordinarthur_os"), - // Phase 5+ — optionnels jusque-là MISTRAL_API_KEY: z.string().optional(), MISTRAL_MODEL: z.string().default("mistral-small-latest"), GROQ_API_KEY: z.string().optional(), @@ -31,12 +32,53 @@ let cached: AppConfig | null = null; export function loadConfig(): AppConfig { if (cached) return cached; + + loadEnvFile(); + const parsed = EnvSchema.safeParse(process.env); if (!parsed.success) { // eslint-disable-next-line no-console console.error("[config] invalid env:", parsed.error.flatten().fieldErrors); throw new Error("Invalid environment configuration"); } + cached = parsed.data; return cached; } + +function loadEnvFile() { + const candidates = [ + path.resolve(process.cwd(), ".env"), + path.resolve(process.cwd(), "apps/api/.env"), + path.resolve(__dirname, "../../.env"), + ]; + + const envPath = candidates.find((candidate) => existsSync(candidate)); + if (!envPath) return; + + const raw = readFileSync(envPath, "utf8"); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const separatorIndex = trimmed.indexOf("="); + if (separatorIndex === -1) continue; + + const key = trimmed.slice(0, separatorIndex).trim(); + const value = trimmed.slice(separatorIndex + 1).trim(); + + if (!key || process.env[key] !== undefined) continue; + process.env[key] = stripWrappingQuotes(value); + } +} + +function stripWrappingQuotes(value: string) { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + + return value; +} diff --git a/apps/api/src/db/db.module.ts b/apps/api/src/db/db.module.ts index bfea3ad..f5d3958 100644 --- a/apps/api/src/db/db.module.ts +++ b/apps/api/src/db/db.module.ts @@ -4,7 +4,7 @@ import { APP_CONFIG } from "../config/config.module"; import type { AppConfig } from "../config/env"; export const SUPABASE = Symbol("SUPABASE"); -export type Supabase = SupabaseClient; +export type Supabase = SupabaseClient; @Global() @Module({ @@ -12,7 +12,7 @@ export type Supabase = SupabaseClient; { provide: SUPABASE, inject: [APP_CONFIG], - useFactory: (config: AppConfig): SupabaseClient => + useFactory: (config: AppConfig): Supabase => createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { auth: { persistSession: false, autoRefreshToken: false }, db: { schema: config.SUPABASE_SCHEMA }, diff --git a/apps/api/src/lib/zod-pipe.ts b/apps/api/src/lib/zod-pipe.ts new file mode 100644 index 0000000..2dd1906 --- /dev/null +++ b/apps/api/src/lib/zod-pipe.ts @@ -0,0 +1,16 @@ +import { BadRequestException, PipeTransform } from "@nestjs/common"; +import type { ZodSchema } from "zod"; + +export class ZodPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + transform(value: unknown): T { + const parsed = this.schema.safeParse(value); + if (!parsed.success) { + throw new BadRequestException({ + message: "Validation failed", + issues: parsed.error.flatten(), + }); + } + return parsed.data; + } +} diff --git a/apps/api/src/modules/jobs/jobs.controller.ts b/apps/api/src/modules/jobs/jobs.controller.ts new file mode 100644 index 0000000..c221edb --- /dev/null +++ b/apps/api/src/modules/jobs/jobs.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Query, +} from "@nestjs/common"; +import { + JobIngestRequest, + JobListQuery, + JobPatchDto, + JobSearchCriteriaUpsert, +} from "@ordinarthur-os/shared"; +import { ZodPipe } from "../../lib/zod-pipe"; +import { JobsService } from "./jobs.service"; + +@Controller("jobs") +export class JobsController { + constructor(private readonly jobs: JobsService) {} + + @Post("ingest") + ingest(@Body(new ZodPipe(JobIngestRequest)) body: JobIngestRequest) { + return this.jobs.ingest(body.jobs); + } + + @Get() + list(@Query(new ZodPipe(JobListQuery)) q: JobListQuery) { + return this.jobs.list(q); + } + + @Patch(":id") + patch( + @Param("id", new ParseUUIDPipe()) id: string, + @Body(new ZodPipe(JobPatchDto)) patch: JobPatchDto, + ) { + return this.jobs.patch(id, patch); + } + + // ---- criteria ---------------------------------------------------------- + + @Get("criteria") + listCriteria(@Query("active") active?: string) { + return this.jobs.listCriteria(active === "true"); + } + + @Post("criteria") + createCriteria(@Body(new ZodPipe(JobSearchCriteriaUpsert)) input: JobSearchCriteriaUpsert) { + return this.jobs.createCriteria(input); + } + + @Put("criteria/:id") + updateCriteria( + @Param("id", new ParseUUIDPipe()) id: string, + @Body(new ZodPipe(JobSearchCriteriaUpsert)) input: JobSearchCriteriaUpsert, + ) { + return this.jobs.updateCriteria(id, input); + } + + @Delete("criteria/:id") + deleteCriteria(@Param("id", new ParseUUIDPipe()) id: string) { + return this.jobs.deleteCriteria(id); + } +} diff --git a/apps/api/src/modules/jobs/jobs.module.ts b/apps/api/src/modules/jobs/jobs.module.ts new file mode 100644 index 0000000..f5bc05f --- /dev/null +++ b/apps/api/src/modules/jobs/jobs.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { JobsController } from "./jobs.controller"; +import { JobsService } from "./jobs.service"; + +@Module({ + controllers: [JobsController], + providers: [JobsService], +}) +export class JobsModule {} diff --git a/apps/api/src/modules/jobs/jobs.service.ts b/apps/api/src/modules/jobs/jobs.service.ts new file mode 100644 index 0000000..71d36cc --- /dev/null +++ b/apps/api/src/modules/jobs/jobs.service.ts @@ -0,0 +1,149 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { + Job, + JobIngestDto, + JobIngestResponse, + JobListQuery, + JobPatchDto, + JobSearchCriteria, + JobSearchCriteriaUpsert, +} from "@ordinarthur-os/shared"; +import { InjectSupabase, type Supabase } from "../../db/db.module"; + +const RETENTION_DAYS = 30; + +@Injectable() +export class JobsService { + constructor(@InjectSupabase() private readonly db: Supabase) {} + + async ingest(jobs: JobIngestDto[]): Promise { + let inserted = 0; + let updated = 0; + + // On traite job par job pour distinguer insert vs update. + // Volume attendu : ~quelques dizaines/jour, c'est OK. + for (const j of jobs) { + const { data: existing } = await this.db + .from("jobs") + .select("id") + .eq("source_url", j.source_url) + .maybeSingle(); + + if (existing) { + await this.db + .from("jobs") + .update({ + title: j.title, + company: j.company ?? null, + description: j.description ?? null, + location: j.location ?? null, + remote_type: j.remote_type ?? null, + salary_min: j.salary_min ?? null, + salary_max: j.salary_max ?? null, + stack: j.stack ?? [], + apply_url: j.apply_url ?? null, + last_seen_at: new Date().toISOString(), + }) + .eq("id", existing.id); + updated++; + } else { + const { error } = await this.db.from("jobs").insert({ + source: j.source, + source_url: j.source_url, + title: j.title, + company: j.company ?? null, + description: j.description ?? null, + location: j.location ?? null, + remote_type: j.remote_type ?? null, + salary_min: j.salary_min ?? null, + salary_max: j.salary_max ?? null, + stack: j.stack ?? [], + apply_url: j.apply_url ?? null, + }); + if (error) throw error; + inserted++; + } + } + + const archived = await this.archiveStale(); + return { inserted, updated, archived }; + } + + /** Soft-delete des jobs non revus depuis RETENTION_DAYS jours. */ + private async archiveStale(): Promise { + const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400_000).toISOString(); + const { data, error } = await this.db + .from("jobs") + .update({ archived: true }) + .lt("last_seen_at", cutoff) + .eq("archived", false) + .select("id"); + if (error) throw error; + return data?.length ?? 0; + } + + async list(q: JobListQuery): Promise { + let query = this.db.from("jobs").select("*").order("last_seen_at", { ascending: false }); + + if (typeof q.archived === "boolean") query = query.eq("archived", q.archived); + else query = query.eq("archived", false); // défaut : non archivés + + if (q.remote_type) query = query.eq("remote_type", q.remote_type); + if (q.starred !== undefined) query = query.eq("starred", q.starred); + if (q.since) query = query.gte("last_seen_at", q.since); + if (q.stack?.length) query = query.overlaps("stack", q.stack); + + const { data, error } = await query.limit(500); + if (error) throw error; + return (data ?? []) as Job[]; + } + + async patch(id: string, patch: JobPatchDto): Promise { + const { data, error } = await this.db + .from("jobs") + .update(patch) + .eq("id", id) + .select("*") + .maybeSingle(); + if (error) throw error; + if (!data) throw new NotFoundException(`Job ${id} not found`); + return data as Job; + } + + // ---- criteria ---------------------------------------------------------- + + async listCriteria(activeOnly = false): Promise { + let q = this.db.from("job_search_criteria").select("*").order("created_at", { ascending: true }); + if (activeOnly) q = q.eq("active", true); + const { data, error } = await q; + if (error) throw error; + return (data ?? []) as JobSearchCriteria[]; + } + + async createCriteria(input: JobSearchCriteriaUpsert): Promise { + const { data, error } = await this.db + .from("job_search_criteria") + .insert(input) + .select("*") + .single(); + if (error) throw error; + return data as JobSearchCriteria; + } + + async updateCriteria(id: string, input: JobSearchCriteriaUpsert): Promise { + const { data, error } = await this.db + .from("job_search_criteria") + .update({ ...input, updated_at: new Date().toISOString() }) + .eq("id", id) + .select("*") + .maybeSingle(); + if (error) throw error; + if (!data) throw new NotFoundException(`Criteria ${id} not found`); + return data as JobSearchCriteria; + } + + async deleteCriteria(id: string): Promise { + const { error } = await this.db.from("job_search_criteria").delete().eq("id", id); + if (error) throw error; + } +} diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 4b95bb9..6a802a7 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -3,7 +3,9 @@ * Bearer token stocké en localStorage (saisi par Arthur sur l'écran d'onboarding). */ -const BASE = import.meta.env.VITE_API_BASE_URL ?? ""; +const BASE = + import.meta.env.VITE_API_BASE_URL ?? + (import.meta.env.DEV ? "http://localhost:3000" : ""); const TOKEN_KEY = "ordinarthur.bearer"; export function getToken(): string | null { diff --git a/apps/pwa/src/routeTree.gen.ts b/apps/pwa/src/routeTree.gen.ts index 7d1b6c0..60505f4 100644 --- a/apps/pwa/src/routeTree.gen.ts +++ b/apps/pwa/src/routeTree.gen.ts @@ -1,26 +1,95 @@ -/* prettier-ignore-start */ /* eslint-disable */ + // @ts-nocheck -// Ce fichier est régénéré par @tanstack/router-vite-plugin au premier `pnpm dev`. -// On commit un stub minimal pour que `tsc --noEmit` passe avant le premier dev run. +// noinspection JSUnusedGlobalSymbols -import { Route as rootRoute } from "./routes/__root"; -import { Route as IndexRoute } from "./routes/index"; +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -const IndexRouteWithParent = IndexRoute.update({ - path: "/", - getParentRoute: () => rootRoute, -} as any); +import { Route as rootRouteImport } from './routes/__root' +import { Route as JobsRouteImport } from './routes/jobs' +import { Route as IndexRouteImport } from './routes/index' +import { Route as SettingsJobsRouteImport } from './routes/settings.jobs' -export const routeTree = rootRoute.addChildren({ - IndexRoute: IndexRouteWithParent, -} as any); +const JobsRoute = JobsRouteImport.update({ + id: '/jobs', + path: '/jobs', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsJobsRoute = SettingsJobsRouteImport.update({ + id: '/settings/jobs', + path: '/settings/jobs', + getParentRoute: () => rootRouteImport, +} as any) -declare module "@tanstack/react-router" { +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/jobs': typeof JobsRoute + '/settings/jobs': typeof SettingsJobsRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/jobs': typeof JobsRoute + '/settings/jobs': typeof SettingsJobsRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/jobs': typeof JobsRoute + '/settings/jobs': typeof SettingsJobsRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/jobs' | '/settings/jobs' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/jobs' | '/settings/jobs' + id: '__root__' | '/' | '/jobs' | '/settings/jobs' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + JobsRoute: typeof JobsRoute + SettingsJobsRoute: typeof SettingsJobsRoute +} + +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/": { parentRoute: typeof rootRoute }; + '/jobs': { + id: '/jobs' + path: '/jobs' + fullPath: '/jobs' + preLoaderRoute: typeof JobsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/settings/jobs': { + id: '/settings/jobs' + path: '/settings/jobs' + fullPath: '/settings/jobs' + preLoaderRoute: typeof SettingsJobsRouteImport + parentRoute: typeof rootRouteImport + } } } -/* prettier-ignore-end */ +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + JobsRoute: JobsRoute, + SettingsJobsRoute: SettingsJobsRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/apps/pwa/src/routes/__root.tsx b/apps/pwa/src/routes/__root.tsx index dbcd006..5269e26 100644 --- a/apps/pwa/src/routes/__root.tsx +++ b/apps/pwa/src/routes/__root.tsx @@ -15,8 +15,8 @@ function RootLayout() { @@ -25,7 +25,7 @@ function RootLayout() {
- +
diff --git a/apps/pwa/src/routes/index.tsx b/apps/pwa/src/routes/index.tsx index 1b47189..0c4b835 100644 --- a/apps/pwa/src/routes/index.tsx +++ b/apps/pwa/src/routes/index.tsx @@ -15,7 +15,7 @@ function Dashboard() { return (
- + Un assistant qui n'agit jamais sans ton clic. @@ -57,8 +57,8 @@ function Dashboard() {
- - + + @@ -67,6 +67,29 @@ function Dashboard() {
+ + +
+ +

+ La Phase 1 est la première verticale métier: ingestion backend, listing éditorial, + filtres de recherche et critères intégrés directement dans la vue jobs. +

+ + Ouvrir les jobs + +
+
+ +

+ Les presets de critères vivent maintenant dans le header de la page jobs pour rester + au plus près du tri quotidien. +

+
+
); } diff --git a/apps/pwa/src/routes/jobs.tsx b/apps/pwa/src/routes/jobs.tsx new file mode 100644 index 0000000..c08224d --- /dev/null +++ b/apps/pwa/src/routes/jobs.tsx @@ -0,0 +1,483 @@ +import { useEffect, useMemo, useState } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + Job, + JobListQuery, + JobPatchDto, + JobSearchCriteria, + RemoteType, +} from "@ordinarthur-os/shared"; +import { api } from "@/api/client"; +import { BigHeading, DataChip, GridFrame, Label, SectionHeader } from "@/design"; + +export const Route = createFileRoute("/jobs")({ component: JobsPage }); + +const REMOTE_TYPES: Array<{ label: string; value: RemoteType | "all" }> = [ + { label: "Tout", value: "all" }, + { label: "Remote", value: "remote" }, + { label: "Hybrid", value: "hybrid" }, + { label: "Onsite", value: "onsite" }, +]; + +function JobsPage() { + const queryClient = useQueryClient(); + const [remoteType, setRemoteType] = useState("all"); + const [starredOnly, setStarredOnly] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [stackFilter, setStackFilter] = useState(""); + const [activeCriteriaId, setActiveCriteriaId] = useState(null); + + const criteria = useQuery({ + queryKey: ["job-criteria"], + queryFn: () => api("/jobs/criteria?active=true"), + }); + + const activeCriteria = useMemo( + () => criteria.data?.find((item) => item.id === activeCriteriaId) ?? null, + [activeCriteriaId, criteria.data], + ); + + const stacks = useMemo(() => normalizeTags(stackFilter), [stackFilter]); + const query = useMemo( + () => ({ + archived: showArchived, + starred: starredOnly || undefined, + remote_type: remoteType === "all" ? undefined : remoteType, + stack: stacks.length ? stacks : undefined, + }), + [remoteType, showArchived, starredOnly, stacks], + ); + + const jobs = useQuery({ + queryKey: ["jobs", query], + queryFn: () => api(`/jobs${toQueryString(query)}`), + }); + + const patchJob = useMutation({ + mutationFn: ({ id, patch }: { id: string; patch: JobPatchDto }) => + api(`/jobs/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["jobs"] }); + }, + }); + + useEffect(() => { + const firstCriteria = criteria.data?.[0]; + if (!firstCriteria) { + setActiveCriteriaId(null); + return; + } + + if (!activeCriteriaId) { + setActiveCriteriaId(firstCriteria.id); + } + }, [activeCriteriaId, criteria.data]); + + useEffect(() => { + if (!activeCriteria) return; + + setRemoteType( + activeCriteria.remote_types.length === 1 && activeCriteria.remote_types[0] + ? activeCriteria.remote_types[0] + : "all", + ); + setStackFilter(activeCriteria.stack.join(", ")); + }, [activeCriteria]); + + const visibleJobs = useMemo( + () => (jobs.data ?? []).filter((job) => matchesCriteria(job, activeCriteria)), + [activeCriteria, jobs.data], + ); + const stats = getJobStats(visibleJobs); + + return ( +
+
+ + + Une veille propre et triable pour les candidatures. + +

+ Les critères actifs sont maintenant fusionnés dans le header de la page jobs, pour + filtrer rapidement sans détour par un écran de réglages séparé. +

+
+ + + + +
+ +
+ + {criteria.data?.map((item) => ( + + ))} +
+ {activeCriteria ? ( +
+ {activeCriteria.titles.map((title) => ( + {title} + ))} + {activeCriteria.locations.map((location) => ( + {location} + ))} + {activeCriteria.stack.map((item) => ( + {item} + ))} + {activeCriteria.salary_min ? ( + Min {formatSalary(activeCriteria.salary_min, null)} + ) : null} +
+ ) : null} +
+ +
+ +
+ {REMOTE_TYPES.map((item) => ( + + ))} +
+
+ +
+ + { + setActiveCriteriaId(null); + setStackFilter(event.target.value); + }} + placeholder="react, node, nest" + className="mt-3 w-full border border-ink bg-transparent px-3 py-2 font-sans text-sm text-ink outline-none placeholder:text-muted" + /> +

+ Séparer par virgules pour filtrer sur plusieurs technos +

+
+ +
+ +
+ + +
+
+
+ +
+
+ +
+
+ + + + +
+
+ + + +
+
+ + + + + +
+ + {jobs.isLoading || criteria.isLoading ? ( + + ) : jobs.isError ? ( + + ) : visibleJobs.length === 0 ? ( + + ) : ( +
+ {visibleJobs.map((job) => ( + patchJob.mutate({ id: job.id, patch })} + /> + ))} +
+ )} +
+
+ ); +} + +function JobRow({ job, onPatch }: { job: Job; onPatch: (patch: JobPatchDto) => void }) { + const [notesDraft, setNotesDraft] = useState(job.notes ?? ""); + + return ( +
+
+
+

{job.title}

+ {job.starred ? Starred : null} + {job.applied_at ? Applied : null} +
+
+ {job.source} + {job.remote_type ? ( + + {job.remote_type} + + ) : null} + {job.salary_min || job.salary_max ? ( + {formatSalary(job.salary_min, job.salary_max)} + ) : null} +
+ {job.description ? ( +

+ {truncate(job.description, 220)} +

+ ) : null} +
+ {job.apply_url ? ( + + Postuler + + ) : null} + + Source + +
+
+ +
+ +

{job.company || "Non précisée"}

+ + +
+ +
+ +

{job.location || "Non précisé"}

+
+ +
+ +
+ {job.stack.length ? ( + job.stack.map((item) => {item}) + ) : ( +

Non renseignée

+ )} +
+
+ +
+ +
+ + + +
+