Building the Saved Opportunities Workspace for Tender Pipeline Management

Introduction

The AI Tender Opportunity Scanner can now:

  • Ingest tender opportunities
  • Analyze tender descriptions with AI
  • Extract industries, technologies, skills, and requirements
  • Match tenders against business profiles
  • Rank personalized recommendations
  • Display recommendations in a React dashboard
  • Provide deep opportunity analysis through the Tender Detail Page

Users can discover relevant tenders and understand whether an opportunity is worth pursuing.

The next challenge is organization.

A company may review dozens or even hundreds of tenders each month. Some opportunities require immediate action, while others may need further research, partner discussions, or internal approval.

Without a structured workspace, users may lose track of:

  • Promising opportunities
  • Submission deadlines
  • Internal decisions
  • Bid preparation progress
  • Opportunities awaiting review
  • Tenders that should be ignored

In this article, we will build the Saved Opportunities Workspace.

This workspace will allow users to:

  • Save promising tenders
  • Organize opportunities by pipeline stage
  • Add internal notes
  • Track deadlines
  • Assign priorities
  • Move opportunities through a bid workflow
  • Remove opportunities from the shortlist
  • Open the Tender Detail Page
  • Create the foundation for future team collaboration

This transforms the application from a recommendation engine into a lightweight tender pipeline management platform.


Development Progress

Project Scope and Roadmap ████████████████████ 100%
MVP Definition ████████████████████ 100%
Technology Stack ████████████████████ 100%
System Architecture ████████████████████ 100%
Database Design ████████████████████ 100%
Backend Project Structure ████████████████████ 100%
SQLAlchemy Models ████████████████████ 100%
Alembic Migration ████████████████████ 100%
Repository Layer ████████████████████ 100%
CRUD Service Layer ████████████████████ 100%
Tender Ingestion Pipeline ████████████████████ 100%
AI Analysis Pipeline ████████████████████ 100%
AI Matching Engine ████████████████████ 100%
React Dashboard ████████████████████ 100%
Tender Detail Page ████████████████████ 100%
Saved Opportunities Workspace ████████████████████ 100%
Pipeline Analytics ░░░░░░░░░░░░░░░░░░░░ 0%

What We Are Building

The Saved Opportunities Workspace will provide a central place for managing tender opportunities.

A simplified version may look like this:

----------------------------------------------------------------
Saved Opportunities
----------------------------------------------------------------
Search: [________________]
Stage: [All] Priority: [All] Deadline: [All]
----------------------------------------------------------------
Opportunity Score Stage Deadline
----------------------------------------------------------------
Azure Cloud Migration 92% Reviewing 12 days
Municipal Data Platform 88% Bid Decision 18 days
Cybersecurity Assessment 84% Preparing Bid 5 days
CRM Implementation 79% Saved 31 days
----------------------------------------------------------------

A board-style version may look like:

Saved Reviewing Bid Decision Preparing Bid
----------------------------------------------------------------
Tender A Tender C Tender E Tender G
Tender B Tender D Tender F Tender H

The MVP will begin with a structured list.

The data model and backend will be designed so that a Kanban board can be added later without changing the underlying architecture.


Why Build a Saved Opportunities Workspace?

The recommendation dashboard is optimized for discovery.

The Tender Detail Page is optimized for evaluation.

The Saved Opportunities Workspace is optimized for execution.

These interfaces answer different questions:

Dashboard:
What opportunities should I review?
Detail Page:
Should I pursue this opportunity?
Saved Workspace:
What are we currently doing with this opportunity?

This distinction is important because tender bidding is a process rather than a single decision.

A typical journey may look like:

Tender Discovered
|
v
Saved for Review
|
v
Qualification Review
|
v
Bid Decision
|
+---- No Bid
|
v
Preparing Bid
|
v
Submitted
|
v
Won or Lost

The workspace should support this lifecycle.


Saved Opportunity Lifecycle

For the first version, we will use the following pipeline stages:

saved
reviewing
bid_decision
preparing_bid
submitted
won
lost
archived

These stages represent the business state of the opportunity.

They should not be confused with the tender’s external procurement status.

For example:

Tender status:
Open
Internal pipeline stage:
Reviewing

The tender may still be open while the user’s organization is deciding whether to bid.


Recommended MVP Stages

For a minimal implementation, we can start with:

Saved
Reviewing
Bid Decision
Preparing Bid
Submitted
Archived

Later, we can add:

Won
Lost
No Bid
Partner Required
Waiting for Approval

Starting with a smaller set keeps the workflow understandable.


Workspace Architecture

React Workspace
|
v
Saved Opportunity API
|
v
Saved Opportunity Service
|
+---- Tender Repository
+---- Match Repository
+---- Saved Opportunity Repository
|
v
PostgreSQL

