mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
chore: apply prettier on the entire project
This commit is contained in:
@@ -1,225 +1,346 @@
|
||||
// Trip PDF via browser print window
|
||||
import { createElement } from 'react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
import {
|
||||
AlertTriangle,
|
||||
BedDouble,
|
||||
Bookmark,
|
||||
Bus,
|
||||
Camera,
|
||||
Car,
|
||||
Clock,
|
||||
Coffee,
|
||||
FileText,
|
||||
Flag,
|
||||
Heart,
|
||||
Hotel,
|
||||
Info,
|
||||
KeyRound,
|
||||
Lightbulb,
|
||||
LogIn,
|
||||
LogOut,
|
||||
LucideIcon,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Plane,
|
||||
Ship,
|
||||
ShoppingBag,
|
||||
Star,
|
||||
Ticket,
|
||||
Train,
|
||||
Users,
|
||||
Utensils,
|
||||
} from 'lucide-react';
|
||||
import { createElement } from 'react';
|
||||
import { accommodationsApi, mapsApi } from '../../api/client';
|
||||
import type { AssignmentsMap, Category, Day, DayNotesMap, Place, Trip } from '../../types';
|
||||
import { getDayOrder, isDayInAccommodationRange } from '../../utils/dayOrder';
|
||||
import { splitReservationDateTime } from '../../utils/formatters';
|
||||
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
return _renderToStaticMarkup(
|
||||
createElement(icon, props)
|
||||
);
|
||||
function renderLucideIcon(icon: LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return '';
|
||||
return _renderToStaticMarkup(createElement(icon, props));
|
||||
}
|
||||
|
||||
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
|
||||
const NOTE_ICON_MAP = {
|
||||
FileText,
|
||||
Info,
|
||||
Clock,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Train,
|
||||
Plane,
|
||||
Bus,
|
||||
Car,
|
||||
Ship,
|
||||
Coffee,
|
||||
Ticket,
|
||||
Star,
|
||||
Heart,
|
||||
Camera,
|
||||
Flag,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
ShoppingBag,
|
||||
Bookmark,
|
||||
};
|
||||
function noteIconSvg(iconId) {
|
||||
const Icon = NOTE_ICON_MAP[iconId] || FileText
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
|
||||
const Icon = NOTE_ICON_MAP[iconId] || FileText;
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' });
|
||||
}
|
||||
|
||||
const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText }
|
||||
const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||
const RESERVATION_ICON_MAP = {
|
||||
flight: Plane,
|
||||
train: Train,
|
||||
bus: Bus,
|
||||
car: Car,
|
||||
cruise: Ship,
|
||||
restaurant: Utensils,
|
||||
event: Ticket,
|
||||
tour: Users,
|
||||
other: FileText,
|
||||
};
|
||||
const RESERVATION_COLOR_MAP = {
|
||||
flight: '#3b82f6',
|
||||
train: '#06b6d4',
|
||||
bus: '#6b7280',
|
||||
car: '#6b7280',
|
||||
cruise: '#0ea5e9',
|
||||
restaurant: '#ef4444',
|
||||
event: '#f59e0b',
|
||||
tour: '#10b981',
|
||||
other: '#6b7280',
|
||||
};
|
||||
function reservationIconSvg(type) {
|
||||
const Icon = RESERVATION_ICON_MAP[type] || Ticket
|
||||
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6'
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color })
|
||||
const Icon = RESERVATION_ICON_MAP[type] || Ticket;
|
||||
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6';
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color });
|
||||
}
|
||||
|
||||
const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound }
|
||||
const ACCOMMODATION_ICON_MAP = {
|
||||
accommodation: Hotel,
|
||||
checkin: LogIn,
|
||||
checkout: LogOut,
|
||||
location: MapPin,
|
||||
note: FileText,
|
||||
confirmation: KeyRound,
|
||||
};
|
||||
function accommodationIconSvg(type) {
|
||||
const Icon = ACCOMMODATION_ICON_MAP[type] || BedDouble
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#03398f', className: 'accommodation-icon' })
|
||||
const Icon = ACCOMMODATION_ICON_MAP[type] || BedDouble;
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#03398f', className: 'accommodation-icon' });
|
||||
}
|
||||
|
||||
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
||||
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
||||
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
||||
const svgClock2= `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
|
||||
const svgCheck = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 7"/></svg>`
|
||||
const svgEuro = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round"><path d="M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55"/><path d="M5 11h8M5 13h8"/></svg>`
|
||||
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`;
|
||||
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`;
|
||||
const svgClock2 = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`;
|
||||
const svgCheck = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 7"/></svg>`;
|
||||
const svgEuro = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round"><path d="M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55"/><path d="M5 11h8M5 13h8"/></svg>`;
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return ''
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function absUrl(url) {
|
||||
if (!url) return null
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
|
||||
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
|
||||
if (!url) return null;
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url;
|
||||
return window.location.origin + (url.startsWith('/') ? '' : '/') + url;
|
||||
}
|
||||
|
||||
function safeImg(url) {
|
||||
if (!url) return null
|
||||
if (url.startsWith('https://') || url.startsWith('http://')) return url
|
||||
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
|
||||
if (!url) return null;
|
||||
if (url.startsWith('https://') || url.startsWith('http://')) return url;
|
||||
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null;
|
||||
}
|
||||
|
||||
// Generate SVG string from Lucide icon name (for category thumbnails)
|
||||
let _renderToStaticMarkup = null
|
||||
let _renderToStaticMarkup = null;
|
||||
async function ensureRenderer() {
|
||||
if (!_renderToStaticMarkup) {
|
||||
const mod = await import('react-dom/server')
|
||||
_renderToStaticMarkup = mod.renderToStaticMarkup
|
||||
const mod = await import('react-dom/server');
|
||||
_renderToStaticMarkup = mod.renderToStaticMarkup;
|
||||
}
|
||||
}
|
||||
function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
const Icon = getCategoryIcon(iconName)
|
||||
return _renderToStaticMarkup(
|
||||
createElement(Icon, { size, strokeWidth: 1.8, color: 'rgba(255,255,255,0.92)' })
|
||||
)
|
||||
if (!_renderToStaticMarkup) return '';
|
||||
const Icon = getCategoryIcon(iconName);
|
||||
return _renderToStaticMarkup(createElement(Icon, { size, strokeWidth: 1.8, color: 'rgba(255,255,255,0.92)' }));
|
||||
}
|
||||
|
||||
function shortDate(d, locale) {
|
||||
if (!d) return ''
|
||||
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
if (!d) return '';
|
||||
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
function longDateRange(days, locale) {
|
||||
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
|
||||
if (!dd.length) return null
|
||||
const f = new Date(dd[0].date + 'T00:00:00Z')
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z')
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`
|
||||
const dd = [...days].filter((d) => d.date).sort((a, b) => a.day_number - b.day_number);
|
||||
if (!dd.length) return null;
|
||||
const f = new Date(dd[0].date + 'T00:00:00Z');
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z');
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`;
|
||||
}
|
||||
|
||||
function dayCost(assignments, dayId, locale) {
|
||||
const total = (assignments[String(dayId)] || []).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
|
||||
const total = (assignments[String(dayId)] || []).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0);
|
||||
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null;
|
||||
}
|
||||
|
||||
// Pre-fetch Google Place photos for all assigned places
|
||||
async function fetchPlacePhotos(assignments) {
|
||||
const photoMap = {} // placeId → photoUrl
|
||||
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
||||
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
||||
const photoMap = {}; // placeId → photoUrl
|
||||
const allPlaces = Object.values(assignments)
|
||||
.flatMap((a) => a.map((x) => x.place))
|
||||
.filter(Boolean);
|
||||
const unique = [...new Map(allPlaces.map((p) => [p.id, p])).values()];
|
||||
|
||||
const toFetch = unique.filter(p => !p.image_url && (p.google_place_id || p.osm_id))
|
||||
const toFetch = unique.filter((p) => !p.image_url && (p.google_place_id || p.osm_id));
|
||||
|
||||
await Promise.allSettled(
|
||||
toFetch.map(async (place) => {
|
||||
try {
|
||||
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name)
|
||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
||||
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name);
|
||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl;
|
||||
} catch {}
|
||||
})
|
||||
)
|
||||
return photoMap
|
||||
);
|
||||
return photoMap;
|
||||
}
|
||||
|
||||
interface downloadTripPDFProps {
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
assignments: AssignmentsMap
|
||||
categories: Category[]
|
||||
dayNotes: DayNotesMap
|
||||
reservations?: any[]
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
locale: string
|
||||
trip: Trip;
|
||||
days: Day[];
|
||||
places: Place[];
|
||||
assignments: AssignmentsMap;
|
||||
categories: Category[];
|
||||
dayNotes: DayNotesMap;
|
||||
reservations?: any[];
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) {
|
||||
await ensureRenderer()
|
||||
const loc = _locale || undefined
|
||||
const tr = _t || (k => k)
|
||||
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
||||
const range = longDateRange(sorted, loc)
|
||||
const coverImg = safeImg(trip?.cover_image)
|
||||
export async function downloadTripPDF({
|
||||
trip,
|
||||
days,
|
||||
places,
|
||||
assignments,
|
||||
categories,
|
||||
dayNotes,
|
||||
reservations = [],
|
||||
t: _t,
|
||||
locale: _locale,
|
||||
}: downloadTripPDFProps) {
|
||||
await ensureRenderer();
|
||||
const loc = _locale || undefined;
|
||||
const tr = _t || ((k) => k);
|
||||
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number);
|
||||
const range = longDateRange(sorted, loc);
|
||||
const coverImg = safeImg(trip?.cover_image);
|
||||
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
||||
const accommodations = await accommodationsApi.list(trip.id);
|
||||
|
||||
// Pre-fetch place photos from Google
|
||||
const photoMap = await fetchPlacePhotos(assignments)
|
||||
const photoMap = await fetchPlacePhotos(assignments);
|
||||
|
||||
const totalAssigned = new Set(
|
||||
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
|
||||
).size
|
||||
Object.values(assignments || {})
|
||||
.flatMap((a) => a.map((x) => x.place?.id))
|
||||
.filter(Boolean)
|
||||
).size;
|
||||
const totalCost = Object.values(assignments || {})
|
||||
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
.flatMap((a) => a)
|
||||
.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0);
|
||||
|
||||
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
|
||||
const pdfGetDayOrder = (d: Day) => d.day_number
|
||||
const pdfGetDayOrder = (d: Day) => d.day_number;
|
||||
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||
const startId = r.day_id
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (!startId || startId === endId) return 'single'
|
||||
if (dayId === startId) return 'start'
|
||||
if (dayId === endId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
const startId = r.day_id;
|
||||
const endId = r.end_day_id ?? startId;
|
||||
if (!startId || startId === endId) return 'single';
|
||||
if (dayId === startId) return 'start';
|
||||
if (dayId === endId) return 'end';
|
||||
return 'middle';
|
||||
};
|
||||
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
|
||||
const phase = pdfGetSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
}
|
||||
const phase = pdfGetSpanPhase(r, dayId);
|
||||
if (phase === 'end') return r.reservation_end_time || null;
|
||||
if (phase === 'middle') return null;
|
||||
return r.reservation_time || null;
|
||||
};
|
||||
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
|
||||
if (phase === 'single') return null
|
||||
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||
}
|
||||
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
|
||||
if (r.type === 'hotel') return false
|
||||
const startId = r.day_id
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (startId == null) return false
|
||||
if (endId !== startId) {
|
||||
const startDay = sorted.find(d => d.id === startId)
|
||||
const endDay = sorted.find(d => d.id === endId)
|
||||
const thisDay = sorted.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
|
||||
}
|
||||
return startId === dayId
|
||||
})
|
||||
if (phase === 'single') return null;
|
||||
if (r.type === 'flight')
|
||||
return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`);
|
||||
if (r.type === 'car')
|
||||
return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`);
|
||||
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`);
|
||||
};
|
||||
const pdfGetTransportForDay = (dayId: number) =>
|
||||
(reservations || []).filter((r) => {
|
||||
if (r.type === 'hotel') return false;
|
||||
const startId = r.day_id;
|
||||
const endId = r.end_day_id ?? startId;
|
||||
if (startId == null) return false;
|
||||
if (endId !== startId) {
|
||||
const startDay = sorted.find((d) => d.id === startId);
|
||||
const endDay = sorted.find((d) => d.id === endId);
|
||||
const thisDay = sorted.find((d) => d.id === dayId);
|
||||
if (!startDay || !endDay || !thisDay) return false;
|
||||
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay);
|
||||
}
|
||||
return startId === dayId;
|
||||
});
|
||||
|
||||
// Build day HTML
|
||||
const daysHtml = sorted.map((day, di) => {
|
||||
const assigned = assignments[String(day.id)] || []
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||
const cost = dayCost(assignments, day.id, loc)
|
||||
const daysHtml = sorted
|
||||
.map((day, di) => {
|
||||
const assigned = assignments[String(day.id)] || [];
|
||||
const notes = (dayNotes || []).filter((n) => n.day_id === day.id);
|
||||
const cost = dayCost(assignments, day.id, loc);
|
||||
|
||||
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||
const dayReservations = pdfGetTransportForDay(day.id)
|
||||
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||
const dayReservations = pdfGetTransportForDay(day.id).filter(
|
||||
(r) => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle')
|
||||
);
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
dayReservations.forEach(r => {
|
||||
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
merged.push({ type: 'reservation', k: pos, data: r })
|
||||
})
|
||||
merged.sort((a, b) => a.k - b.k)
|
||||
const merged = [];
|
||||
assigned.forEach((a) => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }));
|
||||
notes.forEach((n) => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }));
|
||||
dayReservations.forEach((r) => {
|
||||
const pos =
|
||||
r.day_positions?.[day.id] ??
|
||||
r.day_positions?.[String(day.id)] ??
|
||||
r.day_plan_position ??
|
||||
(merged.length > 0 ? Math.max(...merged.map((m) => m.k)) + 0.5 : 0.5);
|
||||
merged.push({ type: 'reservation', k: pos, data: r });
|
||||
});
|
||||
merged.sort((a, b) => a.k - b.k);
|
||||
|
||||
let pi = 0
|
||||
const itemsHtml = merged.length === 0
|
||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||
: merged.map(item => {
|
||||
if (item.type === 'reservation') {
|
||||
const r = item.data
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const icon = reservationIconSvg(r.type)
|
||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||
let subtitle = ''
|
||||
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
const locationLine = r.location || meta.location || ''
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||
const time = splitReservationDateTime(displayTime).time ?? ''
|
||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||
return `
|
||||
let pi = 0;
|
||||
const itemsHtml =
|
||||
merged.length === 0
|
||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||
: merged
|
||||
.map((item) => {
|
||||
if (item.type === 'reservation') {
|
||||
const r = item.data;
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : r.metadata || {};
|
||||
const icon = reservationIconSvg(r.type);
|
||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6';
|
||||
let subtitle = '';
|
||||
if (r.type === 'flight')
|
||||
subtitle = [
|
||||
meta.airline,
|
||||
meta.flight_number,
|
||||
meta.departure_airport && meta.arrival_airport
|
||||
? `${meta.departure_airport} → ${meta.arrival_airport}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
else if (r.type === 'train')
|
||||
subtitle = [
|
||||
meta.train_number,
|
||||
meta.platform ? `Gl. ${meta.platform}` : '',
|
||||
meta.seat ? `Seat ${meta.seat}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
else if (r.type === 'restaurant')
|
||||
subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ');
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ');
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ');
|
||||
const locationLine = r.location || meta.location || '';
|
||||
const phase = pdfGetSpanPhase(r, day.id);
|
||||
const spanLabel = pdfGetSpanLabel(r, phase);
|
||||
const displayTime = pdfGetDisplayTime(r, day.id);
|
||||
const time = splitReservationDateTime(displayTime).time ?? '';
|
||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`;
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||
<div class="note-line" style="background: ${color};"></div>
|
||||
<span class="note-icon">${icon}</span>
|
||||
@@ -229,12 +350,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (item.type === 'note') {
|
||||
const note = item.data
|
||||
return `
|
||||
if (item.type === 'note') {
|
||||
const note = item.data;
|
||||
return `
|
||||
<div class="note-card">
|
||||
<div class="note-line"></div>
|
||||
<span class="note-icon">${noteIconSvg(note.icon)}</span>
|
||||
@@ -242,33 +363,37 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
<div class="note-text">${escHtml(note.text)}</div>
|
||||
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
pi++
|
||||
const place = item.data.place
|
||||
if (!place) return ''
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const color = cat?.color || '#6366f1'
|
||||
pi++;
|
||||
const place = item.data.place;
|
||||
if (!place) return '';
|
||||
const cat = categories.find((c) => c.id === place.category_id);
|
||||
const color = cat?.color || '#6366f1';
|
||||
|
||||
// Image: direct > google photo > fallback icon
|
||||
const directImg = safeImg(place.image_url)
|
||||
const googleImg = photoMap[place.id] || null
|
||||
const img = directImg || googleImg
|
||||
// Image: direct > google photo > fallback icon
|
||||
const directImg = safeImg(place.image_url);
|
||||
const googleImg = photoMap[place.id] || null;
|
||||
const img = directImg || googleImg;
|
||||
|
||||
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
||||
const thumbHtml = img
|
||||
? `<img class="place-thumb" src="${escHtml(img)}" />`
|
||||
: `<div class="place-thumb-fallback" style="background:${color}">
|
||||
const iconSvg = categoryIconSvg(cat?.icon, color, 24);
|
||||
const thumbHtml = img
|
||||
? `<img class="place-thumb" src="${escHtml(img)}" />`
|
||||
: `<div class="place-thumb-fallback" style="background:${color}">
|
||||
${iconSvg}
|
||||
</div>`
|
||||
</div>`;
|
||||
|
||||
const chips = [
|
||||
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
||||
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} EUR</span>` : '',
|
||||
].filter(Boolean).join('')
|
||||
const chips = [
|
||||
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
||||
place.price && parseFloat(place.price) > 0
|
||||
? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} EUR</span>`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
return `
|
||||
<div class="place-card">
|
||||
<div class="place-bar" style="background:${color}"></div>
|
||||
${thumbHtml}
|
||||
@@ -283,31 +408,35 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
||||
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||
).sort((a, b) => {
|
||||
const startA = days.find(d => d.id === a.start_day_id)
|
||||
const startB = days.find(d => d.id === b.start_day_id)
|
||||
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0)
|
||||
})
|
||||
const accommodationsForDay = (accommodations.accommodations || [])
|
||||
.filter((a) => (day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false))
|
||||
.sort((a, b) => {
|
||||
const startA = days.find((d) => d.id === a.start_day_id);
|
||||
const startB = days.find((d) => d.id === b.start_day_id);
|
||||
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0);
|
||||
});
|
||||
|
||||
const accommodationDetails = accommodationsForDay.map(item => {
|
||||
const isCheckIn = day.id === item.start_day_id
|
||||
const isCheckOut = day.id === item.end_day_id
|
||||
const actionLabel = isCheckIn ? tr('reservations.meta.checkIn')
|
||||
: isCheckOut ? tr('reservations.meta.checkOut')
|
||||
: tr('reservations.meta.linkAccommodation')
|
||||
const actionIcon = isCheckIn ? accommodationIconSvg('checkin')
|
||||
: isCheckOut ? accommodationIconSvg('checkout')
|
||||
: accommodationIconSvg('accommodation')
|
||||
const timeStr = isCheckIn ? (item.check_in || '')
|
||||
: isCheckOut ? (item.check_out || '')
|
||||
: ''
|
||||
const accommodationDetails = accommodationsForDay
|
||||
.map((item) => {
|
||||
const isCheckIn = day.id === item.start_day_id;
|
||||
const isCheckOut = day.id === item.end_day_id;
|
||||
const actionLabel = isCheckIn
|
||||
? tr('reservations.meta.checkIn')
|
||||
: isCheckOut
|
||||
? tr('reservations.meta.checkOut')
|
||||
: tr('reservations.meta.linkAccommodation');
|
||||
const actionIcon = isCheckIn
|
||||
? accommodationIconSvg('checkin')
|
||||
: isCheckOut
|
||||
? accommodationIconSvg('checkout')
|
||||
: accommodationIconSvg('accommodation');
|
||||
const timeStr = isCheckIn ? item.check_in || '' : isCheckOut ? item.check_out || '' : '';
|
||||
|
||||
return `
|
||||
return `
|
||||
<div class="day-accommodation">
|
||||
<div class="day-accommodation-title accommodation-center-icon">${actionIcon} ${escHtml(actionLabel)}</div>
|
||||
${timeStr ? `<div class="accommodation-center-icon">${accommodationIconSvg('checkin')} <b>${escHtml(timeStr)}</b></div>` : ''}
|
||||
@@ -315,16 +444,18 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${item.place_address ? `<div class="accommodation-center-icon">${accommodationIconSvg('location')} ${escHtml(item.place_address)}</div>` : ''}
|
||||
${item.notes ? `<div class="accommodation-center-icon">${accommodationIconSvg('note')} ${escHtml(item.notes)}</div>` : ''}
|
||||
${isCheckIn && item.confirmation ? `<div class="accommodation-center-icon">${accommodationIconSvg('confirmation')} ${escHtml(item.confirmation)}</div>` : ''}
|
||||
</div>`
|
||||
}).join('')
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const accommodationsHtml = accommodationsForDay.length > 0
|
||||
? `<div class="day-accommodations-overview">
|
||||
const accommodationsHtml =
|
||||
accommodationsForDay.length > 0
|
||||
? `<div class="day-accommodations-overview">
|
||||
<div class="day-accommodations ${accommodationsForDay.length === 1 ? 'single' : ''}">${accommodationDetails}</div>
|
||||
</div>`
|
||||
: ''
|
||||
: '';
|
||||
|
||||
return `
|
||||
return `
|
||||
<div class="day-section${di > 0 ? ' page-break' : ''}">
|
||||
<div class="day-header">
|
||||
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
|
||||
@@ -333,8 +464,9 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${cost ? `<span class="day-cost">${cost}</span>` : ''}
|
||||
</div>
|
||||
<div class="day-body">${accommodationsHtml}${itemsHtml}</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${loc.split('-')[0]}">
|
||||
@@ -509,9 +641,11 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
<div class="cover-dim"></div>
|
||||
<div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
|
||||
<div class="cover-body">
|
||||
${coverImg
|
||||
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
|
||||
: `<div class="cover-circle-ph"></div>`}
|
||||
${
|
||||
coverImg
|
||||
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
|
||||
: `<div class="cover-circle-ph"></div>`
|
||||
}
|
||||
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div>
|
||||
<div class="cover-title">${escHtml(trip?.title || 'My Trip')}</div>
|
||||
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
|
||||
@@ -530,10 +664,14 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
<div class="cover-stat-num">${totalAssigned}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
|
||||
</div>
|
||||
${totalCost > 0 ? `<div>
|
||||
${
|
||||
totalCost > 0
|
||||
? `<div>
|
||||
<div class="cover-stat-num">${totalCost.toLocaleString(loc)}</div>
|
||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
|
||||
</div>` : ''}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -541,19 +679,24 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
<!-- Days -->
|
||||
${daysHtml}
|
||||
|
||||
</body></html>`
|
||||
</body></html>`;
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'pdf-preview-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'pdf-preview-overlay';
|
||||
overlay.style.cssText =
|
||||
'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;';
|
||||
overlay.onclick = (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
};
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText =
|
||||
'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);';
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText =
|
||||
'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;';
|
||||
header.innerHTML = `
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
@@ -562,18 +705,20 @@ ${daysHtml}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
`;
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||
iframe.srcdoc = html
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;';
|
||||
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts';
|
||||
iframe.srcdoc = html;
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
card.appendChild(header);
|
||||
card.appendChild(iframe);
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
header.querySelector('#pdf-close-btn').onclick = () => overlay.remove()
|
||||
header.querySelector('#pdf-print-btn').onclick = () => { iframe.contentWindow?.print() }
|
||||
header.querySelector('#pdf-close-btn').onclick = () => overlay.remove();
|
||||
header.querySelector('#pdf-print-btn').onclick = () => {
|
||||
iframe.contentWindow?.print();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user