import jsPDF from 'jspdf' import type { DayInfo, Person } from './types' import { getCellStyle } from './cellColors' const MONTH_NAMES: Record = { 1: 'Leden', 2: 'Únor', 3: 'Březen', 4: 'Duben', 5: 'Květen', 6: 'Červen', 7: 'Červenec', 8: 'Srpen', 9: 'Září', 10: 'Říjen', 11: 'Listopad', 12: 'Prosinec', } const DAY_NAMES = ['Ne', 'Po', 'Út', 'St', 'Čt', 'Pá', 'So'] function arrayBufferToBase64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer) const chunks: string[] = [] for (let i = 0; i < bytes.length; i += 8192) { chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192) as unknown as number[])) } return btoa(chunks.join('')) } async function loadFont(doc: jsPDF) { try { const res = await fetch('/DejaVuSans.ttf') const buf = await res.arrayBuffer() const base64 = arrayBufferToBase64(buf) doc.addFileToVFS('DejaVuSans.ttf', base64) doc.addFont('DejaVuSans.ttf', 'DejaVu', 'normal') doc.setFont('DejaVu', 'normal') return true } catch (e) { console.error('Font load failed:', e) return false } } export async function exportMonthPdf( month: number, year: number, dayIndex: DayInfo[], people: Person[], tunnelClosures: Map, tunnelColors: Map, ) { const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }) const hasFont = await loadFont(doc) const monthDays = dayIndex.filter(d => d.month === month && d.year === year) if (monthDays.length === 0) return const firstDayIdx = dayIndex.indexOf(monthDays[0]) const daysFromPrevMonth = Math.max(0, 34 - monthDays.length) const startIdx = Math.max(0, firstDayIdx - daysFromPrevMonth) const days = dayIndex.slice(startIdx, startIdx + 34) const tkbPeople = people.filter(p => p.group === 'TKB') const itPeople = people.filter(p => p.group === 'IT') // Layout: fill entire A4 landscape (297 x 210 mm) const pageW = 297 const pageH = 210 const margin = 3 const nameColW = 50 const daysPerPage = 17 const cellW = (pageW - margin * 2 - nameColW) / daysPerPage // Row height to fill full page const headerH = 24 const totalDataRows = tkbPeople.length + itPeople.length + 2 const availableH = pageH - margin * 2 - headerH const rowH = availableH / totalDataRows // Font sizes — match day number size for readability const nameFontSize = 9 const cellFontSize = 9 const sectionFontSize = 10 const dayNumFontSize = 10 const dayNameFontSize = 7 const closureFontSize = 6 for (let pageNum = 0; pageNum < 2; pageNum++) { if (pageNum > 0) doc.addPage() const pageDays = days.slice(pageNum * daysPerPage, (pageNum + 1) * daysPerPage) if (pageDays.length === 0) continue if (hasFont) doc.setFont('DejaVu', 'normal') const showNames = pageNum === 0 const gridStartX = showNames ? margin + nameColW : margin const gridW = pageDays.length * cellW const totalW = showNames ? nameColW + gridW : gridW const startY = margin // --- Header area --- // Month/year in top-left cell (only page 1) if (showNames) { doc.setFillColor(255, 255, 255) doc.rect(margin, startY, nameColW, headerH, 'F') doc.setFontSize(11) doc.setTextColor(0) doc.text(`${MONTH_NAMES[month]}`, margin + 2, startY + 9) doc.setFontSize(10) doc.text(`${year}`, margin + 2, startY + 17) } // Day columns header pageDays.forEach((d, i) => { const x = gridStartX + i * cellW const isOtherMonth = d.month !== month // Background if (isOtherMonth) { doc.setFillColor(235, 235, 235) doc.rect(x, startY, cellW, headerH, 'F') } else if (d.weekend) { doc.setFillColor(230, 230, 230) doc.rect(x, startY, cellW, headerH, 'F') } // Day number if (isOtherMonth) { doc.setTextColor(180, 180, 180) } else { doc.setTextColor(d.weekend ? 180 : 0, 0, 0) } doc.setFontSize(dayNumFontSize) doc.text(String(d.day), x + cellW / 2, startY + 7, { align: 'center' }) // Day name const dow = new Date(d.year, d.month - 1, d.day).getDay() doc.setFontSize(dayNameFontSize) doc.text(DAY_NAMES[dow], x + cellW / 2, startY + 13, { align: 'center' }) // Tunnel closure const closure = tunnelClosures.get(d.idx) const closureColor = tunnelColors.get(d.idx) if (closure || closureColor) { if (closureColor) { const rgb = hexToRgb(closureColor) doc.setFillColor(rgb.r, rgb.g, rgb.b) } else { doc.setFillColor(255, 165, 0) } if (isOtherMonth) { // Make closure color lighter for other months doc.setGState(doc.GState({ opacity: 0.4 })) } doc.rect(x, startY + 15, cellW, headerH - 15, 'F') if (isOtherMonth) { doc.setGState(doc.GState({ opacity: 1 })) } if (closure) { doc.setFontSize(closureFontSize) doc.setTextColor(isOtherMonth ? 180 : 0) doc.text(closure, x + cellW / 2, startY + 20, { align: 'center' }) } } }) // Header grid lines doc.setDrawColor(0, 0, 0) doc.setLineWidth(0.2) pageDays.forEach((d, i) => { const x = gridStartX + i * cellW const isOtherMonth = d.month !== month doc.setDrawColor(isOtherMonth ? 180 : 0, isOtherMonth ? 180 : 0, isOtherMonth ? 180 : 0) doc.line(x, startY, x, startY + headerH) }) // Right edge of last column const lastDay = pageDays[pageDays.length - 1] const lastIsOther = lastDay && lastDay.month !== month doc.setDrawColor(lastIsOther ? 180 : 0, lastIsOther ? 180 : 0, lastIsOther ? 180 : 0) doc.line(gridStartX + pageDays.length * cellW, startY, gridStartX + pageDays.length * cellW, startY + headerH) doc.setDrawColor(0, 0, 0) doc.setLineWidth(0.3) doc.line(margin, startY, margin + totalW, startY) doc.line(margin, startY + headerH, margin + totalW, startY + headerH) if (showNames) doc.line(gridStartX, startY, gridStartX, startY + headerH) // --- Data rows --- let curY = startY + headerH // TKB section header doc.setFillColor(79, 70, 229) doc.rect(margin, curY, totalW, rowH, 'F') doc.setFontSize(sectionFontSize) doc.setTextColor(255, 255, 255) if (showNames) doc.text('POHOTOVOST TKB', margin + 2, curY + rowH * 0.7) curY += rowH renderPeople(doc, tkbPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month) curY += tkbPeople.length * rowH // IT section header doc.setFillColor(13, 148, 136) doc.rect(margin, curY, totalW, rowH, 'F') doc.setFontSize(sectionFontSize) doc.setTextColor(255, 255, 255) if (showNames) doc.text('POHOTOVOST IT', margin + 2, curY + rowH * 0.7) curY += rowH renderPeople(doc, itPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month) curY += itPeople.length * rowH // Outer border doc.setDrawColor(0, 0, 0) doc.setLineWidth(0.3) doc.rect(margin, startY, totalW, curY - startY) if (showNames) doc.line(gridStartX, startY, gridStartX, curY) } doc.save(`TKB_Plan_${MONTH_NAMES[month]}_${year}.pdf`) } function renderPeople( doc: jsPDF, people: Person[], days: DayInfo[], startY: number, margin: number, nameColW: number, gridStartX: number, cellW: number, rowH: number, showNames: boolean, nameFontSize: number, cellFontSize: number, activeMonth: number, ) { const totalW = nameColW + days.length * cellW const sectionH = people.length * rowH const textY = rowH * 0.65 // Pass 1: Fill backgrounds people.forEach((person, pi) => { const y = startY + pi * rowH days.forEach((d, di) => { const x = gridStartX + di * cellW const isOtherMonth = d.month !== activeMonth const cellData = person.data[String(d.idx)] const value = cellData?.v ?? '' const manualColor = cellData?.color const style = getCellStyle(value || undefined) if (isOtherMonth) { // Light gray wash for other month cells doc.setFillColor(245, 245, 245) doc.rect(x, y, cellW, rowH, 'F') // If there's a value, draw it faded if (manualColor || style) { doc.setGState(doc.GState({ opacity: 0.35 })) const rgb = hexToRgb(manualColor ?? style!.bg) doc.setFillColor(rgb.r, rgb.g, rgb.b) doc.rect(x, y, cellW, rowH, 'F') doc.setGState(doc.GState({ opacity: 1 })) } } else if (manualColor) { const rgb = hexToRgb(manualColor) doc.setFillColor(rgb.r, rgb.g, rgb.b) doc.rect(x, y, cellW, rowH, 'F') } else if (style) { const rgb = hexToRgb(style.bg) doc.setFillColor(rgb.r, rgb.g, rgb.b) doc.rect(x, y, cellW, rowH, 'F') } else if (d.weekend) { doc.setFillColor(240, 240, 240) doc.rect(x, y, cellW, rowH, 'F') } }) }) // Pass 2: Grid lines — gray for other month columns, black for current month doc.setLineWidth(0.15) // Horizontal lines (always black) doc.setDrawColor(0, 0, 0) for (let pi = 0; pi <= people.length; pi++) { doc.line(margin, startY + pi * rowH, margin + totalW, startY + pi * rowH) } // Vertical lines — color depends on which month the column is in days.forEach((d, i) => { const isOtherMonth = d.month !== activeMonth doc.setDrawColor(isOtherMonth ? 190 : 0, isOtherMonth ? 190 : 0, isOtherMonth ? 190 : 0) doc.line(gridStartX + i * cellW, startY, gridStartX + i * cellW, startY + sectionH) }) // Right edge const lastDay = days[days.length - 1] const lastOther = lastDay && lastDay.month !== activeMonth doc.setDrawColor(lastOther ? 190 : 0, lastOther ? 190 : 0, lastOther ? 190 : 0) doc.line(gridStartX + days.length * cellW, startY, gridStartX + days.length * cellW, startY + sectionH) // Name column separator if (showNames) { doc.setDrawColor(0, 0, 0) doc.setLineWidth(0.2) doc.line(gridStartX, startY, gridStartX, startY + sectionH) } // Pass 3: Text people.forEach((person, pi) => { const y = startY + pi * rowH if (showNames) { doc.setFontSize(nameFontSize) doc.setTextColor(0) const displayName = person.name + (person.note ? ` (${person.note})` : '') doc.text(displayName, margin + 1, y + textY, { maxWidth: nameColW - 2 }) } days.forEach((d, di) => { const x = gridStartX + di * cellW const cellData = person.data[String(d.idx)] const value = cellData?.v ?? '' if (value) { const isOtherMonth = d.month !== activeMonth const manualColor = cellData?.color const style = getCellStyle(value || undefined) const textColor = manualColor ? (isLightColor(manualColor) ? '#333333' : '#ffffff') : (style?.text ?? '#333333') const rgb = hexToRgb(textColor) if (isOtherMonth) { doc.setTextColor(200, 200, 200) } else { doc.setTextColor(rgb.r, rgb.g, rgb.b) } doc.setFontSize(cellFontSize) doc.text(value, x + cellW / 2, y + textY, { align: 'center' }) } }) }) } function hexToRgb(hex: string): { r: number; g: number; b: number } { let h = hex.replace('#', '') if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] return { r: parseInt(h.substring(0, 2), 16) || 0, g: parseInt(h.substring(2, 4), 16) || 0, b: parseInt(h.substring(4, 6), 16) || 0, } } function isLightColor(hex: string): boolean { const { r, g, b } = hexToRgb(hex) return (r * 299 + g * 587 + b * 114) / 1000 > 128 }