Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion backend/app/routers/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
4 changes: 4 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%; }
43 changes: 41 additions & 2 deletions frontend/src/components/ContractPanel.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -53,6 +58,7 @@ export default function ContractPanel({ selected, refresh }) {
setError("");
setComposeOpen(false);
setSendError("");
setApproveError("");
if (selected) {
setTo(selected.email || "");
setSubject(defaultSubject(selected));
Expand Down Expand Up @@ -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 (
<div className="panel contract">
<div className="panelHead">
Expand All @@ -166,8 +185,28 @@ export default function ContractPanel({ selected, refresh }) {
<small>
{selected?.missing_fields?.length > 0
? "The application is missing mandatory placement details — the candidate has been asked for them."
: "Only candidates invited to interview receive an agreement."}
: selected?.status === "interview"
? "Only candidates invited to interview receive an agreement."
: "The AI did not recommend interview. You can still approve this candidate manually."}
</small>

{/* Coordinator override: available when the AI stopped short of
interview but the mandatory placement details are present. */}
{selected?.status !== "interview" &&
!(selected?.missing_fields?.length > 0) && (
<div className="approveArea">
<button
className="primary"
type="button"
onClick={handleApprove}
disabled={approving}
>
<Gavel size={16} />{" "}
{approving ? "Approving…" : "Approve & generate contract"}
</button>
{approveError && <p className="signError">{approveError}</p>}
</div>
)}
</div>
)}

Expand Down
11 changes: 11 additions & 0 deletions frontend/src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ export async function sendContract(applicationId, { to, subject, body }) {
return res.json();
}

export async function approveApplication(applicationId) {
const res = await fetch(`${API_URL}/applications/by-id/${applicationId}/approve`, {
method: "POST",
});
if (!res.ok) {
const detail = await res.json().catch(() => null);
throw new Error(detail?.detail || "Could not approve this application.");
}
return res.json();
}

export function contractPreviewUrl(applicationId) {
return `${API_URL}/applications/by-id/${applicationId}/contract-preview`;
}
Expand Down
Loading