fix: lỗi nút xuất PDF và chèn link ở Địa điểm khi xuất PDF
This commit is contained in:
@@ -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
+213
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+5
File diff suppressed because one or more lines are too long
@@ -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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const startRow = globalRowIndex;
|
||||||
|
|
||||||
if (legLocations.length > 0) {
|
if (legLocations.length > 0) {
|
||||||
const startRow = globalRowIndex;
|
|
||||||
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];
|
didDrawCell: (data: any) => {
|
||||||
if (rowData && rowData[3]) {
|
if (data.section === 'body' && data.column.index === 3) {
|
||||||
const coordMatch = rowData[3].match(/([\d.]+),\s*([\d.]+)/);
|
const activeRowIndex = data.row.index;
|
||||||
if (coordMatch) {
|
const targetMapUrl = pdfMapLinks[activeRowIndex];
|
||||||
const lat = coordMatch[1];
|
if (targetMapUrl) {
|
||||||
const lng = coordMatch[2];
|
data.doc.link(
|
||||||
cell.cell.link = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}`;
|
data.cell.x,
|
||||||
}
|
data.cell.y,
|
||||||
}
|
data.cell.width,
|
||||||
|
data.cell.height,
|
||||||
|
{ url: targetMapUrl }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user