Skip to content

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"

  1. Activity appears in calendar slot
  2. Can drag-and-drop to reorder within day
  3. 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
    }
  }
}

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

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 &#x2014; instead of &mdash;).

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 in NTSDBSession)
  • Canonical entity: COtherService with 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_SERVICE component 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 ScheduleEntry for sorting by date then time

Brochure PDF

New PDF type for travel narrative documents (no pricing):

  • TripMakerFacade.generateBrochurePDF() — loads AI brief data and trip schedule
  • PdfTemplateRenderer brochure methods: buildTimingBadge(), buildNarrativeSection(), buildAreasSection(), buildHighlightsSection(), buildPracticalSection()
  • Template: pdf-brochure-template.html with cover header, AI brief sections, trip schedule
  • API endpoint: POST /tripmaker/pdf/generate-brochure
  • XHTML compliance: uses numeric character references (&#x2014;, &#x2022;) for OpenHTMLtoPDF compatibility

Template Externalization

PDF templates are no longer hardcoded to classpath:

  • loadTemplate(String templateName) in TripMakerFacade resolves 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.key in tourlinq.properties (placeholder ##pexels.api.key)
  • DB migration: 0023-itinerary-cover-image.sql — adds cover_image_id, cover_image_url, cover_photographer, cover_photographer_url to nts.trip_itinerary
  • Service: PexelsImageService.javasearchPhoto(destination, apiKey) and downloadAsBase64DataUri(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 from project.travelerDetails
  • API: AiOutlineApi.doGenerateOutline() parses nationalities and residences arrays from request
  • Facade: AiOutlineFacade.generateOutline() accepts nationality/residence lists, includes them in cache key (natProfile) and user message
  • Prompt: ai-outline-prompt.txt updated 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() in tripmaker-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