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 { HealthModule } from "./modules/health/health.module";
|
||||||
import { AuthModule } from "./modules/auth/auth.module";
|
import { AuthModule } from "./modules/auth/auth.module";
|
||||||
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
|
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
|
||||||
|
import { JobsModule } from "./modules/jobs/jobs.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, DbModule, HealthModule, AuthModule],
|
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const EnvSchema = z.object({
|
const EnvSchema = z.object({
|
||||||
@ -10,7 +12,6 @@ const EnvSchema = z.object({
|
|||||||
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
|
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
|
||||||
SUPABASE_SCHEMA: z.string().default("ordinarthur_os"),
|
SUPABASE_SCHEMA: z.string().default("ordinarthur_os"),
|
||||||
|
|
||||||
// Phase 5+ — optionnels jusque-là
|
|
||||||
MISTRAL_API_KEY: z.string().optional(),
|
MISTRAL_API_KEY: z.string().optional(),
|
||||||
MISTRAL_MODEL: z.string().default("mistral-small-latest"),
|
MISTRAL_MODEL: z.string().default("mistral-small-latest"),
|
||||||
GROQ_API_KEY: z.string().optional(),
|
GROQ_API_KEY: z.string().optional(),
|
||||||
@ -31,12 +32,53 @@ let cached: AppConfig | null = null;
|
|||||||
|
|
||||||
export function loadConfig(): AppConfig {
|
export function loadConfig(): AppConfig {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
|
loadEnvFile();
|
||||||
|
|
||||||
const parsed = EnvSchema.safeParse(process.env);
|
const parsed = EnvSchema.safeParse(process.env);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
|
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
|
||||||
throw new Error("Invalid environment configuration");
|
throw new Error("Invalid environment configuration");
|
||||||
}
|
}
|
||||||
|
|
||||||
cached = parsed.data;
|
cached = parsed.data;
|
||||||
return cached;
|
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";
|
import type { AppConfig } from "../config/env";
|
||||||
|
|
||||||
export const SUPABASE = Symbol("SUPABASE");
|
export const SUPABASE = Symbol("SUPABASE");
|
||||||
export type Supabase = SupabaseClient;
|
export type Supabase = SupabaseClient<any, any, any, any, any>;
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@ -12,7 +12,7 @@ export type Supabase = SupabaseClient;
|
|||||||
{
|
{
|
||||||
provide: SUPABASE,
|
provide: SUPABASE,
|
||||||
inject: [APP_CONFIG],
|
inject: [APP_CONFIG],
|
||||||
useFactory: (config: AppConfig): SupabaseClient =>
|
useFactory: (config: AppConfig): Supabase =>
|
||||||
createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
|
createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
|
||||||
auth: { persistSession: false, autoRefreshToken: false },
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
db: { schema: config.SUPABASE_SCHEMA },
|
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).
|
* 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";
|
const TOKEN_KEY = "ordinarthur.bearer";
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
|
|||||||
@ -1,26 +1,95 @@
|
|||||||
/* prettier-ignore-start */
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
// Ce fichier est régénéré par @tanstack/router-vite-plugin au premier `pnpm dev`.
|
// noinspection JSUnusedGlobalSymbols
|
||||||
// On commit un stub minimal pour que `tsc --noEmit` passe avant le premier dev run.
|
|
||||||
|
|
||||||
import { Route as rootRoute } from "./routes/__root";
|
// This file was automatically generated by TanStack Router.
|
||||||
import { Route as IndexRoute } from "./routes/index";
|
// 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({
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
path: "/",
|
import { Route as JobsRouteImport } from './routes/jobs'
|
||||||
getParentRoute: () => rootRoute,
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
} as any);
|
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
|
||||||
|
|
||||||
export const routeTree = rootRoute.addChildren({
|
const JobsRoute = JobsRouteImport.update({
|
||||||
IndexRoute: IndexRouteWithParent,
|
id: '/jobs',
|
||||||
} as any);
|
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 {
|
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>
|
<Label className="text-ink">ORDINARTHUR-OS</Label>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-4">
|
<nav className="flex items-center gap-4">
|
||||||
{/* Routes activées au fur et à mesure des phases */}
|
|
||||||
<NavLink to="/">Dashboard</NavLink>
|
<NavLink to="/">Dashboard</NavLink>
|
||||||
|
<NavLink to="/jobs">Jobs</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -25,7 +25,7 @@ function RootLayout() {
|
|||||||
</main>
|
</main>
|
||||||
<footer className="border-t border-ink">
|
<footer className="border-t border-ink">
|
||||||
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
|
<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>
|
<Label>arthurbarre.fr</Label>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<section>
|
<section>
|
||||||
<Label prefix="[ 00 ]">PHASE 0 · SCAFFOLD</Label>
|
<Label prefix="[ 00 ]">PHASE 0 → 1</Label>
|
||||||
<BigHeading className="mt-4">
|
<BigHeading className="mt-4">
|
||||||
Un assistant <em>qui n'agit jamais</em> sans ton clic.
|
Un assistant <em>qui n'agit jamais</em> sans ton clic.
|
||||||
</BigHeading>
|
</BigHeading>
|
||||||
@ -57,8 +57,8 @@ function Dashboard() {
|
|||||||
<Label prefix="[ 02 ]">ROADMAP</Label>
|
<Label prefix="[ 02 ]">ROADMAP</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<MetaRow label="PHASE 0" value="Scaffold (en cours)" />
|
<MetaRow label="PHASE 0" value="Scaffold socle en place" />
|
||||||
<MetaRow label="PHASE 1" value="Jobs · prio remontée" />
|
<MetaRow label="PHASE 1" value="Jobs · première livraison utilisable" />
|
||||||
<MetaRow label="PHASE 2" value="Todos riches" />
|
<MetaRow label="PHASE 2" value="Todos riches" />
|
||||||
<MetaRow label="PHASE 3" value="Projets + Kanban" />
|
<MetaRow label="PHASE 3" value="Projets + Kanban" />
|
||||||
<MetaRow label="PHASE 4" value="Agenda + Google Calendar" />
|
<MetaRow label="PHASE 4" value="Agenda + Google Calendar" />
|
||||||
@ -67,6 +67,29 @@ function Dashboard() {
|
|||||||
<MetaRow label="PHASE 7" value="Health tab" />
|
<MetaRow label="PHASE 7" value="Health tab" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</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 const RemoteType = z.enum(["remote", "hybrid", "onsite"]);
|
||||||
export type RemoteType = z.infer<typeof RemoteType>;
|
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)
|
// 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