TripMaker — Implementation Guide¶
Version 2.2 March 2026 (updated with TQ-62, TQ-64, and TQ-66 changes)
Technology Stack¶
| Component | Technology |
|---|---|
| Frontend | HTML/CSS/JavaScript with Bootstrap 5, jQuery, ES Modules |
| Backend | Java 17 with embedded Jetty for REST services |
| Database | PostgreSQL with JSONB columns |
| Authentication | Keycloak + OAuth2Proxy |
| PDF Generation | OpenHTMLtoPDF (openhtmltopdf-pdfbox:1.1.31) |
| AI Integration | Claude API via AiOutlineFacade |
| Hotel Supplier | GoGlobal via HotelSearchFacade |
| Activity Supplier | Tiqets via TiqetsFacade |
| Flight Supplier | Google Flights via RapidAPI (tqgflights module, FlightSearchFacade) |
1. Agent Workflow¶
1.1 Complete Workflow Diagram¶
┌─────────────────────────────────────────────────────────────────────┐
│ ITINERARY MANAGEMENT WORKFLOW │
└─────────────────────────────────────────────────────────────────────┘
1. CREATE TRIP PROJECT
↓
• Agent enters master information
• System validates and creates project
• Agent directed to project workspace
2. START NEW ITINERARY
↓
• Agent clicks "Create Itinerary"
• System creates empty itinerary
• Itinerary builder interface opens
3. ADD FLIGHTS
↓
• Agent searches for outbound flights
• Reviews results and selects flight
• Adds airport transport details
• Adds to itinerary
• Repeats for return flights
• (Optional) Adds internal flights
4. ADD ACCOMMODATIONS
↓
• System auto-calculates nights based on flights
• Agent searches accommodations
• Reviews and adds options (can add multiple)
5. ADD ACTIVITIES
↓
• Agent views day-by-day calendar
• Searches or manually adds activities
• Assigns to specific days/time periods
6. ADD EXCLUSIONS
↓
• Agent lists excluded items
7. REVIEW COSTS
↓
• Reviews cost breakdown
• Sets/adjusts margins
• Manually adjusts prices if needed
8. CREATE ADDITIONAL ITINERARIES (OPTIONAL)
↓
• Repeat steps 2-7 for alternatives
9. GENERATE PDF QUOTATION
↓
• Final review
• Generate professional PDF
10. DISTRIBUTE
↓
• Download or email to customer
1.2 Detailed Step-by-Step Process¶
Step 1: Create Trip Project¶
Entry: Agent Dashboard → "New Trip Project" button
Process: 1. Modal opens with trip project form 2. Agent enters: - Number of travelers (triggers dynamic fields) - For each traveler: Age range, Gender - Holiday type (dropdown) - Destinations (autocomplete, multiple) - Start date, End date - Date flexibility (±N days) 3. Click "Create Project" 4. System validates: - All required fields filled - End date after start date - At least 1 traveler 5. Success: Redirect to Project Workspace
Time: ~3 minutes
Step 2: Flight Search & Selection¶
Entry: Project Workspace → Flights tab
Process: 1. Agent fills search form: - Flight type (Outbound/Return/Internal) - Origin airport (3-letter code, autocomplete) - Destination airport - Departure date - Cabin class 2. Click "Search Flights" 3. System displays loading indicator 4. Results appear in cards showing: - Airline logo and name - Departure/arrival times and airports - Duration and stops - Price per person 5. Agent can: - Filter (price, stops, airline) - Sort (price, duration, time) - View details (layovers, aircraft, baggage) 6. Agent clicks "Add to Itinerary" 7. Modal opens for airport transport: - Type: Private/Shared/Taxi/Public/None - Cost (if applicable) - Notes 8. Confirm → Flight added 9. Repeat for return flight and any internal flights
Time: ~5-10 minutes per destination
Step 3: Accommodation Search & Selection¶
Entry: Project Workspace → Accommodations tab
Process: 1. System auto-displays: - "Based on your flights: 3 nights in Istanbul" - Pre-filled check-in/out dates - Number of travelers 2. Agent adjusts if needed, clicks "Search Hotels" 3. Results show with images: - Property name and stars - Room type - Meal plan - Amenities - Total price 4. Agent filters (stars, type, price, amenities) 5. Click "Add as Option" on selected hotels 6. Can add multiple options for same destination 7. Each option labeled (Option A, B, C)
Time: ~5 minutes per destination
Step 4: Activity Planning¶
Entry: Project Workspace → Activities tab
Process: 1. Calendar view shows all trip days 2. Each day divided into: Morning/Afternoon/Evening/Full Day 3. Agent clicks on a time slot 4. Modal with two tabs:
Tab 1: Search Activities - Auto-filled with destination - Enter keywords (optional) - Results show with descriptions, duration, inclusions - Click "Add to Itinerary"
Tab 2: Custom Activity - Enter name, description - Duration, transport included?, guide included? - Cost - Click "Add"
- Activity appears in calendar slot
- Can drag-and-drop to reorder within day
- Repeat for each day
Time: ~10-15 minutes total
Step 5: Exclusions¶
Entry: Project Workspace → Exclusions tab
Process: 1. Simple list interface 2. Click "Add Exclusion" 3. Enter text (e.g., "International travel insurance") 4. Save 5. Can reorder with drag-and-drop
Time: ~2 minutes
Step 6: Cost Review & Margin Setting¶
Entry: Project Workspace → Cost Review tab
Process: 1. View itemized breakdown table: - Component | Details | Base Cost | Margin % | Final Price 2. For each component: - Edit margin percentage inline - Or click final price to manually override 3. System recalculates in real-time 4. View summary cards: - Total base cost - Total margin - Final price - Price per person 5. Export to Excel if needed (agent only)
Time: ~5-10 minutes
Step 7: Create Additional Itineraries (Optional)¶
Process: 1. From itinerary selector, click "New Itinerary" 2. Name it (e.g., "Option 2 - Budget") 3. Repeat steps 2-6 with different selections 4. Common use cases: - Budget vs Luxury versions - Different activity focus - Alternative flight times
Time: Variable
Step 8: Generate PDF Quotation¶
Entry: Project Workspace → "Generate Quotation" button
Process: 1. System validates completeness: - ✓ Flights (outbound + return) - ✓ Accommodations (at least one per destination) - ✓ Costs calculated 2. If incomplete, shows error with missing items 3. If complete, shows preview modal 4. Agent can make final adjustments 5. Click "Generate PDF" 6. Progress indicator (5-10 seconds) 7. Success screen with: - "Download PDF" button - "Email to Customer" option
Time: ~2 minutes
Step 9: Distribution¶
Option A - Download: - Click "Download PDF" - File downloads to agent's device - Agent can send via their own email
Option B - Email Direct: - Click "Email to Customer" - Enter customer email - Add personal message - Click "Send" - System sends email with PDF attached - Confirmation shown
Time: ~1 minute
1.3 Navigation Patterns¶
Within Project Workspace: - Left sidebar always visible with sections - Current section highlighted - Cost summary sticky at bottom - Breadcrumbs at top for context - Auto-save every 2 minutes (status shown)
Between Projects: - Dashboard → Select project → Workspace - Workspace → Breadcrumb "Dashboard" → Dashboard - Quick project switcher in header
2. Web Page Design Specifications¶
2.1 Page Structure & Layout¶
Overall Framework:
┌────────────────────────────────────────────────────────────┐
│ FIXED HEADER │
│ Logo | Navigation | Agent Name | Notifications | Logout │
├────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌───────────────────────────────────────┐ │
│ │ SIDEBAR │ │ MAIN CONTENT AREA │ │
│ │ │ │ │ │
│ │ 250px │ │ Flexible width │ │
│ │ width │ │ │ │
│ │ │ │ Dynamic content based on │ │
│ │ │ │ selected navigation item │ │
│ │ │ │ │ │
│ │ [Cost │ │ │ │
│ │ Summary] │ │ │ │
│ │ │ │ │ │
│ └──────────┘ └───────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
2.2 Individual Pages¶
2.2.1 Agent Dashboard (dashboard.html)¶
Purpose: Central hub for all trip projects
HTML Structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Itinerary Manager - Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="css/app.css" rel="stylesheet">
</head>
<body>
<!-- Header -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="#">
<img src="img/logo.png" alt="Logo" height="40">
Itinerary Manager
</a>
<div class="navbar-nav ms-auto">
<span class="navbar-text me-3">
<i class="bi bi-person-circle"></i> John Agent
</span>
<a class="nav-link" href="#" onclick="logout()">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</div>
</div>
</nav>
<div class="container-fluid" style="margin-top: 80px;">
<!-- Page Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h1><i class="bi bi-briefcase"></i> My Trip Projects</h1>
<button class="btn btn-primary btn-lg" onclick="showCreateProjectModal()">
<i class="bi bi-plus-circle"></i> New Trip Project
</button>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<form id="filterForm">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Search</label>
<input type="text" class="form-control"
id="searchQuery" placeholder="Search projects...">
</div>
<div class="col-md-2">
<label class="form-label">Status</label>
<select class="form-select" id="statusFilter">
<option value="">All Statuses</option>
<option value="DRAFT">Draft</option>
<option value="ACTIVE">Active</option>
<option value="QUOTED">Quoted</option>
<option value="CLOSED">Closed</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">Destination</label>
<input type="text" class="form-control"
id="destinationFilter"
placeholder="Any destination">
</div>
<div class="col-md-2">
<label class="form-label">From Date</label>
<input type="date" class="form-control" id="dateFrom">
</div>
<div class="col-md-2">
<label class="form-label">To Date</label>
<input type="date" class="form-control" id="dateTo">
</div>
<div class="col-md-1 align-self-end">
<button type="button" class="btn btn-outline-secondary w-100"
onclick="resetFilters()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Projects Grid -->
<div class="row" id="projectsContainer">
<!-- Projects will be loaded dynamically -->
<!-- Example card structure: -->
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 project-card" onclick="openProject('project-id')">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">Istanbul & Athens Explorer</h5>
<small>ID: PROJ-2025-001</small>
</div>
<div class="card-body">
<div class="mb-3">
<span class="badge bg-secondary me-1">Istanbul</span>
<span class="badge bg-secondary">Athens</span>
</div>
<div class="info-row">
<i class="bi bi-calendar3"></i>
<strong>May 15 - May 25, 2025</strong>
</div>
<div class="info-row">
<i class="bi bi-people"></i>
4 travelers
</div>
<div class="info-row">
<i class="bi bi-tag"></i>
Family Holiday
</div>
<div class="info-row">
<i class="bi bi-folder"></i>
3 itinerary options
</div>
</div>
<div class="card-footer bg-transparent">
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-success">Active</span>
<small class="text-muted">Updated 2 hours ago</small>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<nav aria-label="Projects pagination">
<ul class="pagination justify-content-center" id="pagination">
<!-- Populated dynamically -->
</ul>
</nav>
</div>
<!-- Create Project Modal -->
<div class="modal fade" id="createProjectModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">
<i class="bi bi-plus-circle"></i> New Trip Project
</h5>
<button type="button" class="btn-close btn-close-white"
data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="projectForm">
<!-- Travelers -->
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Traveler Information</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Number of Travelers</label>
<input type="number" class="form-control"
id="travelerCount" min="1" max="20" value="2"
onchange="updateTravelerFields()">
</div>
<div id="travelerDetailsContainer">
<!-- Populated dynamically -->
</div>
</div>
</div>
<!-- Trip Details -->
<div class="card">
<div class="card-header">
<h6 class="mb-0">Trip Details</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Holiday Type *</label>
<select class="form-select" id="holidayType" required>
<option value="">Select type...</option>
<option>Adventure</option>
<option>Leisure</option>
<option>Cultural</option>
<option>Luxury</option>
<option>Beach</option>
<option>Honeymoon</option>
<option>Family</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Destinations *</label>
<input type="text" class="form-control"
id="destinations"
placeholder="Type to search...">
<div id="selectedDestinations" class="mt-2"></div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Start Date *</label>
<input type="date" class="form-control"
id="startDate" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">End Date *</label>
<input type="date" class="form-control"
id="endDate" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Date Flexibility</label>
<select class="form-select" id="dateFlexibility">
<option value="0">Exact dates only</option>
<option value="1">±1 day</option>
<option value="3" selected>±3 days</option>
<option value="7">±7 days</option>
</select>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary"
onclick="createProject()">
<i class="bi bi-check-circle"></i> Create Project
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js"></script>
<script src="js/dashboard.js"></script>
</body>
</html>
Associated CSS (app.css):
.project-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.project-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.info-row {
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row:last-child {
border-bottom: none;
}
.info-row i {
width: 20px;
color: #6c757d;
margin-right: 8px;
}
Associated JavaScript (dashboard.js):
// Load projects on page load
$(document).ready(function() {
loadProjects();
setupFilters();
setupDestinationAutocomplete();
});
function loadProjects(page = 1) {
const filters = {
page: page,
size: 12,
search: $('#searchQuery').val(),
status: $('#statusFilter').val(),
destination: $('#destinationFilter').val(),
dateFrom: $('#dateFrom').val(),
dateTo: $('#dateTo').val()
};
$.ajax({
url: '/api/v1/itinerary/projects',
method: 'GET',
data: filters,
success: function(response) {
renderProjects(response.data.projects);
renderPagination(response.data.pagination);
},
error: function(xhr) {
showAlert('error', 'Failed to load projects');
}
});
}
function renderProjects(projects) {
const container = $('#projectsContainer');
container.empty();
if (projects.length === 0) {
container.html(`
<div class="col-12">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
No projects found. Create your first trip project to get started!
</div>
</div>
`);
return;
}
projects.forEach(project => {
const card = createProjectCard(project);
container.append(card);
});
}
function createProjectCard(project) {
const destinations = project.destinations.map(d =>
`<span class="badge bg-secondary me-1">${d}</span>`
).join('');
const statusColors = {
'DRAFT': 'secondary',
'ACTIVE': 'success',
'QUOTED': 'info',
'CLOSED': 'dark'
};
return `
<div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 project-card" onclick="openProject('${project.projectId}')">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">${project.destinations.join(' & ')}</h5>
<small>Created ${formatDate(project.createdAt)}</small>
</div>
<div class="card-body">
<div class="mb-3">${destinations}</div>
<div class="info-row">
<i class="bi bi-calendar3"></i>
<strong>${formatDate(project.datePeriodStart)} - ${formatDate(project.datePeriodEnd)}</strong>
</div>
<div class="info-row">
<i class="bi bi-people"></i>
${project.travelerCount} traveler${project.travelerCount > 1 ? 's' : ''}
</div>
<div class="info-row">
<i class="bi bi-tag"></i>
${project.holidayType}
</div>
<div class="info-row">
<i class="bi bi-folder"></i>
${project.itineraryCount} itinerary option${project.itineraryCount !== 1 ? 's' : ''}
</div>
</div>
<div class="card-footer bg-transparent">
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-${statusColors[project.status]}">${project.status}</span>
<small class="text-muted">Updated ${formatRelativeTime(project.lastModified)}</small>
</div>
</div>
</div>
</div>
`;
}
function openProject(projectId) {
window.location.href = `workspace.html?projectId=${projectId}`;
}
function showCreateProjectModal() {
$('#createProjectModal').modal('show');
updateTravelerFields();
}
function updateTravelerFields() {
const count = parseInt($('#travelerCount').val());
const container = $('#travelerDetailsContainer');
container.empty();
for (let i = 1; i <= count; i++) {
container.append(`
<div class="row mb-2">
<div class="col-md-4">
<label class="form-label">Traveler ${i}</label>
</div>
<div class="col-md-4">
<select class="form-select form-select-sm" name="age[]" required>
<option value="">Age Range</option>
<option value="CHILD">Child (0-12)</option>
<option value="TEEN">Teen (13-17)</option>
<option value="ADULT">Adult (18-64)</option>
<option value="SENIOR">Senior (65+)</option>
</select>
</div>
<div class="col-md-4">
<select class="form-select form-select-sm" name="gender[]" required>
<option value="">Gender</option>
<option value="MALE">Male</option>
<option value="FEMALE">Female</option>
<option value="OTHER">Other</option>
</select>
</div>
</div>
`);
}
}
function setupDestinationAutocomplete() {
$('#destinations').autocomplete({
source: function(request, response) {
$.ajax({
url: '/api/v1/locations/search',
data: { query: request.term },
success: function(data) {
response(data.data.locations);
}
});
},
minLength: 2,
select: function(event, ui) {
addDestination(ui.item.value);
$(this).val('');
return false;
}
});
}
let selectedDestinations = [];
function addDestination(destination) {
if (!selectedDestinations.includes(destination)) {
selectedDestinations.push(destination);
renderSelectedDestinations();
}
}
function removeDestination(destination) {
selectedDestinations = selectedDestinations.filter(d => d !== destination);
renderSelectedDestinations();
}
function renderSelectedDestinations() {
const container = $('#selectedDestinations');
container.empty();
selectedDestinations.forEach(dest => {
container.append(`
<span class="badge bg-primary me-1">
${dest}
<i class="bi bi-x-circle ms-1"
onclick="removeDestination('${dest}')"
style="cursor: pointer;"></i>
</span>
`);
});
}
function createProject() {
// Collect traveler details
const travelerDetails = [];
$('select[name="age[]"]').each(function(index) {
travelerDetails.push({
age: $(this).val(),
gender: $('select[name="gender[]"]').eq(index).val()
});
});
const projectData = {
travelerCount: parseInt($('#travelerCount').val()),
travelerDetails: travelerDetails,
holidayType: $('#holidayType').val(),
destinations: selectedDestinations,
datePeriodStart: $('#startDate').val(),
datePeriodEnd: $('#endDate').val(),
dateFlexibility: parseInt($('#dateFlexibility').val())
};
// Validate
if (!validateProjectData(projectData)) {
return;
}
$.ajax({
url: '/api/v1/itinerary/projects',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(projectData),
success: function(response) {
$('#createProjectModal').modal('hide');
showAlert('success', 'Project created successfully');
openProject(response.data.projectId);
},
error: function(xhr) {
showAlert('error', 'Failed to create project: ' + xhr.responseJSON.error.message);
}
});
}
function validateProjectData(data) {
if (data.destinations.length === 0) {
showAlert('warning', 'Please select at least one destination');
return false;
}
if (!data.holidayType) {
showAlert('warning', 'Please select a holiday type');
return false;
}
if (!data.datePeriodStart || !data.datePeriodEnd) {
showAlert('warning', 'Please select travel dates');
return false;
}
if (data.datePeriodEnd < data.datePeriodStart) {
showAlert('warning', 'End date must be after start date');
return false;
}
return true;
}
// Utility functions
function formatDate(dateString) {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function formatRelativeTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours < 1) return 'Just now';
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
const days = Math.floor(hours / 24);
return `${days} day${days > 1 ? 's' : ''} ago`;
}
function showAlert(type, message) {
const alertClass = {
'success': 'alert-success',
'error': 'alert-danger',
'warning': 'alert-warning',
'info': 'alert-info'
}[type];
const alert = $(`
<div class="alert ${alertClass} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`);
$('body').prepend(alert);
setTimeout(() => alert.alert('close'), 5000);
}
2.2.2 Project Workspace (workspace.html)¶
This is the main working interface. Due to length constraints, I'll provide the key components:
Left Sidebar:
<div class="sidebar bg-light border-end" style="width: 250px;">
<!-- Project Info -->
<div class="p-3 border-bottom">
<h6 class="mb-1" id="projectTitle">Loading...</h6>
<small class="text-muted" id="projectDates"></small>
</div>
<!-- Itinerary Selector -->
<div class="p-3 border-bottom">
<label class="form-label small">Active Itinerary</label>
<select class="form-select form-select-sm mb-2" id="itinerarySelector">
<!-- Populated dynamically -->
</select>
<button class="btn btn-sm btn-outline-primary w-100"
onclick="createNewItinerary()">
<i class="bi bi-plus"></i> New Itinerary
</button>
</div>
<!-- Navigation -->
<nav class="nav flex-column p-3">
<a class="nav-link active" href="#" onclick="showSection('overview')">
<i class="bi bi-house-door"></i> Overview
</a>
<a class="nav-link" href="#" onclick="showSection('flights')">
<i class="bi bi-airplane"></i> Flights
<span class="badge bg-success float-end" id="flightCount">0</span>
</a>
<a class="nav-link" href="#" onclick="showSection('accommodations')">
<i class="bi bi-building"></i> Accommodations
<span class="badge bg-success float-end" id="accommodationCount">0</span>
</a>
<a class="nav-link" href="#" onclick="showSection('activities')">
<i class="bi bi-calendar-event"></i> Activities
<span class="badge bg-success float-end" id="activityCount">0</span>
</a>
<a class="nav-link" href="#" onclick="showSection('exclusions')">
<i class="bi bi-x-circle"></i> Exclusions
</a>
<a class="nav-link" href="#" onclick="showSection('cost-review')">
<i class="bi bi-calculator"></i> Cost Review
</a>
</nav>
<!-- Cost Summary (Sticky) -->
<div class="p-3 border-top mt-auto" style="position: sticky; bottom: 0; background: white;">
<div class="card">
<div class="card-body p-2">
<div class="d-flex justify-content-between mb-1">
<small class="text-muted">Base Cost:</small>
<small><strong id="baseCost">$0</strong></small>
</div>
<div class="d-flex justify-content-between mb-2">
<small class="text-muted">Final Price:</small>
<small><strong class="text-success" id="finalPrice">$0</strong></small>
</div>
<button class="btn btn-primary btn-sm w-100"
onclick="generatePDF()" id="generatePdfBtn">
<i class="bi bi-file-pdf"></i> Generate PDF
</button>
</div>
</div>
</div>
</div>
Flight Search Section:
<div id="flightSection" class="section-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-airplane"></i> Flights</h2>
<button class="btn btn-outline-secondary" onclick="viewFlightsList()">
<i class="bi bi-list"></i> View Added Flights
</button>
</div>
<!-- Search Form -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">Search Flights</h5>
</div>
<div class="card-body">
<form id="flightSearchForm">
<div class="row mb-3">
<div class="col-md-3">
<label class="form-label">Flight Type</label>
<select class="form-select" id="flightType">
<option value="OUTBOUND">Outbound</option>
<option value="RETURN">Return</option>
<option value="INTERNAL">Internal</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Origin</label>
<input type="text" class="form-control airport-search"
id="origin" placeholder="JFK, LAX..." required>
</div>
<div class="col-md-3">
<label class="form-label">Destination</label>
<input type="text" class="form-control airport-search"
id="destination" placeholder="IST, ATH..." required>
</div>
<div class="col-md-3">
<label class="form-label">Date</label>
<input type="date" class="form-control"
id="departureDate" required>
</div>
</div>
<div class="row mb-3">
<div class="col-md-3">
<label class="form-label">Cabin Class</label>
<select class="form-select" id="cabinClass">
<option>Economy</option>
<option>Premium Economy</option>
<option>Business</option>
<option>First</option>
</select>
</div>
<div class="col-md-3 align-self-end">
<button type="button" class="btn btn-primary w-100"
onclick="searchFlights()">
<i class="bi bi-search"></i> Search Flights
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Loading Indicator -->
<div id="flightLoading" class="text-center d-none my-5">
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3">Searching for best flight options...</p>
</div>
<!-- Results -->
<div id="flightResults"></div>
</div>
2.3 Responsive Breakpoints¶
Bootstrap Classes Used:
- col-md-*: Tablets (≥768px)
- col-lg-*: Desktops (≥992px)
- col-xl-*: Large desktops (≥1200px)
Mobile Adaptations: - Sidebar becomes off-canvas menu (hamburger) - Tables stack vertically - Cards take full width - Filters collapse into accordion
3. Backend API Endpoints¶
3.1 Base Configuration¶
Base URL: /api/v1/itinerary
Authentication:
- All requests require authentication
- OAuth2Proxy validates token
- User ID available in X-Forwarded-User header
- Example: request.getHeader("X-Forwarded-User")
Response Format:
{
"success": true,
"data": { ... },
"message": "Operation successful",
"timestamp": "2025-11-12T10:30:00Z"
}
Error Format:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": [ ... ]
},
"timestamp": "2025-11-12T10:30:00Z"
}
3.2 Endpoint Summary Table¶
| Endpoint | Method | Purpose |
|---|---|---|
/projects |
POST | Create trip project |
/projects |
GET | List trip projects |
/projects/{id} |
GET | Get project details |
/projects/{id} |
PUT | Update project |
/projects/{id} |
DELETE | Delete project |
/projects/{id}/itineraries |
POST | Create itinerary |
/itineraries/{id} |
GET | Get itinerary |
/itineraries/{id} |
PUT | Update itinerary |
/itineraries/{id} |
DELETE | Delete itinerary |
/flights/search |
POST | Search flights (Google Flights via RapidAPI) |
/itineraries/{id}/flights |
POST | Add flight |
/itineraries/{id}/flights |
GET | Get flights |
/flights/{id} |
DELETE | Remove flight |
/accommodations/search |
POST | Search hotels (GoGlobal) |
/itineraries/{id}/accommodations |
POST | Add accommodation |
/itineraries/{id}/accommodations |
GET | Get accommodations |
/accommodations/{id} |
DELETE | Remove accommodation |
/activities/search |
POST | Search activities (Tiqets) |
/itineraries/{id}/activities |
POST | Add activity |
/itineraries/{id}/activities |
GET | Get activities |
/activities/{id} |
PUT | Update activity |
/activities/{id} |
DELETE | Remove activity |
/itineraries/{id}/costs |
GET | Get cost breakdown |
/costs/components/{id}/margin |
PUT | Update margin |
/costs/components/{id}/price |
PUT | Override price |
/projects/{id}/generate-pdf |
POST | Generate PDF |
/pdfs/{id}/download |
GET | Download PDF |
/pdfs/{id}/email |
POST | Email PDF |
3.3 Detailed Endpoint Specifications¶
Project Management¶
Create Trip Project
POST /api/v1/itinerary/projects
Request:
{
"travelerCount": 4,
"travelerDetails": [
{"age": "ADULT", "gender": "MALE"},
{"age": "ADULT", "gender": "FEMALE"}
],
"holidayType": "Family",
"destinations": ["Istanbul", "Athens"],
"datePeriodStart": "2025-05-15",
"datePeriodEnd": "2025-05-25",
"dateFlexibility": 3
}
Response:
{
"success": true,
"data": {
"projectId": "uuid",
"createdAt": "2025-11-12T10:30:00Z"
}
}
Get All Projects
GET /api/v1/itinerary/projects?page=1&size=20&status=ACTIVE
Response:
{
"success": true,
"data": {
"projects": [...],
"pagination": {
"page": 1,
"size": 20,
"totalItems": 45,
"totalPages": 3
}
}
}
Flight Search¶
Search Flights (Google Flights via RapidAPI)
POST /api/v1/itinerary/flights/search
Request:
{
"origin": "JFK",
"destination": "IST",
"departureDate": "2025-05-15",
"adults": 4,
"cabinClass": "ECONOMY"
}
Response:
{
"success": true,
"data": {
"flights": [
{
"offerId": "google-flights-offer-id",
"origin": "JFK",
"destination": "IST",
"departureDateTime": "2025-05-15T10:30:00",
"arrivalDateTime": "2025-05-16T16:00:00",
"duration": "PT11H30M",
"stops": 1,
"layovers": [{"airport": "VIE", "duration": "PT2H15M"}],
"airline": "TK",
"flightNumber": "TK123",
"pricePerPerson": 1200.00,
"totalPrice": 4800.00
}
]
}
}
Add Flight to Itinerary
POST /api/v1/itinerary/itineraries/{itineraryId}/flights
Request:
{
"supplierOfferId": "offer-id",
"flightType": "OUTBOUND",
"airportTransport": {
"type": "PRIVATE_TRANSFER",
"cost": 50.00,
"notes": "Hotel pickup"
}
}
Response:
{
"success": true,
"data": {
"flightOptionId": "uuid",
"baseCostPerPerson": 1200.00
}
}
3.4 Java Implementation¶
Resource Interface:
package com.travelagency.itinerary.resource;
import javax.ws.rs.*;
import javax.ws.rs.core.*;
import javax.servlet.http.HttpServletRequest;
@Path("/api/v1/itinerary/projects")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class TripProjectResource {
private final TripProjectService projectService;
@POST
public Response createTripProject(
@Context HttpServletRequest request,
TripProjectRequest projectRequest) {
String agentId = request.getHeader("X-Forwarded-User");
if (agentId == null) {
return Response.status(401)
.entity(ApiResponse.error("Unauthorized"))
.build();
}
try {
TripProject project = projectService.createProject(
agentId, projectRequest
);
return Response.status(201)
.entity(ApiResponse.success(project))
.build();
} catch (ValidationException e) {
return Response.status(400)
.entity(ApiResponse.error(e.getMessage()))
.build();
}
}
@GET
public Response getProjects(
@Context HttpServletRequest request,
@QueryParam("page") @DefaultValue("1") int page,
@QueryParam("size") @DefaultValue("20") int size,
@QueryParam("status") String status) {
String agentId = request.getHeader("X-Forwarded-User");
ProjectFilter filter = ProjectFilter.builder()
.agentId(agentId)
.status(status)
.build();
PaginatedResult<TripProject> result =
projectService.getProjects(filter, page, size);
return Response.ok(ApiResponse.success(result)).build();
}
}
Service Layer:
package com.travelagency.itinerary.service;
public class TripProjectService {
private final TripProjectRepository repository;
public TripProject createProject(
String agentId,
TripProjectRequest request) {
// Validate
validateProjectRequest(request);
// Create
TripProject project = TripProject.builder()
.projectId(UUID.randomUUID().toString())
.agentId(agentId)
.travelerCount(request.getTravelerCount())
.travelerDetails(request.getTravelerDetails())
.holidayType(request.getHolidayType())
.destinations(request.getDestinations())
.datePeriodStart(request.getDatePeriodStart())
.datePeriodEnd(request.getDatePeriodEnd())
.dateFlexibility(request.getDateFlexibility())
.status("DRAFT")
.createdAt(Instant.now())
.lastModified(Instant.now())
.build();
return repository.save(project);
}
private void validateProjectRequest(TripProjectRequest request) {
if (request.getTravelerCount() < 1) {
throw new ValidationException("At least 1 traveler required");
}
if (request.getDestinations().isEmpty()) {
throw new ValidationException("At least 1 destination required");
}
if (request.getDatePeriodEnd().isBefore(request.getDatePeriodStart())) {
throw new ValidationException("End date must be after start date");
}
}
}
4. PDF Library Recommendation¶
4.1 Recommended: Flying Saucer¶
Why Flying Saucer: 1. HTML/CSS Based - Reuse your Bootstrap knowledge 2. Template Approach - Easy for non-developers to edit 3. Professional Output - High-quality PDF rendering 4. LGPL License - Free for commercial use
Maven Dependencies:
<dependencies>
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf</artifactId>
<version>9.1.22</version>
</dependency>
<dependency>
<groupId>com.lowagie</groupId>
<artifactId>itext</artifactId>
<version>2.1.7</version>
</dependency>
</dependencies>
Implementation:
package com.travelagency.itinerary.pdf;
import org.xhtmlrenderer.pdf.ITextRenderer;
import java.io.*;
public class QuotationPDFGenerator {
public byte[] generateQuotation(TripProject project) {
// 1. Render HTML template
String html = renderTemplate(project);
// 2. Convert to PDF
try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
ITextRenderer renderer = new ITextRenderer();
renderer.setDocumentFromString(html);
renderer.layout();
renderer.createPDF(os);
return os.toByteArray();
} catch (Exception e) {
throw new PDFGenerationException("Failed to generate PDF", e);
}
}
private String renderTemplate(TripProject project) {
// Use any templating engine (Freemarker, Thymeleaf, etc.)
// Or simple string replacement for basic templates
String template = loadTemplate("quotation-template.html");
// Replace placeholders
template = template.replace("${projectTitle}", project.getTitle());
template = template.replace("${dates}", formatDates(project));
// ... etc
return template;
}
}
HTML Template (quotation-template.html):
<!DOCTYPE html>
<html>
<head>
<style>
@page { size: A4; margin: 2cm; }
body { font-family: Arial; font-size: 11pt; }
.header { text-align: center; border-bottom: 3px solid #0d6efd; padding-bottom: 20px; }
.section-title { background: #0d6efd; color: white; padding: 10px; font-weight: bold; }
table { width: 100%; border-collapse: collapse; }
th { background: #e8eef7; padding: 10px; }
td { padding: 10px; border-bottom: 1px solid #ddd; }
.price { font-size: 18pt; color: #0d6efd; font-weight: bold; }
</style>
</head>
<body>
<div class="header">
<h1>Travel Quotation</h1>
<p>${projectTitle}</p>
</div>
<div class="section-title">Trip Information</div>
<table>
<tr><th>Destinations:</th><td>${destinations}</td></tr>
<tr><th>Travel Dates:</th><td>${dates}</td></tr>
</table>
<!-- More sections... -->
<div style="text-align: right;">
<div class="price">$${totalPrice}</div>
</div>
</body>
</html>
4.2 Alternative: Apache PDFBox¶
Use if you need programmatic control:
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.29</version>
</dependency>
5. Database Schema¶
-- Trip Projects
CREATE TABLE trip_projects (
project_id UUID PRIMARY KEY,
agent_id VARCHAR(255) NOT NULL,
traveler_count INTEGER NOT NULL CHECK (traveler_count > 0),
traveler_details JSONB NOT NULL,
holiday_type VARCHAR(50),
destinations JSONB NOT NULL,
date_period_start DATE NOT NULL,
date_period_end DATE NOT NULL CHECK (date_period_end >= date_period_start),
date_flexibility INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'DRAFT',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_projects_agent ON trip_projects(agent_id);
CREATE INDEX idx_projects_destinations ON trip_projects USING GIN(destinations);
-- Itineraries
CREATE TABLE itineraries (
itinerary_id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES trip_projects(project_id) ON DELETE CASCADE,
itinerary_name VARCHAR(255) NOT NULL,
status VARCHAR(20) DEFAULT 'DRAFT',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_itineraries_project ON itineraries(project_id);
-- Flight Options
CREATE TABLE flight_options (
flight_option_id UUID PRIMARY KEY,
itinerary_id UUID NOT NULL REFERENCES itineraries(itinerary_id) ON DELETE CASCADE,
flight_type VARCHAR(20) NOT NULL,
origin_airport VARCHAR(3) NOT NULL,
destination_airport VARCHAR(3) NOT NULL,
departure_datetime TIMESTAMP NOT NULL,
arrival_datetime TIMESTAMP NOT NULL,
layovers JSONB,
total_duration_minutes INTEGER NOT NULL,
airline_code VARCHAR(3) NOT NULL,
flight_number VARCHAR(10),
cabin_class VARCHAR(30),
base_cost_per_person DECIMAL(10,2) NOT NULL,
airport_transport JSONB,
supplier_offer_id VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Accommodation Options
CREATE TABLE accommodation_options (
accommodation_id UUID PRIMARY KEY,
itinerary_id UUID NOT NULL REFERENCES itineraries(itinerary_id) ON DELETE CASCADE,
destination_city VARCHAR(100) NOT NULL,
property_name VARCHAR(255) NOT NULL,
property_type VARCHAR(50),
star_rating INTEGER,
check_in_date DATE NOT NULL,
check_out_date DATE NOT NULL,
number_of_nights INTEGER NOT NULL,
room_type VARCHAR(255),
meal_plan VARCHAR(50),
amenities JSONB,
base_cost_total DECIMAL(10,2) NOT NULL,
supplier_offer_id VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Activities
CREATE TABLE activities (
activity_id UUID PRIMARY KEY,
itinerary_id UUID NOT NULL REFERENCES itineraries(itinerary_id) ON DELETE CASCADE,
activity_date DATE NOT NULL,
time_period VARCHAR(20) NOT NULL,
activity_name VARCHAR(255) NOT NULL,
description TEXT,
duration_minutes INTEGER,
transport_included BOOLEAN DEFAULT FALSE,
guide_included BOOLEAN DEFAULT FALSE,
base_cost_total DECIMAL(10,2) NOT NULL,
source VARCHAR(20) NOT NULL,
supplier_offer_id VARCHAR(255),
display_order INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Exclusions
CREATE TABLE exclusions (
exclusion_id UUID PRIMARY KEY,
itinerary_id UUID NOT NULL REFERENCES itineraries(itinerary_id) ON DELETE CASCADE,
exclusion_text TEXT NOT NULL,
display_order INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Cost Overrides
CREATE TABLE cost_overrides (
override_id UUID PRIMARY KEY,
component_type VARCHAR(50) NOT NULL,
component_id UUID NOT NULL,
original_base_cost DECIMAL(10,2) NOT NULL,
margin_percentage DECIMAL(5,2),
final_price DECIMAL(10,2) NOT NULL,
manually_adjusted BOOLEAN DEFAULT FALSE,
adjustment_reason TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
6. Implementation Roadmap¶
Phase 1: Foundation (2 weeks)¶
- Database schema
- Base REST API structure
- Authentication integration
- Dashboard page
Phase 2: Trip Projects (1 week)¶
- CRUD operations
- Project listing/filtering
Phase 3: Flight Management (2 weeks)¶
- Google Flights (RapidAPI) integration
- Search interface
- Add to itinerary
Phase 4: Accommodation (2 weeks)¶
- GoGlobal Hotel API integration
- Search and add functionality
- Multiple options support
Phase 5: Activities (1.5 weeks)¶
- Tiqets Activity API integration
- Calendar interface
- Manual entry
Phase 6: Cost Management (1 week)¶
- Calculation engine
- Margin management
- Override functionality
Phase 7: PDF Generation (1 week)¶
- Flying Saucer setup
- Template creation
- Email distribution
Phase 8: Testing & Polish (1 week)¶
- Integration testing
- UI refinement
- Bug fixes
Total: 11.5 weeks
TQ-62 Implementation Details¶
Backend Changes¶
PDF Generation Pipeline (TripMakerFacade.generatePDF())¶
The PDF generation flow:
1. Load project via getProject(projectId) and itinerary via getItinerary(itineraryId) (TQ-64: no longer loads all itineraries)
2. Load all components once: flights, accommodations, activities, other services, exclusions
3. Compute cost breakdown using pre-loaded components (TQ-64: avoids duplicate DB calls)
4. Load HTML template via loadTemplate("pdf-quotation-template.html") — tries TLINQ_HOME/templates/tripmaker/ first, falls back to classpath (TQ-66)
5. Substitute placeholders (%DESTINATION%, %FLIGHTS_SECTION%, %TRIP_SCHEDULE_SECTION%, etc.)
6. Render to PDF bytes via PdfRendererBuilder (OpenHTMLtoPDF)
7. Store in DB as CGeneratedPdf with BYTEA column, expires_at set to 90 days (TQ-64)
Brochure Generation Pipeline (TripMakerFacade.generateBrochurePDF())¶
The brochure generation flow (TQ-66):
1. Load project, itinerary, and all components (flights, accommodations, activities, other services)
2. Load HTML template via loadTemplate("pdf-brochure-template.html")
3. Populate AI brief sections from aiBrief map: timing badge, narrative, areas, sights/activities, practical info
4. Build trip schedule (same chronological renderer as quotation)
5. Substitute placeholders and render to PDF
6. Store in DB as CGeneratedPdf
No pricing data is included in the brochure — it is strictly a travel narrative document.
Template Externalization (TQ-66)¶
loadTemplate(String templateName) resolves templates with filesystem-first priority:
1. Check TLINQ_HOME/templates/tripmaker/<templateName> — if the file exists, load it
2. Fall back to classpath resource /tripmaker/<templateName>
This allows layout changes without rebuilding. Templates use %PLACEHOLDER% substitution and must be valid XHTML (use numeric character references like — instead of —).
Key design decisions: - No cost columns in flights/accommodations tables — client-facing only - Simplified activities table: 2 columns (Date in DD-MMM format, Description) - Other services table: 2 columns (Date, Category + Description) - Trip schedule: chronological timeline merging all components sorted by date/time - Cost summary rows: type + description only, no amounts. Flight descriptions enriched with airline code and date - Title: "Itinerary Summary"
buildFlightsSection() (line 903)¶
Columns: Route, Departure, Arrival, Airline, Flight #, Cabin. No cost column.
buildAccommodationsSection() (line 926)¶
Columns: Hotel, Location, Check-in, Check-out, Nights, Room Type. No cost column.
buildActivitiesSection() (line 948)¶
Uses SimpleDateFormat("dd-MMM") for compact date display. Only 2 columns: Date, Description (name + " — " + description).
buildCostRows() (line 982)¶
Iterates cost components, rendering type + details. Flight descriptions enriched:
// Match flight by route, append airline code and date
if ("FLIGHT".equalsIgnoreCase(type) && details.contains(route)) {
details = details + " — " + airlineCode + ", " + briefDateFmt.format(departureDatetime);
}
Entity Mapping: GGHotelOffer.category → CHotelOffer.roomCategory¶
The GoGlobal category field (star rating as string) maps to roomCategory on the hotel offer. Used for star display in frontend.
Pagination: 1-indexed pageId¶
HotelSearchFacade.getSearchResults(searchId, pageId, resPerPage) uses 1-indexed page numbers. The frontend loadPage() passes the page number directly.
Frontend Changes¶
Hotel Grouping: groupOffersByHotel() (tripmaker-hotels.js:330)¶
Groups flat offer array by hotelName into objects:
{
hotelName: "The Ritz-Carlton",
thumbnail: "url",
category: "5", // star rating
cityCode: "BAL",
currency: "USD",
minPrice: 450.00, // lowest offer price
offers: [...] // all room options
}
Rendered in 4-column responsive grid (col-xl-3 col-lg-4 col-md-6 col-12).
Room Selection Modal: openRoomSelectionDialog() (tripmaker-hotels.js:415)¶
Opens Bootstrap modal showing: - Title: hotel name + star symbols - Table: Room Description | Board Type | Beds | Cancellation | Price | Add button
formatCancellation() interprets cancellationType:
- Contains "FREE"/"REFUNDABLE" → green "Free cancellation"
- Contains "NON"/"PENALTY" → red "Non-refundable"
- Other → gray with info icon
City Best-Match: findBestCityMatch() (tripmaker-hotels.js:32)¶
Resolution priority:
1. Exact match: cityName.toLowerCase() === searchTerm.toLowerCase()
2. Starts-with: cityName.toLowerCase().startsWith(searchTerm)
3. Fallback: First result in array
Used in two contexts:
- Area parameter from AI brief (?area=Seminyak)
- Default destination pre-fill from project
Keyword Extraction: extractSearchKeyword() (tripmaker-project.js:326)¶
Strips action prefixes and generic suffixes from AI brief activity names:
// Prefix patterns (applied in order):
/^(walk through the|visit the|explore the|tour of the|...)\s+/i
/^(visit|explore|tour|experience|discover|see|enjoy)\s+/i
// Suffix patterns:
/\s+(gardens?|museum tour|guided tour|walking tour|...tour|excursion|experience|adventure)$/i
Example: "Walk through the Tegallalang Rice Terraces" → "Tegallalang Rice Terraces"
KPI Section Cards (tripmaker-project.js:200-273)¶
Async fetch of all component lists in parallel:
const [flights, hotels, activities, exclusions] = await Promise.all([
TripMakerAPI.listFlights(itin.itineraryId).catch(() => []),
TripMakerAPI.listAccommodations(itin.itineraryId).catch(() => []),
TripMakerAPI.listActivities(itin.itineraryId).catch(() => []),
TripMakerAPI.listExclusions(itin.itineraryId).catch(() => [])
]);
Rendering per card type:
- Flights: <li>DXB → DPS, EK, 15 Mar</li> (up to 5 items)
- Hotels: <li>The Ritz-Carlton (Bali)</li> (up to 5 items)
- Activities: <li>15 Mar — Temple Tour</li> (up to 5 items, DD MMM date format)
- Exclusions: <span class="tm-exclusion-pill">Travel Insurance</span> (up to 8 items, light-pink pills)
Each card has "Manage →" button linking to sub-page with projectId and itineraryId.
AI Brief Re-render on Itinerary Switch (tripmaker-project.js:542-543)¶
function selectItinerary(id) {
// ... update pills, re-render sections ...
if (aiBrief) renderAiBriefContent(); // re-renders with new itineraryId in links
}
Key File Reference¶
JavaScript Modules¶
| File | Purpose |
|---|---|
tqweb-adm/js/modules/tripmaker-common.js |
Shared API wrapper (TripMakerAPI), constants (RES_PER_PAGE, DEFAULT_DEPARTURE_CITY, SUPPLIER_CODES), initSubpage(), setupAutocomplete(), createDestinationManager(), validateProjectForm(), traveler utilities, renderStars() |
tqweb-adm/js/modules/tripmaker-dash.js |
Dashboard page: KPIs, project cards, create/edit modal |
tqweb-adm/js/modules/tripmaker-project.js |
Project hub: itinerary strip, section cards, AI brief, cost summary |
tqweb-adm/js/modules/tripmaker-hotels.js |
Hotel search: grouping, room modal, city autocomplete |
tqweb-adm/js/modules/tripmaker-activities.js |
Activity search: calendar/list view, Tiqets integration |
tqweb-adm/js/modules/tripmaker-exclusions.js |
Exclusion CRUD: add/edit/delete/reorder |
tqweb-adm/js/modules/tripmaker-flights.js |
Flight search: Google Flights (RapidAPI) integration, airport transport |
tqweb-adm/js/modules/tripmaker-costs.js |
Cost review: breakdown, margins, overrides |
HTML Pages¶
| File | Purpose |
|---|---|
tqweb-adm/tripmaker-dash.html |
Dashboard with KPI bar, project grid, create modal |
tqweb-adm/tripmaker-project.html |
Project hub with itinerary strip, section cards, AI brief |
tqweb-adm/tripmaker-hotels.html |
Hotel search and results with room selection modal |
tqweb-adm/tripmaker-activities.html |
Activity management with calendar grid |
tqweb-adm/tripmaker-exclusions.html |
Exclusion list management |
tqweb-adm/tripmaker-flights.html |
Flight search and management |
tqweb-adm/tripmaker-costs.html |
Cost review and margin management |
CSS¶
| File | Purpose |
|---|---|
tqweb-adm/css/tripmaker.css |
Shared styles with design tokens: --tm-primary: #362c5d, --tm-accent: #FFC166, component-specific colors |
Java Classes¶
| File | Purpose |
|---|---|
tqapp/.../tripmaker/TripMakerFacade.java |
Core facade: all CRUD, search, cost, PDF operations, Tiqets city cache |
tqapp/.../tripmaker/PdfTemplateRenderer.java |
PDF section rendering: flights, accommodations, activities, other services, exclusions, cost rows, trip schedule, brochure sections |
tqapp/.../tripmaker/CTripProject.java |
Canonical project entity |
tqapp/.../tripmaker/CItinerary.java |
Canonical itinerary entity |
tqapp/.../tripmaker/CFlightOption.java |
Canonical flight entity |
tqapp/.../tripmaker/CAccommodationOption.java |
Canonical accommodation entity |
tqapp/.../tripmaker/CActivity.java |
Canonical activity entity |
tqapp/.../tripmaker/CExclusion.java |
Canonical exclusion entity |
tqapp/.../tripmaker/COtherService.java |
Canonical other service entity |
tqapp/.../tripmaker/CCostOverride.java |
Canonical cost override entity |
tqapp/.../tripmaker/CGeneratedPdf.java |
Canonical generated PDF entity |
tqapi/.../api/TripMakerApi.java |
REST API endpoints (35+ POST endpoints) |
tqapi/.../api/DashboardApi.java |
Dashboard KPI aggregation using COUNT queries |
tqapp/.../nts/service/NTSClientService.java |
NTS query infrastructure: queryCountS(), querySearchSorted(), queryCountGroupBy() |
tqapp/.../nts/db/tripmaker/AirportCacheHelper.java |
Airport cache search and resolution (LIKE-safe queries) |
tqcommon/.../util/RetryHelper.java |
Exponential backoff retry utility for external API calls |
tqcommon/.../util/TlinqHttpClient.java |
HTTP client base with configurable connect/read timeouts |
Configuration¶
| File | Purpose |
|---|---|
config/entities/tripmaker-entities.xml |
Entity definitions with field mappings |
config/api-roles.properties |
Endpoint permissions (all require agent,admin) |
config/nts-client.xml |
NTS service definitions |
PDF Templates¶
| File | Purpose |
|---|---|
config/templates/tripmaker/pdf-quotation-template.html |
Externalized quotation template (edit without rebuild) |
config/templates/tripmaker/pdf-brochure-template.html |
Externalized brochure template (edit without rebuild) |
tqapp/src/main/resources/tripmaker/pdf-quotation-template.html |
Classpath fallback quotation template |
tqapp/src/main/resources/tripmaker/pdf-brochure-template.html |
Classpath fallback brochure template |
TQ-64 Code Quality and Performance Improvements¶
Frontend Consolidation¶
Extracted shared utilities from individual subpage modules into tripmaker-common.js to eliminate duplication:
| Function | Purpose | Replaced in |
|---|---|---|
initSubpage(onReady) |
Common subpage init: URL parsing, project loading, breadcrumb, loading indicator | flights, hotels, activities, exclusions, costs |
setupAutocomplete(inputSel, listSel, opts) |
Generic debounced autocomplete with searchFn, renderItem, onSelect, onClear callbacks |
flights (airport), hotels (city), common (destination) |
createDestinationManager(chipsSelector, removeCallbackName) |
Stateful destination chip manager returning {destinations, add, remove, set, render} |
dash, project |
validateProjectForm(data, destinations) |
Shared form validation returning error string or null | dash, project |
updateTravelerRows(countSel, containerSel, details) |
Render traveler input rows | dash, project |
collectTravelerDetails(containerSel) |
Extract traveler form data as array | dash, project |
renderStars(rating) |
Star rating HTML (1-5 stars) | hotels, activities |
Shared constants: RES_PER_PAGE (10), DEFAULT_DEPARTURE_CITY ('Dubai'), SUPPLIER_CODES.
Performance Optimizations¶
COUNT Queries for Component Counts¶
countComponents() now uses JPQL SELECT COUNT(e) via NTSClientService.queryCountS() instead of loading all entities and calling .size(). This reduced getProjectExpanded() from 4 full table scans per itinerary to 4 lightweight COUNT queries.
NTS entity name mapping:
FLIGHT → FlightOptionEntity, ACCOMMODATION → AccommodationOptionEntity,
ACTIVITY → ActivityEntity, OTHER_SERVICE → OtherServiceEntity, EXCLUSION → ExclusionEntity
Dashboard COUNT GROUP BY¶
DashboardApi.getItineraryStats() previously loaded up to 10,000 projects to count by status. Now uses TripMakerFacade.getProjectCountsByStatus() which executes a single SELECT status, COUNT(*) GROUP BY status query via NTSClientService.queryCountGroupBy().
Batch Override Loading in applyGlobalMargin()¶
Previously: 3N+7 DB queries (load components, per-component override lookup, per-component save, then reload everything for cost breakdown).
Now: loads components once, batch-loads all overrides via loadOverrideMap(), saves overrides using saveOverrideWithMap() (reuses loaded map), and passes pre-loaded components to getItineraryCostBreakdown(). Reduced to N+4 queries.
Overloaded getItineraryCostBreakdown()¶
New signature accepts pre-loaded component lists to avoid duplicate DB calls:
public Map<String, Object> getItineraryCostBreakdown(Integer itineraryId,
List<CFlightOption> flights, List<CAccommodationOption> accommodations,
List<CActivity> activities)
Used by both applyGlobalMargin() and generatePDF().
Default Date Filter on listProjects()¶
When no explicit date filter is provided, listProjects() adds datePeriodEnd >= today to only return current/future projects. Explicit dateFrom/dateTo filters from the caller override this default. This keeps the result set small without requiring DB-level pagination.
Tiqets City Cache¶
searchActivities() previously called tiqets.listCities(null, true) on every request. Now cached in a static volatile field with a 24-hour TTL via getTiqetsCities() helper.
PDF Expiration¶
generatePDF() now sets pdf.setExpiresAt() to 90 days from generation using the existing CGeneratedPdf.expiresAt field.
Integration Hardening¶
HTTP Timeouts¶
TlinqHttpClient now applies connect and read timeouts on all HttpURLConnection objects:
- Default connect timeout: 15 seconds
- Default read timeout: 60 seconds
- Custom timeouts via constructor: new TlinqHttpPostClient(url, secure, connectMs, readMs)
- Claude API reads ai.timeout.seconds from AppConfig for the read timeout
Retry with Exponential Backoff¶
New RetryHelper.withRetry(maxAttempts, initialBackoffMs, callable) utility:
- Doubles backoff on each retry
- Logs failed attempts at WARNING level
- Preserves TlinqClientException cause chain
- Handles thread interruption
Applied to Claude API calls in AiOutlineFacade: 3 attempts, 2-second initial backoff.
AirportCacheRefresher Race Condition Fix¶
runFullRefresh() previously opened a Hibernate session before the refreshRunning.compareAndSet() guard, wasting a DB connection when another refresh was already running. The CAS guard and session open are now in the correct order in runFullRefresh(), with refreshRunning.set(false) in the finally block.
Logging¶
All logging in TripMakerApi.java converted to:
- Parameterized format: logger.log(Level.INFO, "message {0}", param) instead of string concatenation
- Trace-level logs (BEGIN/END pairs, request params) downgraded from INFO to FINE
- Removed reqData.toString() logging (7 occurrences) and ex.printStackTrace() calls (6 occurrences)
CSS Cleanup¶
Removed unused styles: .exclusion-item (replaced by .exclusion-card), .cost-breakdown-table (only .cost-table used in HTML). Merged split .itin-pill declarations into a single block. Net reduction: 77 lines.
NTS Query Infrastructure¶
Three reusable methods added to NTSClientService:
| Method | Purpose |
|---|---|
queryCountS(entityName, criteria) |
JPQL COUNT with parameterized WHERE |
querySearchSorted(entityName, classOfT, criteria, orderBy, asc, offset, limit) |
JPQL search with ORDER BY and Hibernate pagination |
queryCountGroupBy(entityName, groupByField, criteria) |
JPQL GROUP BY returning Map<String, Long> |
All methods use the same operator whitelist and parameterized query pattern as the existing querySearchS().
TQ-66 Feature Additions¶
Other Services Entity¶
New entity COtherService for miscellaneous trip components (transport, visa, meet & greet, etc.):
- DB table:
nts.other_service(migration:0022-other-service-table.sql) - JPA entity:
OtherServiceEntity(registered inNTSDBSession) - Canonical entity:
COtherServicewith fields: category, description, serviceDate, serviceTime, quantity, supplierName, costs (base/original with currency) - Categories: TRANSPORT, VISA, MEET_GREET, OTHERS
- Currency conversion: automatic server-side conversion to local currency on save
- Cost integration: included in cost breakdown and cost override system as
OTHER_SERVICEcomponent type
Trip Schedule Section¶
Chronological timeline added to both quotation and brochure PDFs:
PdfTemplateRenderer.buildTripScheduleSection()merges all components into date-sorted entries- Flights: departure date/time with route
- Accommodations: check-in and check-out as separate entries
- Activities: activity date/time with name and description
- Other services: service date/time with category and description
- Inner class
ScheduleEntryfor sorting by date then time
Brochure PDF¶
New PDF type for travel narrative documents (no pricing):
TripMakerFacade.generateBrochurePDF()— loads AI brief data and trip schedulePdfTemplateRendererbrochure methods:buildTimingBadge(),buildNarrativeSection(),buildAreasSection(),buildHighlightsSection(),buildPracticalSection()- Template:
pdf-brochure-template.htmlwith cover header, AI brief sections, trip schedule - API endpoint:
POST /tripmaker/pdf/generate-brochure - XHTML compliance: uses numeric character references (
—,•) for OpenHTMLtoPDF compatibility
Template Externalization¶
PDF templates are no longer hardcoded to classpath:
loadTemplate(String templateName)inTripMakerFacaderesolves templates with filesystem-first priority- External path:
TLINQ_HOME/templates/tripmaker/<templateName> - Fallback: classpath
/tripmaker/<templateName> - Allows layout changes without rebuilding the application
- Both quotation and brochure templates available in
config/templates/tripmaker/
Pexels Cover Image Integration¶
Destination cover photo for brochure PDF hero image:
- Config:
pexels.api.keyintourlinq.properties(placeholder##pexels.api.key) - DB migration:
0023-itinerary-cover-image.sql— addscover_image_id,cover_image_url,cover_photographer,cover_photographer_urltonts.trip_itinerary - Service:
PexelsImageService.java—searchPhoto(destination, apiKey)anddownloadAsBase64DataUri(imageUrl)with retry - Flow: On first brochure generation, searches Pexels for landscape photo matching destination, downloads as base64 data URI, embeds in HTML template, saves Pexels photo ID/URL on itinerary for reuse
- Graceful degradation: If API key unconfigured or call fails, brochure generates without cover image
- Attribution: "Photo by [Photographer] on Pexels" rendered below image (Pexels license requirement)
AI Brief Nationality-Aware Visa Information¶
Traveler nationality and residence propagated through to Claude API for destination-specific visa advice:
- Frontend:
buildAiBriefParams()extracts unique nationality/residence ISO codes fromproject.travelerDetails - API:
AiOutlineApi.doGenerateOutline()parsesnationalitiesandresidencesarrays from request - Facade:
AiOutlineFacade.generateOutline()accepts nationality/residence lists, includes them in cache key (natProfile) and user message - Prompt:
ai-outline-prompt.txtupdated to instruct Claude to provide nationality-specific visa requirements - Cache: Different nationality combinations produce different cache keys, ensuring correct visa info per traveler profile
Project Dialog Layout Improvements¶
Optimized the Edit Trip Project dialog for better input distribution:
- Travelers section: "Number of Travelers" label and input inline on one row; column headers (Name, Age, Gender, Nationality, Residence) added above traveler detail rows via
updateTravelerRows()intripmaker-common.js - Trip Details section: Rearranged to 3-column first row (Status, Holiday Type, Budget Tier) and 2-column second row (Departure City, Destinations) — applies to both dashboard create dialog and project edit dialog
End of Implementation Guide