- 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>
677 lines
25 KiB
Vue
677 lines
25 KiB
Vue
<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>
|