feat: auto-save drafts + publish step for viewers
Editors' changes are now auto-saved (5 s after the last edit, at most
30 s after the first unsaved one, retried on failure) with a status
indicator; leaving the page with unsaved changes prompts a warning.
Each schedule file keeps a published snapshot next to the draft.
Viewer/cis roles load and export only the published snapshot; the
editor's draft becomes visible to them only after "Publikovat".
Toolbar shows the publish state ("Nepublikované změny" / "Publikováno
HH:MM"), the file manager shows the publish date, and viewers pick up a
new publish automatically within a minute.
Server: GET /api/files/:id?view=published, POST /api/files/:id/publish,
?view=published on all exports, one-time migration that seeds the
published snapshot from the current data on startup.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
103
web/server.js
103
web/server.js
@@ -46,12 +46,7 @@ function listScheduleFiles() {
|
|||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(readFileSync(join(SCHEDULES_DIR, f), 'utf-8'))
|
const raw = JSON.parse(readFileSync(join(SCHEDULES_DIR, f), 'utf-8'))
|
||||||
result.push({
|
result.push(fileMeta(raw))
|
||||||
id: raw.id,
|
|
||||||
name: raw.name,
|
|
||||||
createdAt: raw.createdAt,
|
|
||||||
modifiedAt: raw.modifiedAt,
|
|
||||||
})
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
result.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt))
|
result.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt))
|
||||||
@@ -69,6 +64,41 @@ function saveScheduleFile(fileObj) {
|
|||||||
writeFileSync(join(SCHEDULES_DIR, `${fileObj.id}.json`), JSON.stringify(fileObj, null, 2), 'utf-8')
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
function getMostRecentFile() {
|
||||||
const files = listScheduleFiles()
|
const files = listScheduleFiles()
|
||||||
return files.length > 0 ? files[0] : null
|
return files.length > 0 ? files[0] : null
|
||||||
@@ -97,6 +127,15 @@ function migrateIfNeeded() {
|
|||||||
|
|
||||||
migrateIfNeeded()
|
migrateIfNeeded()
|
||||||
|
|
||||||
|
// Give every existing file a published snapshot (one-time, idempotent)
|
||||||
|
for (const f of listScheduleFiles()) {
|
||||||
|
const file = loadScheduleFile(f.id)
|
||||||
|
if (file && ensurePublished(file)) {
|
||||||
|
saveScheduleFile(file)
|
||||||
|
console.log(`Published snapshot initialised for ${file.name} (${file.id})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Diff computation ---
|
// --- Diff computation ---
|
||||||
|
|
||||||
function computeDiff(file1, file2) {
|
function computeDiff(file1, file2) {
|
||||||
@@ -187,7 +226,8 @@ app.get('/api/files/:id', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const file = loadScheduleFile(req.params.id)
|
const file = loadScheduleFile(req.params.id)
|
||||||
if (!file) return res.status(404).json({ error: 'File not found' })
|
if (!file) return res.status(404).json({ error: 'File not found' })
|
||||||
res.json(file)
|
const view = req.query.view === 'published' ? 'published' : 'draft'
|
||||||
|
res.json({ ...fileMeta(file), view, data: pickData(file, view) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading file:', err)
|
console.error('Error loading file:', err)
|
||||||
res.status(500).json({ error: 'Failed to load file' })
|
res.status(500).json({ error: 'Failed to load file' })
|
||||||
@@ -207,7 +247,7 @@ app.get('/api/files/:id/export-excel', async (req, res) => {
|
|||||||
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
||||||
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
||||||
|
|
||||||
const input = JSON.stringify({ schedule: fileData.data, month, year })
|
const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year })
|
||||||
const scriptPath = join(__dirname, 'export_full_excel.py')
|
const scriptPath = join(__dirname, 'export_full_excel.py')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -257,7 +297,7 @@ app.get('/api/files/:id/export-harmonogram', async (req, res) => {
|
|||||||
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
||||||
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
||||||
|
|
||||||
const input = JSON.stringify({ schedule: fileData.data, month, year })
|
const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year })
|
||||||
const scriptPath = join(__dirname, 'export_harmonogram.py')
|
const scriptPath = join(__dirname, 'export_harmonogram.py')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -306,7 +346,7 @@ app.get('/api/files/:id/export-dochazka', async (req, res) => {
|
|||||||
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
||||||
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
||||||
|
|
||||||
const input = JSON.stringify({ schedule: fileData.data, month, year })
|
const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year })
|
||||||
const scriptPath = join(__dirname, 'export_dochazka.py')
|
const scriptPath = join(__dirname, 'export_dochazka.py')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -356,14 +396,11 @@ app.post('/api/files', (req, res) => {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
modifiedAt: now,
|
modifiedAt: now,
|
||||||
data,
|
data,
|
||||||
|
published: JSON.parse(JSON.stringify(data)),
|
||||||
|
publishedAt: now,
|
||||||
}
|
}
|
||||||
saveScheduleFile(fileObj)
|
saveScheduleFile(fileObj)
|
||||||
res.json({
|
res.json(fileMeta(fileObj))
|
||||||
id: fileObj.id,
|
|
||||||
name: fileObj.name,
|
|
||||||
createdAt: fileObj.createdAt,
|
|
||||||
modifiedAt: fileObj.modifiedAt,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating file:', err)
|
console.error('Error creating file:', err)
|
||||||
res.status(500).json({ error: 'Failed to create file' })
|
res.status(500).json({ error: 'Failed to create file' })
|
||||||
@@ -387,18 +424,40 @@ app.put('/api/files/:id', (req, res) => {
|
|||||||
file.modifiedAt = new Date().toISOString()
|
file.modifiedAt = new Date().toISOString()
|
||||||
saveScheduleFile(file)
|
saveScheduleFile(file)
|
||||||
|
|
||||||
res.json({
|
res.json(fileMeta(file))
|
||||||
id: file.id,
|
|
||||||
name: file.name,
|
|
||||||
createdAt: file.createdAt,
|
|
||||||
modifiedAt: file.modifiedAt,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating file:', err)
|
console.error('Error updating file:', err)
|
||||||
res.status(500).json({ error: 'Failed to update file' })
|
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)' })
|
||||||
|
}
|
||||||
|
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
|
// Delete file
|
||||||
app.delete('/api/files/:id', (req, res) => {
|
app.delete('/api/files/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
123
web/src/App.tsx
123
web/src/App.tsx
@@ -138,19 +138,22 @@ function App() {
|
|||||||
const [fileId, setFileId] = useState<string | null>(null)
|
const [fileId, setFileId] = useState<string | null>(null)
|
||||||
const [fileName, setFileName] = useState<string>('')
|
const [fileName, setFileName] = useState<string>('')
|
||||||
const [fileData, setFileData] = useState<ScheduleData | null>(null)
|
const [fileData, setFileData] = useState<ScheduleData | null>(null)
|
||||||
|
const [fileMeta, setFileMeta] = useState<{ publishedAt: string | null; hasUnpublishedChanges: boolean }>({ publishedAt: null, hasUnpublishedChanges: false })
|
||||||
const [loading, setLoading] = useState(hasFavOnLoad || viewerAutoLoad)
|
const [loading, setLoading] = useState(hasFavOnLoad || viewerAutoLoad)
|
||||||
const [compareFileId, setCompareFileId] = useState<string | null>(null)
|
const [compareFileId, setCompareFileId] = useState<string | null>(null)
|
||||||
const autoOpenAttempted = useRef(false)
|
const autoOpenAttempted = useRef(false)
|
||||||
|
|
||||||
|
// Viewers/cis only ever see the published snapshot; editors work on the draft
|
||||||
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
|
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/files/${id}`)
|
const res = await fetch(`/api/files/${id}${isReadOnlyRole ? '?view=published' : ''}`)
|
||||||
if (!res.ok) throw new Error('Failed to load file')
|
if (!res.ok) throw new Error('Failed to load file')
|
||||||
const file: ScheduleFileWithData = await res.json()
|
const file: ScheduleFileWithData = await res.json()
|
||||||
setFileId(file.id)
|
setFileId(file.id)
|
||||||
setFileName(file.name)
|
setFileName(file.name)
|
||||||
setFileData(normalizeDayIndex(file.data))
|
setFileData(normalizeDayIndex(file.data))
|
||||||
|
setFileMeta({ publishedAt: file.publishedAt ?? null, hasUnpublishedChanges: !!file.hasUnpublishedChanges })
|
||||||
setCompareFileId(compareId ?? null)
|
setCompareFileId(compareId ?? null)
|
||||||
setMode('editor')
|
setMode('editor')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -159,7 +162,23 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isReadOnlyRole])
|
||||||
|
|
||||||
|
// Viewers: pick up a new publish automatically (poll metadata, reload when publishedAt changes)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReadOnlyRole || mode !== 'editor' || !fileId) return
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/files')
|
||||||
|
if (!res.ok) return
|
||||||
|
const files: { id: string; publishedAt?: string | null }[] = await res.json()
|
||||||
|
const me = files.find(f => f.id === fileId)
|
||||||
|
if (me && (me.publishedAt ?? null) !== fileMeta.publishedAt) handleOpenFile(fileId)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const interval = setInterval(check, 60_000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isReadOnlyRole, mode, fileId, fileMeta.publishedAt, handleOpenFile])
|
||||||
|
|
||||||
// Auto-open favourite file when authenticated; for viewers with no favourite, auto-open most recent
|
// Auto-open favourite file when authenticated; for viewers with no favourite, auto-open most recent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -267,7 +286,12 @@ function App() {
|
|||||||
if (mode === 'editor' && fileData && fileId) {
|
if (mode === 'editor' && fileData && fileId) {
|
||||||
return (
|
return (
|
||||||
<ScheduleApp
|
<ScheduleApp
|
||||||
|
// Remount viewers on a new publish so the grid shows the fresh snapshot
|
||||||
|
key={isReadOnlyRole ? `${fileId}:${fileMeta.publishedAt ?? ''}` : fileId}
|
||||||
fileId={fileId}
|
fileId={fileId}
|
||||||
|
publishedAt={fileMeta.publishedAt}
|
||||||
|
hasUnpublishedChanges={fileMeta.hasUnpublishedChanges}
|
||||||
|
viewPublished={isReadOnlyRole}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
data={fileData}
|
data={fileData}
|
||||||
compareFileId={compareFileId}
|
compareFileId={compareFileId}
|
||||||
@@ -331,9 +355,15 @@ interface ScheduleAppProps {
|
|||||||
isReadOnly?: boolean
|
isReadOnly?: boolean
|
||||||
canExportHarmonogram?: boolean
|
canExportHarmonogram?: boolean
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
|
publishedAt?: string | null
|
||||||
|
hasUnpublishedChanges?: boolean
|
||||||
|
viewPublished?: boolean // data shown is the published snapshot (viewer/cis)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, canExportHarmonogram = true, onLogout }: ScheduleAppProps) {
|
const AUTOSAVE_DELAY_MS = 5_000 // save this long after the last change
|
||||||
|
const AUTOSAVE_MAX_WAIT_MS = 30_000 // ...but never later than this after the first unsaved change
|
||||||
|
|
||||||
|
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, canExportHarmonogram = true, onLogout, publishedAt: initialPublishedAt = null, hasUnpublishedChanges: initialUnpublished = false, viewPublished = false }: ScheduleAppProps) {
|
||||||
const {
|
const {
|
||||||
people, tunnelClosures, tunnelColors,
|
people, tunnelClosures, tunnelColors,
|
||||||
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
|
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
|
||||||
@@ -349,6 +379,13 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||||
|
const [dirty, setDirty] = useState(false) // unsaved changes exist
|
||||||
|
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
||||||
|
const [publishedAt, setPublishedAt] = useState<string | null>(initialPublishedAt)
|
||||||
|
const [unpublished, setUnpublished] = useState(initialUnpublished)
|
||||||
|
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle')
|
||||||
|
const dirtySinceRef = useRef<number | null>(null)
|
||||||
|
const savingRef = useRef(false)
|
||||||
const [selectedDenDays, setSelectedDenDays] = useState<number[]>([])
|
const [selectedDenDays, setSelectedDenDays] = useState<number[]>([])
|
||||||
const [currentFileId, setCurrentFileId] = useState<string | null>(fileId)
|
const [currentFileId, setCurrentFileId] = useState<string | null>(fileId)
|
||||||
const [compareData, setCompareData] = useState<ScheduleData | null>(null)
|
const [compareData, setCompareData] = useState<ScheduleData | null>(null)
|
||||||
@@ -462,7 +499,9 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
})
|
})
|
||||||
}, [data.dayIndex])
|
}, [data.dayIndex])
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (savingRef.current) return false
|
||||||
|
savingRef.current = true
|
||||||
setSaveStatus('saving')
|
setSaveStatus('saving')
|
||||||
try {
|
try {
|
||||||
const payload = getSchedulePayload()
|
const payload = getSchedulePayload()
|
||||||
@@ -481,14 +520,78 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Save failed')
|
if (!res.ok) throw new Error('Save failed')
|
||||||
}
|
}
|
||||||
|
setDirty(false)
|
||||||
|
dirtySinceRef.current = null
|
||||||
|
setLastSavedAt(new Date())
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
setTimeout(() => setSaveStatus('idle'), 3000)
|
setTimeout(() => setSaveStatus('idle'), 3000)
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
setSaveStatus('error')
|
setSaveStatus('error')
|
||||||
setTimeout(() => setSaveStatus('idle'), 4000)
|
setTimeout(() => setSaveStatus('idle'), 4000)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
savingRef.current = false
|
||||||
}
|
}
|
||||||
}, [getSchedulePayload, currentFileId])
|
}, [getSchedulePayload, currentFileId])
|
||||||
|
|
||||||
|
// Any state change (getSchedulePayload identity changes with every edit) marks the draft dirty
|
||||||
|
const firstPayloadRef = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstPayloadRef.current) { firstPayloadRef.current = false; return }
|
||||||
|
if (isReadOnly) return
|
||||||
|
if (dirtySinceRef.current === null) dirtySinceRef.current = Date.now()
|
||||||
|
setDirty(true)
|
||||||
|
setUnpublished(true)
|
||||||
|
}, [getSchedulePayload, isReadOnly])
|
||||||
|
|
||||||
|
// Auto-save: debounce after the last change, with an upper bound since the first unsaved change
|
||||||
|
const saveRef = useRef(handleSave)
|
||||||
|
useEffect(() => { saveRef.current = handleSave }, [handleSave])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty || isReadOnly || !currentFileId) return
|
||||||
|
const since = dirtySinceRef.current ?? Date.now()
|
||||||
|
const wait = Math.max(0, Math.min(AUTOSAVE_DELAY_MS, since + AUTOSAVE_MAX_WAIT_MS - Date.now()))
|
||||||
|
const t = setTimeout(() => { saveRef.current() }, wait)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [dirty, getSchedulePayload, isReadOnly, currentFileId])
|
||||||
|
|
||||||
|
// Retry a failed auto-save after a while; warn before leaving with unsaved changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty || isReadOnly) return
|
||||||
|
const retry = setInterval(() => { if (!savingRef.current) saveRef.current() }, AUTOSAVE_MAX_WAIT_MS)
|
||||||
|
const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = '' }
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload)
|
||||||
|
return () => { clearInterval(retry); window.removeEventListener('beforeunload', onBeforeUnload) }
|
||||||
|
}, [dirty, isReadOnly])
|
||||||
|
|
||||||
|
// Publish: save the current draft and make it the version viewers see
|
||||||
|
const handlePublish = useCallback(async () => {
|
||||||
|
if (!currentFileId) return
|
||||||
|
if (!confirm('Publikovat aktuální stav plánu?\nUživatelé s náhledem (prohlížeč) uvidí tuto verzi.')) return
|
||||||
|
setPublishStatus('publishing')
|
||||||
|
try {
|
||||||
|
const payload = getSchedulePayload()
|
||||||
|
const res = await fetch(`/api/files/${currentFileId}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ data: payload }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Publish failed')
|
||||||
|
const meta = await res.json()
|
||||||
|
setPublishedAt(meta.publishedAt ?? new Date().toISOString())
|
||||||
|
setUnpublished(false)
|
||||||
|
setDirty(false)
|
||||||
|
dirtySinceRef.current = null
|
||||||
|
setLastSavedAt(new Date())
|
||||||
|
setPublishStatus('published')
|
||||||
|
setTimeout(() => setPublishStatus('idle'), 3000)
|
||||||
|
} catch {
|
||||||
|
setPublishStatus('error')
|
||||||
|
setTimeout(() => setPublishStatus('idle'), 4000)
|
||||||
|
}
|
||||||
|
}, [currentFileId, getSchedulePayload])
|
||||||
|
|
||||||
const handleSaveAs = useCallback(async () => {
|
const handleSaveAs = useCallback(async () => {
|
||||||
const name = prompt('Nazev noveho souboru:', fileName ? `${fileName} - kopie` : '')
|
const name = prompt('Nazev noveho souboru:', fileName ? `${fileName} - kopie` : '')
|
||||||
if (!name) return
|
if (!name) return
|
||||||
@@ -735,6 +838,12 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onSaveAs={handleSaveAs}
|
onSaveAs={handleSaveAs}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
|
dirty={dirty}
|
||||||
|
lastSavedAt={lastSavedAt}
|
||||||
|
onPublish={currentFileId && !isReadOnly ? handlePublish : undefined}
|
||||||
|
publishStatus={publishStatus}
|
||||||
|
publishedAt={publishedAt}
|
||||||
|
hasUnpublished={unpublished}
|
||||||
onScrollToMonth={handleScrollToMonth}
|
onScrollToMonth={handleScrollToMonth}
|
||||||
activeMonth={activeMonth}
|
activeMonth={activeMonth}
|
||||||
showMetro={showMetro}
|
showMetro={showMetro}
|
||||||
@@ -760,7 +869,7 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
onExportPdf={handleExportPdf}
|
onExportPdf={handleExportPdf}
|
||||||
onExportExcel={currentFileId ? (month: number) => {
|
onExportExcel={currentFileId ? (month: number) => {
|
||||||
const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear()
|
const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear()
|
||||||
window.location.href = `/api/files/${currentFileId}/export-excel?month=${month}&year=${year}`
|
window.location.href = `/api/files/${currentFileId}/export-excel?month=${month}&year=${year}${viewPublished ? '&view=published' : ''}`
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)}
|
onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)}
|
||||||
onCheckFpd={() => setShowFpd(true)}
|
onCheckFpd={() => setShowFpd(true)}
|
||||||
@@ -865,11 +974,11 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
onClose={() => setShowFpd(false)}
|
onClose={() => setShowFpd(false)}
|
||||||
onExportDochazka={currentFileId && !isReadOnly ? () => {
|
onExportDochazka={currentFileId && !isReadOnly ? () => {
|
||||||
const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026
|
const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026
|
||||||
window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}`
|
window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}${viewPublished ? '&view=published' : ''}`
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onExportHarmonogram={currentFileId && canExportHarmonogram ? () => {
|
onExportHarmonogram={currentFileId && canExportHarmonogram ? () => {
|
||||||
const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026
|
const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026
|
||||||
window.location.href = `/api/files/${currentFileId}/export-harmonogram?month=${activeMonth}&year=${year}`
|
window.location.href = `/api/files/${currentFileId}/export-harmonogram?month=${activeMonth}&year=${year}${viewPublished ? '&view=published' : ''}`
|
||||||
} : undefined}
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: Fi
|
|||||||
<th className="px-4 py-3 w-8"></th>
|
<th className="px-4 py-3 w-8"></th>
|
||||||
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Název</th>
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Název</th>
|
||||||
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Upraveno</th>
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Upraveno</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Publikováno</th>
|
||||||
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Vytvořeno</th>
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Vytvořeno</th>
|
||||||
<th className="px-4 py-3 text-right text-xs text-slate-400 uppercase tracking-wider">Akce</th>
|
<th className="px-4 py-3 text-right text-xs text-slate-400 uppercase tracking-wider">Akce</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -282,6 +283,19 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: Fi
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm text-slate-400">{formatDate(file.modifiedAt)}</span>
|
<span className="text-sm text-slate-400">{formatDate(file.modifiedAt)}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-400">{file.publishedAt ? formatDate(file.publishedAt) : '—'}</span>
|
||||||
|
{file.hasUnpublishedChanges && (
|
||||||
|
<span
|
||||||
|
title="Rozpracované změny, které uživatelé s náhledem zatím nevidí"
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded bg-amber-700/40 text-amber-300 border border-amber-700/50"
|
||||||
|
>
|
||||||
|
nepublikované změny
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm text-slate-400">{formatDate(file.createdAt)}</span>
|
<span className="text-sm text-slate-400">{formatDate(file.createdAt)}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -16,12 +16,29 @@ const MONTH_BUTTONS = [
|
|||||||
{ month: 12, label: 'Pro' },
|
{ month: 12, label: 'Pro' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function formatStamp(d: Date | string | null | undefined): string {
|
||||||
|
if (!d) return ''
|
||||||
|
const dt = typeof d === 'string' ? new Date(d) : d
|
||||||
|
if (isNaN(dt.getTime())) return ''
|
||||||
|
const hh = String(dt.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(dt.getMinutes()).padStart(2, '0')
|
||||||
|
const today = new Date()
|
||||||
|
const sameDay = dt.getFullYear() === today.getFullYear() && dt.getMonth() === today.getMonth() && dt.getDate() === today.getDate()
|
||||||
|
return sameDay ? `${hh}:${mm}` : `${dt.getDate()}. ${dt.getMonth() + 1}. ${hh}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
onUndo: () => void
|
onUndo: () => void
|
||||||
canUndo: boolean
|
canUndo: boolean
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onSaveAs: () => void
|
onSaveAs: () => void
|
||||||
saveStatus: 'idle' | 'saving' | 'saved' | 'error'
|
saveStatus: 'idle' | 'saving' | 'saved' | 'error'
|
||||||
|
dirty?: boolean // unsaved changes (auto-save pending)
|
||||||
|
lastSavedAt?: Date | null
|
||||||
|
onPublish?: () => void
|
||||||
|
publishStatus?: 'idle' | 'publishing' | 'published' | 'error'
|
||||||
|
publishedAt?: string | null
|
||||||
|
hasUnpublished?: boolean // draft differs from what viewers see
|
||||||
onScrollToMonth: (month: number) => void
|
onScrollToMonth: (month: number) => void
|
||||||
activeMonth?: number
|
activeMonth?: number
|
||||||
showMetro: boolean
|
showMetro: boolean
|
||||||
@@ -59,6 +76,12 @@ export function Toolbar({
|
|||||||
onSave,
|
onSave,
|
||||||
onSaveAs,
|
onSaveAs,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
|
dirty = false,
|
||||||
|
lastSavedAt = null,
|
||||||
|
onPublish,
|
||||||
|
publishStatus = 'idle',
|
||||||
|
publishedAt = null,
|
||||||
|
hasUnpublished = false,
|
||||||
onScrollToMonth,
|
onScrollToMonth,
|
||||||
activeMonth,
|
activeMonth,
|
||||||
showMetro,
|
showMetro,
|
||||||
@@ -185,8 +208,66 @@ export function Toolbar({
|
|||||||
>
|
>
|
||||||
Uložit jako
|
Uložit jako
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Auto-save state */}
|
||||||
|
<span
|
||||||
|
className={`text-[10px] whitespace-nowrap ${
|
||||||
|
saveStatus === 'error' ? 'text-red-400'
|
||||||
|
: dirty ? 'text-amber-400'
|
||||||
|
: 'text-slate-500'
|
||||||
|
}`}
|
||||||
|
title="Změny se ukládají automaticky několik sekund po poslední úpravě"
|
||||||
|
>
|
||||||
|
{saveStatus === 'error' ? 'Uložení selhalo, zkusím znovu'
|
||||||
|
: dirty ? 'Neuložené změny…'
|
||||||
|
: lastSavedAt ? `Uloženo ${formatStamp(lastSavedAt)}`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{onPublish && (
|
||||||
|
<>
|
||||||
|
<div className="h-5 w-px bg-slate-700" />
|
||||||
|
<button
|
||||||
|
onClick={onPublish}
|
||||||
|
disabled={publishStatus === 'publishing'}
|
||||||
|
title={hasUnpublished
|
||||||
|
? 'Uložit a zveřejnit aktuální stav pro uživatele s náhledem'
|
||||||
|
: 'Náhled odpovídá aktuálnímu stavu'}
|
||||||
|
className={`px-3 py-1 rounded text-xs border cursor-pointer transition-colors
|
||||||
|
${publishStatus === 'published'
|
||||||
|
? 'bg-green-700/70 text-green-100 border-green-600'
|
||||||
|
: publishStatus === 'error'
|
||||||
|
? 'bg-red-700/70 text-red-100 border-red-600'
|
||||||
|
: publishStatus === 'publishing'
|
||||||
|
? 'bg-slate-700 text-slate-400 border-slate-600'
|
||||||
|
: hasUnpublished
|
||||||
|
? 'bg-amber-700/70 text-amber-100 border-amber-600 hover:bg-amber-600/70'
|
||||||
|
: 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{publishStatus === 'publishing' ? 'Publikuji...'
|
||||||
|
: publishStatus === 'published' ? 'Publikováno'
|
||||||
|
: publishStatus === 'error' ? 'Chyba!'
|
||||||
|
: 'Publikovat'}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] whitespace-nowrap ${hasUnpublished ? 'text-amber-400' : 'text-slate-500'}`}
|
||||||
|
title={publishedAt ? `Naposledy publikováno ${formatStamp(publishedAt)}` : 'Zatím nepublikováno'}
|
||||||
|
>
|
||||||
|
{hasUnpublished
|
||||||
|
? '● Nepublikované změny'
|
||||||
|
: publishedAt ? `Publikováno ${formatStamp(publishedAt)}` : 'Nepublikováno'}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReadOnly && publishedAt && (
|
||||||
|
<span className="text-[10px] text-slate-500 whitespace-nowrap" title="Zobrazená verze plánu">
|
||||||
|
Publikováno {formatStamp(publishedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{onExportPdf && activeMonth && (
|
{onExportPdf && activeMonth && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export interface ScheduleFile {
|
|||||||
name: string
|
name: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
modifiedAt: string
|
modifiedAt: string
|
||||||
|
publishedAt?: string | null // when the viewer-visible snapshot was last published
|
||||||
|
hasUnpublishedChanges?: boolean // draft differs from the published snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleFileWithData extends ScheduleFile {
|
export interface ScheduleFileWithData extends ScheduleFile {
|
||||||
|
|||||||
Reference in New Issue
Block a user