The workspace combines:

  • Tender information
  • Match information
  • Saved-state information
  • Internal workflow metadata

Saved Opportunity Data Model

The original saved-tender concept may only contain:

user_id
tender_id

That is enough to bookmark a tender, but not enough to manage a pipeline.

We need a richer model.

Create or extend:

saved_opportunities

Recommended fields:

id
user_id
business_profile_id
tender_id
pipeline_stage
priority
internal_notes
saved_at
updated_at
next_action
next_action_date
assigned_to

For the initial single-user version, assigned_to can remain optional.


SQLAlchemy Model

Create:

app/models/saved_opportunity.py

Example:

from datetime import date, datetime
from enum import Enum
from sqlalchemy import (
Date,
DateTime,
Enum as SqlEnum,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base

Define the pipeline stages:

class OpportunityStage(str, Enum):
SAVED = "saved"
REVIEWING = "reviewing"
BID_DECISION = "bid_decision"
PREPARING_BID = "preparing_bid"
SUBMITTED = "submitted"
WON = "won"
LOST = "lost"
ARCHIVED = "archived"

Define priority levels:

class OpportunityPriority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"

Create the model:

class SavedOpportunity(Base):
__tablename__ = "saved_opportunities"
__table_args__ = (
UniqueConstraint(
"user_id",
"tender_id",
name="uq_saved_opportunity_user_tender",
),
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
business_profile_id: Mapped[int | None] = mapped_column(
ForeignKey(
"business_profiles.id",
ondelete="SET NULL",
),
nullable=True,
index=True,
)
tender_id: Mapped[int] = mapped_column(
ForeignKey("tenders.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
pipeline_stage: Mapped[OpportunityStage] = mapped_column(
SqlEnum(
OpportunityStage,
name="opportunity_stage",
),
default=OpportunityStage.SAVED,
nullable=False,
index=True,
)
priority: Mapped[OpportunityPriority] = mapped_column(
SqlEnum(
OpportunityPriority,
name="opportunity_priority",
),
default=OpportunityPriority.MEDIUM,
nullable=False,
index=True,
)
internal_notes: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)
next_action: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
)
next_action_date: Mapped[date | None] = mapped_column(
Date,
nullable=True,
index=True,
)
assigned_to: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
)
saved_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False,
)
tender = relationship(
"Tender",
lazy="joined",
)

Why Use a Unique Constraint?

The constraint:

user_id + tender_id

prevents the same user from saving the same tender multiple times.

Without this constraint, repeated clicks could create duplicate records.

The database should enforce this rule even if the frontend already disables duplicate saves.


Creating the Alembic Migration

Generate a migration:

alembic revision --autogenerate -m "create saved opportunities table"

Review the migration carefully.

Then apply it:

alembic upgrade head

Confirm that the migration creates:

  • The table
  • Foreign keys
  • Enum types
  • Indexes
  • Unique constraint

Pydantic Schemas

Create:

app/schemas/saved_opportunity.py

Start with a create schema:

from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
from app.models.saved_opportunity import (
OpportunityPriority,
OpportunityStage,
)
class SavedOpportunityCreate(BaseModel):
tender_id: int
business_profile_id: int | None = None
pipeline_stage: OpportunityStage = OpportunityStage.SAVED
priority: OpportunityPriority = OpportunityPriority.MEDIUM
internal_notes: str | None = None
next_action: str | None = None
next_action_date: date | None = None

Create an update schema:

class SavedOpportunityUpdate(BaseModel):
pipeline_stage: OpportunityStage | None = None
priority: OpportunityPriority | None = None
internal_notes: str | None = None
next_action: str | None = None
next_action_date: date | None = None
assigned_to: str | None = None

Create the response schema:

class SavedOpportunityResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
tender_id: int
business_profile_id: int | None
pipeline_stage: OpportunityStage
priority: OpportunityPriority
internal_notes: str | None
next_action: str | None
next_action_date: date | None
assigned_to: str | None
saved_at: datetime
updated_at: datetime

Creating the Workspace View Model

The basic response only contains saved-opportunity fields.

The frontend also needs tender and matching data.

Create:

class SavedOpportunityListItem(BaseModel):
id: int
tender_id: int
title: str
buyer_name: str | None
country: str | None
deadline: date | None
days_remaining: int | None
contract_value: float | None
currency: str | None
match_score: float | None
recommendation_level: str | None
pipeline_stage: OpportunityStage
priority: OpportunityPriority
next_action: str | None
next_action_date: date | None
updated_at: datetime

This schema is specifically designed for the workspace.

It avoids returning unnecessary tender fields.


Repository Layer

Create:

