feat: TKB shift scheduler — personnel shift planning web app
Full rewrite of METRO HMG for TKB tunnel department: - People-based grid (18 TKB + 5 IT), year-long calendar - Color-coded shift values (4/6/8/12/A/B/D/N/U/O) - Drag-and-drop cells, multi-cell selection (click/ctrl/shift/drag) - Right-click context menu with color palette - Tunnel closure + Metro + D8 info rows (toggleable) - Czech holidays highlighted with names - PDF export (2-page A4 landscape, DejaVu font for Czech chars) - Improvement proposals system - Sticky headers (vertical + horizontal scroll) - Cell value filter toggles in legend Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
338
web/src/PdfExport.ts
Normal file
338
web/src/PdfExport.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import jsPDF from 'jspdf'
|
||||
import type { DayInfo, Person } from './types'
|
||||
import { getCellStyle } from './cellColors'
|
||||
|
||||
const MONTH_NAMES: Record<number, string> = {
|
||||
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<number, string>,
|
||||
tunnelColors: Map<number, string>,
|
||||
) {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user