add jobs pages
This commit is contained in:
parent
bc0c15873f
commit
eb430b59e6
29
AGENTS.md
Normal file
29
AGENTS.md
Normal file
@ -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`
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<any, any, any, any, any>;
|
||||
|
||||
@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 },
|
||||
|
||||
16
apps/api/src/lib/zod-pipe.ts
Normal file
16
apps/api/src/lib/zod-pipe.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { BadRequestException, PipeTransform } from "@nestjs/common";
|
||||
import type { ZodSchema } from "zod";
|
||||
|
||||
export class ZodPipe<T> implements PipeTransform<unknown, T> {
|
||||
constructor(private readonly schema: ZodSchema<T>) {}
|
||||
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;
|
||||
}
|
||||
}
|
||||
68
apps/api/src/modules/jobs/jobs.controller.ts
Normal file
68
apps/api/src/modules/jobs/jobs.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/jobs/jobs.module.ts
Normal file
9
apps/api/src/modules/jobs/jobs.module.ts
Normal file
@ -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 {}
|
||||
149
apps/api/src/modules/jobs/jobs.service.ts
Normal file
149
apps/api/src/modules/jobs/jobs.service.ts
Normal file
@ -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<JobIngestResponse> {
|
||||
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<number> {
|
||||
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<Job[]> {
|
||||
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<Job> {
|
||||
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<JobSearchCriteria[]> {
|
||||
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<JobSearchCriteria> {
|
||||
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<JobSearchCriteria> {
|
||||
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<void> {
|
||||
const { error } = await this.db.from("job_search_criteria").delete().eq("id", id);
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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<FileRouteTypes>()
|
||||
|
||||
@ -15,8 +15,8 @@ function RootLayout() {
|
||||
<Label className="text-ink">ORDINARTHUR-OS</Label>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
{/* Routes activées au fur et à mesure des phases */}
|
||||
<NavLink to="/">Dashboard</NavLink>
|
||||
<NavLink to="/jobs">Jobs</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@ -25,7 +25,7 @@ function RootLayout() {
|
||||
</main>
|
||||
<footer className="border-t border-ink">
|
||||
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
|
||||
<Label>v0.0.0 · phase 0</Label>
|
||||
<Label>v0.0.0 · phase 1</Label>
|
||||
<Label>arthurbarre.fr</Label>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@ -15,7 +15,7 @@ function Dashboard() {
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<section>
|
||||
<Label prefix="[ 00 ]">PHASE 0 · SCAFFOLD</Label>
|
||||
<Label prefix="[ 00 ]">PHASE 0 → 1</Label>
|
||||
<BigHeading className="mt-4">
|
||||
Un assistant <em>qui n'agit jamais</em> sans ton clic.
|
||||
</BigHeading>
|
||||
@ -57,8 +57,8 @@ function Dashboard() {
|
||||
<Label prefix="[ 02 ]">ROADMAP</Label>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<MetaRow label="PHASE 0" value="Scaffold (en cours)" />
|
||||
<MetaRow label="PHASE 1" value="Jobs · prio remontée" />
|
||||
<MetaRow label="PHASE 0" value="Scaffold socle en place" />
|
||||
<MetaRow label="PHASE 1" value="Jobs · première livraison utilisable" />
|
||||
<MetaRow label="PHASE 2" value="Todos riches" />
|
||||
<MetaRow label="PHASE 3" value="Projets + Kanban" />
|
||||
<MetaRow label="PHASE 4" value="Agenda + Google Calendar" />
|
||||
@ -67,6 +67,29 @@ function Dashboard() {
|
||||
<MetaRow label="PHASE 7" value="Health tab" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<GridFrame cols={2}>
|
||||
<div className="p-6 space-y-4">
|
||||
<Label prefix="[ 03 ]">PRIORITÉ</Label>
|
||||
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
|
||||
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.
|
||||
</p>
|
||||
<a
|
||||
href="/jobs"
|
||||
className="inline-flex items-center border border-ink px-3 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg"
|
||||
>
|
||||
Ouvrir les jobs
|
||||
</a>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<Label prefix="[ 04 ]">MODE</Label>
|
||||
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
|
||||
Les presets de critères vivent maintenant dans le header de la page jobs pour rester
|
||||
au plus près du tri quotidien.
|
||||
</p>
|
||||
</div>
|
||||
</GridFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
483
apps/pwa/src/routes/jobs.tsx
Normal file
483
apps/pwa/src/routes/jobs.tsx
Normal file
@ -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<RemoteType | "all">("all");
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [stackFilter, setStackFilter] = useState("");
|
||||
const [activeCriteriaId, setActiveCriteriaId] = useState<string | null>(null);
|
||||
|
||||
const criteria = useQuery({
|
||||
queryKey: ["job-criteria"],
|
||||
queryFn: () => api<JobSearchCriteria[]>("/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<JobListQuery>(
|
||||
() => ({
|
||||
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<Job[]>(`/jobs${toQueryString(query)}`),
|
||||
});
|
||||
|
||||
const patchJob = useMutation({
|
||||
mutationFn: ({ id, patch }: { id: string; patch: JobPatchDto }) =>
|
||||
api<Job>(`/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 (
|
||||
<div className="space-y-10">
|
||||
<section className="space-y-4">
|
||||
<Label prefix="[ 01 ]">PHASE 1 · JOBS</Label>
|
||||
<BigHeading>
|
||||
Une veille <em>propre et triable</em> pour les candidatures.
|
||||
</BigHeading>
|
||||
<p className="max-w-3xl font-sans text-sm leading-6 text-ink">
|
||||
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é.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<SectionHeader
|
||||
number="02"
|
||||
label="HEADER FILTERS"
|
||||
title="Critères actifs, remote, stack, favoris et archivage"
|
||||
/>
|
||||
|
||||
<GridFrame cols={12}>
|
||||
<div className="col-span-12 border-b border-ink p-4">
|
||||
<Label>CRITÈRES</Label>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCriteriaId(null)}
|
||||
className={filterButtonClass(activeCriteriaId === null)}
|
||||
>
|
||||
Tous les jobs
|
||||
</button>
|
||||
{criteria.data?.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCriteriaId(item.id)}
|
||||
className={filterButtonClass(activeCriteriaId === item.id)}
|
||||
>
|
||||
{item.name || "Sans nom"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeCriteria ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{activeCriteria.titles.map((title) => (
|
||||
<DataChip key={title}>{title}</DataChip>
|
||||
))}
|
||||
{activeCriteria.locations.map((location) => (
|
||||
<DataChip key={location}>{location}</DataChip>
|
||||
))}
|
||||
{activeCriteria.stack.map((item) => (
|
||||
<DataChip key={item}>{item}</DataChip>
|
||||
))}
|
||||
{activeCriteria.salary_min ? (
|
||||
<DataChip>Min {formatSalary(activeCriteria.salary_min, null)}</DataChip>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 border-b border-ink p-4 md:col-span-4 md:border-b-0 md:border-r md:border-ink">
|
||||
<Label>REMOTE TYPE</Label>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{REMOTE_TYPES.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setRemoteType(item.value)}
|
||||
className={filterButtonClass(remoteType === item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 border-b border-ink p-4 md:col-span-4 md:border-b-0 md:border-r md:border-ink">
|
||||
<Label>STACK</Label>
|
||||
<input
|
||||
value={stackFilter}
|
||||
onChange={(event) => {
|
||||
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"
|
||||
/>
|
||||
<p className="mt-2 font-mono text-[11px] uppercase tracking-label text-muted">
|
||||
Séparer par virgules pour filtrer sur plusieurs technos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 p-4 md:col-span-4">
|
||||
<Label>OPTIONS</Label>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStarredOnly((value) => !value)}
|
||||
className={filterButtonClass(starredOnly)}
|
||||
>
|
||||
Favoris
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived((value) => !value)}
|
||||
className={filterButtonClass(showArchived)}
|
||||
>
|
||||
Archivés
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GridFrame>
|
||||
|
||||
<section className="border border-ink">
|
||||
<div className="border-b border-ink px-4 py-3">
|
||||
<Label prefix="[ 03 ]">SYNTHÈSE</Label>
|
||||
</div>
|
||||
<div className="grid gap-0 md:grid-cols-4 md:divide-x md:divide-ink">
|
||||
<StatCell label="Résultats" value={String(visibleJobs.length)} />
|
||||
<StatCell label="Favoris" value={String(stats.starred)} />
|
||||
<StatCell label="Remote" value={String(stats.remote)} />
|
||||
<StatCell label="Nouvelles 7j" value={String(stats.fresh)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SectionHeader
|
||||
number="04"
|
||||
label="LISTE"
|
||||
title={
|
||||
jobs.isLoading
|
||||
? "Chargement des offres"
|
||||
: `${visibleJobs.length} offres prêtes à être triées`
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="border border-ink">
|
||||
<div className="hidden border-b border-ink px-4 py-3 md:grid md:grid-cols-[2.2fr,1.2fr,1fr,1fr,1.2fr] md:gap-4">
|
||||
<Label>Poste</Label>
|
||||
<Label>Entreprise</Label>
|
||||
<Label>Lieu</Label>
|
||||
<Label>Stack</Label>
|
||||
<Label>Suivi</Label>
|
||||
</div>
|
||||
|
||||
{jobs.isLoading || criteria.isLoading ? (
|
||||
<EmptyState text="Connexion au BFF pour charger les jobs et leurs filtres." />
|
||||
) : jobs.isError ? (
|
||||
<EmptyState text="Impossible de charger les offres. Vérifie le bearer token et l’API." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
<EmptyState text="Aucun job ne correspond à ces filtres pour le moment." />
|
||||
) : (
|
||||
<div className="divide-y divide-ink">
|
||||
{visibleJobs.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
onPatch={(patch) => patchJob.mutate({ id: job.id, patch })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobRow({ job, onPatch }: { job: Job; onPatch: (patch: JobPatchDto) => void }) {
|
||||
const [notesDraft, setNotesDraft] = useState(job.notes ?? "");
|
||||
|
||||
return (
|
||||
<article className="grid gap-4 px-4 py-4 md:grid-cols-[2.2fr,1.2fr,1fr,1fr,1.2fr] md:items-start">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-sans text-lg font-light text-ink">{job.title}</h3>
|
||||
{job.starred ? <DataChip dotColor="accent">Starred</DataChip> : null}
|
||||
{job.applied_at ? <DataChip dotColor="accent">Applied</DataChip> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DataChip>{job.source}</DataChip>
|
||||
{job.remote_type ? (
|
||||
<DataChip dotColor={job.remote_type === "remote" ? "accent" : "ink"}>
|
||||
{job.remote_type}
|
||||
</DataChip>
|
||||
) : null}
|
||||
{job.salary_min || job.salary_max ? (
|
||||
<DataChip>{formatSalary(job.salary_min, job.salary_max)}</DataChip>
|
||||
) : null}
|
||||
</div>
|
||||
{job.description ? (
|
||||
<p className="max-w-2xl font-sans text-sm leading-6 text-ink/80">
|
||||
{truncate(job.description, 220)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{job.apply_url ? (
|
||||
<a
|
||||
href={job.apply_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono text-[11px] uppercase tracking-label text-ink underline underline-offset-4"
|
||||
>
|
||||
Postuler
|
||||
</a>
|
||||
) : null}
|
||||
<a
|
||||
href={job.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono text-[11px] uppercase tracking-label text-muted underline underline-offset-4"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>ENTREPRISE</Label>
|
||||
<p className="font-sans text-sm text-ink">{job.company || "Non précisée"}</p>
|
||||
<MetaMini label="Vu le" value={formatDate(job.last_seen_at)} />
|
||||
<MetaMini label="Ajouté le" value={formatDate(job.first_seen_at)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>LIEU</Label>
|
||||
<p className="font-sans text-sm text-ink">{job.location || "Non précisé"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>STACK</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{job.stack.length ? (
|
||||
job.stack.map((item) => <DataChip key={item}>{item}</DataChip>)
|
||||
) : (
|
||||
<p className="font-sans text-sm text-muted">Non renseignée</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>SUIVI</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPatch({ starred: !job.starred })}
|
||||
className={actionButtonClass(job.starred)}
|
||||
>
|
||||
{job.starred ? "Unstar" : "Star"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onPatch({ applied_at: job.applied_at ? null : new Date().toISOString() })
|
||||
}
|
||||
className={actionButtonClass(Boolean(job.applied_at))}
|
||||
>
|
||||
{job.applied_at ? "Retirer applied" : "Marquer applied"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPatch({ archived: !job.archived })}
|
||||
className={actionButtonClass(job.archived)}
|
||||
>
|
||||
{job.archived ? "Restaurer" : "Archiver"}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(event) => setNotesDraft(event.target.value)}
|
||||
onBlur={() => {
|
||||
if (notesDraft !== (job.notes ?? "")) {
|
||||
onPatch({ notes: notesDraft.trim() ? notesDraft : null });
|
||||
}
|
||||
}}
|
||||
rows={4}
|
||||
placeholder="Notes de candidature, contact, next step…"
|
||||
className="w-full border border-ink bg-transparent px-3 py-2 font-sans text-sm text-ink outline-none placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="space-y-2 px-4 py-4">
|
||||
<Label>{label}</Label>
|
||||
<p className="font-sans text-3xl font-light text-ink">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaMini({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="border-b border-ink py-2 last:border-b-0">
|
||||
<p className="font-mono text-[11px] uppercase tracking-label text-muted">{label}</p>
|
||||
<p className="mt-1 font-sans text-sm text-ink">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="px-4 py-12">
|
||||
<p className="max-w-xl font-sans text-sm leading-6 text-ink">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getJobStats(jobs: Job[]) {
|
||||
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
return {
|
||||
starred: jobs.filter((job) => job.starred).length,
|
||||
remote: jobs.filter((job) => job.remote_type === "remote").length,
|
||||
fresh: jobs.filter((job) => new Date(job.last_seen_at).getTime() >= oneWeekAgo).length,
|
||||
};
|
||||
}
|
||||
|
||||
function toQueryString(query: JobListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (typeof query.archived === "boolean") params.set("archived", String(query.archived));
|
||||
if (typeof query.starred === "boolean") params.set("starred", String(query.starred));
|
||||
if (query.remote_type) params.set("remote_type", query.remote_type);
|
||||
query.stack?.forEach((value) => params.append("stack", value));
|
||||
|
||||
const serialized = params.toString();
|
||||
return serialized ? `?${serialized}` : "";
|
||||
}
|
||||
|
||||
function normalizeTags(value: string) {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function truncate(value: string, length: number) {
|
||||
return value.length <= length ? value : `${value.slice(0, length).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatSalary(min?: number | null, max?: number | null) {
|
||||
const formatter = new Intl.NumberFormat("fr-FR");
|
||||
if (min && max) return `${formatter.format(min)}–${formatter.format(max)}€`;
|
||||
if (min) return `dès ${formatter.format(min)}€`;
|
||||
if (max) return `jusqu’à ${formatter.format(max)}€`;
|
||||
return "—";
|
||||
}
|
||||
|
||||
function filterButtonClass(active: boolean) {
|
||||
return [
|
||||
"border px-3 py-2 font-mono text-[11px] uppercase tracking-label transition-colors",
|
||||
active ? "border-ink bg-ink text-bg" : "border-ink text-ink hover:bg-ink hover:text-bg",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function actionButtonClass(active: boolean) {
|
||||
return [
|
||||
"border px-3 py-2 font-mono text-[11px] uppercase tracking-label transition-colors",
|
||||
active ? "border-accent bg-accent text-bg" : "border-ink text-ink hover:bg-ink hover:text-bg",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function matchesCriteria(job: Job, criteria: JobSearchCriteria | null) {
|
||||
if (!criteria) return true;
|
||||
|
||||
const title = job.title.toLowerCase();
|
||||
const location = (job.location ?? "").toLowerCase();
|
||||
|
||||
const titleMatch =
|
||||
criteria.titles.length === 0 ||
|
||||
criteria.titles.some((item) => title.includes(item.toLowerCase()));
|
||||
const locationMatch =
|
||||
criteria.locations.length === 0 ||
|
||||
criteria.locations.some((item) => location.includes(item.toLowerCase()));
|
||||
const remoteMatch =
|
||||
criteria.remote_types.length === 0 ||
|
||||
(job.remote_type ? criteria.remote_types.includes(job.remote_type) : false);
|
||||
const salaryMatch =
|
||||
!criteria.salary_min || (job.salary_max ?? job.salary_min ?? 0) >= criteria.salary_min;
|
||||
|
||||
return titleMatch && locationMatch && remoteMatch && salaryMatch;
|
||||
}
|
||||
387
apps/pwa/src/routes/settings.jobs.tsx
Normal file
387
apps/pwa/src/routes/settings.jobs.tsx
Normal file
@ -0,0 +1,387 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { JobSearchCriteria, JobSearchCriteriaUpsert, RemoteType } from "@ordinarthur-os/shared";
|
||||
import { api } from "@/api/client";
|
||||
import { BigHeading, DataChip, GridFrame, Label, SectionHeader } from "@/design";
|
||||
|
||||
export const Route = createFileRoute("/settings/jobs")({ component: JobCriteriaPage });
|
||||
|
||||
const DEFAULT_FORM: JobSearchCriteriaUpsert = {
|
||||
name: "",
|
||||
titles: [],
|
||||
locations: [],
|
||||
stack: [],
|
||||
remote_types: [],
|
||||
salary_min: null,
|
||||
active: true,
|
||||
};
|
||||
|
||||
const REMOTE_TYPES: RemoteType[] = ["remote", "hybrid", "onsite"];
|
||||
|
||||
function JobCriteriaPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [createForm, setCreateForm] = useState<CriteriaFormState>(toFormState(DEFAULT_FORM));
|
||||
|
||||
const criteria = useQuery({
|
||||
queryKey: ["job-criteria"],
|
||||
queryFn: () => api<JobSearchCriteria[]>("/jobs/criteria"),
|
||||
});
|
||||
|
||||
const createCriteria = useMutation({
|
||||
mutationFn: (payload: JobSearchCriteriaUpsert) =>
|
||||
api<JobSearchCriteria>("/jobs/criteria", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCreateForm(toFormState(DEFAULT_FORM));
|
||||
void queryClient.invalidateQueries({ queryKey: ["job-criteria"] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateCriteria = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: JobSearchCriteriaUpsert }) =>
|
||||
api<JobSearchCriteria>(`/jobs/criteria/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["job-criteria"] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCriteria = useMutation({
|
||||
mutationFn: (id: string) => api<void>(`/jobs/criteria/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["job-criteria"] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<section className="space-y-4">
|
||||
<Label prefix="[ 01 ]">SETTINGS · JOBS</Label>
|
||||
<BigHeading>
|
||||
Les critères restent <em>visibles et éditables</em>.
|
||||
</BigHeading>
|
||||
<p className="max-w-3xl font-sans text-sm leading-6 text-ink">
|
||||
Chaque critère alimente l’ingestion quotidienne hors repo. Ici, la PWA sert de cockpit
|
||||
simple pour affiner titres, localisations, stack, remote et seuil de salaire.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<SectionHeader
|
||||
number="02"
|
||||
label="NOUVEAU"
|
||||
title="Ajouter un critère de veille"
|
||||
/>
|
||||
|
||||
<CriteriaForm
|
||||
key="create"
|
||||
mode="create"
|
||||
form={createForm}
|
||||
onChange={setCreateForm}
|
||||
onSubmit={() => createCriteria.mutate(toPayload(createForm))}
|
||||
busy={createCriteria.isPending}
|
||||
/>
|
||||
|
||||
<SectionHeader
|
||||
number="03"
|
||||
label="CRITÈRES"
|
||||
title={
|
||||
criteria.isLoading
|
||||
? "Chargement des critères"
|
||||
: `${criteria.data?.length ?? 0} critères enregistrés`
|
||||
}
|
||||
/>
|
||||
|
||||
{criteria.isLoading ? (
|
||||
<div className="border border-ink px-4 py-10">
|
||||
<p className="font-sans text-sm text-ink">Lecture des critères en cours.</p>
|
||||
</div>
|
||||
) : criteria.isError ? (
|
||||
<div className="border border-ink px-4 py-10">
|
||||
<p className="font-sans text-sm text-ink">
|
||||
Impossible de charger les critères. Vérifie l’API et le bearer token.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{criteria.data?.map((item) => (
|
||||
<EditableCriteriaCard
|
||||
key={item.id}
|
||||
criteria={item}
|
||||
onSave={(payload) => updateCriteria.mutate({ id: item.id, payload })}
|
||||
onDelete={() => deleteCriteria.mutate(item.id)}
|
||||
saving={updateCriteria.isPending}
|
||||
deleting={deleteCriteria.isPending}
|
||||
/>
|
||||
))}
|
||||
{criteria.data?.length === 0 ? (
|
||||
<div className="border border-ink px-4 py-10">
|
||||
<p className="font-sans text-sm text-ink">
|
||||
Aucun critère pour l’instant. Ajoute-en un pour alimenter l’ingestion quotidienne.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableCriteriaCard({
|
||||
criteria,
|
||||
onSave,
|
||||
onDelete,
|
||||
saving,
|
||||
deleting,
|
||||
}: {
|
||||
criteria: JobSearchCriteria;
|
||||
onSave: (payload: JobSearchCriteriaUpsert) => void;
|
||||
onDelete: () => void;
|
||||
saving: boolean;
|
||||
deleting: boolean;
|
||||
}) {
|
||||
const [form, setForm] = useState<CriteriaFormState>(() => toFormState(criteria));
|
||||
|
||||
return (
|
||||
<div className="border border-ink">
|
||||
<div className="border-b border-ink px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label prefix="[ LIVE ]">{criteria.name || "CRITÈRE SANS NOM"}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DataChip dotColor={criteria.active ? "accent" : "ink"}>
|
||||
{criteria.active ? "Actif" : "Inactif"}
|
||||
</DataChip>
|
||||
<DataChip>Maj {formatDate(criteria.updated_at)}</DataChip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CriteriaForm
|
||||
mode="edit"
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
onSubmit={() => onSave(toPayload(form))}
|
||||
onDelete={onDelete}
|
||||
busy={saving || deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CriteriaForm({
|
||||
mode,
|
||||
form,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
busy,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
form: CriteriaFormState;
|
||||
onChange: (next: CriteriaFormState) => void;
|
||||
onSubmit: () => void;
|
||||
onDelete?: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<GridFrame cols={12}>
|
||||
<FormCell label="Nom">
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(event) => onChange({ ...form, name: event.target.value })}
|
||||
placeholder="Frontend senior Marseille"
|
||||
className="w-full border border-ink bg-transparent px-3 py-2 font-sans text-sm text-ink outline-none placeholder:text-muted"
|
||||
/>
|
||||
</FormCell>
|
||||
|
||||
<FormCell label="Titres" className="md:col-span-3">
|
||||
<TagTextarea
|
||||
value={form.titles}
|
||||
placeholder="développeur front, frontend engineer"
|
||||
onChange={(value) => onChange({ ...form, titles: value })}
|
||||
/>
|
||||
</FormCell>
|
||||
|
||||
<FormCell label="Localisations" className="md:col-span-3">
|
||||
<TagTextarea
|
||||
value={form.locations}
|
||||
placeholder="Marseille, Aix-en-Provence, remote"
|
||||
onChange={(value) => onChange({ ...form, locations: value })}
|
||||
/>
|
||||
</FormCell>
|
||||
|
||||
<FormCell label="Stack" className="md:col-span-3">
|
||||
<TagTextarea
|
||||
value={form.stack}
|
||||
placeholder="react, typescript, nestjs"
|
||||
onChange={(value) => onChange({ ...form, stack: value })}
|
||||
/>
|
||||
</FormCell>
|
||||
|
||||
<FormCell label="Salaire min" className="md:col-span-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.salaryMin}
|
||||
onChange={(event) => onChange({ ...form, salaryMin: event.target.value })}
|
||||
placeholder="45000"
|
||||
className="w-full border border-ink bg-transparent px-3 py-2 font-sans text-sm text-ink outline-none placeholder:text-muted"
|
||||
/>
|
||||
</FormCell>
|
||||
|
||||
<FormCell label="Remote" className="md:col-span-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{REMOTE_TYPES.map((remoteType) => {
|
||||
const active = form.remoteTypes.includes(remoteType);
|
||||
return (
|
||||
<button
|
||||
key={remoteType}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...form,
|
||||
remoteTypes: toggleArrayValue(form.remoteTypes, remoteType),
|
||||
})
|
||||
}
|
||||
className={buttonClass(active)}
|
||||
>
|
||||
{remoteType}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormCell>
|
||||
|
||||
<FormCell label="Statut" className="md:col-span-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...form, active: !form.active })}
|
||||
className={buttonClass(form.active)}
|
||||
>
|
||||
{form.active ? "Actif" : "Inactif"}
|
||||
</button>
|
||||
</FormCell>
|
||||
|
||||
<div className="col-span-12 flex flex-wrap items-center gap-3 p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={busy}
|
||||
className="border border-ink bg-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-bg transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{mode === "create" ? "Créer le critère" : "Enregistrer"}
|
||||
</button>
|
||||
{mode === "edit" && onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
className="border border-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-opacity disabled:opacity-50"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</GridFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function FormCell({
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={["col-span-12 border-b border-ink p-4 md:col-span-6", className].filter(Boolean).join(" ")}>
|
||||
<Label>{label}</Label>
|
||||
<div className="mt-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagTextarea({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
rows={4}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full border border-ink bg-transparent px-3 py-2 font-sans text-sm text-ink outline-none placeholder:text-muted"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function toPayload(form: CriteriaFormState): JobSearchCriteriaUpsert {
|
||||
return {
|
||||
name: form.name.trim() || null,
|
||||
titles: splitTags(form.titles),
|
||||
locations: splitTags(form.locations),
|
||||
stack: splitTags(form.stack),
|
||||
remote_types: form.remoteTypes,
|
||||
salary_min: form.salaryMin.trim() ? Number(form.salaryMin) : null,
|
||||
active: form.active,
|
||||
};
|
||||
}
|
||||
|
||||
function toFormState(value: JobSearchCriteriaUpsert | JobSearchCriteria): CriteriaFormState {
|
||||
return {
|
||||
name: value.name ?? "",
|
||||
titles: (value.titles ?? []).join(", "),
|
||||
locations: (value.locations ?? []).join(", "),
|
||||
stack: (value.stack ?? []).join(", "),
|
||||
remoteTypes: [...(value.remote_types ?? [])],
|
||||
salaryMin: value.salary_min?.toString() ?? "",
|
||||
active: value.active ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function splitTags(value: string) {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toggleArrayValue<T extends string>(values: T[], next: T) {
|
||||
return values.includes(next) ? values.filter((item) => item !== next) : [...values, next];
|
||||
}
|
||||
|
||||
function buttonClass(active: boolean) {
|
||||
return [
|
||||
"border px-3 py-2 font-mono text-[11px] uppercase tracking-label transition-colors",
|
||||
active ? "border-ink bg-ink text-bg" : "border-ink text-ink hover:bg-ink hover:text-bg",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
interface CriteriaFormState {
|
||||
name: string;
|
||||
titles: string;
|
||||
locations: string;
|
||||
stack: string;
|
||||
remoteTypes: RemoteType[];
|
||||
salaryMin: string;
|
||||
active: boolean;
|
||||
}
|
||||
54
packages/db/migrations/0002_jobs.sql
Normal file
54
packages/db/migrations/0002_jobs.sql
Normal file
@ -0,0 +1,54 @@
|
||||
-- 0002_jobs.sql — Phase 1
|
||||
-- Tables: jobs, job_search_criteria
|
||||
set search_path to ordinarthur_os, public;
|
||||
|
||||
create table if not exists ordinarthur_os.jobs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
source text not null, -- 'Indeed','WeLoveDevs',...
|
||||
source_url text not null unique, -- clé de dedup
|
||||
title text not null,
|
||||
company text,
|
||||
description text,
|
||||
location text,
|
||||
remote_type text check (remote_type in ('remote','hybrid','onsite')),
|
||||
salary_min int,
|
||||
salary_max int,
|
||||
stack text[] not null default '{}',
|
||||
apply_url text,
|
||||
first_seen_at timestamptz not null default now(),
|
||||
last_seen_at timestamptz not null default now(),
|
||||
archived boolean not null default false,
|
||||
starred boolean not null default false,
|
||||
applied_at timestamptz,
|
||||
notes text
|
||||
);
|
||||
create index if not exists jobs_last_seen_idx on ordinarthur_os.jobs(last_seen_at desc);
|
||||
create index if not exists jobs_archived_idx on ordinarthur_os.jobs(archived);
|
||||
create index if not exists jobs_remote_type_idx on ordinarthur_os.jobs(remote_type);
|
||||
create index if not exists jobs_stack_gin on ordinarthur_os.jobs using gin (stack);
|
||||
|
||||
alter table ordinarthur_os.jobs enable row level security;
|
||||
drop policy if exists jobs_service_role on ordinarthur_os.jobs;
|
||||
create policy jobs_service_role on ordinarthur_os.jobs
|
||||
for all using (auth.role() = 'service_role')
|
||||
with check (auth.role() = 'service_role');
|
||||
|
||||
create table if not exists ordinarthur_os.job_search_criteria (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text,
|
||||
titles text[] not null default '{}',
|
||||
locations text[] not null default '{}',
|
||||
stack text[] not null default '{}',
|
||||
remote_types text[] not null default '{}',
|
||||
salary_min int,
|
||||
active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create index if not exists job_criteria_active_idx on ordinarthur_os.job_search_criteria(active);
|
||||
|
||||
alter table ordinarthur_os.job_search_criteria enable row level security;
|
||||
drop policy if exists job_criteria_service_role on ordinarthur_os.job_search_criteria;
|
||||
create policy job_criteria_service_role on ordinarthur_os.job_search_criteria
|
||||
for all using (auth.role() = 'service_role')
|
||||
with check (auth.role() = 'service_role');
|
||||
@ -23,6 +23,96 @@ export type AuthVerifyResponse = z.infer<typeof AuthVerifyResponse>;
|
||||
export const RemoteType = z.enum(["remote", "hybrid", "onsite"]);
|
||||
export type RemoteType = z.infer<typeof RemoteType>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 1 — Jobs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const JobIngestDto = z.object({
|
||||
source: z.string().min(1),
|
||||
source_url: z.string().url(),
|
||||
title: z.string().min(1),
|
||||
company: z.string().optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
location: z.string().optional().nullable(),
|
||||
remote_type: RemoteType.optional().nullable(),
|
||||
salary_min: z.number().int().nonnegative().optional().nullable(),
|
||||
salary_max: z.number().int().nonnegative().optional().nullable(),
|
||||
stack: z.array(z.string()).default([]),
|
||||
apply_url: z.string().url().optional().nullable(),
|
||||
});
|
||||
export type JobIngestDto = z.infer<typeof JobIngestDto>;
|
||||
|
||||
export const JobIngestRequest = z.object({
|
||||
jobs: z.array(JobIngestDto).min(1).max(500),
|
||||
});
|
||||
export type JobIngestRequest = z.infer<typeof JobIngestRequest>;
|
||||
|
||||
export const JobIngestResponse = z.object({
|
||||
inserted: z.number().int().nonnegative(),
|
||||
updated: z.number().int().nonnegative(),
|
||||
archived: z.number().int().nonnegative(),
|
||||
});
|
||||
export type JobIngestResponse = z.infer<typeof JobIngestResponse>;
|
||||
|
||||
export const Job = JobIngestDto.extend({
|
||||
id: z.string().uuid(),
|
||||
first_seen_at: z.string(),
|
||||
last_seen_at: z.string(),
|
||||
archived: z.boolean(),
|
||||
starred: z.boolean(),
|
||||
applied_at: z.string().nullable(),
|
||||
notes: z.string().nullable(),
|
||||
});
|
||||
export type Job = z.infer<typeof Job>;
|
||||
|
||||
export const JobPatchDto = z.object({
|
||||
starred: z.boolean().optional(),
|
||||
archived: z.boolean().optional(),
|
||||
applied_at: z.string().datetime().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
export type JobPatchDto = z.infer<typeof JobPatchDto>;
|
||||
|
||||
export const JobListQuery = z.object({
|
||||
archived: z
|
||||
.union([z.boolean(), z.enum(["true", "false"])])
|
||||
.transform((v) => (typeof v === "boolean" ? v : v === "true"))
|
||||
.optional(),
|
||||
remote_type: RemoteType.optional(),
|
||||
stack: z.array(z.string()).optional(),
|
||||
since: z.string().datetime().optional(),
|
||||
starred: z
|
||||
.union([z.boolean(), z.enum(["true", "false"])])
|
||||
.transform((v) => (typeof v === "boolean" ? v : v === "true"))
|
||||
.optional(),
|
||||
});
|
||||
export type JobListQuery = z.infer<typeof JobListQuery>;
|
||||
|
||||
export const JobSearchCriteria = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().nullable(),
|
||||
titles: z.array(z.string()),
|
||||
locations: z.array(z.string()),
|
||||
stack: z.array(z.string()),
|
||||
remote_types: z.array(RemoteType),
|
||||
salary_min: z.number().int().nullable(),
|
||||
active: z.boolean(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
export type JobSearchCriteria = z.infer<typeof JobSearchCriteria>;
|
||||
|
||||
export const JobSearchCriteriaUpsert = z.object({
|
||||
name: z.string().optional().nullable(),
|
||||
titles: z.array(z.string()).default([]),
|
||||
locations: z.array(z.string()).default([]),
|
||||
stack: z.array(z.string()).default([]),
|
||||
remote_types: z.array(RemoteType).default([]),
|
||||
salary_min: z.number().int().nonnegative().nullable().optional(),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
export type JobSearchCriteriaUpsert = z.infer<typeof JobSearchCriteriaUpsert>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 5 — AI proposed actions (kept here so PWA + API agree)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
7623
pnpm-lock.yaml
generated
Normal file
7623
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user