app/repositories/saved_opportunity_repository.py

The repository should handle database access only.

from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.saved_opportunity import SavedOpportunity

Create a save method:

class SavedOpportunityRepository:
def __init__(self, db: Session):
self.db = db
def create(
self,
opportunity: SavedOpportunity,
) -> SavedOpportunity:
self.db.add(opportunity)
self.db.commit()
self.db.refresh(opportunity)
return opportunity

Find an existing record:

def get_by_user_and_tender(
self,
user_id: int,
tender_id: int,
) -> SavedOpportunity | None:
statement = (
select(SavedOpportunity)
.where(
SavedOpportunity.user_id == user_id,
SavedOpportunity.tender_id == tender_id,
)
)
return self.db.scalar(statement)

List saved opportunities:

def list_for_user(
self,
user_id: int,
) -> list[SavedOpportunity]:
statement = (
select(SavedOpportunity)
.where(
SavedOpportunity.user_id == user_id
)
.order_by(
SavedOpportunity.updated_at.desc()
)
)
return list(
self.db.scalars(statement).all()
)

Delete a saved opportunity:

def delete(
self,
opportunity: SavedOpportunity,
) -> None:
self.db.delete(opportunity)
self.db.commit()

Why Keep Queries in a Repository?

The repository isolates persistence logic.

The service layer should not repeatedly contain:

select(...)
where(...)
join(...)
order_by(...)

This separation makes the application easier to test and maintain.


Saved Opportunity Service

Create:

app/services/saved_opportunity_service.py

Responsibilities:

  • Save an opportunity
  • Prevent duplicates
  • Validate tender existence
  • Update pipeline stages
  • Update notes and priorities
  • Remove saved opportunities
  • Return workspace list items

Example:

class SavedOpportunityService:
def __init__(
self,
saved_repository,
tender_repository,
match_repository,
):
self.saved_repository = saved_repository
self.tender_repository = tender_repository
self.match_repository = match_repository

Saving an Opportunity

def save_opportunity(
self,
user_id: int,
data: SavedOpportunityCreate,
) -> SavedOpportunity:
existing = (
self.saved_repository
.get_by_user_and_tender(
user_id=user_id,
tender_id=data.tender_id,
)
)
if existing:
return existing
tender = self.tender_repository.get_by_id(
data.tender_id
)
if not tender:
raise ValueError("Tender not found")
opportunity = SavedOpportunity(
user_id=user_id,
tender_id=data.tender_id,
business_profile_id=data.business_profile_id,
pipeline_stage=data.pipeline_stage,
priority=data.priority,
internal_notes=data.internal_notes,
next_action=data.next_action,
next_action_date=data.next_action_date,
)
return self.saved_repository.create(
opportunity
)

Why Make Saving Idempotent?

An idempotent save operation means:

Save once:
One record
Save twice:
Still one record

This is safer than returning an error every time the user clicks the button twice.

The database constraint remains the final protection against duplicates.


Updating an Opportunity

Users need to modify:

  • Pipeline stage
  • Priority
  • Notes
  • Next action
  • Next-action date
  • Assignment

Example service method:

def update_opportunity(
self,
opportunity_id: int,
user_id: int,
data: SavedOpportunityUpdate,
) -> SavedOpportunity:
opportunity = (
self.saved_repository.get_by_id(
opportunity_id
)
)
if not opportunity:
raise ValueError(
"Saved opportunity not found"
)
if opportunity.user_id != user_id:
raise PermissionError(
"Access denied"
)
updates = data.model_dump(
exclude_unset=True
)
for field, value in updates.items():
setattr(
opportunity,
field,
value,
)
return self.saved_repository.update(
opportunity
)

Removing an Opportunity

A user may decide that a tender no longer belongs in the active workspace.

Two options are possible:

Hard delete
Archive

For the MVP:

  • Use archived when historical visibility matters
  • Use delete when the user explicitly removes the bookmark

An archive action is usually safer because it preserves previous notes and decisions.


API Endpoints

Create:

app/api/routes/saved_opportunities.py

Recommended endpoints:

POST /saved-opportunities
GET /saved-opportunities
GET /saved-opportunities/{id}
PATCH /saved-opportunities/{id}
DELETE /saved-opportunities/{id}

Optional convenience endpoint:

GET /saved-opportunities/check/{tender_id}

This tells the frontend whether the current tender is already saved.


Save Endpoint

