Skip to main content

max / goingson

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