feat(admin): add publish/unpublish workflow UI to dashboard

SUMMARY:
Implemented admin UI for document publishing workflow, enabling admins
to publish internal documents to public visibility with category selection
and unpublish documents with audit trail reasoning.

CHANGES:

1. Enhanced Document List View (loadDocuments):
   - Added visibility badges (public/internal/confidential/archived)
   - Added workflow status badges (draft/review/published)
   - Added conditional Publish button (internal + not published)
   - Added conditional Unpublish button (public + published)
   - Improved layout with category display

2. Publish Modal (openPublishModal):
   - Category selection dropdown (7 categories)
   - Display order input (optional)
   - Form validation (category required)
   - Integration with POST /api/documents/:id/publish

3. Unpublish Modal (openUnpublishModal):
   - Required reason textarea (audit trail)
   - Document context display (title, visibility, category)
   - Integration with POST /api/documents/:id/unpublish

4. Badge Helper Functions:
   - getVisibilityBadge(visibility) - colored badges
   - getStatusBadge(status) - workflow state badges

5. Event Delegation:
   - Added openPublishModal, closePublishModal handlers
   - Added openUnpublishModal, closeUnpublishModal handlers

INTEGRATION:
✓ Uses existing API endpoints (tested syntax)
✓ CSP compliant (no inline styles/handlers)
✓ Follows event delegation pattern
✓ Proper form validation and error handling

TESTING:
✓ JavaScript syntax validated (node -c)
✓ CSP compliance verified (0 violations)
✓ Server accessibility confirmed (HTTP 200)

NEXT STEPS (Optional):
- Create dedicated drafts dashboard page (from SCHEDULED_TASKS.md)
- Add bulk publish operations
- Implement review workflow state transitions

FRAMEWORK COMPLIANCE:
Addresses SCHEDULED_TASKS.md item "Admin UI for Publish Workflow"
Maintains CSP compliance (inst_008)

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TheFlow 2025-10-19 13:42:47 +13:00
parent 0decd9882d
commit e83f8e9883

View file

