diff --git a/backend/app/routers/applications.py b/backend/app/routers/applications.py index 23e64d8..94428ff 100644 --- a/backend/app/routers/applications.py +++ b/backend/app/routers/applications.py @@ -14,7 +14,10 @@ ) from app.services import application_repository as repo from app.services import email_service -from app.services.application_service import ApplicationService +from app.services.application_service import ( + CONTRACT_CRITICAL_FIELDS, + ApplicationService, +) from app.services.contract_service import SIGNATURE_DATE_POS, ContractService from app.services.pdf_service import pdf_service from app.services.storage_service import storage_service @@ -129,6 +132,50 @@ def delete_application(application_id: str): raise HTTPException(status_code=404, detail="Application not found") +@router.post("/by-id/{application_id}/approve", response_model=ApplicationResponse) +def approve_application(application_id: str): + """Coordinator override: manually move a candidate to `interview` and issue + the contract, for a borderline case the AI left at `pending` (or + `request_clarification`). The coordinator's judgement outranks the score. + + The mandatory-field gate still holds: a contract must name a host + organisation and workplace supervisor, so if those are missing the override + is refused with the specific gaps — approving cannot fabricate them. + """ + application = _require_by_id(application_id) + + if application.contract_task_id: + raise HTTPException(status_code=409, detail="Application already has a contract") + + missing = [f for f in CONTRACT_CRITICAL_FIELDS if not getattr(application, f, "")] + if missing: + raise HTTPException( + status_code=422, + detail=( + "Cannot issue a contract — the application is missing: " + + ", ".join(f.replace("_", " ") for f in missing) + ), + ) + + task_id, task_dir = storage_service.create_task() + ContractService.create_contract_pdf( + name=application.name, + email=application.email, + recommended_role=application.recommended_role, + candidate_score=application.candidate_score, + company_name=application.company_name, + supervisor_name=application.supervisor_name, + supervisor_contact=application.supervisor_contact, + output_path=task_dir / "original.pdf", + ) + + application.status = "interview" + application.missing_fields = [] + application.contract_task_id = task_id + application.contract_pdf_path = str(storage_service.original_path(task_id)) + return repo.update(application) + + @router.get("/by-id/{application_id}/contract-preview") def preview_contract(application_id: str): application = _require_by_id(application_id) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1ad1a59..84ff63e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -501,3 +501,48 @@ def test_signature_date_is_stamped_at_signing_not_generation(tmp_path): signed_text = fitz.open(str(signed))[0].get_text() assert signed_text.count(today) == 2 # header + signing date + + +def test_manual_approve_generates_contract_when_fields_present(): + """Coordinator override: a borderline `pending` candidate whose placement + details are all present can be approved manually, which flips it to + `interview` and issues the contract.""" + from app.models.application import ApplicationResponse + from app.services import application_repository as repo + + app_in = ApplicationResponse( + name="Olga Volkova", email="olga@example.com", candidate_score=60, + recommended_role="Backend Developer Internship", status="pending", + report="borderline", email_subject="s", email_body="b", + company_name="Comarch S.A.", supervisor_name="Jan Kowalski", + supervisor_contact="jan@comarch.com", + ) + repo.add(app_in) + + r = client.post(f"/applications/by-id/{app_in.id}/approve") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "interview" + assert body["contract_task_id"] + # A second approve is a no-op conflict, not a duplicate contract. + assert client.post(f"/applications/by-id/{app_in.id}/approve").status_code == 409 + + +def test_manual_approve_refused_when_mandatory_field_missing(): + """The mandatory-field gate is not bypassable: approving an application that + omits the supervisor is refused, since the contract could not name one.""" + from app.models.application import ApplicationResponse + from app.services import application_repository as repo + + app_in = ApplicationResponse( + name="No Supervisor", email="ns@example.com", candidate_score=60, + recommended_role="Backend Developer Internship", + status="request_clarification", report="r", email_subject="s", email_body="b", + company_name="Comarch S.A.", supervisor_name="", supervisor_contact="", + missing_fields=["supervisor_name", "supervisor_contact"], + ) + repo.add(app_in) + + r = client.post(f"/applications/by-id/{app_in.id}/approve") + assert r.status_code == 422 + assert "supervisor" in r.json()["detail"].lower() diff --git a/frontend/src/App.css b/frontend/src/App.css index b312047..76c40e5 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -714,3 +714,7 @@ } .sentBox strong { display: block; color: var(--text-h); font-size: 13.5px; } .sentBox small { color: var(--text); font-size: 12px; } + +/* Coordinator manual-approve override (pending → contract) */ +.approveArea { margin-top: 16px; width: 100%; } +.approveArea .primary { width: 100%; } diff --git a/frontend/src/components/ContractPanel.jsx b/frontend/src/components/ContractPanel.jsx index e195f84..8f3c747 100644 --- a/frontend/src/components/ContractPanel.jsx +++ b/frontend/src/components/ContractPanel.jsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from "react"; -import { CheckCircle2, Download, FileText, Mail, PenLine, Send } from "lucide-react"; +import { CheckCircle2, Download, FileText, Gavel, Mail, PenLine, Send } from "lucide-react"; import { + approveApplication, contractPreviewUrl, sendContract, signApplication, @@ -39,6 +40,10 @@ export default function ContractPanel({ selected, refresh }) { const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(""); + // Coordinator override: manually approve a borderline candidate. + const [approving, setApproving] = useState(false); + const [approveError, setApproveError] = useState(""); + // Reset the pad and the compose form whenever a different candidate is selected. useEffect(() => { const canvas = canvasRef.current; @@ -53,6 +58,7 @@ export default function ContractPanel({ selected, refresh }) { setError(""); setComposeOpen(false); setSendError(""); + setApproveError(""); if (selected) { setTo(selected.email || ""); setSubject(defaultSubject(selected)); @@ -150,6 +156,19 @@ export default function ContractPanel({ selected, refresh }) { } } + async function handleApprove() { + setApproveError(""); + setApproving(true); + try { + await approveApplication(selected.id); + await refresh(); + } catch (err) { + setApproveError(err.message || "Could not approve this application."); + } finally { + setApproving(false); + } + } + return (
{approveError}
} +