fix: lỗi nút xuất PDF và chèn link ở Địa điểm khi xuất PDF

This commit is contained in:
2026-06-24 19:54:14 +07:00
parent 957ba6c72c
commit 5d47e6d291
6 changed files with 407 additions and 21 deletions
+132
View File
@@ -0,0 +1,132 @@
# To AI Agent: Implement Clickable Google Maps Hyperlinks in PDF Export Table
## 1. Context & Feature Objective
We are upgrading the PDF export functionality within the Yotrip Travel Planner application. Currently, the PDF generates a static table containing location names, addresses, and coordinates.
**Objective:** Automatically turn every location row inside the PDF table into an interactive, clickable hyperlink. When a user clicks on the location cell in the generated PDF document, it must immediately open a browser tab navigating directly to that exact location on Google Maps.
---
## 2. Technical Architecture & Data Strategy
Because `jspdf-autotable` draws text onto a canvas layout, raw HTML tags like `<a>` will fail. We must implement a two-step rendering lifecycle:
1. **Extraction & Styling State:** During the data loop, verify coordinates or address data. Generate a standard universal Google Maps search URL, save it into a coordinate-tracking index object, and transform the raw text cell into a styled cell object (Yale Blue text `#28536b` to mimic an online link).
2. **Link Injection Layer:** Utilize the `didDrawCell` hook callback inside the `doc.autoTable` configuration to position a native invisible link window (`doc.link()`) precisely over the drawn dimensions of that specific location cell.
---
## 3. Code Refactoring Specification
### Step 1: Update Data Preparation Loop
Locate the loop processing `currentTour.legs` inside your PDF generation code block. Initialize a temporary lookup map array named `pdfMapLinks` and refactor the item distribution logic as follows:
```typescript
// Initialize an index mapping registry for hyperlinks before the loop execution
const pdfMapLinks: { [key: number]: string } = {};
if (currentTour?.legs && currentTour.legs.length > 0) {
currentTour.legs.forEach((leg: any, legIdx: number) => {
const legName = leg.note || `Chặng ${legIdx + 1}`;
const legLocations = (leg.locations || []).filter((loc: any) =>
loc && (loc.plannedStart || loc.plannedEnd || loc.name)
);
const startRow = globalRowIndex;
if (legLocations.length > 0) {
legRowRanges[leg.id] = { start: startRow, count: legLocations.length };
legLocations.forEach((loc: any, locIdx: number) => {
const isStartPoint = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
const timeSource = isStartPoint ? loc.plannedEnd : loc.plannedStart;
const timeStr = timeSource ? formatDateTime(timeSource) : '';
const locName = loc.name || '';
const addressStr = loc.address || '';
const coordStr = loc.latitude && loc.longitude
? `\n${loc.latitude}, ${loc.longitude}`
: '';
const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n');
const noteText = loc.note || '';
// 1. Generate standard universal Google Maps URL query pattern
let mapUrl = '';
if (loc.latitude && loc.longitude) {
// Absolute Precision using GPS coordinates
mapUrl = `https://www.google.com/maps/search/?api=1&query=${loc.latitude},${loc.longitude}`;
} else if (addressStr || locName) {
// Text-search query fallback if GPS coordinates are missing
mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(addressStr || locName)}`;
}
// 2. Register current row array position to map lookup index
const currentRowPosition = tableRows.length;
if (mapUrl) {
pdfMapLinks[currentRowPosition] = mapUrl;
}
// 3. Convert raw string into custom styled autoTable Cell configuration object
const locationCellObj = {
content: locationText,
// Apply custom link colors matching theme style #28536b (Yale Blue)
styles: mapUrl ? { textColor: [40, 83, 107], fontStyle: 'bold' as const } : {}
};
tableRows.push([
stt++,
timeStr,
locIdx === 0 ? legName : '',
locationCellObj, // Injected as cell structure object
noteText
]);
globalRowIndex++;
});
} else {
legRowRanges[leg.id] = { start: startRow, count: 1 };
tableRows.push([
stt++,
'',
legName,
'Chưa có địa điểm trong chặng này',
''
]);
globalRowIndex++;
}
});
}
### Step 2: Inject Coordinate-Based Overlay inside doc.autoTable
Locate the core configuration module block where doc.autoTable({}) is invoked. Inject the didDrawCell event engine to deploy the link bounds:
doc.autoTable({
head: [['STT', 'Thời gian', 'Chặng', 'Địa điểm', 'Ghi chú']],
body: tableRows,
theme: 'grid',
// HOOK HANDLER: Overlays active coordinate zones on top of native cells after writing
didDrawCell: (data: any) => {
// Target explicitly: body section only + column index 3 (Location Column)
if (data.section === 'body' && data.column.index === 3) {
const activeRowIndex = data.row.index;
const targetMapUrl = pdfMapLinks[activeRowIndex];
if (targetMapUrl) {
// Build active link container utilizing jsPDF coordinates framework
data.doc.link(
data.cell.x,
data.cell.y,
data.cell.width,
data.cell.height,
{ url: targetMapUrl }
);
}
}
},
styles: { font: 'Roboto' } // Retain existing styling structure
});
## 4. Quality Control & Acceptance Criteria
[ ] Visual Differentiation: Location text cells containing map URLs must render cleanly in bold dark-blue ([40, 83, 107]) while empty state texts stay muted.
[ ] Coordinate Precision: Clicking a location card possessing explicit coordinates (latitude, longitude) must directly map to those absolute markers instead of doing an inaccurate keyword address query.
[ ] Boundary Accuracy: The click targets must fit perfectly inside the grid cell borders. Clicking near the edges of column 3 must register properly, while clicking column 2 (Leg name) or column 4 (Notes) must remain non-reactive.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+49 -20
View File
@@ -511,6 +511,7 @@ export const TourDetailPage = ({
const tableColumn = ["STT", "Ngày giờ", "Chặng", "Địa điểm", "Ghi chú"]; const tableColumn = ["STT", "Ngày giờ", "Chặng", "Địa điểm", "Ghi chú"];
const tableRows: any[] = []; const tableRows: any[] = [];
const legRowRanges: Record<string, { start: number; count: number }> = {}; const legRowRanges: Record<string, { start: number; count: number }> = {};
const pdfMapLinks: { [key: number]: string } = {};
const formatDateTime = (dateStr: string) => { const formatDateTime = (dateStr: string) => {
if (!dateStr) return ''; if (!dateStr) return '';
@@ -534,16 +535,15 @@ export const TourDetailPage = ({
loc && (loc.plannedStart || loc.plannedEnd || loc.name) loc && (loc.plannedStart || loc.plannedEnd || loc.name)
); );
if (legLocations.length > 0) {
const startRow = globalRowIndex; const startRow = globalRowIndex;
if (legLocations.length > 0) {
legRowRanges[leg.id] = { start: startRow, count: legLocations.length }; legRowRanges[leg.id] = { start: startRow, count: legLocations.length };
legLocations.forEach((loc: any) => { legLocations.forEach((loc: any, locIdx: number) => {
const timeStr = loc.plannedStart const isStartPoint = loc.plannedStart && new Date(loc.plannedStart).getTime() === 0;
? formatDateTime(loc.plannedStart) const timeSource = isStartPoint ? loc.plannedEnd : loc.plannedStart;
: loc.plannedEnd const timeStr = timeSource ? formatDateTime(timeSource) : '';
? formatDateTime(loc.plannedEnd)
: '';
const locName = loc.name || ''; const locName = loc.name || '';
const addressStr = loc.address || ''; const addressStr = loc.address || '';
@@ -551,18 +551,44 @@ export const TourDetailPage = ({
? `\n${loc.latitude}, ${loc.longitude}` ? `\n${loc.latitude}, ${loc.longitude}`
: ''; : '';
const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n'); const locationText = [locName, addressStr, coordStr].filter(Boolean).join('\n');
const noteText = loc.note || ''; const noteText = loc.note || '';
let mapUrl = '';
if (loc.latitude && loc.longitude) {
mapUrl = `https://www.google.com/maps/search/?api=1&query=${loc.latitude},${loc.longitude}`;
} else if (addressStr || locName) {
mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(addressStr || locName)}`;
}
const currentRowPosition = tableRows.length;
if (mapUrl) {
pdfMapLinks[currentRowPosition] = mapUrl;
}
const locationCellObj = {
content: locationText,
styles: mapUrl ? { textColor: [40, 83, 107], fontStyle: 'bold' as const } : {}
};
tableRows.push([ tableRows.push([
stt++, stt++,
timeStr, timeStr,
legName, locIdx === 0 ? legName : '',
locationText, locationCellObj,
noteText noteText
]); ]);
globalRowIndex++; globalRowIndex++;
}); });
} else {
legRowRanges[leg.id] = { start: startRow, count: 1 };
tableRows.push([
stt++,
'',
legName,
'Chưa có địa điểm trong chặng này',
''
]);
globalRowIndex++;
} }
}); });
} }
@@ -618,17 +644,20 @@ export const TourDetailPage = ({
cell.rowSpan = 1; cell.rowSpan = 1;
} }
} }
if (cell.column.index === 3) {
const rowData = tableRows[cell.row.index];
if (rowData && rowData[3]) {
const coordMatch = rowData[3].match(/([\d.]+),\s*([\d.]+)/);
if (coordMatch) {
const lat = coordMatch[1];
const lng = coordMatch[2];
cell.cell.link = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}`;
}
} }
},
didDrawCell: (data: any) => {
if (data.section === 'body' && data.column.index === 3) {
const activeRowIndex = data.row.index;
const targetMapUrl = pdfMapLinks[activeRowIndex];
if (targetMapUrl) {
data.doc.link(
data.cell.x,
data.cell.y,
data.cell.width,
data.cell.height,
{ url: targetMapUrl }
);
} }
} }
} }