Files
TKB_plan/web/server.js
Docker Config Backup 707e18c625 fix: correct name to Kópáček Ondřej; O (otcovská) looks like D, counts like N
The new worker's surname was misheard: Kropáček → Kópáček everywhere
(default data, both Excel templates, export rosters). The person id
tkb-kropacek is kept. The server now normalises the name in stored
files at startup and in every incoming save, so clients that still
hold the old name cannot write it back.

"O" (otcovská dovolená) is now rendered in the same yellow as "D" in
the app and the month Excel, labelled "Otcovská dovolená", and for the
FPD balance a working-day O is credited like N (a normal worked day)
both in the web FPD check and in the month Excel's FPD column, which
previously counted N and O as zero and disagreed with the web.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:28:48 +02:00

617 lines
21 KiB
JavaScript

import express from 'express'
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, readdirSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import crypto from 'crypto'
import multer from 'multer'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const app = express()
const PORT = process.env.PORT || 3090
const SERVER_VERSION = Date.now().toString()
const SCHEDULES_DIR = join(__dirname, 'schedules')
const SAVED_SCHEDULE_PATH = join(__dirname, 'saved_schedule.json')
const DEFAULT_DATA_PATH = join(__dirname, 'src', 'data.json')
// Multer config for Excel uploads
const upload = multer({
dest: join(__dirname, 'uploads'),
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.originalname.endsWith('.xlsx')) {
cb(null, true)
} else {
cb(new Error('Only .xlsx files are accepted'))
}
}
})
app.use(express.json({ limit: '10mb' }))
// --- Helpers ---
function ensureSchedulesDir() {
if (!existsSync(SCHEDULES_DIR)) {
mkdirSync(SCHEDULES_DIR, { recursive: true })
}
}
function listScheduleFiles() {
ensureSchedulesDir()
const files = readdirSync(SCHEDULES_DIR).filter(f => f.endsWith('.json'))
const result = []
for (const f of files) {
try {
const raw = JSON.parse(readFileSync(join(SCHEDULES_DIR, f), 'utf-8'))
result.push(fileMeta(raw))
} catch {}
}
result.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt))
return result
}
function loadScheduleFile(id) {
const filePath = join(SCHEDULES_DIR, `${id}.json`)
if (!existsSync(filePath)) return null
return JSON.parse(readFileSync(filePath, 'utf-8'))
}
function saveScheduleFile(fileObj) {
ensureSchedulesDir()
writeFileSync(join(SCHEDULES_DIR, `${fileObj.id}.json`), JSON.stringify(fileObj, null, 2), 'utf-8')
}
// --- Draft vs. published ---
// file.data = working copy (editor's draft, auto-saved)
// file.published = snapshot viewers see; updated only by POST /api/files/:id/publish
function ensurePublished(file) {
// Files created before publishing existed: their current data becomes the published snapshot
if (!file.published) {
file.published = JSON.parse(JSON.stringify(file.data))
file.publishedAt = file.modifiedAt || new Date().toISOString()
return true
}
return false
}
function hasUnpublishedChanges(file) {
if (!file.published) return true
return JSON.stringify(file.data) !== JSON.stringify(file.published)
}
function fileMeta(file) {
return {
id: file.id,
name: file.name,
createdAt: file.createdAt,
modifiedAt: file.modifiedAt,
publishedAt: file.publishedAt ?? null,
hasUnpublishedChanges: hasUnpublishedChanges(file),
}
}
// People renamed after data already existed: fix the name wherever it is stored
// (files on disk at startup, and every incoming save from a client that still has the old name)
const PERSON_RENAMES = { 'Kropáček Ondřej': 'Kópáček Ondřej' }
function normalizePeople(data) {
let changed = false
for (const p of (data && data.people) || []) {
const fixed = PERSON_RENAMES[p.name]
if (fixed) { p.name = fixed; changed = true }
}
return changed
}
// Pick draft or published data for a request (?view=published)
function pickData(file, view) {
return view === 'published' && file.published ? file.published : file.data
}
function getMostRecentFile() {
const files = listScheduleFiles()
return files.length > 0 ? files[0] : null
}
// --- Migration on startup ---
function migrateIfNeeded() {
ensureSchedulesDir()
const existing = readdirSync(SCHEDULES_DIR).filter(f => f.endsWith('.json'))
if (existing.length === 0 && existsSync(SAVED_SCHEDULE_PATH)) {
console.log('Migrating saved_schedule.json to schedules/ directory...')
const data = JSON.parse(readFileSync(SAVED_SCHEDULE_PATH, 'utf-8'))
const now = new Date().toISOString()
const fileObj = {
id: crypto.randomUUID(),
name: `Migrated_Schedule_${now.slice(0, 10)}`,
createdAt: now,
modifiedAt: now,
data,
}
saveScheduleFile(fileObj)
console.log(`Migrated as ${fileObj.id} (${fileObj.name})`)
}
}
migrateIfNeeded()
// Give every existing file a published snapshot (one-time, idempotent)
for (const f of listScheduleFiles()) {
const file = loadScheduleFile(f.id)
if (!file) continue
let dirty = false
if (ensurePublished(file)) { dirty = true; console.log(`Published snapshot initialised for ${file.name} (${file.id})`) }
const renamedDraft = normalizePeople(file.data)
const renamedPublished = normalizePeople(file.published)
if (renamedDraft || renamedPublished) { dirty = true; console.log(`People renamed in ${file.name} (${file.id})`) }
if (dirty) saveScheduleFile(file)
}
// --- Diff computation ---
function computeDiff(file1, file2) {
const changes = []
// Build lookup: personId -> { dayIdx -> value }
function buildMap(data) {
const map = {}
if (!data) return map
const people = data.people || data.stations
if (!people) return map
for (const person of people) {
const cells = {}
if (person.data) {
for (const [idx, cellData] of Object.entries(person.data)) {
if (cellData && cellData.v !== undefined && cellData.v !== null && cellData.v !== '') {
cells[idx] = cellData.v
}
}
}
map[person.id || person.code] = cells
}
return map
}
const map1 = buildMap(file1.data)
const map2 = buildMap(file2.data)
// All person IDs from both files
const allIds = new Set([...Object.keys(map1), ...Object.keys(map2)])
for (const id of allIds) {
const cells1 = map1[id] || {}
const cells2 = map2[id] || {}
const allIdx = new Set([...Object.keys(cells1), ...Object.keys(cells2)])
for (const idx of allIdx) {
const v1 = cells1[idx]
const v2 = cells2[idx]
const dayIdx = parseInt(idx)
if (v1 !== undefined && v2 === undefined) {
changes.push({ personId: id, dayIdx, type: 'removed', oldValue: v1 })
} else if (v1 === undefined && v2 !== undefined) {
changes.push({ personId: id, dayIdx, type: 'added', newValue: v2 })
} else if (v1 !== v2) {
changes.push({ personId: id, dayIdx, type: 'changed', oldValue: v1, newValue: v2 })
}
}
}
return { changes }
}
// --- API: File management ---
// List all files
app.get('/api/files', (_req, res) => {
try {
res.json(listScheduleFiles())
} catch (err) {
console.error('Error listing files:', err)
res.status(500).json({ error: 'Failed to list files' })
}
})
// Diff between two files
app.get('/api/files/diff/:id1/:id2', (req, res) => {
try {
const file1 = loadScheduleFile(req.params.id1)
const file2 = loadScheduleFile(req.params.id2)
if (!file1) return res.status(404).json({ error: `File ${req.params.id1} not found` })
if (!file2) return res.status(404).json({ error: `File ${req.params.id2} not found` })
res.json(computeDiff(file1, file2))
} catch (err) {
console.error('Error computing diff:', err)
res.status(500).json({ error: 'Failed to compute diff' })
}
})
// Import Excel (not yet implemented for TKB format)
app.post('/api/files/import-excel', upload.single('file'), (_req, res) => {
res.status(501).json({ error: 'Excel import not yet implemented for TKB format' })
})
// Get single file
app.get('/api/files/:id', (req, res) => {
try {
const file = loadScheduleFile(req.params.id)
if (!file) return res.status(404).json({ error: 'File not found' })
const view = req.query.view === 'published' ? 'published' : 'draft'
res.json({ ...fileMeta(file), view, data: pickData(file, view) })
} catch (err) {
console.error('Error loading file:', err)
res.status(500).json({ error: 'Failed to load file' })
}
})
// Export full schedule Excel for a given month
app.get('/api/files/:id/export-excel', async (req, res) => {
const { id } = req.params
const month = parseInt(req.query.month) || (new Date().getMonth() + 1)
const year = parseInt(req.query.year) || new Date().getFullYear()
const filePath = join(SCHEDULES_DIR, `${id}.json`)
if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' })
let fileData
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
catch { return res.status(500).json({ error: 'Failed to read file' }) }
const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year })
const scriptPath = join(__dirname, 'export_full_excel.py')
try {
const { spawn } = await import('child_process')
await new Promise((resolve, reject) => {
const chunks = []
const errChunks = []
const proc = spawn('python3', [scriptPath], { cwd: __dirname })
proc.stdout.on('data', d => chunks.push(d))
proc.stderr.on('data', d => errChunks.push(d))
proc.on('close', code => {
if (code !== 0) {
const errMsg = Buffer.concat(errChunks).toString()
console.error('export_full_excel error:', errMsg)
res.status(500).json({ error: errMsg })
reject(new Error(errMsg))
} else {
const xlsx = Buffer.concat(chunks)
const MONTH_NAMES = ['','Leden','Únor','Březen','Duben','Květen','Červen','Červenec','Srpen','Září','Říjen','Listopad','Prosinec']
const mm = String(month).padStart(2, '0')
const fname = `Pohotovost_TKB+IT_smeny_${year}_${mm}_${MONTH_NAMES[month]}.xlsx`
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`)
res.send(xlsx)
resolve()
}
})
proc.on('error', reject)
proc.stdin.write(input)
proc.stdin.end()
})
} catch (err) {
if (!res.headersSent) res.status(500).json({ error: String(err) })
}
})
// Export harmonogram Excel for a given month (import into attendance system)
app.get('/api/files/:id/export-harmonogram', async (req, res) => {
const { id } = req.params
const month = parseInt(req.query.month) || (new Date().getMonth() + 1)
const year = parseInt(req.query.year) || new Date().getFullYear()
const filePath = join(SCHEDULES_DIR, `${id}.json`)
if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' })
let fileData
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
catch { return res.status(500).json({ error: 'Failed to read file' }) }
const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year })
const scriptPath = join(__dirname, 'export_harmonogram.py')
try {
const { spawn } = await import('child_process')
await new Promise((resolve, reject) => {
const chunks = []
const errChunks = []
const proc = spawn('python3', [scriptPath], { cwd: __dirname })
proc.stdout.on('data', d => chunks.push(d))
proc.stderr.on('data', d => errChunks.push(d))
proc.on('close', code => {
if (code !== 0) {
const errMsg = Buffer.concat(errChunks).toString()
console.error('export_harmonogram error:', errMsg)
if (!res.headersSent) res.status(500).json({ error: errMsg })
reject(new Error(errMsg))
} else {
const xlsx = Buffer.concat(chunks)
const mm = String(month).padStart(2, '0')
const fname = `import_harmonogram_${year}_${mm}.xlsx`
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`)
res.send(xlsx)
resolve()
}
})
proc.on('error', reject)
proc.stdin.write(input)
proc.stdin.end()
})
} catch (err) {
if (!res.headersSent) res.status(500).json({ error: 'Export failed', details: err.message })
}
})
// Export docházka Excel for a given month
app.get('/api/files/:id/export-dochazka', async (req, res) => {
const { id } = req.params
const month = parseInt(req.query.month) || (new Date().getMonth() + 1)
const year = parseInt(req.query.year) || new Date().getFullYear()
const filePath = join(SCHEDULES_DIR, `${id}.json`)
if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' })
let fileData
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
catch { return res.status(500).json({ error: 'Failed to read file' }) }
const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year })
const scriptPath = join(__dirname, 'export_dochazka.py')
try {
const { spawn } = await import('child_process')
await new Promise((resolve, reject) => {
const chunks = []
const errChunks = []
const proc = spawn('python3', [scriptPath])
proc.stdout.on('data', d => chunks.push(d))
proc.stderr.on('data', d => errChunks.push(d))
proc.on('close', code => {
if (code !== 0) {
const errMsg = Buffer.concat(errChunks).toString()
console.error('export_dochazka error:', errMsg)
reject(new Error(errMsg))
} else {
const xlsx = Buffer.concat(chunks)
const MONTH_NAMES = ['','Leden','Únor','Březen','Duben','Květen','Červen','Červenec','Srpen','Září','Říjen','Listopad','Prosinec']
const mm = String(month).padStart(2, '0')
const fname = `OP2416101755_TKB_${year}_${mm}_${MONTH_NAMES[month]}.xlsx`
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`)
res.send(xlsx)
resolve()
}
})
proc.on('error', reject)
proc.stdin.write(input)
proc.stdin.end()
})
} catch (err) {
if (!res.headersSent) res.status(500).json({ error: 'Export failed', details: err.message })
}
})
// Create new file
app.post('/api/files', (req, res) => {
try {
const { name, data } = req.body
if (!data || (!data.people && !data.stations) || !data.dayIndex) {
return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' })
}
normalizePeople(data)
const now = new Date().toISOString()
const fileObj = {
id: crypto.randomUUID(),
name: name || `Schedule_${now.slice(0, 10)}`,
createdAt: now,
modifiedAt: now,
data,
published: JSON.parse(JSON.stringify(data)),
publishedAt: now,
}
saveScheduleFile(fileObj)
res.json(fileMeta(fileObj))
} catch (err) {
console.error('Error creating file:', err)
res.status(500).json({ error: 'Failed to create file' })
}
})
// Update existing file
app.put('/api/files/:id', (req, res) => {
try {
const file = loadScheduleFile(req.params.id)
if (!file) return res.status(404).json({ error: 'File not found' })
const { name, data } = req.body
if (name !== undefined) file.name = name
if (data) {
if ((!data.people && !data.stations) || !data.dayIndex) {
return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' })
}
normalizePeople(data)
file.data = data
}
file.modifiedAt = new Date().toISOString()
saveScheduleFile(file)
res.json(fileMeta(file))
} catch (err) {
console.error('Error updating file:', err)
res.status(500).json({ error: 'Failed to update file' })
}
})
// Publish: optionally save the given draft, then make it the version viewers see
app.post('/api/files/:id/publish', (req, res) => {
try {
const file = loadScheduleFile(req.params.id)
if (!file) return res.status(404).json({ error: 'File not found' })
const now = new Date().toISOString()
const { data } = req.body || {}
if (data) {
if ((!data.people && !data.stations) || !data.dayIndex) {
return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' })
}
normalizePeople(data)
file.data = data
file.modifiedAt = now
}
file.published = JSON.parse(JSON.stringify(file.data))
file.publishedAt = now
saveScheduleFile(file)
console.log(`Published ${file.name} (${file.id}) at ${now}`)
res.json(fileMeta(file))
} catch (err) {
console.error('Error publishing file:', err)
res.status(500).json({ error: 'Failed to publish file' })
}
})
// Delete file
app.delete('/api/files/:id', (req, res) => {
try {
const filePath = join(SCHEDULES_DIR, `${req.params.id}.json`)
if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' })
unlinkSync(filePath)
res.json({ ok: true })
} catch (err) {
console.error('Error deleting file:', err)
res.status(500).json({ error: 'Failed to delete file' })
}
})
// --- Backward compatible endpoints ---
// GET /api/schedule — Load most recently modified file (or fallback to src/data.json)
app.get('/api/schedule', (_req, res) => {
try {
const recent = getMostRecentFile()
if (recent) {
const file = loadScheduleFile(recent.id)
if (file) return res.json(file.data)
}
// Fallback to old paths
if (existsSync(SAVED_SCHEDULE_PATH)) {
return res.json(JSON.parse(readFileSync(SAVED_SCHEDULE_PATH, 'utf-8')))
}
res.json(JSON.parse(readFileSync(DEFAULT_DATA_PATH, 'utf-8')))
} catch (err) {
console.error('Error loading schedule:', err)
res.status(500).json({ error: 'Failed to load schedule' })
}
})
// POST /api/schedule — Save to most recently modified file (or create new one)
app.post('/api/schedule', (req, res) => {
try {
const data = req.body
if (!data || (!data.people && !data.stations) || !data.dayIndex) {
return res.status(400).json({ error: 'Invalid schedule data' })
}
const recent = getMostRecentFile()
const now = new Date().toISOString()
if (recent) {
const file = loadScheduleFile(recent.id)
if (file) {
file.data = data
file.modifiedAt = now
saveScheduleFile(file)
console.log(`Schedule saved to ${file.name} (${file.id}) at ${now}`)
return res.json({ ok: true, id: file.id })
}
}
// No existing file — create new
const fileObj = {
id: crypto.randomUUID(),
name: `Schedule_${now.slice(0, 10)}`,
createdAt: now,
modifiedAt: now,
data,
}
saveScheduleFile(fileObj)
console.log(`Schedule saved as new file ${fileObj.name} (${fileObj.id}) at ${now}`)
res.json({ ok: true, id: fileObj.id })
} catch (err) {
console.error('Error saving schedule:', err)
res.status(500).json({ error: 'Failed to save schedule' })
}
})
// --- API: Proposals ---
const PROPOSALS_PATH = join(__dirname, '..', 'proposals.md')
app.get('/api/proposals', (_req, res) => {
try {
if (existsSync(PROPOSALS_PATH)) {
res.json({ content: readFileSync(PROPOSALS_PATH, 'utf-8') })
} else {
res.json({ content: '' })
}
} catch (err) {
console.error('Error reading proposals:', err)
res.status(500).json({ error: 'Failed to read proposals' })
}
})
app.post('/api/proposals', (req, res) => {
try {
const { text, author } = req.body
if (!text?.trim()) return res.status(400).json({ error: 'Empty proposal' })
const now = new Date().toLocaleString('cs-CZ')
const entry = `\n---\n\n**${now}** ${author ? `(${author})` : ''}\n\n${text.trim()}\n`
let content = ''
if (existsSync(PROPOSALS_PATH)) {
content = readFileSync(PROPOSALS_PATH, 'utf-8')
} else {
content = '# Navrhy na vylepseni TKB Planu sluzeb\n'
}
content += entry
writeFileSync(PROPOSALS_PATH, content, 'utf-8')
res.json({ ok: true })
} catch (err) {
console.error('Error saving proposal:', err)
res.status(500).json({ error: 'Failed to save proposal' })
}
})
// API: export as Excel (legacy endpoint — not yet implemented for TKB format)
app.get('/api/export-excel', (_req, res) => {
res.status(501).json({ error: 'Excel export not yet implemented for TKB format' })
})
// Version endpoint — clients poll this and reload when version changes
app.get('/api/version', (_req, res) => {
res.json({ version: SERVER_VERSION })
})
// Serve static files from dist/
app.use(express.static(join(__dirname, 'dist'), {
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
}
}))
// SPA fallback
app.get('/{*splat}', (_req, res) => {
res.sendFile(join(__dirname, 'dist', 'index.html'))
})
app.listen(PORT, () => {
console.log(`TKB server running on http://localhost:${PORT}`)
})