.
Deploy / deploy (push) Successful in 38s

This commit is contained in:
2026-05-08 01:00:48 +02:00
parent f011522848
commit 95a6973313
3 changed files with 148 additions and 63 deletions
+19
View File
@@ -337,6 +337,25 @@ function handleEvents($method, $id, $db) {
]);
echo json_encode(['id' => $db->lastInsertId()]);
break;
case 'PUT':
if ($id) {
$data = json_decode(file_get_contents('php://input'), true);
$fields = [];
$params = [];
foreach (['team_id','title','description','severity','event_type','occurred_at'] as $f) {
if (isset($data[$f])) {
$fields[] = "$f = ?";
$params[] = $data[$f];
}
}
if ($fields) {
$params[] = $id;
$stmt = $db->prepare("UPDATE events SET " . implode(', ', $fields) . " WHERE id = ?");
$stmt->execute($params);
}
echo json_encode(['updated' => true]);
}
break;
case 'DELETE':
if ($id) {
$stmt = $db->prepare("DELETE FROM events WHERE id = ?");
+110 -63
View File
@@ -3,6 +3,7 @@ let teams = [];
let events = [];
let documents = [];
let editingDocId = null;
let linkingEventId = null;
let nodes = [];
let links = [];
let shapes = [];
@@ -131,6 +132,7 @@ function initApp() {
document.getElementById('saveNode').addEventListener('click', saveNode);
document.getElementById('saveLink').addEventListener('click', saveLink);
document.getElementById('saveShape').addEventListener('click', saveShape);
document.getElementById('saveLinkDocs').addEventListener('click', saveLinkDocs);
document.getElementById('addUserBtn').addEventListener('click', addUser);
document.getElementById('logoutBtn').addEventListener('click', logout);
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
@@ -269,8 +271,12 @@ function renderTimeline() {
</div>
<h6 class="event-title mt-1 mb-1">${esc(e.title)}</h6>
${e.description ? '<p class="mb-1 small text-secondary">' + renderDocLinks(e.description) + '</p>' : ''}
<div class="text-end">
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteEvent(${e.id}, this)" title="Delete event" style="font-size:.7rem;"><i class="fas fa-trash"></i></button>
<div class="d-flex justify-content-between align-items-center mt-1">
<div class="doc-links-inline" id="docLinks-${e.id}">${renderInlineDocLinks(e.description || '')}</div>
<div>
<button class="btn btn-outline-info btn-sm py-0 px-1" onclick="linkDocsToEvent(${e.id})" title="Link documents" style="font-size:.7rem;"><i class="fas fa-link me-1"></i>Docs</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteEvent(${e.id}, this)" title="Delete event" style="font-size:.7rem;"><i class="fas fa-trash"></i></button>
</div>
</div>
<div class="mt-2" id="comments-${e.id}">
<div class="d-flex align-items-center mb-1"><small class="text-secondary fw-bold"><i class="fas fa-comment-dots me-1"></i>Comments ${e.comments && e.comments.length ? '(' + e.comments.length + ')' : ''}</small></div>
@@ -297,6 +303,16 @@ function renderDocLinks(text) {
}).join('');
}
function renderInlineDocLinks(desc) {
const ids = extractDocIdsFromDesc(desc);
if (!ids.length) return '';
const labels = ids.map(id => {
const d = documents.find(x => x.id == id);
return d ? `<a href="#" onclick="event.preventDefault();openDocument(${id})" class="doc-link-badge" style="display:inline-block;padding:1px 6px;margin:1px;border-radius:3px;background:var(--neptune-accent);color:#fff;font-size:.65rem;text-decoration:none;">${esc(d.title)}</a>` : '';
}).join('');
return labels ? '<small class="text-secondary me-1"><i class="fas fa-file-alt me-1"></i></small>' + labels : '';
}
function openDocument(id) {
const tab = document.getElementById('documents-tab');
if (tab) tab.click();
@@ -319,17 +335,16 @@ function openDocument(id) {
target.style.borderColor = '';
}, 3000);
} else {
loadDocuments().then(() => {
setTimeout(() => {
const el = document.getElementById('documentContainer').querySelector(`[data-doc-id="${id}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.style.boxShadow = '0 0 20px rgba(59,130,246,.5)';
el.style.borderColor = 'var(--neptune-accent)';
setTimeout(() => { el.style.boxShadow = ''; el.style.borderColor = ''; }, 3000);
}
}, 100);
});
loadDocuments();
setTimeout(() => {
const el = document.getElementById('documentContainer').querySelector(`[data-doc-id="${id}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.style.boxShadow = '0 0 20px rgba(59,130,246,.5)';
el.style.borderColor = 'var(--neptune-accent)';
setTimeout(() => { el.style.boxShadow = ''; el.style.borderColor = ''; }, 3000);
}
}, 500);
}
const filter = document.getElementById('searchDocs');
if (filter) filter.value = '';
@@ -338,6 +353,53 @@ function openDocument(id) {
}, 300);
}
function linkDocsToEvent(eventId) {
linkingEventId = eventId;
const list = document.getElementById('linkDocsList');
if (!documents.length) {
list.innerHTML = '<div class="text-secondary small text-center py-3">No documents available. Create some first.</div>';
} else {
const alreadyLinked = extractDocIdsFromDesc(events.find(e => e.id == eventId)?.description || '');
list.innerHTML = documents.map(d => {
const checked = alreadyLinked.includes(d.id) ? 'checked' : '';
return `<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" value="${d.id}" id="ld-${d.id}" ${checked}>
<label class="form-check-label small" for="ld-${d.id}">${esc(d.title)} <span class="text-secondary">(${DOC_TYPE_LABELS[d.doc_type] || d.doc_type})</span></label>
</div>`;
}).join('');
}
new bootstrap.Modal(document.getElementById('linkDocsModal')).show();
}
function extractDocIdsFromDesc(desc) {
const ids = [];
const re = /\[doc:(\d+)\]/g;
let m;
while ((m = re.exec(desc)) !== null) ids.push(parseInt(m[1]));
return ids;
}
async function saveLinkDocs() {
const modal = bootstrap.Modal.getInstance(document.getElementById('linkDocsModal'));
if (!linkingEventId) { modal.hide(); return; }
const checks = document.querySelectorAll('#linkDocsList input[type="checkbox"]:checked');
const selectedIds = Array.from(checks).map(c => parseInt(c.value));
const event = events.find(e => e.id == linkingEventId);
if (!event) { modal.hide(); return; }
let desc = event.description || '';
desc = desc.replace(/\[doc:\d+\].*?\[\/doc\]\s*/g, '').trim();
const docParts = selectedIds.map(id => {
const d = documents.find(x => x.id == id);
return d ? `[doc:${d.id}]${d.title}[/doc]` : '';
}).filter(Boolean);
if (docParts.length) desc = (desc ? desc + '\n' : '') + 'Linked: ' + docParts.join(', ');
await apiFetch('events/' + linkingEventId, { method: 'PUT', body: JSON.stringify({ description: desc }) });
modal.hide();
linkingEventId = null;
loadEvents();
}
function renderComments(event) {
if (!event.comments || !event.comments.length) return '<div class="text-secondary small py-1"><i class="fas fa-comment me-1"></i>No comments yet</div>';
return event.comments.map(c => `
@@ -1079,7 +1141,11 @@ function renderDocuments() {
const meta = DOC_TYPE_ICONS[d.doc_type] || DOC_TYPE_ICONS.deployment;
const label = DOC_TYPE_LABELS[d.doc_type] || d.doc_type;
const date = new Date(d.occurred_at).toLocaleString();
const contentPreview = d.content ? d.content.substring(0, 150) + (d.content.length > 150 ? '...' : '') : '';
let contentPreview = '';
if (d.content) {
try { const p = JSON.parse(d.content); contentPreview = p.notes || ''; } catch (e) { contentPreview = d.content; }
if (contentPreview.length > 150) contentPreview = contentPreview.substring(0, 150) + '...';
}
return `
<div class="col-md-6 col-lg-4 mb-3">
<div class="card bg-dark border-secondary h-100 doc-card" data-doc-id="${d.id}">
@@ -1165,28 +1231,15 @@ function updateDocFormFields() {
<input type="text" class="form-control form-control-sm" id="${f.id}" placeholder="${f.placeholder || ''}">
</div>`;
}).join('');
// Restore values if editing
if (editingDocId && window._editDocData) {
const data = window._editDocData;
templates.forEach(f => {
const el = document.getElementById(f.id);
if (el) el.value = data._fields?.[f.id] || '';
if (el && data._parsedFields?.[f.id]) el.value = data._parsedFields[f.id];
});
}
}
function openNewDocument(type) {
editingDocId = null;
document.getElementById('documentModalLabel').textContent = 'New ' + (DOC_TYPE_LABELS[type] || type) + ' Document';
document.getElementById('saveDocument').innerHTML = '<i class="fas fa-save me-1"></i> Save Document';
document.getElementById('docType').value = type;
document.getElementById('docTitle').value = '';
document.getElementById('docContent').value = '';
window._editDocData = null;
updateDocFormFields();
new bootstrap.Modal(document.getElementById('documentModal')).show();
}
function collectDocFields() {
const docType = document.getElementById('docType').value;
const templates = DOC_FIELD_TEMPLATES[docType] || [];
@@ -1202,34 +1255,28 @@ async function saveDocument() {
const docType = document.getElementById('docType').value;
const teamId = document.getElementById('docTeam').value;
const title = document.getElementById('docTitle').value.trim();
const content = document.getElementById('docContent').value.trim();
const notes = document.getElementById('docContent').value.trim();
const fields = collectDocFields();
if (!title) return alert('Title is required');
if (!teamId) return alert('Team is required');
// Build structured content from fields
const label = DOC_TYPE_LABELS[docType] || docType;
let fullContent = content;
const fieldEntries = Object.entries(fields).filter(([, v]) => v);
if (fieldEntries.length) {
const fieldPrefix = fieldEntries.map(([k, v]) => {
const tmpl = (DOC_FIELD_TEMPLATES[docType] || []).find(t => t.id === k);
return (tmpl ? tmpl.label + ': ' : k + ': ') + v;
}).join('\n');
fullContent = fieldPrefix + (content ? '\n\n' + content : '');
}
const templates = DOC_FIELD_TEMPLATES[docType] || [];
const fieldsObj = {};
templates.forEach(f => {
const v = fields[f.id];
if (v) fieldsObj[f.id] = v;
});
const data = {
doc_type: docType,
team_id: teamId,
title: title,
content: fullContent,
content: JSON.stringify({ fields: fieldsObj, notes: notes }),
occurred_at: new Date().toISOString().slice(0, 16)
};
if (editingDocId) {
data._fields = fields;
await apiFetch('documents/' + editingDocId, { method: 'PUT', body: JSON.stringify(data) });
editingDocId = null;
} else {
@@ -1243,35 +1290,34 @@ async function saveDocument() {
loadEvents();
}
function openNewDocument(type) {
editingDocId = null;
document.getElementById('documentModalLabel').textContent = 'New ' + (DOC_TYPE_LABELS[type] || type) + ' Document';
document.getElementById('saveDocument').innerHTML = '<i class="fas fa-save me-1"></i> Save Document';
document.getElementById('docType').value = type;
document.getElementById('docTitle').value = '';
document.getElementById('docContent').value = '';
window._editDocData = null;
updateDocFormFields();
new bootstrap.Modal(document.getElementById('documentModal')).show();
}
async function editDocument(id) {
const d = documents.find(x => x.id == id);
if (!d) return;
editingDocId = id;
window._editDocData = d;
// Parse structured content back into fields
const docType = d.doc_type;
const templates = DOC_FIELD_TEMPLATES[docType] || [];
let parsedNotes = '';
const parsedFields = {};
if (d.content) {
const lines = d.content.split('\n');
let inContent = false;
let contentParts = [];
for (const line of lines) {
if (!inContent && line.includes(': ')) {
const colonIdx = line.indexOf(': ');
const fieldLabel = line.substring(0, colonIdx);
const fieldVal = line.substring(colonIdx + 2);
const tmpl = templates.find(t => t.label === fieldLabel);
if (tmpl) {
parsedFields[tmpl.id] = fieldVal;
continue;
}
}
if (!line.trim() && !inContent) { inContent = true; continue; }
if (inContent) contentParts.push(line);
try {
const parsed = JSON.parse(d.content);
if (parsed.fields) Object.assign(parsedFields, parsed.fields);
parsedNotes = parsed.notes || '';
} catch (e) {
parsedNotes = d.content;
}
d._parsedContent = contentParts.join('\n').trim();
}
document.getElementById('documentModalLabel').textContent = 'Edit ' + (DOC_TYPE_LABELS[d.doc_type] || d.doc_type) + ' Document';
@@ -1279,8 +1325,9 @@ async function editDocument(id) {
document.getElementById('docType').value = d.doc_type;
document.getElementById('docTeam').value = d.team_id;
document.getElementById('docTitle').value = d.title;
document.getElementById('docContent').value = d._parsedContent || '';
document.getElementById('docContent').value = parsedNotes;
d._parsedFields = parsedFields;
updateDocFormFields();
new bootstrap.Modal(document.getElementById('documentModal')).show();
}
+19
View File
@@ -441,6 +441,25 @@
</div>
</div>
<!-- ==================== LINK DOCS MODAL ==================== -->
<div class="modal fade" id="linkDocsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content bg-dark">
<div class="modal-header border-secondary">
<h5 class="modal-title"><i class="fas fa-link text-info me-1"></i> Link Documents to Event</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="linkDocsList" class="mb-2"></div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-info text-dark" id="saveLinkDocs"><i class="fas fa-link me-1"></i> Link Selected</button>
</div>
</div>
</div>
</div>
<!-- ==================== SETTINGS MODAL ==================== -->
<div class="modal fade" id="settingsModal" tabindex="-1">
<div class="modal-dialog">