@@ -337,6 +337,25 @@ function handleEvents($method, $id, $db) {
|
|||||||
]);
|
]);
|
||||||
echo json_encode(['id' => $db->lastInsertId()]);
|
echo json_encode(['id' => $db->lastInsertId()]);
|
||||||
break;
|
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':
|
case 'DELETE':
|
||||||
if ($id) {
|
if ($id) {
|
||||||
$stmt = $db->prepare("DELETE FROM events WHERE id = ?");
|
$stmt = $db->prepare("DELETE FROM events WHERE id = ?");
|
||||||
|
|||||||
+101
-54
@@ -3,6 +3,7 @@ let teams = [];
|
|||||||
let events = [];
|
let events = [];
|
||||||
let documents = [];
|
let documents = [];
|
||||||
let editingDocId = null;
|
let editingDocId = null;
|
||||||
|
let linkingEventId = null;
|
||||||
let nodes = [];
|
let nodes = [];
|
||||||
let links = [];
|
let links = [];
|
||||||
let shapes = [];
|
let shapes = [];
|
||||||
@@ -131,6 +132,7 @@ function initApp() {
|
|||||||
document.getElementById('saveNode').addEventListener('click', saveNode);
|
document.getElementById('saveNode').addEventListener('click', saveNode);
|
||||||
document.getElementById('saveLink').addEventListener('click', saveLink);
|
document.getElementById('saveLink').addEventListener('click', saveLink);
|
||||||
document.getElementById('saveShape').addEventListener('click', saveShape);
|
document.getElementById('saveShape').addEventListener('click', saveShape);
|
||||||
|
document.getElementById('saveLinkDocs').addEventListener('click', saveLinkDocs);
|
||||||
document.getElementById('addUserBtn').addEventListener('click', addUser);
|
document.getElementById('addUserBtn').addEventListener('click', addUser);
|
||||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||||
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
|
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
|
||||||
@@ -269,9 +271,13 @@ function renderTimeline() {
|
|||||||
</div>
|
</div>
|
||||||
<h6 class="event-title mt-1 mb-1">${esc(e.title)}</h6>
|
<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>' : ''}
|
${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>
|
<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>
|
||||||
<div class="mt-2" id="comments-${e.id}">
|
<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="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>
|
<div class="comment-log">${renderComments(e)}</div>
|
||||||
@@ -297,6 +303,16 @@ function renderDocLinks(text) {
|
|||||||
}).join('');
|
}).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) {
|
function openDocument(id) {
|
||||||
const tab = document.getElementById('documents-tab');
|
const tab = document.getElementById('documents-tab');
|
||||||
if (tab) tab.click();
|
if (tab) tab.click();
|
||||||
@@ -319,7 +335,7 @@ function openDocument(id) {
|
|||||||
target.style.borderColor = '';
|
target.style.borderColor = '';
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
loadDocuments().then(() => {
|
loadDocuments();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = document.getElementById('documentContainer').querySelector(`[data-doc-id="${id}"]`);
|
const el = document.getElementById('documentContainer').querySelector(`[data-doc-id="${id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -328,8 +344,7 @@ function openDocument(id) {
|
|||||||
el.style.borderColor = 'var(--neptune-accent)';
|
el.style.borderColor = 'var(--neptune-accent)';
|
||||||
setTimeout(() => { el.style.boxShadow = ''; el.style.borderColor = ''; }, 3000);
|
setTimeout(() => { el.style.boxShadow = ''; el.style.borderColor = ''; }, 3000);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 500);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const filter = document.getElementById('searchDocs');
|
const filter = document.getElementById('searchDocs');
|
||||||
if (filter) filter.value = '';
|
if (filter) filter.value = '';
|
||||||
@@ -338,6 +353,53 @@ function openDocument(id) {
|
|||||||
}, 300);
|
}, 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) {
|
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>';
|
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 => `
|
return event.comments.map(c => `
|
||||||
@@ -1079,7 +1141,11 @@ function renderDocuments() {
|
|||||||
const meta = DOC_TYPE_ICONS[d.doc_type] || DOC_TYPE_ICONS.deployment;
|
const meta = DOC_TYPE_ICONS[d.doc_type] || DOC_TYPE_ICONS.deployment;
|
||||||
const label = DOC_TYPE_LABELS[d.doc_type] || d.doc_type;
|
const label = DOC_TYPE_LABELS[d.doc_type] || d.doc_type;
|
||||||
const date = new Date(d.occurred_at).toLocaleString();
|
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 `
|
return `
|
||||||
<div class="col-md-6 col-lg-4 mb-3">
|
<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}">
|
<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 || ''}">
|
<input type="text" class="form-control form-control-sm" id="${f.id}" placeholder="${f.placeholder || ''}">
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
// Restore values if editing
|
|
||||||
if (editingDocId && window._editDocData) {
|
if (editingDocId && window._editDocData) {
|
||||||
const data = window._editDocData;
|
const data = window._editDocData;
|
||||||
templates.forEach(f => {
|
templates.forEach(f => {
|
||||||
const el = document.getElementById(f.id);
|
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() {
|
function collectDocFields() {
|
||||||
const docType = document.getElementById('docType').value;
|
const docType = document.getElementById('docType').value;
|
||||||
const templates = DOC_FIELD_TEMPLATES[docType] || [];
|
const templates = DOC_FIELD_TEMPLATES[docType] || [];
|
||||||
@@ -1202,34 +1255,28 @@ async function saveDocument() {
|
|||||||
const docType = document.getElementById('docType').value;
|
const docType = document.getElementById('docType').value;
|
||||||
const teamId = document.getElementById('docTeam').value;
|
const teamId = document.getElementById('docTeam').value;
|
||||||
const title = document.getElementById('docTitle').value.trim();
|
const title = document.getElementById('docTitle').value.trim();
|
||||||
const content = document.getElementById('docContent').value.trim();
|
const notes = document.getElementById('docContent').value.trim();
|
||||||
const fields = collectDocFields();
|
const fields = collectDocFields();
|
||||||
|
|
||||||
if (!title) return alert('Title is required');
|
if (!title) return alert('Title is required');
|
||||||
if (!teamId) return alert('Team is required');
|
if (!teamId) return alert('Team is required');
|
||||||
|
|
||||||
// Build structured content from fields
|
const templates = DOC_FIELD_TEMPLATES[docType] || [];
|
||||||
const label = DOC_TYPE_LABELS[docType] || docType;
|
const fieldsObj = {};
|
||||||
let fullContent = content;
|
templates.forEach(f => {
|
||||||
const fieldEntries = Object.entries(fields).filter(([, v]) => v);
|
const v = fields[f.id];
|
||||||
if (fieldEntries.length) {
|
if (v) fieldsObj[f.id] = v;
|
||||||
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 data = {
|
const data = {
|
||||||
doc_type: docType,
|
doc_type: docType,
|
||||||
team_id: teamId,
|
team_id: teamId,
|
||||||
title: title,
|
title: title,
|
||||||
content: fullContent,
|
content: JSON.stringify({ fields: fieldsObj, notes: notes }),
|
||||||
occurred_at: new Date().toISOString().slice(0, 16)
|
occurred_at: new Date().toISOString().slice(0, 16)
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingDocId) {
|
if (editingDocId) {
|
||||||
data._fields = fields;
|
|
||||||
await apiFetch('documents/' + editingDocId, { method: 'PUT', body: JSON.stringify(data) });
|
await apiFetch('documents/' + editingDocId, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
editingDocId = null;
|
editingDocId = null;
|
||||||
} else {
|
} else {
|
||||||
@@ -1243,44 +1290,44 @@ async function saveDocument() {
|
|||||||
loadEvents();
|
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) {
|
async function editDocument(id) {
|
||||||
const d = documents.find(x => x.id == id);
|
const d = documents.find(x => x.id == id);
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
editingDocId = id;
|
editingDocId = id;
|
||||||
window._editDocData = d;
|
window._editDocData = d;
|
||||||
|
|
||||||
// Parse structured content back into fields
|
let parsedNotes = '';
|
||||||
const docType = d.doc_type;
|
|
||||||
const templates = DOC_FIELD_TEMPLATES[docType] || [];
|
|
||||||
const parsedFields = {};
|
const parsedFields = {};
|
||||||
if (d.content) {
|
if (d.content) {
|
||||||
const lines = d.content.split('\n');
|
try {
|
||||||
let inContent = false;
|
const parsed = JSON.parse(d.content);
|
||||||
let contentParts = [];
|
if (parsed.fields) Object.assign(parsedFields, parsed.fields);
|
||||||
for (const line of lines) {
|
parsedNotes = parsed.notes || '';
|
||||||
if (!inContent && line.includes(': ')) {
|
} catch (e) {
|
||||||
const colonIdx = line.indexOf(': ');
|
parsedNotes = d.content;
|
||||||
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);
|
|
||||||
}
|
|
||||||
d._parsedContent = contentParts.join('\n').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('documentModalLabel').textContent = 'Edit ' + (DOC_TYPE_LABELS[d.doc_type] || d.doc_type) + ' Document';
|
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('saveDocument').innerHTML = '<i class="fas fa-save me-1"></i> Update Document';
|
||||||
document.getElementById('docType').value = d.doc_type;
|
document.getElementById('docType').value = d.doc_type;
|
||||||
document.getElementById('docTeam').value = d.team_id;
|
document.getElementById('docTeam').value = d.team_id;
|
||||||
document.getElementById('docTitle').value = d.title;
|
document.getElementById('docTitle').value = d.title;
|
||||||
document.getElementById('docContent').value = d._parsedContent || '';
|
document.getElementById('docContent').value = parsedNotes;
|
||||||
|
|
||||||
|
d._parsedFields = parsedFields;
|
||||||
updateDocFormFields();
|
updateDocFormFields();
|
||||||
new bootstrap.Modal(document.getElementById('documentModal')).show();
|
new bootstrap.Modal(document.getElementById('documentModal')).show();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,6 +441,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ==================== -->
|
<!-- ==================== SETTINGS MODAL ==================== -->
|
||||||
<div class="modal fade" id="settingsModal" tabindex="-1">
|
<div class="modal fade" id="settingsModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|||||||
Reference in New Issue
Block a user