@@ -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 = ?");
|
||||
|
||||
+101
-54
@@ -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,9 +271,13 @@ 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">
|
||||
<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>
|
||||
<div class="comment-log">${renderComments(e)}</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,7 +335,7 @@ function openDocument(id) {
|
||||
target.style.borderColor = '';
|
||||
}, 3000);
|
||||
} else {
|
||||
loadDocuments().then(() => {
|
||||
loadDocuments();
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById('documentContainer').querySelector(`[data-doc-id="${id}"]`);
|
||||
if (el) {
|
||||
@@ -328,8 +344,7 @@ function openDocument(id) {
|
||||
el.style.borderColor = 'var(--neptune-accent)';
|
||||
setTimeout(() => { el.style.boxShadow = ''; el.style.borderColor = ''; }, 3000);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}, 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,44 +1290,44 @@ 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;
|
||||
try {
|
||||
const parsed = JSON.parse(d.content);
|
||||
if (parsed.fields) Object.assign(parsedFields, parsed.fields);
|
||||
parsedNotes = parsed.notes || '';
|
||||
} catch (e) {
|
||||
parsedNotes = d.content;
|
||||
}
|
||||
}
|
||||
if (!line.trim() && !inContent) { inContent = true; continue; }
|
||||
if (inContent) contentParts.push(line);
|
||||
}
|
||||
d._parsedContent = contentParts.join('\n').trim();
|
||||
}
|
||||
|
||||
document.getElementById('documentModalLabel').textContent = 'Edit ' + (DOC_TYPE_LABELS[d.doc_type] || d.doc_type) + ' Document';
|
||||
document.getElementById('saveDocument').innerHTML = '<i class="fas fa-save me-1"></i> Update Document';
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user