- Add dochazka Excel export using direct ZIP/XML manipulation of template (preserves styles.xml byte-for-byte to avoid Excel "repaired styles" warning) - Calculate per-person stravné doplatek, transport (AUV), and indiv1 (closure count + Janouš internet) per sichtovnice.py logic - Filter exported people to TEMPLATE_NAMES (12 fixed template rows) - Add server version polling + auto-reload on deploy - Add FPD check modal for monthly hour validation - Add "162" filter button to hide first 5 TKB people from view Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
445 lines
14 KiB
JavaScript
445 lines
14 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({
|
|
id: raw.id,
|
|
name: raw.name,
|
|
createdAt: raw.createdAt,
|
|
modifiedAt: raw.modifiedAt,
|
|
})
|
|
} 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')
|
|
}
|
|
|
|
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()
|
|
|
|
// --- 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' })
|
|
res.json(file)
|
|
} catch (err) {
|
|
console.error('Error loading file:', err)
|
|
res.status(500).json({ error: 'Failed to load file' })
|
|
}
|
|
})
|
|
|
|
// Export file as Excel (not yet implemented for TKB format)
|
|
app.get('/api/files/:id/export-excel', (_req, res) => {
|
|
res.status(501).json({ error: 'Excel export not yet implemented for TKB format' })
|
|
})
|
|
|
|
// 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: fileData.data, 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)' })
|
|
}
|
|
const now = new Date().toISOString()
|
|
const fileObj = {
|
|
id: crypto.randomUUID(),
|
|
name: name || `Schedule_${now.slice(0, 10)}`,
|
|
createdAt: now,
|
|
modifiedAt: now,
|
|
data,
|
|
}
|
|
saveScheduleFile(fileObj)
|
|
res.json({
|
|
id: fileObj.id,
|
|
name: fileObj.name,
|
|
createdAt: fileObj.createdAt,
|
|
modifiedAt: fileObj.modifiedAt,
|
|
})
|
|
} 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)' })
|
|
}
|
|
file.data = data
|
|
}
|
|
file.modifiedAt = new Date().toISOString()
|
|
saveScheduleFile(file)
|
|
|
|
res.json({
|
|
id: file.id,
|
|
name: file.name,
|
|
createdAt: file.createdAt,
|
|
modifiedAt: file.modifiedAt,
|
|
})
|
|
} catch (err) {
|
|
console.error('Error updating file:', err)
|
|
res.status(500).json({ error: 'Failed to update 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}`)
|
|
})
|