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:
Docker Config Backup
2026-04-02 09:48:38 +02:00
commit b4158d687f
47 changed files with 14185 additions and 0 deletions

338
web/src/PdfExport.ts Normal file
View 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
}