Skip to main content

max / goingson

12.2 KB · 242 lines History Blame Raw
1 /**
2 * GoingsOn - Projects Render Module
3 * Project dashboard rendering: tasks, events, emails, milestones
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttr;
10
11 /**
12 * Empty state for a dashboard column. Same canonical primitive as the rest
13 * of the app, with the compact `--dashboard` modifier and an optional
14 * action button.
15 * @param {string} message
16 * @param {string} iconKey - EMPTY_STATE_ICONS key
17 * @param {{label: string, onClick: string}} [action]
18 */
19 function renderDashboardEmpty(message, iconKey, action) {
20 let html = `<div class="empty-state empty-state--dashboard">${GoingsOn.ui.emptyStateIcon(iconKey)}`
21 + `<p class="empty-state-text">${esc(message)}</p>`;
22 if (action && action.label && action.onClick) {
23 html += `<button class="btn btn-sm btn-primary empty-state-action" onclick="${escAttr(action.onClick)}">${esc(action.label)}</button>`;
24 }
25 return html + `</div>`;
26 }
27
28 // ============ Dashboard Rendering ============
29
30 /**
31 * Render the project dashboard: tasks, events, emails, milestones, and attachments.
32 * @param {string} projectId - Project ID to render dashboard for
33 */
34 async function renderDashboard(projectId) {
35 const tasksEl = document.getElementById('project-tasks-list');
36 const eventsEl = document.getElementById('project-events-list');
37 const emailsEl = document.getElementById('project-emails-list');
38 const attachmentsEl = document.getElementById('project-attachments-list');
39
40 // Wire up attach button
41 const attachBtn = document.getElementById('project-attach-btn');
42 if (attachBtn) {
43 attachBtn.onclick = () => GoingsOn.attachments.pickAndAttach(null, projectId);
44 }
45
46 // Load all data in parallel
47 try {
48 const [tasks, events, emails, milestones, attachments] = await Promise.all([
49 GoingsOn.api.tasks.listByProject(projectId),
50 GoingsOn.api.events.listByProject(projectId),
51 GoingsOn.api.emails.listByProject(projectId),
52 GoingsOn.api.milestones.list(projectId),
53 GoingsOn.api.attachments.list(null, projectId),
54 ]);
55
56 // Render milestones
57 renderMilestones(milestones, projectId);
58
59 // Render tasks (pre-sorted by urgency DESC from backend)
60 if (tasks.length === 0) {
61 tasksEl.innerHTML = renderDashboardEmpty('No tasks linked yet.', 'tasks');
62 } else if (tasks.every(t => t.status === 'Completed')) {
63 tasksEl.innerHTML = '<div class="empty-state empty-state--compact"><p class="empty-state-text">All tasks complete.</p></div>';
64 } else {
65 tasksEl.innerHTML = tasks.map(t => {
66 const progress = t.subtaskProgress ?? 0;
67
68 return `
69 <div class="card card--list-item" onclick="GoingsOn.tasks.openSubtasks('${escAttr(t.id)}')"
70 tabindex="0" role="button" aria-label="Open task ${esc(t.description)}">
71 <div class="dashboard-item-title">
72 ${esc(t.description)}
73 ${GoingsOn.tasks.renderTaskBadges(t)}
74 </div>
75 ${t.subtaskCount > 0 ? `
76 <div class="progress-bar-container" style="margin: 0.5rem 0;" title="${t.subtaskCompleted}/${t.subtaskCount} subtasks"
77 role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100">
78 <div class="progress-bar" style="width: ${progress}%"></div>
79 </div>
80 ` : ''}
81 <div class="dashboard-item-meta">
82 <span class="priority-${t.priority.toLowerCase()}" aria-label="Priority ${t.priority}">${t.priority}</span>
83 ${t.dueFormatted ? `&bull; ${t.dueFormatted}` : ''}
84 </div>
85 </div>
86 `}).join('');
87 }
88
89 // Render events (pre-sorted by start_time ASC from backend)
90 if (events.length === 0) {
91 eventsEl.innerHTML = renderDashboardEmpty('No events linked yet.', 'events');
92 } else {
93 eventsEl.innerHTML = events.map(e => `
94 <div class="card card--list-item" onclick="GoingsOn.events.open('${escAttr(e.id)}')"
95 tabindex="0" role="button" aria-label="Open event ${esc(e.title)}">
96 <div class="dashboard-item-title">${esc(e.title)}</div>
97 <div class="dashboard-item-meta">
98 ${e.dateFormatted} &bull; ${e.timeFormatted}
99 </div>
100 </div>
101 `).join('');
102 }
103
104 // Render emails
105 if (emails.length === 0) {
106 emailsEl.innerHTML = renderDashboardEmpty('No emails linked yet.', 'emails');
107 } else {
108 emailsEl.innerHTML = emails.map(e => `
109 <div class="card card--list-item ${e.is_read ? '' : 'unread'}" onclick="GoingsOn.emails.open('${escAttr(e.id)}')"
110 tabindex="0" role="button" aria-label="Open email ${esc(e.subject)}">
111 <div class="dashboard-item-title">${esc(e.subject)}</div>
112 <div class="dashboard-item-meta">
113 ${esc(e.from)} &bull; ${e.receivedFormatted}
114 </div>
115 </div>
116 `).join('');
117 }
118
119 // Render attachments
120 if (attachmentsEl) {
121 if (attachments.length === 0) {
122 attachmentsEl.innerHTML = renderDashboardEmpty(
123 'No attachments yet.',
124 'attachments',
125 { label: 'Attach File', onClick: `GoingsOn.attachments.pickAndAttach(null, '${escAttr(projectId)}')` },
126 );
127 } else {
128 attachmentsEl.innerHTML = attachments.map(a => `
129 <div class="card card--list-item" onclick="GoingsOn.attachments.openPanel(null, '${escAttr(projectId)}')"
130 tabindex="0" role="button" aria-label="View attachment ${esc(a.filename)}">
131 <div class="dashboard-item-title">${esc(a.filename)}</div>
132 <div class="dashboard-item-meta">${esc(a.fileSizeFormatted)}</div>
133 </div>
134 `).join('');
135 }
136 }
137 } catch (err) {
138 tasksEl.innerHTML = `<div class="empty-state empty-state--dashboard empty-state--error">Error: ${esc(err.message)}</div>`;
139 }
140 }
141
142 // ============ Milestones Rendering ============
143
144 // Track completed milestones collapse state (ephemeral UI state)
145 let showCompletedMilestones = false;
146
147 function toggleCompletedMilestones() {
148 showCompletedMilestones = !showCompletedMilestones;
149 const projectId = GoingsOn.state.currentProjectId;
150 if (projectId) {
151 GoingsOn.projects.loadDashboard(projectId);
152 }
153 }
154
155 /**
156 * Render the milestones section of the project dashboard.
157 * @param {Array<Object>} milestones - Milestone objects from the backend
158 * @param {string} projectId - Parent project ID
159 */
160 function renderMilestones(milestones, projectId) {
161 const section = document.getElementById('project-milestones-section');
162 if (!section) return;
163
164 if (!milestones || milestones.length === 0) {
165 section.innerHTML = `
166 <div class="milestones-header">
167 <h3>Milestones</h3>
168 <button class="btn btn-sm btn-primary" onclick="GoingsOn.projects.openNewMilestone()">+ Add</button>
169 </div>
170 <p class="milestones-empty">No milestones yet</p>
171 `;
172 return;
173 }
174
175 const openMilestones = milestones.filter(m => m.status !== 'completed');
176 const completedMilestones = milestones.filter(m => m.status === 'completed');
177
178 let html = `
179 <div class="milestones-header">
180 <h3>Milestones</h3>
181 <button class="btn btn-sm btn-primary" onclick="GoingsOn.projects.openNewMilestone()">+ Add</button>
182 </div>
183 <div class="milestones-list">
184 ${openMilestones.map((m, idx) => `
185 <div class="milestone-card">
186 <div class="milestone-info">
187 <span class="milestone-name">${esc(m.name)}</span>
188 ${m.targetDate ? `<span class="milestone-date">${esc(m.targetDate)}</span>` : ''}
189 </div>
190 <div class="milestone-progress">
191 <div class="progress-bar-container" title="${m.completedCount}/${m.taskCount} tasks">
192 <div class="progress-bar" style="width: ${m.progress}%"></div>
193 </div>
194 <span class="milestone-progress-text">${m.completedCount}/${m.taskCount}</span>
195 </div>
196 <div class="milestone-actions">
197 ${idx > 0 ? `<button class="btn btn-sm btn-icon milestone-reorder-btn" onclick="event.stopPropagation(); GoingsOn.projects.moveMilestone('${escAttr(m.id)}', -1)" title="Move up">\u25B2</button>` : '<span class="btn btn-sm btn-icon milestone-reorder-btn" style="visibility:hidden">\u25B2</span>'}
198 ${idx < openMilestones.length - 1 ? `<button class="btn btn-sm btn-icon milestone-reorder-btn" onclick="event.stopPropagation(); GoingsOn.projects.moveMilestone('${escAttr(m.id)}', 1)" title="Move down">\u25BC</button>` : '<span class="btn btn-sm btn-icon milestone-reorder-btn" style="visibility:hidden">\u25BC</span>'}
199 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.projects.openEditMilestone('${escAttr(m.id)}')">Edit</button>
200 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.projects.deleteMilestone('${escAttr(m.id)}')">Delete</button>
201 </div>
202 </div>
203 `).join('')}
204 </div>`;
205
206 // Completed milestones section (collapsed by default)
207 if (completedMilestones.length > 0) {
208 html += `
209 <div class="milestones-completed-section">
210 <button class="btn btn-link milestones-completed-toggle" onclick="GoingsOn.projects.toggleCompletedMilestones()">
211 ${showCompletedMilestones ? 'Hide' : 'Show'} ${completedMilestones.length} completed
212 </button>
213 <div class="milestones-completed-list" style="display: ${showCompletedMilestones ? 'block' : 'none'};">
214 ${completedMilestones.map(m => `
215 <div class="milestone-card completed milestone-card-summary">
216 <div class="milestone-info">
217 <span class="milestone-name">${esc(m.name)}</span>
218 <span class="milestone-complete-badge">Complete</span>
219 </div>
220 <div class="milestone-actions">
221 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.projects.openEditMilestone('${escAttr(m.id)}')">Edit</button>
222 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.projects.deleteMilestone('${escAttr(m.id)}')">Delete</button>
223 </div>
224 </div>
225 `).join('')}
226 </div>
227 </div>`;
228 }
229
230 section.innerHTML = html;
231 }
232
233 // ============ Populate Namespace ============
234
235 GoingsOn.projectsRender = {
236 renderDashboard,
237 renderMilestones,
238 toggleCompletedMilestones,
239 };
240
241 })();
242