@router.post(
"",
response_model=SavedOpportunityResponse,
)
def save_opportunity(
payload: SavedOpportunityCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = build_saved_opportunity_service(db)
return service.save_opportunity(
user_id=current_user.id,
data=payload,
)

List Endpoint

@router.get(
"",
response_model=list[SavedOpportunityListItem],
)
def list_saved_opportunities(
stage: OpportunityStage | None = None,
priority: OpportunityPriority | None = None,
search: str | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = build_saved_opportunity_service(db)
return service.list_workspace_items(
user_id=current_user.id,
stage=stage,
priority=priority,
search=search,
)

Update Endpoint

@router.patch(
"/{opportunity_id}",
response_model=SavedOpportunityResponse,
)
def update_saved_opportunity(
opportunity_id: int,
payload: SavedOpportunityUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = build_saved_opportunity_service(db)
return service.update_opportunity(
opportunity_id=opportunity_id,
user_id=current_user.id,
data=payload,
)

Delete Endpoint

@router.delete(
"/{opportunity_id}",
status_code=204,
)
def delete_saved_opportunity(
opportunity_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = build_saved_opportunity_service(db)
service.delete_opportunity(
opportunity_id=opportunity_id,
user_id=current_user.id,
)

Ownership and Authorization

Every saved opportunity belongs to a user.

The backend must validate ownership before:

  • Reading notes
  • Updating stages
  • Changing priorities
  • Removing opportunities

Never trust the frontend to enforce ownership.

This is unsafe:

repository.get_by_id(opportunity_id)

without verifying:

opportunity.user_id == current_user.id

A future multi-tenant version may replace user_id ownership with:

organization_id

Building the Workspace Frontend

Create the following structure:

src/
├── components/
└── SavedOpportunities/
├── OpportunityRow.jsx
├── OpportunityCard.jsx
├── OpportunityFilters.jsx
├── OpportunitySearch.jsx
├── StageBadge.jsx
├── PriorityBadge.jsx
├── DeadlineIndicator.jsx
├── EmptyWorkspace.jsx
└── WorkspaceSummary.jsx
├── hooks/
└── useSavedOpportunities.js
├── pages/
└── SavedOpportunitiesPage.jsx
└── services/
└── savedOpportunityService.js

Adding the React Route

Add:

<Route
path="/saved-opportunities"
element={<SavedOpportunitiesPage />}
/>

Add a navigation item:

<NavLink to="/saved-opportunities">
Saved Opportunities
</NavLink>

Frontend API Service

Create:

src/services/savedOpportunityService.js
import axios from "axios";
const API_URL =
"http://localhost:8000";

Fetch saved opportunities:

export const getSavedOpportunities =
async (filters = {}) => {
const response = await axios.get(
`${API_URL}/saved-opportunities`,
{
params: filters,
}
);
return response.data;
};

Save a tender:

export const saveOpportunity =
async (payload) => {
const response = await axios.post(
`${API_URL}/saved-opportunities`,
payload
);
return response.data;
};

Update a saved opportunity:

export const updateSavedOpportunity =
async (
opportunityId,
payload
) => {
const response = await axios.patch(
`${API_URL}/saved-opportunities/${opportunityId}`,
payload
);
return response.data;
};

Delete a saved opportunity:

export const deleteSavedOpportunity =
async (opportunityId) => {
await axios.delete(
`${API_URL}/saved-opportunities/${opportunityId}`
);
};

Creating the Custom Hook

Create:

src/hooks/useSavedOpportunities.js
import {
useCallback,
useEffect,
useState,
} from "react";
import {
deleteSavedOpportunity,
getSavedOpportunities,
updateSavedOpportunity,
} from "../services/savedOpportunityService";
export const useSavedOpportunities =
(filters) => {
const [
opportunities,
setOpportunities,
] = useState([]);
const [
loading,
setLoading,
] = useState(true);
const [
error,
setError,
] = useState(null);
const loadOpportunities =
useCallback(async () => {
setLoading(true);
setError(null);
try {
const result =
await getSavedOpportunities(
filters
);
setOpportunities(result);
} catch {
setError(
"Unable to load saved opportunities."
);
} finally {
setLoading(false);
}
}, [filters]);

Add update support:

const updateOpportunity =
async (
opportunityId,
payload
) => {
const updated =
await updateSavedOpportunity(
opportunityId,
payload
);
setOpportunities(
current =>
current.map(item =>
item.id === opportunityId
? {
...item,
...updated,
}
: item
)
);
};

Add delete support:

const removeOpportunity =
async opportunityId => {
await deleteSavedOpportunity(
opportunityId
);
setOpportunities(
current =>
current.filter(
item =>
item.id !== opportunityId
)
);
};

Complete the hook:

useEffect(() => {
loadOpportunities();
}, [loadOpportunities]);
return {
opportunities,
loading,
error,
reload: loadOpportunities,
updateOpportunity,
removeOpportunity,
};

Avoiding Unnecessary Reloads

When a user changes a stage, the frontend can update the local state immediately.

This avoids reloading the entire workspace after every small change.

The workflow becomes:

User changes stage
|
v
PATCH API request
|
v
Update one local item

This provides a faster interface.


Creating the Workspace Page

Create:

src/pages/SavedOpportunitiesPage.jsx

Start with state for the filters:

const [
filters,
setFilters
] = useState({
search: "",
stage: "",
priority: "",
});

Load the data:

const {
opportunities,
loading,
error,
updateOpportunity,
removeOpportunity,
} = useSavedOpportunities(
filters
);

Page structure:

<main className="saved-workspace">
<WorkspaceSummary
opportunities={opportunities}
/>
<OpportunityFilters
filters={filters}
onChange={setFilters}
/>
<OpportunityList
opportunities={opportunities}
onUpdate={updateOpportunity}
onRemove={removeOpportunity}
/>
</main>

Creating the Workspace Header

The header should display:

Saved Opportunities
Manage tenders from initial review through submission.

Example:

<header className="workspace-header">
<div>
<h1>
Saved Opportunities
</h1>
<p>
Manage promising tenders and track bid progress.
</p>
</div>
</header>

Creating the Workspace Summary

Create:

WorkspaceSummary.jsx

Display counts such as:

Total Saved: 18
Reviewing: 6
Preparing Bid: 3
Submitted: 2
Urgent Deadlines: 4

Example:

const total =
opportunities.length;
const reviewing =
opportunities.filter(
item =>
item.pipeline_stage ===
"reviewing"
).length;

A summary gives the user immediate situational awareness.


Opportunity List or Kanban Board?

There are two common interface options.

Table or List

Advantages:

  • Easier to scan
  • Better for detailed information
  • Easier to filter and sort
  • Better on smaller screens
  • Simpler to implement

Kanban Board

Advantages:

  • Better for workflow visualization
  • Easier stage movement
  • Good for team collaboration
  • Familiar sales-pipeline pattern

For the MVP, use a list or table.

Later, add a board view that uses the same API and database fields.


Creating the Opportunity Row

Create:

OpportunityRow.jsx

Each row should display:

  • Tender title
  • Buyer
  • Match score
  • Stage
  • Priority
  • Deadline
  • Next action
  • Action menu

Example:

<tr>
<td>
<Link
to={`/tenders/${opportunity.tender_id}`}
>
{opportunity.title}
</Link>
</td>
<td>
{opportunity.match_score}%
</td>
<td>
<StageBadge
stage={
opportunity.pipeline_stage
}
/>
</td>
<td>
<PriorityBadge
priority={
opportunity.priority
}
/>
</td>
<td>
<DeadlineIndicator
daysRemaining={
opportunity.days_remaining
}
/>
</td>
</tr>

Creating the Stage Badge

Create:

StageBadge.jsx

Example:

const stageLabels = {
saved: "Saved",
reviewing: "Reviewing",
bid_decision: "Bid Decision",
preparing_bid: "Preparing Bid",
submitted: "Submitted",
won: "Won",
lost: "Lost",
archived: "Archived",
};

Render:

<span
className={`stage-badge ${stage}`}
>
{stageLabels[stage]}
</span>

Changing the Pipeline Stage

Use a select field:

<select
value={
opportunity.pipeline_stage
}
onChange={event =>
onUpdate(
opportunity.id,
{
pipeline_stage:
event.target.value,
}
)
}
>
<option value="saved">
Saved
</option>
<option value="reviewing">
Reviewing
</option>
<option value="bid_decision">
Bid Decision
</option>
<option value="preparing_bid">
Preparing Bid
</option>
<option value="submitted">
Submitted
</option>
</select>

The user can now move an opportunity through the workflow.


Why Make Stage Changes Inline?

A separate edit page would require:

Open record
Click edit
Change stage
Save
Return to workspace

Inline editing reduces the workflow to:

Select new stage

This is faster for frequent pipeline updates.


Creating the Priority Badge

Create:

PriorityBadge.jsx

Priority levels:

Low
Medium
High
Urgent

Example label mapping:

const priorityLabels = {
low: "Low",
medium: "Medium",
high: "High",
urgent: "Urgent",
};

Priority should represent internal importance, not match score.

A tender can have:

Match score:
95%
Priority:
Low

For example, the organization may lack resources to respond.


Deadline Indicator

Create:

DeadlineIndicator.jsx

Example logic:

const getDeadlineState =
daysRemaining => {
if (daysRemaining === null) {
return "unknown";
}
if (daysRemaining < 0) {
return "expired";
}
if (daysRemaining <= 3) {
return "critical";
}
if (daysRemaining <= 7) {
return "urgent";
}
if (daysRemaining <= 14) {
return "warning";
}
return "normal";
};

Display examples:

Expired
2 days left
6 days left
13 days left
28 days left

Why Separate Match Score and Deadline Urgency?

A high match score describes strategic fit.

Deadline urgency describes available response time.

These are independent dimensions.

Example:

Match score:
94%
Deadline:
2 days

The opportunity is highly relevant but operationally risky.


Adding Search

Create:

OpportunitySearch.jsx
<input
type="search"
value={filters.search}
placeholder="Search saved opportunities"
onChange={event =>
onChange({
...filters,
search:
event.target.value,
})
}
/>

Search may cover:

  • Tender title
  • Buyer name
  • Reference number
  • Technologies
  • Industry
  • Internal notes

For small datasets, client-side search is acceptable.

For larger workspaces, send the search query to the backend.


Adding Filters

Create:

OpportunityFilters.jsx

Recommended filters:

Pipeline stage
Priority
Minimum match score
Deadline range
Country
Industry

MVP filters:

Stage
Priority
Search

Example:

<select
value={filters.stage}
onChange={event =>
onChange({
...filters,
stage:
event.target.value,
})
}
>
<option value="">
All stages
</option>
<option value="saved">
Saved
</option>
<option value="reviewing">
Reviewing
</option>
<option value="preparing_bid">
Preparing Bid
</option>
</select>

Backend Filtering

The API should support:

GET /saved-opportunities?stage=reviewing
GET /saved-opportunities?priority=high
GET /saved-opportunities?search=azure

Multiple filters can be combined:

GET /saved-opportunities
?stage=reviewing
&priority=high
&search=cloud

The repository can construct the query dynamically.


Sorting Opportunities

Useful sort options include:

Deadline soonest
Highest match score
Recently saved
Recently updated
Highest priority
Contract value

Recommended default:

Urgent deadlines first

A practical ordering strategy is:

1. Expired or urgent deadlines
2. High priority
3. Highest match score
4. Most recently updated

However, this logic can become confusing.

For the MVP, use an explicit sort selector.


Adding Internal Notes

Users need to record information that does not belong in the public tender record.

Examples:

Contact procurement manager before Friday.
Potential partner: Example Security BV.
Legal team must review the liability clause.
We need approval from the delivery director.

Create an editable notes field:

<textarea
value={
opportunity.internal_notes || ""
}
onChange={event =>
setNotes(
event.target.value
)
}
/>

Save notes with:

await onUpdate(
opportunity.id,
{
internal_notes:
notes,
}
);

Avoid Saving on Every Keystroke

Do not send an API request after every character.

Better options:

  • Save button
  • Debounced save
  • Save when focus leaves the field

For the MVP, use an explicit save button.

This makes the behavior predictable.


Adding a Next Action

Every active tender should ideally have a next action.

Examples:

Review technical requirements
Schedule bid decision meeting
Contact implementation partner
Estimate delivery costs
Prepare clarification questions

Display:

<input
value={
opportunity.next_action || ""
}
placeholder="Add next action"
onChange={...}
/>

Also store:

next_action_date

This creates a basic task-management layer without building a full project-management system.


Opportunity Action Menu

Each row may include:

View details
Edit notes
Change stage
Archive
Remove
Open source tender

Use a compact menu to avoid overcrowding the table.

Example:

<button
aria-label="Opportunity actions"
>
</button>

Connecting the Save Button on the Detail Page

The Tender Detail Page already contains a Save button.

Connect it to:

saveOpportunity({
tender_id:
tender.id,
business_profile_id:
activeProfileId,
});

After saving, change the button state:

Save Opportunity

to:

Saved

Optionally provide:

View in Workspace

Checking Whether a Tender Is Saved

The detail page should know whether the tender is already in the workspace.

Possible approaches:

Include Saved State in the Detail Response

{
"id": 123,
"title": "Azure Migration",
"is_saved": true,
"saved_opportunity_id": 47
}

Call a Separate Check Endpoint

GET /saved-opportunities/check/123

The first option reduces API requests and is usually better.


Optimistic UI Updates

When the user changes a stage, the interface can update immediately before the server responds.

Workflow:

User selects "Preparing Bid"
|
v
UI updates immediately
|
v
PATCH request
|
+---- Success: keep change
|
+---- Error: restore previous value

This creates a faster experience.

However, optimistic updates require rollback logic.

For the MVP, updating after a successful response is simpler and safer.


Empty Workspace State

New users may have no saved opportunities.

Do not show an empty table.

Create:

EmptyWorkspace.jsx

Display:

No saved opportunities yet.
Review your personalized recommendations and save the tenders you want to track.

Add a button:

Browse Recommendations

Link it to the dashboard.


No Filter Results State

This is different from an empty workspace.

Example:

No opportunities match the selected filters.

Provide:

Clear Filters

Do not incorrectly suggest that the user has never saved an opportunity.


Loading State

Display:

Loading saved opportunities...

A future improvement may use skeleton rows.

The page should not flash an empty-state message before loading completes.

The correct order is:

Loading
Error
Empty
Results

Error State

Display:

Unable to load saved opportunities.

Add:

Retry

Example:

<button onClick={reload}>
Retry
</button>

Confirmation Before Removal

Removing an opportunity may delete:

  • Notes
  • Stage history
  • Priority settings
  • Next actions

Ask for confirmation:

Remove this opportunity from your workspace?

For archive actions, confirmation may not be necessary because the record can be restored.


Responsive Design

Desktop tables do not always work well on mobile.

Recommended behavior:

Desktop:
Table
Tablet:
Compact table
Mobile:
Opportunity cards

A mobile card may display:

Azure Cloud Migration
92% Match
High Priority
Reviewing
5 days left
Next action:
Review security requirements
[View Details]

Workspace Styling

Example page container:

.saved-workspace {
max-width: 1400px;
margin: 0 auto;
padding: 32px;
}

Filters:

.workspace-filters {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: 24px 0;
}

Table:

.opportunity-table {
width: 100%;
border-collapse: collapse;
}

Rows:

.opportunity-table th,
.opportunity-table td {
padding: 16px;
border-bottom: 1px solid #e5e7eb;
text-align: left;
}

Stage Styling

Example:

.stage-badge {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.875rem;
font-weight: 600;
}

Keep the badge styles centralized so they are consistent across:

  • Workspace
  • Tender Detail Page
  • Future analytics dashboard
  • Future Kanban board

Priority Styling

Priority should be visually clear without overwhelming the interface.

Example:

Low Neutral
Medium Standard
High Strong
Urgent Critical

Do not rely only on color.

Include text labels for accessibility.


Accessibility Considerations

The workspace should support:

  • Keyboard navigation
  • Descriptive button labels
  • Visible focus states
  • Proper table headers
  • Text labels in addition to colors
  • Accessible form controls
  • Confirmation messages after updates

Example:

<button
aria-label={`Remove ${opportunity.title}`}
>
Remove
</button>

Pagination

A user may eventually save hundreds of opportunities.

Support:

?page=1
&page_size=25

Example API response:

{
"items": [],
"page": 1,
"page_size": 25,
"total": 128,
"total_pages": 6
}

For the first version, pagination can be deferred when the dataset is small.

The API design should still allow it later.


Preventing N+1 Queries

The workspace list may require:

  • Saved opportunity
  • Tender
  • Match result
  • Business profile

Loading each relationship separately can cause many database queries.

Use:

  • Joins
  • Eager loading
  • A dedicated list query
  • A tailored response projection

The workspace endpoint should ideally retrieve all required row data in one optimized query.


Example Repository Query

A simplified query may look like:

statement = (
select(
SavedOpportunity,
Tender,
TenderMatch,
)
.join(
Tender,
Tender.id ==
SavedOpportunity.tender_id,
)
.outerjoin(
TenderMatch,
(
TenderMatch.tender_id ==
Tender.id
)
& (
TenderMatch.business_profile_id ==
SavedOpportunity.business_profile_id
),
)
.where(
SavedOpportunity.user_id ==
user_id
)
)

The final implementation should return view models rather than exposing raw ORM tuples.


Testing the Backend

Create:

tests/services/test_saved_opportunity_service.py

Test:

  • Saving a tender
  • Preventing duplicates
  • Saving a missing tender
  • Updating a stage
  • Updating a priority
  • Updating notes
  • Ownership protection
  • Archiving an opportunity
  • Removing an opportunity

Duplicate Save Test

def test_save_same_tender_twice_returns_one_record(
service,
repository,
):
first = service.save_opportunity(
user_id=1,
data=create_payload,
)
second = service.save_opportunity(
user_id=1,
data=create_payload,
)
assert first.id == second.id
assert repository.count() == 1

Ownership Test

def test_user_cannot_update_another_users_opportunity(
service,
):
with pytest.raises(PermissionError):
service.update_opportunity(
opportunity_id=10,
user_id=999,
data=SavedOpportunityUpdate(
priority="urgent"
),
)

API Testing

Test:

POST /saved-opportunities
GET /saved-opportunities
PATCH /saved-opportunities/{id}
DELETE /saved-opportunities/{id}

Also test:

  • Authentication required
  • Invalid tender ID
  • Invalid stage
  • Invalid priority
  • Duplicate save
  • Unauthorized update
  • Unauthorized delete

Frontend Testing

Test the workspace with mock data:

const opportunities = [
{
id: 1,
tender_id: 101,
title:
"Azure Cloud Migration",
match_score: 92,
pipeline_stage:
"reviewing",
priority:
"high",
days_remaining: 6,
next_action:
"Review security requirements",
},
];

Verify:

  • Records render
  • Search works
  • Filters work
  • Stage changes work
  • Priority changes work
  • Delete confirmation works
  • Empty state renders
  • Error state renders
  • Tender links work

Integration Test Scenario

A useful end-to-end test is:

1. Open recommendation dashboard
2. Open tender detail page
3. Save tender
4. Open Saved Opportunities Workspace
5. Confirm tender appears
6. Change stage to Reviewing
7. Add next action
8. Set priority to High
9. Refresh page
10. Confirm changes remain

This validates the complete workflow.


Pipeline History

The current model stores only the latest stage.

Later, users may want to know:

When was the tender saved?
When did it enter review?
Who changed the stage?
When was the bid submitted?

A future table may be added:

opportunity_stage_history

Fields:

id
saved_opportunity_id
previous_stage
new_stage
changed_by
changed_at

This creates an audit trail.

It is useful for larger teams and enterprise customers.


Team Collaboration Roadmap

Future collaboration features may include:

  • Opportunity owners
  • Comments
  • Mentions
  • Approval workflows
  • Task assignments
  • Activity history
  • Shared notes
  • Bid-team roles

The MVP should not attempt to implement all of these.

The assigned_to and updated_at fields provide a starting point.


Notifications Roadmap

Future reminders may include:

Tender deadline in seven days
Bid decision still pending
Next action overdue
Clarification deadline approaching
Submission deadline tomorrow

These notifications can later be delivered through:

  • Email
  • In-app notifications
  • Slack
  • Microsoft Teams

Kanban Board Roadmap

The saved-opportunity data model already supports a board view because every record has a pipeline stage.

Future layout:

Saved
|
+-- Tender A
+-- Tender B
Reviewing
|
+-- Tender C
Preparing Bid
|
+-- Tender D

Drag-and-drop would update:

pipeline_stage

through the existing PATCH endpoint.

No new backend model is required.


Current User Workflow

The full user journey now becomes:

Tender Sources
|
v
Tender Ingestion
|
v
AI Analysis
|
v
Personalized Matching
|
v
Recommendation Dashboard
|
v
Tender Detail Page
|
v
Save Opportunity
|
v
Manage Bid Pipeline

This is the first complete business workflow in the application.


Current Architecture

External Tender Sources
|
v
Ingestion Pipeline
|
v
Tender Database
|
v
AI Analysis Pipeline
|
v
Matching Engine
|
v
Recommendation Dashboard
|
v
Tender Detail Page
|
v
Saved Opportunities Workspace

The application now supports both discovery and workflow management.


Current Deliverables

At this stage, we have designed:

✔ Saved opportunity database model
✔ Pipeline-stage enum
✔ Priority enum
✔ Alembic migration
✔ Pydantic request schemas
✔ Workspace response model
✔ Repository layer
✔ Service layer
✔ Save endpoint
✔ List endpoint
✔ Update endpoint
✔ Delete endpoint
✔ User ownership validation
✔ Workspace React page
✔ API service
✔ Custom data hook
✔ Search
✔ Filters
✔ Stage management
✔ Priority management
✔ Deadline indicators
✔ Internal notes
✔ Next actions
✔ Empty states
✔ Error states
✔ Responsive layout
✔ Backend tests
✔ Frontend tests

The AI Tender Opportunity Scanner can now help users organize promising opportunities and track them through an internal bid pipeline.


Recommended Git Commit

git add .
git commit -m "Build saved opportunities workspace"

Push the changes:

git push origin main

What We Will Build Next

The Saved Opportunities Workspace gives users a structured pipeline.

The next step is visibility.

Users should be able to understand:

  • How many opportunities are in each stage
  • The total potential contract value
  • Upcoming deadlines
  • Average match scores
  • Bid activity over time
  • Submission outcomes
  • Win and loss rates

The next article in the series is:

“Building Tender Pipeline Analytics and Opportunity Performance Dashboards.”


Conclusion

The Saved Opportunities Workspace transforms the AI Tender Opportunity Scanner from a discovery tool into a practical tender management platform.

Users can now save promising opportunities, assign priorities, track internal pipeline stages, record next actions, monitor deadlines, and maintain notes throughout the bid process.

This workspace creates the operational layer between finding a tender and submitting a response.

With opportunity management in place, the next development step is building analytics dashboards that help users understand the size, quality, urgency, and performance of their tender pipeline.

Discover more from BidRadar

Subscribe now to keep reading and get access to the full archive.

Continue reading