Files
homeschool/frontend/src/views/LogView.vue
derekc 956df11f49 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>
2026-03-10 23:18:49 -07:00

677 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page">
<NavBar />
<main class="container">
<div class="page-header">
<h1>Activity Log</h1>
<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" />
<!-- Manual note form -->
<div class="card" v-if="showForm">
<h3>Add a Note</h3>
<form @submit.prevent="createLog">
<div class="field-row">
<div class="field">
<label>Child</label>
<select v-model="newLog.child_id" required>
<option v-for="c in childrenStore.children" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field">
<label>Date</label>
<input v-model="newLog.log_date" type="date" required />
</div>
</div>
<div class="field">
<label>Notes</label>
<textarea v-model="newLog.notes" placeholder="Add a note..." rows="3"></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn-sm" @click="showForm = false">Cancel</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<input v-model="filterDate" type="date" />
<button v-if="filterDate" class="btn-sm" @click="filterDate = ''">Clear</button>
</div>
<!-- Timeline -->
<div class="timeline" v-if="combinedEntries.length">
<template v-for="(group, date) in groupedByDate" :key="date">
<div class="date-heading">{{ formatDate(date) }}</div>
<div class="event-list">
<template v-for="entry in group" :key="entry._key">
<!-- Edit form -->
<div v-if="editingEntry?._key === entry._key" class="event-row editing">
<template v-if="entry._type === 'event'">
<div class="edit-field">
<label>Type</label>
<select v-model="editDraft.event_type">
<option value="start"> Started</option>
<option value="pause"> Paused</option>
<option value="resume"> Resumed</option>
<option value="complete"> Completed</option>
<option value="skip"> Skipped</option>
<option value="reset"> Reset</option>
<option value="break_start"> Break started</option>
<option value="break_pause"> Break paused</option>
<option value="break_resume"> Break resumed</option>
<option value="break_reset"> Break reset</option>
</select>
</div>
<div class="edit-field">
<label>Time</label>
<input type="time" v-model="editDraft.time" />
</div>
</template>
<template v-else>
<div class="edit-field edit-field-full">
<label>Notes</label>
<textarea v-model="editDraft.notes" rows="2"></textarea>
</div>
</template>
<div class="edit-actions">
<button class="btn-sm btn-primary" @click="saveEdit(entry)">Save</button>
<button class="btn-sm" @click="editingEntry = null">Cancel</button>
</div>
</div>
<!-- Display row -->
<div v-else class="event-row" :class="entry._type">
<div class="event-time">{{ fmtTime(entry.occurred_at) }}</div>
<div
class="event-dot"
:style="entry.subject_color ? { background: entry.subject_color } : {}"
:class="{ 'dot-note': entry._type === 'note', 'dot-strike': entry._type === 'strike' }"
></div>
<div class="event-body">
<div class="event-label">
<span class="event-icon">{{ eventIcon(entry) }}</span>
{{ eventLabel(entry) }}
</div>
<div class="event-subject" v-if="entry.subject_name">
{{ entry.subject_icon }} {{ entry.subject_name }}
</div>
<div class="event-notes" v-if="entry.notes">{{ entry.notes }}</div>
<div class="event-child" v-if="childrenStore.children.length > 1">
{{ entry.child_name }}
</div>
</div>
<div class="row-actions">
<button class="btn-sm" v-if="entry._type === 'event' && entry.event_type !== 'session_start'" @click="startEdit(entry)">Edit</button>
<button class="btn-sm" v-if="entry._type === 'note'" @click="startEdit(entry)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteEntry(entry)"></button>
</div>
</div>
</template>
</div>
</template>
</div>
<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>
import { ref, computed, onMounted, watch } from 'vue'
import { useChildrenStore } from '@/stores/children'
import { useAuthStore } from '@/stores/auth'
import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue'
import ChildSelector from '@/components/ChildSelector.vue'
import { formatTime, getHHMM, getDateInTZ, tzDateTimeToUTC } from '@/utils/time'
const childrenStore = useChildrenStore()
const authStore = useAuthStore()
const timeline = ref([])
const manualLogs = ref([])
const strikeEvents = ref([])
const showForm = ref(false)
const filterDate = ref(new Date().toISOString().split('T')[0])
const today = new Date().toISOString().split('T')[0]
const newLog = ref({ child_id: null, log_date: today, notes: '' })
const activeChildId = computed(() => childrenStore.activeChild?.id || null)
const editingEntry = ref(null)
const editDraft = ref({})
// Merge and sort timeline events + manual notes + strike events
const combinedEntries = computed(() => {
const events = timeline.value.map(e => ({
...e,
_type: 'event',
_key: `event-${e.id}`,
occurred_at: e.occurred_at,
child_name: e.child_name,
}))
const notes = manualLogs.value.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 || '',
subject_name: null,
subject_icon: null,
subject_color: null,
}))
const strikes = strikeEvents.value.map(s => ({
...s,
_type: 'strike',
_key: `strike-${s.id}`,
subject_name: null,
subject_icon: null,
subject_color: null,
}))
return [...events, ...notes, ...strikes].sort((a, b) => b.occurred_at.localeCompare(a.occurred_at))
})
// Group by session_date (events) or log_date (notes/strikes)
const groupedByDate = computed(() => {
const groups = {}
for (const entry of combinedEntries.value) {
const d = entry._type === 'event' ? entry.session_date : entry.log_date
if (!groups[d]) groups[d] = []
groups[d].push(entry)
}
return groups
})
function formatDate(dateStr) {
const d = new Date(dateStr + 'T12:00:00')
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}
function fmtTime(isoStr) {
return formatTime(isoStr, authStore.timezone)
}
const EVENT_META = {
session_start: { icon: '🏫', label: 'Day started' },
start: { icon: '▶', label: 'Started' },
pause: { icon: '⏸', label: 'Paused' },
resume: { icon: '↺', label: 'Resumed' },
complete: { icon: '✓', label: 'Completed' },
skip: { icon: '⟶', label: 'Skipped' },
reset: { icon: '↩', label: 'Reset' },
break_start: { icon: '☕', label: 'Break started' },
break_pause: { icon: '⏸', label: 'Break paused' },
break_resume: { icon: '↺', label: 'Break resumed' },
break_reset: { icon: '↩', label: 'Break reset' },
}
function eventIcon(entry) {
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 || '•'
}
function eventLabel(entry) {
if (entry._type === 'note') return 'Note'
if (entry._type === 'strike') {
const added = entry.new_strikes > entry.prev_strikes
return added
? `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 by User — ${entry.block_label}`
const action = EVENT_META[entry.event_type]?.label || entry.event_type
return entry.block_label ? `${action}${entry.block_label}` : action
}
function startEdit(entry) {
editingEntry.value = entry
if (entry._type === 'event') {
editDraft.value = {
event_type: entry.event_type,
time: getHHMM(entry.occurred_at, authStore.timezone),
}
} else {
editDraft.value = { notes: entry.notes || '' }
}
}
async function saveEdit(entry) {
if (entry._type === 'event') {
const dateInTZ = getDateInTZ(entry.occurred_at, authStore.timezone)
const utcDate = tzDateTimeToUTC(dateInTZ, editDraft.value.time, authStore.timezone)
await api.patch(`/api/logs/timeline/${entry.id}`, {
event_type: editDraft.value.event_type,
occurred_at: utcDate.toISOString(),
})
} else {
await api.patch(`/api/logs/${entry.id}`, { notes: editDraft.value.notes })
}
editingEntry.value = null
await loadData()
}
async function deleteEntry(entry) {
if (!confirm('Delete this entry?')) return
if (entry._type === 'event') {
await api.delete(`/api/logs/timeline/${entry.id}`)
timeline.value = timeline.value.filter(e => e.id !== entry.id)
} else if (entry._type === 'strike') {
await api.delete(`/api/logs/strikes/${entry.id}`)
strikeEvents.value = strikeEvents.value.filter(s => s.id !== entry.id)
} else {
await api.delete(`/api/logs/${entry.id}`)
manualLogs.value = manualLogs.value.filter(l => l.id !== entry.id)
}
}
async function loadData() {
const params = {}
if (activeChildId.value) params.child_id = activeChildId.value
if (filterDate.value) params.log_date = filterDate.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 }),
])
timeline.value = tRes.data
manualLogs.value = lRes.data
strikeEvents.value = sRes.data
}
async function createLog() {
await api.post('/api/logs', { ...newLog.value, session_id: null, subject_id: null, duration_minutes: null })
newLog.value = { child_id: newLog.value.child_id, log_date: today, notes: '' }
showForm.value = false
await loadData()
}
// 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
exportChildId.value = childrenStore.activeChild.id
}
await loadData()
})
watch([activeChildId, filterDate], loadData)
</script>
<style scoped>
.page { min-height: 100vh; background: #0f172a; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; }
h1 { font-size: 1.75rem; font-weight: 700; }
.card { background: #1e293b; border-radius: 1rem; padding: 1.5rem; margin-bottom: 1.5rem; }
.card h3 { margin-bottom: 1.25rem; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
.field input, .field select, .field textarea {
width: 100%;
padding: 0.65rem 0.9rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
resize: vertical;
}
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.filter-bar { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; }
.filter-bar input {
padding: 0.5rem 0.75rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.875rem;
}
.date-heading {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
margin: 1.5rem 0 0.75rem;
}
.event-list { display: flex; flex-direction: column; gap: 0.4rem; }
.event-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #1e293b;
border-radius: 0.75rem;
border-left: 3px solid transparent;
}
.event-row.event { border-left-color: #334155; }
.event-row.note { border-left-color: #4f46e5; }
.event-row.editing { border-left-color: #4f46e5; flex-wrap: wrap; align-items: center; gap: 0.6rem; }
.edit-field { display: flex; flex-direction: column; gap: 0.25rem; }
.edit-field label { font-size: 0.75rem; color: #64748b; }
.edit-field select,
.edit-field input,
.edit-field textarea {
padding: 0.4rem 0.6rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.4rem;
color: #f1f5f9;
font-size: 0.85rem;
}
.edit-field textarea { resize: vertical; }
.edit-field-full { flex: 1; min-width: 200px; }
.edit-actions { display: flex; gap: 0.4rem; align-items: flex-end; margin-left: auto; }
.row-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
.event-time {
font-size: 0.78rem;
color: #475569;
width: 68px;
flex-shrink: 0;
padding-top: 0.15rem;
font-variant-numeric: tabular-nums;
}
.event-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #334155;
flex-shrink: 0;
margin-top: 0.4rem;
}
.event-dot.dot-note { background: #4f46e5; }
.event-dot.dot-strike { background: #ef4444; }
.event-body { flex: 1; min-width: 0; }
.event-label {
font-size: 0.9rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.4rem;
}
.event-icon { font-size: 0.85rem; }
.event-subject {
font-size: 0.8rem;
color: #64748b;
margin-top: 0.15rem;
}
.event-notes {
font-size: 0.85rem;
color: #94a3b8;
margin-top: 0.2rem;
}
.event-child {
font-size: 0.75rem;
color: #475569;
margin-top: 0.15rem;
}
.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;
color: #fff;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
}
.btn-primary:hover { background: #4338ca; }
.btn-sm {
padding: 0.35rem 0.75rem;
border: 1px solid #334155;
background: transparent;
color: #94a3b8;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.btn-sm:hover { background: #334155; }
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; }
</style>