@ -225,22 +225,44 @@ async function loadDocuments() {
return;
}
container.innerHTML = response.documents.map(doc => `
<div class="px-6 py-4 flex items-center justify-between">
container.innerHTML = response.documents.map(doc => {
const visibilityBadge = getVisibilityBadge(doc.visibility || 'internal');
const statusBadge = getStatusBadge(doc.workflow_status || 'draft');
const canPublish = doc.visibility === 'internal' && doc.workflow_status !== 'published';
const canUnpublish = doc.visibility === 'public' && doc.workflow_status === 'published';
return `
<div class="px-6 py-4 flex items-center justify-between border-b border-gray-100">
<div class="flex-1">
<h4 class="text-sm font-medium text-gray-900">${doc.title}</h4>
<p class="text-sm text-gray-500">${doc.quadrant || 'No quadrant'} ${formatDate(doc.created_at)}</p>
<div class="flex items-center space-x-3 mt-1">
<p class="text-sm text-gray-500">${doc.quadrant || 'No quadrant'}</p>
${statusBadge}
${visibilityBadge}
${doc.category ? `<span class="text-xs text-gray-400">Category: ${doc.category}</span>` : ''}
</div>
</div>
<div class="flex items-center space-x-2">
<a href="/docs-viewer.html#${doc.slug}" target="_blank" class="text-blue-600 hover:text-blue-900 text-sm">
View
</a>
${canPublish ? `
<button data-action="openPublishModal" data-arg0="${doc._id}" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Publish
</button>
` : ''}
${canUnpublish ? `
<button data-action="openUnpublishModal" data-arg0="${doc._id}" class="px-3 py-1 bg-yellow-600 text-white text-sm rounded hover:bg-yellow-700">
Unpublish
</button>
` : ''}
<button data-action="deleteDocument" data-arg0="${doc._id}" class="text-red-600 hover:text-red-900 text-sm">
Delete
</button>
</div>
</div>
`).join('');
`;
}).join('');
} catch (error) {
console.error('Failed to load documents:', error);
container.innerHTML = '<div class="px-6 py-8 text-center text-red-500">Failed to load documents</div>';
@ -350,7 +372,221 @@ async function deleteDocument(docId) {
}
}
// Open publish modal
async function openPublishModal(docId) {
try {
const response = await apiRequest(`/api/documents/${docId}`);
if (!response.success || !response.document) {
alert('Failed to load document');
return;
}
const doc = response.document;
const modalHTML = `
<div id="publish-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
<h2 class="text-xl font-bold text-gray-900 mb-4">Publish Document</h2>
<div class="mb-4">
<p class="text-sm text-gray-600 mb-2"><strong>Title:</strong> ${doc.title}</p>
<p class="text-sm text-gray-600"><strong>Current Status:</strong> ${doc.workflow_status || 'draft'}</p>
</div>
<form id="publish-form">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Category <span class="text-red-600">*</span>
</label>
<select id="publish-category" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<option value="">Select category...</option>
<option value="getting-started">Getting Started</option>
<option value="technical-reference">Technical Reference</option>
<option value="research-theory">Research & Theory</option>
<option value="advanced-topics">Advanced Topics</option>
<option value="case-studies">Case Studies</option>
<option value="business-leadership">Business & Leadership</option>
<option value="archives">Archives</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Display Order (optional)
</label>
<input type="number" id="publish-order" value="${doc.order || 0}" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<p class="text-xs text-gray-500 mt-1">Higher numbers appear first</p>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" data-action="closePublishModal" class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700">
Publish Document
</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
// Store doc ID for later
document.getElementById('publish-form').dataset.docId = docId;
// Handle form submission
document.getElementById('publish-form').addEventListener('submit', async (e) => {
e.preventDefault();
const category = document.getElementById('publish-category').value;
const order = parseInt(document.getElementById('publish-order').value) || 0;
await publishDocument(docId, category, order);
});
} catch (error) {
console.error('Failed to open publish modal:', error);
alert('Failed to open publish modal');
}
}
// Publish document
async function publishDocument(docId, category, order) {
try {
const response = await apiRequest(`/api/documents/${docId}/publish`, {
method: 'POST',
body: JSON.stringify({ category, order })
});
if (response.success) {
closePublishModal();
loadDocuments();
loadStatistics();
alert('Document published successfully');
} else {
alert(response.message || 'Failed to publish document');
}
} catch (error) {
console.error('Publish error:', error);
alert('Failed to publish document');
}
}
// Close publish modal
function closePublishModal() {
document.getElementById('publish-modal')?.remove();
}
// Open unpublish modal
async function openUnpublishModal(docId) {
try {
const response = await apiRequest(`/api/documents/${docId}`);
if (!response.success || !response.document) {
alert('Failed to load document');
return;
}
const doc = response.document;
const modalHTML = `
<div id="unpublish-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
<h2 class="text-xl font-bold text-gray-900 mb-4">Unpublish Document</h2>
<div class="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded">
<p class="text-sm text-yellow-800 mb-2"><strong>Title:</strong> ${doc.title}</p>
<p class="text-sm text-yellow-800 mb-2"><strong>Current Visibility:</strong> ${doc.visibility}</p>
<p class="text-sm text-yellow-800"><strong>Category:</strong> ${doc.category || 'None'}</p>
</div>
<form id="unpublish-form">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Reason for unpublishing <span class="text-red-600">*</span>
</label>
<textarea id="unpublish-reason" required rows="3"
placeholder="Explain why this document is being unpublished..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"></textarea>
<p class="text-xs text-gray-500 mt-1">This will be recorded in the audit trail</p>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" data-action="closeUnpublishModal" class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-yellow-600 text-white rounded-md hover:bg-yellow-700">
Unpublish Document
</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
// Store doc ID for later
document.getElementById('unpublish-form').dataset.docId = docId;
// Handle form submission
document.getElementById('unpublish-form').addEventListener('submit', async (e) => {
e.preventDefault();
const reason = document.getElementById('unpublish-reason').value;
await unpublishDocument(docId, reason);
});
} catch (error) {
console.error('Failed to open unpublish modal:', error);
alert('Failed to open unpublish modal');
}
}
// Unpublish document
async function unpublishDocument(docId, reason) {
try {
const response = await apiRequest(`/api/documents/${docId}/unpublish`, {
method: 'POST',
body: JSON.stringify({ reason })
});
if (response.success) {
closeUnpublishModal();
loadDocuments();
loadStatistics();
alert('Document unpublished successfully');
} else {
alert(response.message || 'Failed to unpublish document');
}
} catch (error) {
console.error('Unpublish error:', error);
alert('Failed to unpublish document');
}
}
// Close unpublish modal
function closeUnpublishModal() {
document.getElementById('unpublish-modal')?.remove();
}
// Utility functions
function getVisibilityBadge(visibility) {
const badges = {
'public': '<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">Public</span>',
'internal': '<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800">Internal</span>',
'confidential': '<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800">Confidential</span>',
'archived': '<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800">Archived</span>'
};
return badges[visibility] || badges['internal'];
}
function getStatusBadge(status) {
const badges = {
'draft': '<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800">Draft</span>',
'review': '<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">Review</span>',
'published': '<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">Published</span>'
};
return badges[status] || badges['draft'];
}
function getActivityColor(type) {
const colors = {
'create': 'bg-green-500',
@ -417,5 +653,17 @@ document.addEventListener('click', (e) => {
case 'deleteDocument':
deleteDocument(arg0);
break;
case 'openPublishModal':
openPublishModal(arg0);
break;
case 'openUnpublishModal':
openUnpublishModal(arg0);
break;
case 'closePublishModal':
closePublishModal();
break;
case 'closeUnpublishModal':
closeUnpublishModal();
break;
}
});