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>
617 lines
21 KiB
JavaScript
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}`)
|
|
})
|