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 daysMunicipal Data Platform 88% Bid Decision 18 daysCybersecurity Assessment 84% Preparing Bid 5 daysCRM 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 GTender 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 | vSaved for Review | vQualification Review | vBid Decision | +---- No Bid | vPreparing Bid | vSubmitted | vWon or Lost
The workspace should support this lifecycle.
Saved Opportunity Lifecycle
For the first version, we will use the following pipeline stages:
savedreviewingbid_decisionpreparing_bidsubmittedwonlostarchived
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:OpenInternal 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:
SavedReviewingBid DecisionPreparing BidSubmittedArchived
Later, we can add:
WonLostNo BidPartner RequiredWaiting for Approval
Starting with a smaller set keeps the workflow understandable.
Workspace Architecture
React Workspace | vSaved Opportunity API | vSaved Opportunity Service | +---- Tender Repository +---- Match Repository +---- Saved Opportunity Repository | vPostgreSQL
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_idtender_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:
iduser_idbusiness_profile_idtender_idpipeline_stagepriorityinternal_notessaved_atupdated_atnext_actionnext_action_dateassigned_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, datetimefrom enum import Enumfrom sqlalchemy import ( Date, DateTime, Enum as SqlEnum, ForeignKey, Integer, String, Text, UniqueConstraint,)from sqlalchemy.orm import Mapped, mapped_column, relationshipfrom 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, datetimefrom pydantic import BaseModel, ConfigDictfrom 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 selectfrom sqlalchemy.orm import Sessionfrom 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 recordSave 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 deleteArchive
For the MVP:
- Use
archivedwhen 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-opportunitiesGET /saved-opportunitiesGET /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 | vPATCH API request | vUpdate 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 OpportunitiesManage 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: 18Reviewing: 6Preparing Bid: 3Submitted: 2Urgent 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 recordClick editChange stageSaveReturn 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:
LowMediumHighUrgent
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:
Expired2 days left6 days left13 days left28 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 stagePriorityMinimum match scoreDeadline rangeCountryIndustry
MVP filters:
StagePrioritySearch
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=reviewingGET /saved-opportunities?priority=highGET /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 soonestHighest match scoreRecently savedRecently updatedHighest priorityContract value
Recommended default:
Urgent deadlines first
A practical ordering strategy is:
1. Expired or urgent deadlines2. High priority3. Highest match score4. 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 requirementsSchedule bid decision meetingContact implementation partnerEstimate delivery costsPrepare 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 detailsEdit notesChange stageArchiveRemoveOpen 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" | vUI updates immediately | vPATCH 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:
LoadingErrorEmptyResults
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:TableTablet:Compact tableMobile:Opportunity cards
A mobile card may display:
Azure Cloud Migration92% MatchHigh PriorityReviewing5 days leftNext 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 NeutralMedium StandardHigh StrongUrgent 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-opportunitiesGET /saved-opportunitiesPATCH /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 dashboard2. Open tender detail page3. Save tender4. Open Saved Opportunities Workspace5. Confirm tender appears6. Change stage to Reviewing7. Add next action8. Set priority to High9. Refresh page10. 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:
idsaved_opportunity_idprevious_stagenew_stagechanged_bychanged_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 daysBid decision still pendingNext action overdueClarification deadline approachingSubmission deadline tomorrow
These notifications can later be delivered through:
- 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 BReviewing | +-- Tender CPreparing 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 | vTender Ingestion | vAI Analysis | vPersonalized Matching | vRecommendation Dashboard | vTender Detail Page | vSave Opportunity | vManage Bid Pipeline
This is the first complete business workflow in the application.
Current Architecture
External Tender Sources | vIngestion Pipeline | vTender Database | vAI Analysis Pipeline | vMatching Engine | vRecommendation Dashboard | vTender Detail Page | vSaved 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.