diff --git a/.gitignore b/.gitignore index 7d206f3..8d92c69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -# macOS + +migrations/** */ .DS_Store .AppleDouble .LSOverride diff --git a/backend/job/app.py b/backend/job/app.py index b708d6f..cfc8b0e 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -13,11 +13,9 @@ from job.cost.views import HiringCost from sqlalchemy.ext.asyncio import AsyncSession from users.permissions import PermissionTag, require_permission from job.job_post.views import JobPost,JobPostCreate -from job.job_post.models import JobPosts -from job.job_post.serializers import serialize_job_post import logging from users.views import User -from job.job_post.plugins import PlatformAlias +from job.job_post.models import SocialPlatform from fastapi import UploadFile, File, Form from dotenv import load_dotenv from datetime import datetime, time, timezone @@ -118,9 +116,9 @@ class HiringCostCreate(BaseModel): @router.get("/jobs/alias") -async def get_job_alias(): +async def get_job_alias(session: AsyncSession = Depends(get_session)): try: - alias_lst=[k.name for k in PlatformAlias] + alias_lst=await SocialPlatform.list_aliases(session) return JSONResponse(content={"data":alias_lst,"status_code":200}) except HTTPException: raise @@ -245,7 +243,7 @@ async def post_job( try: service=JobPost(session=session) data=payload.model_dump() - if data['mode']=="customScheduled": + if data['mode']=="customScheduled" and data.get('scheduler_date'): data['due_at']=datetime.combine( data['scheduler_date'], data['scheduler_time'] or time(0, 0, 0), @@ -365,6 +363,34 @@ async def fetch_job_posts( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/jobs/fetch") +async def fetch_jobs( + search: str | None = Query(None), + department: str | None = Query(None), + requisition_status: str | None = Query(None), + employment_type: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + # Defaults False, unlike /job/fetch: a requisition list must show CLOSED + # requisitions, and those carry is_active = false. Soft-deleted rows are still + # excluded by the include_deleted branch in fetch_job_posts. + active_only: bool = Query(False), + current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=JobPost(session=session) + data,total=await service.fetch_jobs( + search=search,department=department,requisition_status=requisition_status, + employment_type=employment_type,top=top,skip=skip,active_only=active_only, + ) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/candidate/fetch_by_id") async def fetch_candidate_by_id( candidate_id: str = Query(...), diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index b9f0cb1..e2bfb33 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -667,38 +667,72 @@ class CandidateView: except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + @staticmethod + def _attach_job_post(data,payload,*,as_assigned=False): + """Merge one serialized job post onto a candidate payload. + + Pure, so the per-id path and the batched one below cannot drift. A missing + post attaches nothing, matching the old early return. + """ + if not isinstance(data,dict) or not payload: + return payload + if as_assigned: + data["assigned_job_post"]=payload + if payload.get("created_by_name"): + data["recruiter"]=payload.get("created_by_name") + data["recruiter_id"]=payload.get("created_by") + if payload.get("title"): + data["job_title"]=payload.get("title") + else: + data.setdefault("job_posts",[]).append(payload) + if data.get("recruiter") is None and payload.get("created_by_name"): + data["recruiter"]=payload.get("created_by_name") + data["recruiter_id"]=payload.get("created_by") + if data.get("job_title") is None and payload.get("title"): + data["job_title"]=payload.get("title") + return payload + + async def _job_posts_by_id(self,ids): + """Serialized job posts keyed by id — one query for a whole page of rows. + + active_only=False mirrors get_job_post_by_id, which filters neither flag: + an application assigned to a closed post must keep its title. + """ + wanted=[] + seen=set() + for raw in ids or []: + key=str(raw) if raw else None + if not key or key in seen: + continue + seen.add(key) + wanted.append(key) + if not wanted: + return {} + rows=await JobPosts.get_by_ids(self.session,wanted,active_only=False) + return {str(r.id):serialize_job_post(r) for r in rows} + async def get_job_post_by_id(self,record_id,data=None,*,as_assigned=False): """Load full job_posts row and optionally append it onto a candidate payload.""" try: job_post_data=await JobPosts.get_job_post_by_id(session=self.session,record_id=record_id) if not job_post_data: return None - payload=serialize_job_post(job_post_data) - if isinstance(data,dict): - if as_assigned: - data["assigned_job_post"]=payload - if payload.get("created_by_name"): - data["recruiter"]=payload.get("created_by_name") - data["recruiter_id"]=payload.get("created_by") - if payload.get("title"): - data["job_title"]=payload.get("title") - else: - data.setdefault("job_posts",[]).append(payload) - if data.get("recruiter") is None and payload.get("created_by_name"): - data["recruiter"]=payload.get("created_by_name") - data["recruiter_id"]=payload.get("created_by") - if data.get("job_title") is None and payload.get("title"): - data["job_title"]=payload.get("title") - return payload + return self._attach_job_post(data,serialize_job_post(job_post_data),as_assigned=as_assigned) except Exception as e: raise HTTPException(status_code=500,detail=str(e)) async def attach_job_posts(self,data): - """Normalize list/single, serialize each record, attach full job_posts rows.""" + """Normalize list/single, serialize each record, attach full job_posts rows. + + Two batched queries per page, not two per row — a 200-row pipeline board + issued 600+ sequential job_posts round trips before. + """ single=not isinstance(data,list) records=[data] if single else list(data or []) scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records]) - enriched=[] + + payloads=[] + wanted=[] for record in records: payload=serialize_candidate_profile(record) payload["job_posts"]=[] @@ -707,13 +741,23 @@ class CandidateView: if scored: payload["ai_score"]=scored[0].match_score payload["recommendation"]=self._recommendation(scored[0].match_score) + payloads.append(payload) + if payload.get("assigned_job_post_id"): + wanted.append(payload["assigned_job_post_id"]) + wanted.extend(payload.get("suggested_job_post_ids") or []) + + posts=await self._job_posts_by_id(wanted) + # Copy per attach: two candidates on the same post held independent dicts + # back when every row re-serialized its own. + for payload in payloads: assigned_id=payload.get("assigned_job_post_id") if assigned_id: - await self.get_job_post_by_id(record_id=assigned_id,data=payload,as_assigned=True) + post=posts.get(str(assigned_id)) + self._attach_job_post(payload,dict(post) if post else None,as_assigned=True) for job_id in payload.get("suggested_job_post_ids") or []: - await self.get_job_post_by_id(record_id=job_id,data=payload) - enriched.append(payload) - return enriched[0] if single else enriched + post=posts.get(str(job_id)) + self._attach_job_post(payload,dict(post) if post else None) + return payloads[0] if single else payloads async def attach_profile_detail(self,data): """Detail mode: flatten child collections across every Inbox row for the candidate.""" diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index b688739..54be9a6 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -111,6 +111,10 @@ class JobPosts(SQLModel, table=True): skip: int = 0, ids: list[str] | None = None, active_only: bool = True, + include_deleted: bool = False, + department: str | None = None, + requisition_status: str | None = None, + employment_type: str | None = None, ): if ids: rows = await cls.get_by_ids(session, ids, active_only=active_only) @@ -119,11 +123,19 @@ class JobPosts(SQLModel, table=True): statement = select(cls) if active_only: statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712 + elif not include_deleted: + statement = statement.where(cls.is_deleted == False) # noqa: E712 if search: like = f"%{search.strip()}%" statement = statement.where( - or_(cls.title.ilike(like), cls.location.ilike(like)) + or_(cls.title.ilike(like), cls.location.ilike(like), cls.department.ilike(like)) ) + if department: + statement = statement.where(cls.department == department) + if requisition_status: + statement = statement.where(cls.requisition_status == requisition_status) + if employment_type: + statement = statement.where(cls.employment_type == employment_type) count_statement = select(func.count()).select_from(statement.subquery()) total = (await session.execute(count_statement)).scalar_one() statement = statement.order_by(cls.created_at.desc()) @@ -134,6 +146,23 @@ class JobPosts(SQLModel, table=True): result = await session.execute(statement) return list(result.scalars().all()), total + @classmethod + async def recruiter_names(cls, session: AsyncSession, recruiter_ids) -> dict[str, str]: + """Resolve {recruiter_id: name} for a page of rows in a single query.""" + # Local import and COLUMN select, both load-bearing: users.models imports + # this module at its top, so a module-level import here is a startup cycle; + # and a Users *entity* would drag in its five selectin relations for what is + # a two-column lookup. + from users.models import Users + + uids = {u for u in (recruiter_ids or []) if u} + if not uids: + return {} + result = await session.execute( + select(Users.id, Users.name).where(Users.id.in_(uids)) + ) + return {str(uid): name for uid, name in result.all()} + @classmethod async def insert_job_post(cls, session: AsyncSession, fields: dict): row = cls(**fields) @@ -186,4 +215,45 @@ class JobPosts(SQLModel, table=True): await session.refresh(row) return row -import users.models as _users_models \ No newline at end of file + +class SocialPlatform(SQLModel, table=True): + """Buffer publish aliases (fb → facebook). Spelling tolerance + UI list, not an allowlist.""" + + __tablename__ = "social_platforms" + + id: int | None = Field(default=None, primary_key=True) + alias: str = Field(max_length=40, unique=True, index=True) + buffer_service: str + label: str + is_active: bool = Field(default=True) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @classmethod + async def get_by_alias(cls, session: AsyncSession, alias: str): + key = (alias or "").strip().lower() + if not key: + return None + result = await session.execute(select(cls).where(cls.alias == key)) + return result.scalars().first() + + @classmethod + async def list_active(cls, session: AsyncSession): + result = await session.execute( + select(cls).where(cls.is_active == True).order_by(cls.id.asc()) # noqa: E712 + ) + return list(result.scalars().all()) + + @classmethod + async def list_aliases(cls, session: AsyncSession): + rows = await cls.list_active(session) + return [r.alias for r in rows] + + @classmethod + async def alias_map(cls, session: AsyncSession) -> dict[str, str]: + """alias → Buffer service name for normalize_platform / resolve_channel.""" + rows = await cls.list_active(session) + return {r.alias: r.buffer_service for r in rows} + + +import users.models as _users_models \ No newline at end of file diff --git a/backend/job/job_post/plugins.py b/backend/job/job_post/plugins.py index b19fa26..bae385c 100644 --- a/backend/job/job_post/plugins.py +++ b/backend/job/job_post/plugins.py @@ -9,7 +9,6 @@ import json import os import re from datetime import datetime -from enum import Enum import httpx from dotenv import load_dotenv @@ -40,38 +39,20 @@ def local_status(buffer_status) -> str: return BUFFER_STATUS_TO_LOCAL.get(buffer_status or "", "scheduled") -class PlatformAlias(str, Enum): - """Shorthands people type, mapped to Buffer's own `Service` values. +def normalize_platform(value, aliases=None) -> str: + """Case/punctuation-insensitive key for comparing a requested platform. - Member name = what arrives in the request, value = what Buffer calls it. This is - spelling tolerance only, *not* the list of supported networks: anything absent here - still resolves, because `resolve_channel` matches against the services Buffer - actually reports. A newly connected network needs no entry. - - Names that share a value (ig/insta) become Enum aliases, which is exactly the - intent -- lookup is by member name via `__members__`. + `aliases` is an optional alias→Buffer-service map (from SocialPlatform). Missing + keys fall through to the normalized key so Buffer's live channel list remains the + real allowlist — DB rows are spelling tolerance only. """ - - fb = "facebook" - ig = "instagram" - insta = "instagram" - li = "linkedin" - x = "twitter" - tweet = "twitter" - yt = "youtube" - gbp = "googlebusiness" - google = "googlebusiness" - googlebusinessprofile = "googlebusiness" - - -def normalize_platform(value) -> str: - """Case/punctuation-insensitive key for comparing a requested platform.""" key = re.sub(r"[^a-z0-9]", "", str(value or "").lower()) - alias = PlatformAlias.__members__.get(key) - return alias.value if alias else key + if aliases: + return aliases.get(key, key) + return key -async def resolve_channel(platform, channels=None) -> dict: +async def resolve_channel(platform, channels=None, aliases=None) -> dict: """Return the connected channel for `platform`. Nothing is special-cased per network: the request is matched against the services @@ -79,14 +60,14 @@ async def resolve_channel(platform, channels=None) -> dict: resolve. Falls back to matching the channel's handle or display name so "ahmedmujtababaig" works as well as "linkedin". """ - wanted = normalize_platform(platform) + wanted = normalize_platform(platform, aliases) if not wanted: raise BufferError("No platform given") if channels is None: channels = await list_buffer_channels() for field in ("service", "name", "displayName"): for channel in channels: - if normalize_platform(channel.get(field)) == wanted: + if normalize_platform(channel.get(field), aliases) == wanted: return channel available = sorted({c.get("service") for c in channels if c.get("service")}) raise BufferError( diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 53d737a..65220c2 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -25,3 +25,39 @@ def serialize_job_post(row) -> dict: "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } + + +def serialize_job_row(row, *, recruiter_name=None) -> dict: + """Requisition view of a job post, for the Jobs screen. + + Deliberately separate from serialize_job_post: that payload is shared by the + inbox, candidate and matching paths, and widening it would change five + response shapes at once. + """ + return { + "id": str(row.id), + "title": row.title, + "department": row.department or None, + "location": row.location, + "employment_type": row.employment_type, + "vacancies": row.vacancies, + "platform": row.platform or None, + # Two different lifecycles, never conflate: requisition_status is hiring + # (open/closed/on_hold), status is Buffer publishing (draft/scheduled/...). + "requisition_status": row.requisition_status, + "status": row.status, + "experience_min": row.experience_min, + "experience_max": row.experience_max, + "salary": row.salary, + "requirements": list(row.requirements or []), + "optional_skills": list(row.optional_skills or []), + "description": row.description, + "is_active": row.is_active, + "closed_at": row.closed_at.isoformat() if row.closed_at else None, + "current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None, + "recruiter_name": recruiter_name, + "created_by": str(row.created_by) if row.created_by else None, + "created_by_name": row.user.name if getattr(row, "user", None) else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index a01bcb0..a880367 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -6,7 +6,7 @@ from dotenv import load_dotenv from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator -from job.job_post.models import JobPosts +from job.job_post.models import JobPosts,SocialPlatform from job.job_post.plugins import ( BufferError, create_buffer_post, @@ -17,7 +17,7 @@ from job.job_post.plugins import ( render_job_post, resolve_channel, ) -from job.job_post.serializers import serialize_job_post +from job.job_post.serializers import serialize_job_post, serialize_job_row load_dotenv() @@ -31,7 +31,8 @@ class JobPostCreate(BaseModel): salary: str = "Anonymous" location: str | None = None employment_type: str | None = None - platform: str = "linkedin" + department: str | None = None + vacancies: int = 1 description: str | None = None platform: str | None = None channel_id: str | None = None @@ -45,8 +46,8 @@ class JobPostCreate(BaseModel): allowed = {"addToQueue", "shareNow", "customScheduled"} if self.mode not in allowed: raise ValueError(f"mode must be one of {sorted(allowed)}") - if self.mode == "customScheduled" and not self.due_at: - raise ValueError("due_at is required when mode is customScheduled") + if self.mode == "customScheduled" and not self.due_at and not self.scheduler_date: + raise ValueError("due_at or scheduler_date is required when mode is customScheduled") return self class JobPost: @@ -55,7 +56,7 @@ class JobPost: self.buffer_api=os.getenv("BUFFER_API") self.channel_id=os.getenv("BUFFER_CHANNEL_ID") - async def _resolve_target(self,payload): + async def _resolve_target(self,payload,aliases=None): """Pick the Buffer channel to post to, and the service it belongs to. Precedence: an explicit channel_id, then the requested platform, then the @@ -65,7 +66,7 @@ class JobPost: if payload.get("channel_id"): return payload["channel_id"],None if payload.get("platform"): - channel=await resolve_channel(payload["platform"]) + channel=await resolve_channel(payload["platform"],aliases=aliases) return channel["id"],channel.get("service") if self.channel_id: return self.channel_id,None @@ -75,8 +76,9 @@ class JobPost: ) async def post_job(self,payload,current_user): + aliases=await SocialPlatform.alias_map(self.session) try: - channel_id,service=await self._resolve_target(payload) + channel_id,service=await self._resolve_target(payload,aliases) except (httpx.HTTPError,BufferError,RuntimeError) as e: raise HTTPException(status_code=502,detail=f"Failed to resolve Buffer channel: {e}") from e @@ -90,6 +92,9 @@ class JobPost: "requirements":list(payload.get("requirements") or []), "optional_skills":list(payload.get("optional_skills") or []), "salary":payload.get("salary") or "Anonymous", + # department is NOT NULL with a server_default of "" — pass "", never None. + "department":payload.get("department") or "", + "vacancies":payload.get("vacancies") or 1, "description":payload.get("description"), "post_text":text, "channel_id":channel_id, @@ -99,7 +104,7 @@ class JobPost: # Only set platform when it is actually known: passing None would override the # column default and break the NOT NULL constraint. Buffer's channelService # replaces this with the authoritative value once the post is created. - known_platform=service or normalize_platform(payload.get("platform")) + known_platform=service or normalize_platform(payload.get("platform"),aliases) if known_platform: fields["platform"]=known_platform row=await JobPosts.insert_job_post(self.session,fields) @@ -143,3 +148,18 @@ class JobPost: active_only=active_only, ) return [serialize_job_post(r) for r in rows],total + + async def fetch_jobs(self,search=None,department=None,requisition_status=None, + employment_type=None,top=None,skip=0,active_only=True): + rows,total=await JobPosts.fetch_job_posts( + self.session,search=search,top=top,skip=skip,active_only=active_only, + department=department,requisition_status=requisition_status, + employment_type=employment_type, + ) + names=await JobPosts.recruiter_names( + self.session,[r.current_recruiter_id for r in rows], + ) + return [ + serialize_job_row(r,recruiter_name=names.get(str(r.current_recruiter_id))) + for r in rows + ],total diff --git a/backend/migrations/manual/003_seed_social_platforms.sql b/backend/migrations/manual/003_seed_social_platforms.sql new file mode 100644 index 0000000..b37b4bb --- /dev/null +++ b/backend/migrations/manual/003_seed_social_platforms.sql @@ -0,0 +1,22 @@ +-- 003_seed_social_platforms.sql +-- Manual one-shot: seed Buffer publish aliases formerly hardcoded in +-- PlatformAlias (job/job_post/plugins.py). Spelling tolerance + /jobs/alias UI +-- list only — missing aliases still resolve via Buffer's live channel list. +-- +-- Order: (1) alembic upgrade / autogenerate so app.social_platforms exists, +-- (2) this file. Applied automatically at startup by alembic_setup.run_manual_sql() +-- once the schema is at head; recorded in manual_migrations. Safe to re-run by hand. + +INSERT INTO app.social_platforms (alias, buffer_service, label, is_active, created_at, updated_at) +VALUES + ('fb', 'facebook', 'Facebook', true, NOW(), NOW()), + ('ig', 'instagram', 'Instagram', true, NOW(), NOW()), + ('insta', 'instagram', 'Instagram', true, NOW(), NOW()), + ('li', 'linkedin', 'LinkedIn', true, NOW(), NOW()), + ('x', 'twitter', 'Twitter/X', true, NOW(), NOW()), + ('tweet', 'twitter', 'Twitter/X', true, NOW(), NOW()), + ('yt', 'youtube', 'YouTube', true, NOW(), NOW()), + ('gbp', 'googlebusiness', 'Google Business', true, NOW(), NOW()), + ('google', 'googlebusiness', 'Google Business', true, NOW(), NOW()), + ('googlebusinessprofile', 'googlebusiness', 'Google Business', true, NOW(), NOW()) +ON CONFLICT (alias) DO NOTHING; diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 338d98a..db30cad 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,7 +23,7 @@ - + diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 8741241..1b37bf0 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -186,8 +186,8 @@ export function toRows(res) { /** * favorite/rating live on the `inbox` row, not on the user, so the server applies * the change to EVERY application belonging to the candidate and hands back the - * refreshed detail payload. Pipeline stage is not writable here — no endpoint - * updates inbox_messages.application_status yet. + * refreshed detail payload. Pipeline stage is not writable here — it moves one + * APPLICATION at a time through PATCH /candidate/stage (api/pipeline.js). */ export function update(userId, payload) { return request('/candidate/update', { method: 'PATCH', params: { user_id: userId }, body: payload }) diff --git a/frontend/src/api/jobPosts.js b/frontend/src/api/jobPosts.js index 6d49764..63c29e1 100644 --- a/frontend/src/api/jobPosts.js +++ b/frontend/src/api/jobPosts.js @@ -18,3 +18,19 @@ export function list({ search, top, skip, ids, activeOnly = true } = {}) { }, }) } + +/** + * Create a job post — backend/job/app.py `POST /job/post-job` (job_board.create). + * + * CREATES the row AND publishes it through Buffer; there is no draft-only path. + * A 502 means the row was created but the Buffer post failed (post_job marks it + * status="failed" before re-raising), so do not report it as "nothing happened". + */ +export function create(payload) { + return request('/job/post-job', { method: 'POST', body: payload }) +} + +/** Connected Buffer channels — GET /job/buffer/channels (job_board.view). */ +export function listChannels() { + return request('/job/buffer/channels') +} diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js new file mode 100644 index 0000000..e089abe --- /dev/null +++ b/frontend/src/api/jobs.js @@ -0,0 +1,61 @@ +import { request } from '../lib/apiClient' + +/** + * Job requisitions — backend/job/app.py `GET /jobs/fetch`. + * + * Distinct from api/jobPosts.js on purpose: that serves the Matching and CV-import + * PICKERS off /job/fetch (job_board.view). This is the requisition list behind + * jobs.view, and carries department / vacancies / requisition_status, which the + * picker payload does not. + */ +export function list({ search, department, requisitionStatus, employmentType, + top, skip, activeOnly } = {}) { + return request('/jobs/fetch', { + params: { + search, + department, + requisition_status: requisitionStatus, + employment_type: employmentType, + top, + skip, + active_only: activeOnly, + }, + }) +} + +/* requisition_status is the HIRING lifecycle. The row's separate `status` field is + the Buffer publishing lifecycle — never map the two onto one badge. */ +const REQ_STATUS_LABEL = { open: 'Open', closed: 'Closed', on_hold: 'On Hold' } +export const JOB_STATUSES = Object.values(REQ_STATUS_LABEL) + +function experienceLabel(min, max) { + if (min == null && max == null) return null + if (min != null && max != null) return `${min}–${max} years` + return `${min ?? max}+ years` +} + +/** API row -> what the Jobs table and detail modal render. */ +export function toJobView(row) { + return { + id: row.id, + title: row.title, + department: row.department, + location: row.location, + type: row.employment_type, + vacancies: row.vacancies, + platform: row.platform || null, + status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status, + publishStatus: row.status, + recruiter: row.recruiter_name, + recruiterId: row.current_recruiter_id, + createdByName: row.created_by_name, + // A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working. + created: row.created_at ? new Date(row.created_at) : null, + closedAt: row.closed_at ? new Date(row.closed_at) : null, + experience: experienceLabel(row.experience_min, row.experience_max), + salary: row.salary, + skills: row.requirements ?? [], + optionalSkills: row.optional_skills ?? [], + description: row.description, + } +} diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js new file mode 100644 index 0000000..c1c0dab --- /dev/null +++ b/frontend/src/api/pipeline.js @@ -0,0 +1,122 @@ +/* ============================================================ + pipeline.js — the kanban board's endpoints (backend/job/app.py). + + The board is stitched from two modules, because there is no pipeline-specific + READ endpoint: + + - rows come from GET /candidate/fetch (api/candidates.js `list`), the + inbox -> users -> roles join, which is the only list payload carrying BOTH + `application_status` (the stage) and `inbox_id` (what the write below needs); + - the write is PATCH /candidate/stage, here. + + Stage lives on inbox_messages.application_status and the transition history in + application_stage_transitions; the server closes the open interval and opens a + new one in the same commit, so the board never has to touch history itself. + ============================================================ */ + +import { request } from '../lib/apiClient' + +/** + * Candidate_application_Status (backend/inbox/enums.py) -> the board column. + * + * The enum has 11 values and the board 7 columns, so this is deliberately + * many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere) + * and reads as Applied rather than as an outcome, ONHOLD parks in Screening, and + * APPROVED is the pre-HIRED spelling of a hire. + * + * Anything unmapped falls through to Applied rather than vanishing from the + * board — a card with no column is a candidate nobody sees. + */ +export const STAGE_FROM_STATUS = { + PENDING: 'Applied', + CLOSED: 'Applied', + PROCESS: 'Screening', + ONHOLD: 'Screening', + SCREENING: 'Screening', + ASSESSMENT: 'Assessment', + INTERVIEW: 'Interview', + OFFER: 'Offer', + HIRED: 'Hired', + APPROVED: 'Hired', + REJECTED: 'Rejected', +} + +/** + * Column -> the status WRITTEN on a drop. Not the inverse of the map above: the + * legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are + * never written, so the vocabulary converges on the canonical value as cards get + * moved. Applied writes PENDING because the enum has no APPLIED member. + */ +export const STATUS_FROM_STAGE = { + Applied: 'PENDING', + Screening: 'SCREENING', + Assessment: 'ASSESSMENT', + Interview: 'INTERVIEW', + Offer: 'OFFER', + Hired: 'HIRED', + Rejected: 'REJECTED', +} + +/** + * Move one application to another stage. Requires pipeline.edit. + * + * `inboxId` is the INTEGER inbox.id — the row the candidate profile returns as + * `inbox_id`, not the inbox_messages uuid the Inbox screen calls `id`; the route + * runs int() on it and 404s on anything else. + * + * The server rejects a no-op move with 400 ("already at stage"), so callers must + * not fire on a drop into the card's current column. + */ +export function changeStage({ inboxId, toStage, changeReason }) { + return request('/candidate/stage', { + method: 'PATCH', + body: { inbox_id: inboxId, to_stage: toStage, change_reason: changeReason ?? null }, + }) +} + +/** + * Stage history for one application — GET /pipeline/transitions/fetch + * (pipeline.view). Rows are valid-time intervals: `valid_to` null is the stage + * the candidate is in now. The board itself does not render history; this is the + * feed behind a stage timeline on the profile. + * + * One of inboxId / transitionId is required — the route 400s with neither. + */ +export function listTransitions({ inboxId, transitionId } = {}) { + return request('/pipeline/transitions/fetch', { + params: { inbox_id: inboxId, transition_id: transitionId }, + }) +} + +/** + * Candidate-profile row -> one kanban card. + * + * `id` is the inbox id, not the user id: the board is one card per APPLICATION + * and `inbox` holds one row per (user, message), so a candidate who mailed us + * three times legitimately occupies three cards with three independent stages. + * `userId` rides along for the deep link into the profile. + * + * Skills are absent by construction — the list payload carries ai_score but not + * matched_keywords (job/candidate/views.py::attach_job_posts sets only the + * score), so the card drops its tag row rather than rendering three blanks. + */ +export function toBoardCard(row) { + const jobTitle = row.job_title ?? row.assigned_job_post?.title ?? null + return { + id: row.inbox_id, + inboxId: row.inbox_id, + userId: row.user_id ?? null, + name: row.name || row.email || 'Unknown', + email: row.email ?? null, + stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied', + status: row.application_status ?? null, + jobId: row.assigned_job_post_id ?? null, + jobTitle, + currentTitle: row.current_title || null, + currentCompany: row.current_employment || null, + experience: row.experience || null, + aiScore: row.ai_score ?? null, + recommendation: row.recommendation ?? null, + applied: row.created_at ? new Date(row.created_at) : null, + } +} diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 044a2bc..6501ffd 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -29,13 +29,21 @@ export const qk = { }, jobs: { all: () => ['jobs'], - list: () => ['jobs', 'list'], + list: (p = {}) => ['jobs', 'list', p], }, candidates: { all: () => ['candidates'], list: (p = {}) => ['candidates', 'list', p], detail: (id) => ['candidates', 'detail', id], }, + // Board rows come from the same endpoint as qk.candidates.list but are cached + // MAPPED (kanban cards, not the raw envelope), so they need their own key — + // sharing one would poison whichever screen mounted first. + pipeline: { + all: () => ['pipeline'], + board: (p = {}) => ['pipeline', 'board', p], + transitions: (inboxId) => ['pipeline', 'transitions', inboxId], + }, analytics: { all: () => ['analytics'], kpis: (p = {}) => ['analytics', 'kpis', p], diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index b5afe7f..3f2ffcb 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -94,7 +94,7 @@ export default function Candidates() { const navigate = useNavigate() const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates }) - const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) + const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) const candidates = useMemo(() => candidatesQuery.data ?? [], [candidatesQuery.data]) const jobsById = useMemo( () => Object.fromEntries((jobsQuery.data ?? []).map((j) => [j.id, j])), diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index e188e4f..63a4b23 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -42,7 +42,7 @@ let rowSeq = 0 export default function CvImport() { const { toast } = useToast() const qc = useQueryClient() - const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) + const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) const jobs = jobsQuery.data ?? [] const [jobId, setJobId] = useState('') diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index aa1d5a9..c9cc71e 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -1,33 +1,54 @@ /* ============================================================ - Jobs — the reference CRUD pattern for the app: filtered DataTable plus - view / reassign / create-edit / delete modals. The other CRUD screens follow - this shape. + Jobs — requisition list on live backend data (GET /jobs/fetch). + + Facets, columns and actions that had no backing column are gone rather than + rendered as placeholders — the Candidates / Inbox screens set that precedent. + Create is wired to POST /job/post-job (create + Buffer publish). Edit / delete + / reassign stay off until real write endpoints exist; publishing still routes + to /jobboard. ============================================================ */ import { useEffect, useMemo, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' -import { Avatar, Badge, FieldError, Icon, ProgressBar } from '../ui/primitives' +import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { useToast } from '../ui/Toast' +import { useAuth } from '../auth/AuthContext' import { useFormState } from '../components/AuthLayout' -import { seedQuery, useSeedMutation } from '../data/seedQueries' -import { - businessUnits, departments, educationLevels, empTypes, fmtDate, fmtShort, - getRecruiterByName, grades, jobStatuses, locations, moneyK, TODAY, -} from '../data/seed' +import { qk } from '../lib/queryKeys' +import { friendlyAuthError } from '../lib/errors' +import * as jobsApi from '../api/jobs' +import * as jobPostsApi from '../api/jobPosts' +import { JOB_STATUSES } from '../api/jobs' +import { empTypes, fmtShort } from '../data/seed' + +const JOB_LIMIT = 200 + +async function fetchJobs() { + const res = await jobsApi.list({ top: JOB_LIMIT }) + const rows = Array.isArray(res?.data) ? res.data : [] + return rows.map(jobsApi.toJobView) +} + +function splitLines(text) { + return String(text || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean) +} export default function Jobs() { const { toast } = useToast() + const { can } = useAuth() const navigate = useNavigate() const location = useLocation() + const qc = useQueryClient() - const { data: jobs = [] } = useQuery(seedQuery('jobs')) - const { data: managers = [] } = useQuery(seedQuery('managers')) - const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) - const updateJobs = useSeedMutation('jobs') + const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs }) + const jobs = useMemo(() => jobsQuery.data ?? [], [jobsQuery.data]) const [q, setQ] = useState('') const [dept, setDept] = useState('') @@ -35,18 +56,51 @@ export default function Jobs() { const [type, setType] = useState('') const [viewing, setViewing] = useState(null) - const [editing, setEditing] = useState(undefined) // undefined = closed, null = create - const [reassigning, setReassigning] = useState(null) - const [deleting, setDeleting] = useState(null) + const [creating, setCreating] = useState(false) // Deep-link intents from global search, the dashboard and the manager portal. useEffect(() => { const st = location.state if (!st) return - if (st.openCreate) setEditing(null) + if (st.openCreate) setCreating(true) if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null) }, [location.state, jobs]) + const channelsQuery = useQuery({ + queryKey: qk.jobPosts.all(), + queryFn: async () => (await jobPostsApi.listChannels())?.data ?? [], + enabled: creating, + }) + + const createJob = useMutation({ + mutationFn: (payload) => jobPostsApi.create(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.jobs.all() }) + setCreating(false) + toast('Job created and sent to the channel', 'success') + }, + onError: (err) => { + // 502: row was created but Buffer publish failed — refresh the board and + // say so; a flat "create failed" toast would be wrong. + qc.invalidateQueries({ queryKey: qk.jobs.all() }) + if (err?.status === 502) { + setCreating(false) + toast('Job created, but publishing failed — see its status on the board.', 'error') + return + } + toast(friendlyAuthError(err, 'Could not create the job'), 'error') + }, + }) + + const departmentOptions = useMemo( + () => [...new Set(jobs.map((j) => j.department).filter(Boolean))].sort(), + [jobs], + ) + const typeOptions = useMemo( + () => [...new Set(jobs.map((j) => j.type).filter(Boolean))].sort(), + [jobs], + ) + const rows = useMemo( () => jobs.filter((j) => { @@ -55,7 +109,10 @@ export default function Jobs() { if (type && j.type !== type) return false if (q) { const term = q.toLowerCase() - const hay = (j.title + j.id + j.department + j.manager + j.recruiter + j.location).toLowerCase() + const hay = [j.title, j.department, j.recruiter, j.location] + .filter(Boolean) + .join(' ') + .toLowerCase() if (!hay.includes(term)) return false } return true @@ -66,36 +123,32 @@ export default function Jobs() { const openCount = jobs.filter((j) => j.status === 'Open').length const columns = [ - { key: 'id', label: 'Job ID', sortable: true, render: (j) => {j.id} }, { key: 'title', label: 'Job Title', sortable: true, render: (j) => ( <>
{j.title}
-
{j.businessUnit} · {j.grade}
+
{j.department || '—'}
), }, - { key: 'department', label: 'Department', sortable: true }, - { - key: 'manager', label: 'Hiring Manager', sortable: true, - render: (j) => ( -
{j.manager}
- ), - }, - { key: 'location', label: 'Location', sortable: true, render: (j) => {j.location} }, - { key: 'type', label: 'Type', render: (j) => {j.type} }, - { key: 'applications', label: 'Apps', sortable: true, align: 'center', render: (j) => {j.applications} }, + { key: 'department', label: 'Department', sortable: true, render: (j) => j.department || '—' }, + { key: 'location', label: 'Location', sortable: true, render: (j) => {j.location || '—'} }, + { key: 'type', label: 'Type', render: (j) => j.type ? {j.type} : '—' }, + { key: 'platform', label: 'Platform', sortable: true, render: (j) => j.platform ? {j.platform} : '—' }, + { key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => {j.vacancies ?? '—'} }, { key: 'status', label: 'Status', sortable: true, render: (j) => {j.status} }, - { key: 'created', label: 'Created', sortable: true, sortValue: (j) => j.created.getTime(), render: (j) => {fmtShort(j.created)} }, + { + key: 'created', label: 'Created', sortable: true, + sortValue: (j) => (j.created ? j.created.getTime() : 0), + render: (j) => {j.created ? fmtShort(j.created) : '—'}, + }, { key: '_a', label: 'Actions', align: 'right', render: (j) => (
- -
), }, @@ -112,109 +165,78 @@ export default function Jobs() { - + {can('job_board.create') && ( + + )}
-
-
-
- - setQ(e.target.value)} placeholder="Search jobs, IDs, managers…" /> -
- - - + {jobsQuery.isPending && ( +
+ Fetching requisitions from the server.
-
- + )} + {jobsQuery.isError && ( +
+ + {friendlyAuthError(jobsQuery.error, 'Request failed')} + +
+ )} + {!jobsQuery.isPending && !jobsQuery.isError && ( + <> +
+
+
+ + setQ(e.target.value)} placeholder="Search title, department, location…" /> +
+ + + +
+
+ + + )}
{viewing && ( setViewing(null)} - onEdit={() => { const j = viewing; setViewing(null); setEditing(j) }} onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }} - onReassign={() => { const j = viewing; setViewing(null); setReassigning(j) }} /> )} - {editing !== undefined && ( + {creating && ( setEditing(undefined)} - onSave={(next, isEdit) => { - updateJobs((js) => (isEdit ? js.map((j) => (j.id === next.id ? next : j)) : [next, ...js])) - setEditing(undefined) - toast(isEdit ? 'Job updated successfully' : 'Job created successfully', 'success') - }} - onInvalid={() => toast('Please fix the highlighted fields', 'error')} + departmentOptions={departmentOptions} + channels={channelsQuery.data ?? []} + channelsLoading={channelsQuery.isPending} + channelsError={channelsQuery.isError} + busy={createJob.isPending} + onClose={() => setCreating(false)} + onSubmit={(payload) => createJob.mutate(payload)} /> )} - - {reassigning && ( - setReassigning(null)} - onSave={(name) => { - updateJobs((js) => js.map((j) => (j.id === reassigning.id ? { ...j, recruiter: name } : j))) - setReassigning(null) - toast('Recruiter reassigned', 'success') - }} - /> - )} - - {deleting && ( - setDeleting(null)} - footer={ - <> - - - - } - > -
- - - -
-

Delete “{deleting.title}”?

-

- This will permanently remove requisition {deleting.id} and its {deleting.applications} applications. - This action cannot be undone. -

-
-
-
- )}
) } @@ -224,189 +246,97 @@ const SECTION_LABEL = { textTransform: 'uppercase', marginBottom: 6, } -function JobDetail({ job: j, onClose, onEdit, onPublish, onReassign }) { - const r = getRecruiterByName(j.recruiter) - const loadCls = r ? (r.workload > 80 ? 'b-red' : r.workload > 60 ? 'b-amber' : 'b-green') : '' - - return ( - - - - - - } - > -
- - - -
-
{j.title}
-
{j.id} · {j.department} · {j.businessUnit}
-
-
{j.status}
-
- -
-
Hiring Manager
{j.manager}
-
-
Assigned Recruiter
-
- {j.recruiter} - {r && {r.workload}% load} - -
-
-
Location
{j.location}
-
Employment Type
{j.type}
-
Grade
{j.grade}
-
Vacancies
{j.vacancies}
-
Salary Range
{moneyK(j.salaryMin)} – {moneyK(j.salaryMax)}
-
Experience
{j.experience}
-
Education
{j.education}
-
Deadline
{fmtDate(j.deadline)}
-
- -
-
-
Description
-

{j.description}

-
-
-
Key Responsibilities
-
    - {j.responsibilities.map((x) =>
  • {x}
  • )} -
-
-
-
Required Skills
-
{j.skills.map((s) => {s})}
-
-
-
Benefits
-
{j.benefits.map((s) => {s})}
-
- -
-
- Hiring progress -
- {j.progress}% -
- - ) +function channelLabel(ch) { + const name = ch.displayName || ch.name || ch.id + const service = ch.service ? String(ch.service) : '' + return service ? `${name} (${service})` : name } -function Reassign({ job, recruiters, onClose, onSave }) { - const [name, setName] = useState(job.recruiter) - return ( - - - - - } - > -
- - -
-

- Workload is recalculated automatically across the recruiter’s assigned requisitions. -

-
- ) +function publishConsequence(channel, mode) { + const service = channel?.service + ? String(channel.service).charAt(0).toUpperCase() + String(channel.service).slice(1) + : (channel?.displayName || channel?.name || 'the selected channel') + if (mode === 'shareNow') return `Publishes to ${service} — posts immediately` + if (mode === 'customScheduled') return `Publishes to ${service} — scheduled for later` + return `Publishes to ${service} — added to queue` } -function JobForm({ job, managers, recruiters, count, onClose, onSave, onInvalid }) { - const isEdit = Boolean(job) +function JobForm({ + departmentOptions, channels, channelsLoading, channelsError, busy, onClose, onSubmit, +}) { const form = useFormState({ - title: job?.title ?? '', - department: job?.department ?? departments[0], - businessUnit: job?.businessUnit ?? businessUnits[0], - grade: job?.grade ?? grades[0], - type: job?.type ?? empTypes[0], - manager: job?.manager ?? managers[0]?.name ?? '', - recruiter: job?.recruiter ?? recruiters[0]?.name ?? '', - salaryMin: job?.salaryMin ?? '', - salaryMax: job?.salaryMax ?? '', - experience: job?.experience ?? '', - education: job?.education ?? educationLevels[0], - location: job?.location ?? locations[0], - vacancies: job?.vacancies ?? 1, - description: job?.description ?? '', - responsibilities: job ? job.responsibilities.join('\n') : '', - skills: job ? job.skills.join(', ') : '', - benefits: job ? job.benefits.join(', ') : '', - deadline: '', - status: job?.status ?? 'Open', + title: '', + department: '', + location: '', + employment_type: empTypes[0] || 'Full-time', + vacancies: '1', + experience_min: '', + experience_max: '', + salary: '', + requirements: '', + optional_skills: '', + description: '', + channel_id: '', + mode: 'addToQueue', + scheduler_date: '', + scheduler_time: '09:00', }) + // Default the channel once the list arrives — same derivation pattern as + // Candidates.jsx's job-post picker (avoid an effect loop on setField). + const channelId = form.values.channel_id || (channels[0] ? String(channels[0].id) : '') + const selectedChannel = channels.find((c) => String(c.id) === String(channelId)) + function submit() { + if (busy) return const v = form.values const errors = {} if (!v.title.trim()) errors.title = 'Job title is required' - if (!v.description.trim()) errors.description = 'Description is required' - if (!v.salaryMin || Number(v.salaryMin) <= 0) errors.salaryMin = 'Enter a valid amount' + const vacancies = Number(v.vacancies) + if (!Number.isFinite(vacancies) || vacancies < 1) errors.vacancies = 'Vacancies must be at least 1' + const expMin = v.experience_min === '' ? null : Number(v.experience_min) + const expMax = v.experience_max === '' ? null : Number(v.experience_max) + if (expMin != null && !Number.isFinite(expMin)) errors.experience_min = 'Enter a valid number' + if (expMax != null && !Number.isFinite(expMax)) errors.experience_max = 'Enter a valid number' + if ( + expMin != null && expMax != null + && Number.isFinite(expMin) && Number.isFinite(expMax) + && expMin > expMax + ) { + errors.experience_max = 'Must be greater than or equal to minimum' + } + if (!channelId) errors.channel_id = 'Select a channel' + if (v.mode === 'customScheduled' && !v.scheduler_date) { + errors.scheduler_date = 'Date is required when scheduling for later' + } form.setErrors(errors) - if (Object.keys(errors).length) { - onInvalid() - return + if (Object.keys(errors).length) return + + const payload = { + title: v.title.trim(), + department: v.department.trim() || null, + location: v.location.trim() || null, + employment_type: v.employment_type || null, + vacancies, + experience_min: expMin, + experience_max: expMax, + salary: v.salary.trim() || 'Anonymous', + requirements: splitLines(v.requirements), + optional_skills: splitLines(v.optional_skills), + description: v.description.trim() || null, + channel_id: channelId, + mode: v.mode, } - - const skills = v.skills.split(',').map((s) => s.trim()).filter(Boolean) - const benefits = v.benefits.split(',').map((s) => s.trim()).filter(Boolean) - const responsibilities = v.responsibilities.split('\n').map((s) => s.trim()).filter(Boolean) - const salaryMin = Number(v.salaryMin) - const salaryMax = Number(v.salaryMax) || salaryMin + 20000 - - if (isEdit) { - onSave( - { - ...job, - title: v.title, department: v.department, businessUnit: v.businessUnit, grade: v.grade, - type: v.type, manager: v.manager, recruiter: v.recruiter, salaryMin, salaryMax, - experience: v.experience, education: v.education, location: v.location, - vacancies: Number(v.vacancies) || 1, description: v.description, responsibilities, - skills: skills.length ? skills : job.skills, - benefits: benefits.length ? benefits : job.benefits, - status: v.status, - }, - true, - ) - return + if (v.mode === 'customScheduled') { + payload.scheduler_date = v.scheduler_date + if (v.scheduler_time) { + // Backend expects a time; "HH:MM" is enough for FastAPI's time parser. + payload.scheduler_time = v.scheduler_time.length === 5 + ? `${v.scheduler_time}:00` + : v.scheduler_time + } } - - onSave( - { - id: `JOB-${1001 + count}`, - title: v.title, department: v.department, businessUnit: v.businessUnit, grade: v.grade, - manager: v.manager, managerId: '', recruiter: v.recruiter, recruiterId: '', - location: v.location, type: v.type, vacancies: Number(v.vacancies) || 1, - applications: 0, status: v.status, created: new Date(TODAY), - deadline: v.deadline ? new Date(v.deadline) : new Date('2026-08-09'), - salaryMin, salaryMax, - experience: v.experience || '3+ years', education: v.education, - skills, benefits, description: v.description, - responsibilities: responsibilities.length ? responsibilities : ['Own key projects'], - progress: 0, - }, - false, - ) + onSubmit(payload) } const field = (name) => ({ @@ -416,15 +346,15 @@ function JobForm({ job, managers, recruiters, count, onClose, onSave, onInvalid return ( - - + } @@ -432,89 +362,175 @@ function JobForm({ job, managers, recruiters, count, onClose, onSave, onInvalid
{ e.preventDefault(); submit() }}>
- - + + {form.errors.title}
- - + + + + {departmentOptions.map((d) =>
- - -
-
- - + +
- -
-
- - -
-
- - -
- -
- - - {form.errors.salaryMin} -
-
- - -
-
- - -
-
- - -
-
- - +
- + + {form.errors.vacancies} +
+ +
+ + + {form.errors.experience_min} +
+
+ + + {form.errors.experience_max} +
+
+ +
- -