Add PDF export to Activity Log with date range filtering
- Backend: add date_from/date_to query params to timeline, strikes, and logs endpoints - Frontend: Export PDF button opens a dialog to select child and date range, generates a printable HTML report in a new tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,10 @@
|
||||
<main class="container">
|
||||
<div class="page-header">
|
||||
<h1>Activity Log</h1>
|
||||
<button class="btn-sm" @click="showForm = !showForm">+ Add Note</button>
|
||||
<div class="header-actions">
|
||||
<button class="btn-sm btn-export" @click="showExportDialog = true">⬇ Export PDF</button>
|
||||
<button class="btn-sm" @click="showForm = !showForm">+ Add Note</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChildSelector style="margin-bottom: 1.5rem" />
|
||||
@@ -120,6 +123,37 @@
|
||||
<div v-else class="empty-state">No activity recorded yet.</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Export Dialog -->
|
||||
<div class="dialog-overlay" v-if="showExportDialog" @click.self="showExportDialog = false">
|
||||
<div class="dialog">
|
||||
<h2>Export Activity Report</h2>
|
||||
<div class="export-fields">
|
||||
<div class="field">
|
||||
<label>Child</label>
|
||||
<select v-model="exportChildId">
|
||||
<option :value="null">All children</option>
|
||||
<option v-for="c in childrenStore.children" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>From</label>
|
||||
<input v-model="exportDateFrom" type="date" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>To</label>
|
||||
<input v-model="exportDateTo" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="exportError" class="export-error">{{ exportError }}</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn-sm" @click="showExportDialog = false">Cancel</button>
|
||||
<button class="btn-primary btn-sm" :disabled="exporting" @click="generatePDF">
|
||||
{{ exporting ? 'Generating…' : 'Generate PDF' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -296,9 +330,165 @@ async function createLog() {
|
||||
}
|
||||
|
||||
|
||||
// Export
|
||||
const showExportDialog = ref(false)
|
||||
const exportChildId = ref(null)
|
||||
const exportDateFrom = ref('')
|
||||
const exportDateTo = ref('')
|
||||
const exporting = ref(false)
|
||||
const exportError = ref('')
|
||||
|
||||
async function generatePDF() {
|
||||
if (!exportDateFrom.value || !exportDateTo.value) {
|
||||
exportError.value = 'Please select both a from and to date.'
|
||||
return
|
||||
}
|
||||
if (exportDateFrom.value > exportDateTo.value) {
|
||||
exportError.value = '"From" date must be before "To" date.'
|
||||
return
|
||||
}
|
||||
exportError.value = ''
|
||||
exporting.value = true
|
||||
|
||||
try {
|
||||
const params = { date_from: exportDateFrom.value, date_to: exportDateTo.value }
|
||||
if (exportChildId.value) params.child_id = exportChildId.value
|
||||
|
||||
const [tRes, lRes, sRes] = await Promise.all([
|
||||
api.get('/api/logs/timeline', { params }),
|
||||
api.get('/api/logs', { params }),
|
||||
api.get('/api/logs/strikes', { params }),
|
||||
])
|
||||
|
||||
const events = tRes.data.map(e => ({ ...e, _type: 'event', _key: `event-${e.id}`, child_name: e.child_name }))
|
||||
const notes = lRes.data.map(l => ({ ...l, _type: 'note', _key: `note-${l.id}`, occurred_at: l.log_date + 'T00:00:00', child_name: childrenStore.children.find(c => c.id === l.child_id)?.name || '' }))
|
||||
const strikes = sRes.data.map(s => ({ ...s, _type: 'strike', _key: `strike-${s.id}` }))
|
||||
const all = [...events, ...notes, ...strikes].sort((a, b) => a.occurred_at.localeCompare(b.occurred_at))
|
||||
|
||||
// Group by date ascending
|
||||
const groups = {}
|
||||
for (const entry of all) {
|
||||
const d = entry._type === 'event' ? entry.session_date : entry.log_date
|
||||
if (!groups[d]) groups[d] = []
|
||||
groups[d].push(entry)
|
||||
}
|
||||
|
||||
const childLabel = exportChildId.value
|
||||
? childrenStore.children.find(c => c.id === exportChildId.value)?.name || 'Unknown'
|
||||
: 'All Children'
|
||||
|
||||
const dateLabel = (str) => {
|
||||
const d = new Date(str + 'T12:00:00')
|
||||
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
const fmtFrom = dateLabel(exportDateFrom.value)
|
||||
const fmtTo = dateLabel(exportDateTo.value)
|
||||
const exportedOn = new Date().toLocaleDateString([], { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
|
||||
const rowsHtml = Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)).map(([date, entries]) => {
|
||||
const heading = dateLabel(date)
|
||||
const rows = entries.map(entry => {
|
||||
const time = entry._type !== 'note'
|
||||
? formatTime(entry.occurred_at, authStore.timezone)
|
||||
: ''
|
||||
const icon = (() => {
|
||||
if (entry._type === 'note') return '📝'
|
||||
if (entry._type === 'strike') return entry.new_strikes > entry.prev_strikes ? '✕' : '↩'
|
||||
if (entry.event_type === 'complete' && !entry.block_label) return '🏁'
|
||||
return EVENT_META[entry.event_type]?.icon || '•'
|
||||
})()
|
||||
const label = (() => {
|
||||
if (entry._type === 'note') return 'Note'
|
||||
if (entry._type === 'strike') return entry.new_strikes > entry.prev_strikes ? `Strike added (${entry.new_strikes}/3)` : `Strike removed (${entry.new_strikes}/3)`
|
||||
if (entry.event_type === 'complete' && !entry.block_label) return 'Day ended'
|
||||
if (entry.event_type === 'complete' && entry.block_label) return `Marked Done — ${entry.block_label}`
|
||||
const action = EVENT_META[entry.event_type]?.label || entry.event_type
|
||||
return entry.block_label ? `${action} — ${entry.block_label}` : action
|
||||
})()
|
||||
const typeClass = entry._type === 'note' ? 'note' : entry._type === 'strike' ? 'strike' : 'event'
|
||||
const dotColor = entry.subject_color || (entry._type === 'note' ? '#6366f1' : entry._type === 'strike' ? '#ef4444' : '#94a3b8')
|
||||
const subjectRow = entry.subject_name ? `<div class="row-subject">${entry.subject_icon || ''} ${entry.subject_name}</div>` : ''
|
||||
const notesRow = entry.notes ? `<div class="row-notes">${entry.notes}</div>` : ''
|
||||
const childRow = childrenStore.children.length > 1 ? `<div class="row-child">${entry.child_name}</div>` : ''
|
||||
return `
|
||||
<div class="event-row ${typeClass}">
|
||||
<div class="event-time">${time}</div>
|
||||
<div class="event-dot" style="background:${dotColor}"></div>
|
||||
<div class="event-body">
|
||||
<div class="event-label"><span class="event-icon">${icon}</span>${label}</div>
|
||||
${subjectRow}${notesRow}${childRow}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
return `<div class="day-group"><div class="date-heading">${heading}</div><div class="event-list">${rows}</div></div>`
|
||||
}).join('')
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Activity Report — ${childLabel}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1e293b; background: #fff; padding: 1.5rem 2rem; font-size: 13px; }
|
||||
.report-header { border-bottom: 2px solid #4f46e5; padding-bottom: 0.75rem; margin-bottom: 1.25rem; }
|
||||
.report-title { font-size: 1.3rem; font-weight: 700; color: #0f172a; }
|
||||
.report-subtitle { font-size: 0.85rem; color: #475569; margin-top: 0.2rem; }
|
||||
.report-meta { display: flex; gap: 1.5rem; margin-top: 0.5rem; font-size: 0.78rem; color: #64748b; }
|
||||
.meta-item strong { color: #334155; }
|
||||
.day-group { margin-bottom: 1.25rem; }
|
||||
.date-heading { font-size: 1rem; font-weight: 700; color: #0f172a; background: #f1f5f9; border-left: 4px solid #4f46e5; padding: 0.45rem 0.75rem; margin-bottom: 0.6rem; border-radius: 0.25rem; }
|
||||
.event-list { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.event-row { display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.5rem 0.6rem; border-radius: 0.4rem; border-left: 3px solid #e2e8f0; background: #f8fafc; }
|
||||
.event-row.note { border-left-color: #6366f1; background: #f5f3ff; }
|
||||
.event-row.strike { border-left-color: #ef4444; background: #fef2f2; }
|
||||
.event-time { font-size: 0.72rem; color: #94a3b8; width: 60px; flex-shrink: 0; padding-top: 0.1rem; font-variant-numeric: tabular-nums; }
|
||||
.event-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; margin-top: 0.3rem; }
|
||||
.event-body { flex: 1; }
|
||||
.event-label { font-size: 0.85rem; font-weight: 500; color: #1e293b; display: flex; align-items: center; gap: 0.3rem; }
|
||||
.event-icon { font-size: 0.8rem; }
|
||||
.row-subject { font-size: 0.75rem; color: #64748b; margin-top: 0.1rem; }
|
||||
.row-notes { font-size: 0.8rem; color: #475569; margin-top: 0.15rem; font-style: italic; }
|
||||
.row-child { font-size: 0.7rem; color: #94a3b8; margin-top: 0.1rem; }
|
||||
.empty { text-align: center; padding: 3rem; color: #94a3b8; font-size: 0.9rem; }
|
||||
.report-footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #e2e8f0; font-size: 0.72rem; color: #94a3b8; text-align: center; }
|
||||
@media print {
|
||||
body { padding: 1rem 1.5rem; }
|
||||
.event-row { page-break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="report-header">
|
||||
<div class="report-title">🏠 Homeschool Dashboard</div>
|
||||
<div class="report-subtitle">Activity Report</div>
|
||||
<div class="report-meta">
|
||||
<div class="meta-item"><strong>Child:</strong> ${childLabel}</div>
|
||||
<div class="meta-item"><strong>Period:</strong> ${fmtFrom} – ${fmtTo}</div>
|
||||
<div class="meta-item"><strong>Exported:</strong> ${exportedOn}</div>
|
||||
</div>
|
||||
</div>
|
||||
${rowsHtml || '<div class="empty">No activity recorded for this period.</div>'}
|
||||
<div class="report-footer">Generated by Homeschool Dashboard</div>
|
||||
<script>window.onload = () => { window.print() }<\/script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const win = window.open('', '_blank')
|
||||
win.document.write(html)
|
||||
win.document.close()
|
||||
showExportDialog.value = false
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await childrenStore.fetchChildren()
|
||||
if (childrenStore.activeChild) newLog.value.child_id = childrenStore.activeChild.id
|
||||
if (childrenStore.activeChild) {
|
||||
newLog.value.child_id = childrenStore.activeChild.id
|
||||
exportChildId.value = childrenStore.activeChild.id
|
||||
}
|
||||
await loadData()
|
||||
})
|
||||
|
||||
@@ -434,6 +624,30 @@ h1 { font-size: 1.75rem; font-weight: 700; }
|
||||
|
||||
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
|
||||
|
||||
.header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.btn-export { border-color: #4f46e5; color: #818cf8; }
|
||||
.btn-export:hover { background: #1e1b4b; }
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.dialog {
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 1rem;
|
||||
padding: 2rem; width: 400px; max-width: 90vw; display: flex; flex-direction: column; gap: 1.25rem;
|
||||
}
|
||||
.dialog h2 { font-size: 1.1rem; font-weight: 700; }
|
||||
.export-fields { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.export-fields .field { margin-bottom: 0; }
|
||||
.export-fields .field label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.3rem; }
|
||||
.export-fields .field input,
|
||||
.export-fields .field select {
|
||||
width: 100%; padding: 0.55rem 0.8rem; background: #0f172a;
|
||||
border: 1px solid #334155; border-radius: 0.5rem; color: #f1f5f9; font-size: 0.875rem;
|
||||
}
|
||||
.export-error { color: #f87171; font-size: 0.82rem; margin: 0; }
|
||||
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.65rem 1.25rem;
|
||||
background: #4f46e5;
|
||||
|
||||
Reference in New Issue
Block a user