From 202d05c4f9190163008ed3c6e1c252ac57f616ca Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 19 Aug 2026 20:10:42 +0500 Subject: [PATCH] Real Excel export for the Jobs list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /jobs/export (jobs.export): styled .xlsx via openpyxl — branded banner, dark-green frozen header with auto-filter, zebra rows, color- coded status, bulleted requirements/nice-to-have, date formatting. Honors the same filters as /jobs/fetch. - Export button on the Jobs screen now downloads the file through the authenticated downloadFile helper, carrying the active UI filters, with an Exporting... busy state (was a fake success toast). - openpyxl pinned in backend requirements. Co-Authored-By: Claude Fable 5 --- backend/job/app.py | 32 ++++++- backend/job/job_post/export.py | 160 +++++++++++++++++++++++++++++++++ backend/requirements.txt | 1 + frontend/src/api/jobs.js | 18 +++- frontend/src/screens/Jobs.jsx | 18 +++- 5 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 backend/job/job_post/export.py diff --git a/backend/job/app.py b/backend/job/app.py index 5134fd1..d1fe38b 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter,Depends,Query +from fastapi import APIRouter,Depends,Query,Response from fastapi.responses import FileResponse,JSONResponse from fastapi import HTTPException from db_setup import get_session @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from users.permissions import PermissionTag, require_permission from job.job_post.views import JobPost,JobPostCreate from job_assist.execute_agent import run_field_assist +from job.job_post.export import build_jobs_workbook import logging from users.views import User from job.job_post.models import SocialPlatform @@ -465,6 +466,35 @@ async def fetch_jobs( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/jobs/export") +async def export_jobs( + search: str | None = Query(None), + department: str | None = Query(None), + requisition_status: str | None = Query(None), + employment_type: str | None = Query(None), + active_only: bool = Query(False), + current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)), + session: AsyncSession = Depends(get_session), +): + """Styled .xlsx of the requisition list — same filters as /jobs/fetch, no paging.""" + try: + service=JobPost(session=session) + data,_=await service.fetch_jobs( + search=search,department=department,requisition_status=requisition_status, + employment_type=employment_type,top=None,skip=0,active_only=active_only, + ) + filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx" + return Response( + content=build_jobs_workbook(data), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition":f'attachment; filename="{filename}"'}, + ) + 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/job_post/export.py b/backend/job/job_post/export.py new file mode 100644 index 0000000..17d9199 --- /dev/null +++ b/backend/job/job_post/export.py @@ -0,0 +1,160 @@ +"""Styled .xlsx export of job requisitions — openpyxl only. + +Pure module: no FastAPI imports and no HTTPException. + +Takes the already-serialized rows from JobPost.fetch_jobs (serialize_job_row +dicts) so the export always matches what the Jobs screen shows, filters +included. Returns the finished workbook as bytes for a Response body. +""" + +from __future__ import annotations + +from datetime import datetime +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter + +BRAND_DARK = "0B3D2E" # header/banner green, matches the app chrome +BRAND_STRIPE = "EFF7F2" # zebra row tint +BORDER_TINT = "CBDCD2" + +STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold"} +STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700"} + +# (header, column width) +COLUMNS = [ + ("Title", 34), + ("Department", 16), + ("Location", 20), + ("Type", 12), + ("Platform", 12), + ("Vacancies", 11), + ("Experience", 13), + ("Salary", 16), + ("Status", 10), + ("Publishing", 12), + ("Recruiter", 18), + ("Created By", 18), + ("Created", 13), + ("Requirements", 46), + ("Nice to Have", 34), + ("Description", 60), +] + +_THIN = Side(style="thin", color=BORDER_TINT) +_BORDER = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN) + + +def _experience(row) -> str: + lo, hi = row.get("experience_min"), row.get("experience_max") + if lo is None and hi is None: + return "" + if lo is not None and hi is not None: + return f"{lo}-{hi} years" + return f"{lo if lo is not None else hi}+ years" + + +def _bullets(items) -> str: + return "\n".join(f"• {str(i).strip()}" for i in (items or []) if str(i).strip()) + + +def _created(row): + raw = row.get("created_at") + if not raw: + return None + try: + return datetime.fromisoformat(raw).replace(tzinfo=None) + except ValueError: + return None + + +def build_jobs_workbook(rows) -> bytes: + wb = Workbook() + ws = wb.active + ws.title = "Jobs" + ws.sheet_properties.tabColor = BRAND_DARK + ws.sheet_view.showGridLines = False + + last_col = get_column_letter(len(COLUMNS)) + for idx, (_, width) in enumerate(COLUMNS, start=1): + ws.column_dimensions[get_column_letter(idx)].width = width + + # Banner + ws.merge_cells(f"A1:{last_col}1") + banner = ws["A1"] + banner.value = "Jobs Export" + banner.font = Font(size=16, bold=True, color=BRAND_DARK) + banner.alignment = Alignment(vertical="center") + ws.row_dimensions[1].height = 30 + + ws.merge_cells(f"A2:{last_col}2") + sub = ws["A2"] + sub.value = ( + f"TalentFlow · generated {datetime.now().strftime('%d %b %Y, %H:%M')} · " + f"{len(rows)} requisition{'s' if len(rows) != 1 else ''}" + ) + sub.font = Font(size=10, color="6B7A72") + ws.row_dimensions[3].height = 6 + + # Header + header_row = 4 + for idx, (label, _) in enumerate(COLUMNS, start=1): + cell = ws.cell(row=header_row, column=idx, value=label) + cell.font = Font(bold=True, color="FFFFFF", size=11) + cell.fill = PatternFill("solid", fgColor=BRAND_DARK) + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.border = _BORDER + ws.row_dimensions[header_row].height = 22 + + # Data + top = Alignment(vertical="top", wrap_text=False) + wrap = Alignment(vertical="top", wrap_text=True) + center = Alignment(horizontal="center", vertical="top") + for r, row in enumerate(rows, start=header_row + 1): + status_key = row.get("requisition_status") or "" + values = [ + row.get("title") or "", + row.get("department") or "", + row.get("location") or "", + row.get("employment_type") or "", + row.get("platform") or "", + row.get("vacancies"), + _experience(row), + row.get("salary") or "", + STATUS_LABELS.get(status_key, status_key), + row.get("status") or "", + row.get("recruiter_name") or "", + row.get("created_by_name") or "", + _created(row), + _bullets(row.get("requirements")), + _bullets(row.get("optional_skills")), + (row.get("description") or "").strip(), + ] + stripe = r % 2 == 0 + for c, value in enumerate(values, start=1): + cell = ws.cell(row=r, column=c, value=value) + cell.border = _BORDER + cell.alignment = top + if stripe: + cell.fill = PatternFill("solid", fgColor=BRAND_STRIPE) + ws.cell(row=r, column=1).font = Font(bold=True) + ws.cell(row=r, column=6).alignment = center + status_cell = ws.cell(row=r, column=9) + status_cell.alignment = center + if status_key in STATUS_COLORS: + status_cell.font = Font(bold=True, color=STATUS_COLORS[status_key]) + created_cell = ws.cell(row=r, column=13) + if created_cell.value is not None: + created_cell.number_format = "dd mmm yyyy" + for c in (14, 15, 16): + ws.cell(row=r, column=c).alignment = wrap + + last_row = header_row + max(len(rows), 1) + ws.auto_filter.ref = f"A{header_row}:{last_col}{last_row}" + ws.freeze_panes = f"A{header_row + 1}" + + buf = BytesIO() + wb.save(buf) + return buf.getvalue() diff --git a/backend/requirements.txt b/backend/requirements.txt index dfb3ac5..fc1aad3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -44,3 +44,4 @@ langgraph==1.2.10 # StateGraph agent framework in agent/agent_setup.py # editable from the repo root — run once per environment: # pip install -e .. # Its dependencies are already satisfied by the pins above. +openpyxl==3.1.5 diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index fae4530..54ad6da 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -1,4 +1,4 @@ -import { request } from '../lib/apiClient' +import { downloadFile, request } from '../lib/apiClient' /** * Job requisitions — backend/job/app.py `GET /jobs/fetch`. @@ -65,6 +65,22 @@ export function toJobView(row) { const LABEL_TO_STATUS = { Open: 'open', Closed: 'closed', 'On Hold': 'on_hold' } +/** + * Styled .xlsx download of the requisition list — GET /jobs/export + * (jobs.export). Same filters as list(); `status` takes the UI label. + * downloadFile triggers the browser save from the Content-Disposition name. + */ +export function exportXlsx({ search, department, status, employmentType } = {}) { + return downloadFile('/jobs/export', { + params: { + search: search || undefined, + department: department || undefined, + requisition_status: status ? (LABEL_TO_STATUS[status] ?? status) : undefined, + employment_type: employmentType || undefined, + }, + }) +} + export function update(jobPostId, body) { return request('/jobs/update', { method: 'PATCH', diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index d3faf9f..ebfc1f4 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -181,6 +181,20 @@ export default function Jobs() { const openCount = jobs.filter((j) => j.status === 'Open').length + const [exporting, setExporting] = useState(false) + async function exportJobs() { + if (exporting) return + setExporting(true) + try { + await jobsApi.exportXlsx({ search: q, department: dept, status, employmentType: type }) + toast('Jobs exported to Excel', 'success') + } catch (err) { + toast(friendlyAuthError(err, 'Could not export jobs'), 'error') + } finally { + setExporting(false) + } + } + const columns = [ { key: 'title', label: 'Job Title', sortable: true, @@ -224,8 +238,8 @@ export default function Jobs() {

{jobs.length} requisitions · {openCount} currently open

- {can('job_board.create') && (