Auto-populate activity log from timer events with edit and delete
- New GET /api/logs/timeline endpoint joins TimerEvent with block/subject/session data
- New PATCH and DELETE /api/logs/timeline/{id} endpoints for editing/removing events
- LogView redesigned as a chronological timeline grouped by date
- Edit inline: timer events support type + time correction; notes support text edit
- Delete works for both auto events and manual notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,16 +3,112 @@ from datetime import date
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.dependencies import get_db, get_current_user
|
from app.dependencies import get_db, get_current_user
|
||||||
from app.models.activity import ActivityLog
|
from app.models.activity import ActivityLog
|
||||||
from app.models.child import Child
|
from app.models.child import Child
|
||||||
|
from app.models.session import DailySession, TimerEvent
|
||||||
|
from app.models.schedule import ScheduleBlock
|
||||||
|
from app.models.subject import Subject # noqa: F401
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.activity import ActivityLogCreate, ActivityLogOut, ActivityLogUpdate
|
from app.schemas.activity import ActivityLogCreate, ActivityLogOut, ActivityLogUpdate, TimelineEventOut, TimelineEventUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/timeline", response_model=list[TimelineEventOut])
|
||||||
|
async def get_timeline(
|
||||||
|
child_id: int | None = None,
|
||||||
|
log_date: date | None = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = (
|
||||||
|
select(TimerEvent)
|
||||||
|
.join(DailySession, TimerEvent.session_id == DailySession.id)
|
||||||
|
.join(Child, DailySession.child_id == Child.id)
|
||||||
|
.where(Child.user_id == current_user.id)
|
||||||
|
.options(
|
||||||
|
selectinload(TimerEvent.block).selectinload(ScheduleBlock.subject),
|
||||||
|
selectinload(TimerEvent.session).selectinload(DailySession.child),
|
||||||
|
)
|
||||||
|
.order_by(TimerEvent.occurred_at.desc())
|
||||||
|
)
|
||||||
|
if child_id:
|
||||||
|
query = query.where(DailySession.child_id == child_id)
|
||||||
|
if log_date:
|
||||||
|
query = query.where(DailySession.session_date == log_date)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
events = result.scalars().all()
|
||||||
|
|
||||||
|
return [_to_timeline_out(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_timeline_out(e: TimerEvent) -> TimelineEventOut:
|
||||||
|
blk = e.block
|
||||||
|
sub = blk.subject if blk else None
|
||||||
|
return TimelineEventOut(
|
||||||
|
id=e.id,
|
||||||
|
event_type=e.event_type,
|
||||||
|
occurred_at=e.occurred_at,
|
||||||
|
session_date=e.session.session_date,
|
||||||
|
child_id=e.session.child_id,
|
||||||
|
child_name=e.session.child.name,
|
||||||
|
block_label=(blk.label or sub.name) if blk and sub else (blk.label if blk else None),
|
||||||
|
subject_name=sub.name if sub else None,
|
||||||
|
subject_icon=sub.icon if sub else None,
|
||||||
|
subject_color=sub.color if sub else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_timer_event(event_id: int, current_user: User, db: AsyncSession) -> TimerEvent:
|
||||||
|
result = await db.execute(
|
||||||
|
select(TimerEvent)
|
||||||
|
.join(DailySession, TimerEvent.session_id == DailySession.id)
|
||||||
|
.join(Child, DailySession.child_id == Child.id)
|
||||||
|
.where(TimerEvent.id == event_id, Child.user_id == current_user.id)
|
||||||
|
.options(
|
||||||
|
selectinload(TimerEvent.block).selectinload(ScheduleBlock.subject),
|
||||||
|
selectinload(TimerEvent.session).selectinload(DailySession.child),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/timeline/{event_id}", response_model=TimelineEventOut)
|
||||||
|
async def update_timeline_event(
|
||||||
|
event_id: int,
|
||||||
|
body: TimelineEventUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
event = await _get_timer_event(event_id, current_user, db)
|
||||||
|
if body.event_type is not None:
|
||||||
|
event.event_type = body.event_type
|
||||||
|
if body.occurred_at is not None:
|
||||||
|
event.occurred_at = body.occurred_at
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(event)
|
||||||
|
event = await _get_timer_event(event_id, current_user, db)
|
||||||
|
return _to_timeline_out(event)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/timeline/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_timeline_event(
|
||||||
|
event_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
event = await _get_timer_event(event_id, current_user, db)
|
||||||
|
await db.delete(event)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ActivityLogOut])
|
@router.get("", response_model=list[ActivityLogOut])
|
||||||
async def list_logs(
|
async def list_logs(
|
||||||
child_id: int | None = None,
|
child_id: int | None = None,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@@ -27,3 +27,21 @@ class ActivityLogOut(BaseModel):
|
|||||||
duration_minutes: int | None
|
duration_minutes: int | None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineEventUpdate(BaseModel):
|
||||||
|
event_type: str | None = None
|
||||||
|
occurred_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineEventOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
event_type: str
|
||||||
|
occurred_at: datetime
|
||||||
|
session_date: date
|
||||||
|
child_id: int
|
||||||
|
child_name: str
|
||||||
|
block_label: str | None = None
|
||||||
|
subject_name: str | None = None
|
||||||
|
subject_icon: str | None = None
|
||||||
|
subject_color: str | None = None
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
<main class="container">
|
<main class="container">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Activity Log</h1>
|
<h1>Activity Log</h1>
|
||||||
<button class="btn-primary" @click="showForm = !showForm">+ Log Activity</button>
|
<button class="btn-sm" @click="showForm = !showForm">+ Add Note</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChildSelector style="margin-bottom: 1.5rem" />
|
<ChildSelector style="margin-bottom: 1.5rem" />
|
||||||
|
|
||||||
<!-- Add form -->
|
<!-- Manual note form -->
|
||||||
<div class="card" v-if="showForm">
|
<div class="card" v-if="showForm">
|
||||||
<h3>Log an Activity</h3>
|
<h3>Add a Note</h3>
|
||||||
<form @submit.prevent="createLog">
|
<form @submit.prevent="createLog">
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -25,110 +25,250 @@
|
|||||||
<input v-model="newLog.log_date" type="date" required />
|
<input v-model="newLog.log_date" type="date" required />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
|
||||||
<div class="field">
|
|
||||||
<label>Subject (optional)</label>
|
|
||||||
<select v-model="newLog.subject_id">
|
|
||||||
<option :value="null">None</option>
|
|
||||||
<option v-for="s in subjects" :key="s.id" :value="s.id">{{ s.icon }} {{ s.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Duration (minutes)</label>
|
|
||||||
<input v-model.number="newLog.duration_minutes" type="number" min="0" placeholder="e.g. 30" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Notes</label>
|
<label>Notes</label>
|
||||||
<textarea v-model="newLog.notes" placeholder="What did they do?" rows="3"></textarea>
|
<textarea v-model="newLog.notes" placeholder="Add a note..." rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn-sm" @click="showForm = false">Cancel</button>
|
<button type="button" class="btn-sm" @click="showForm = false">Cancel</button>
|
||||||
<button type="submit" class="btn-primary">Save Log</button>
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter bar -->
|
<!-- Filter bar -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<input v-model="filterDate" type="date" placeholder="Filter by date" />
|
<input v-model="filterDate" type="date" />
|
||||||
<button v-if="filterDate" class="btn-sm" @click="filterDate = ''">Clear</button>
|
<button v-if="filterDate" class="btn-sm" @click="filterDate = ''">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs -->
|
<!-- Timeline -->
|
||||||
<div class="log-list">
|
<div class="timeline" v-if="combinedEntries.length">
|
||||||
<div v-for="log in filteredLogs" :key="log.id" class="log-row">
|
<template v-for="(group, date) in groupedByDate" :key="date">
|
||||||
<div class="log-date">{{ log.log_date }}</div>
|
<div class="date-heading">{{ formatDate(date) }}</div>
|
||||||
<div class="log-content">
|
<div class="event-list">
|
||||||
<div class="log-subject" v-if="log.subject_id">
|
<template v-for="entry in group" :key="entry._key">
|
||||||
{{ subjectDisplay(log.subject_id) }}
|
|
||||||
|
<!-- 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>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-notes" v-if="log.notes">{{ log.notes }}</div>
|
<div class="edit-field">
|
||||||
<div class="log-meta" v-if="log.duration_minutes">
|
<label>Time</label>
|
||||||
{{ log.duration_minutes }} min
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-sm btn-danger" @click="deleteLog(log.id)">✕</button>
|
|
||||||
|
<!-- Display row -->
|
||||||
|
<div v-else class="event-row" :class="entry._type">
|
||||||
|
<div class="event-time">{{ formatTime(entry.occurred_at) }}</div>
|
||||||
|
<div
|
||||||
|
class="event-dot"
|
||||||
|
:style="entry.subject_color ? { background: entry.subject_color } : {}"
|
||||||
|
:class="{ 'dot-note': entry._type === 'note' }"
|
||||||
|
></div>
|
||||||
|
<div class="event-body">
|
||||||
|
<div class="event-label">
|
||||||
|
<span class="event-icon">{{ eventIcon(entry) }}</span>
|
||||||
|
{{ eventLabel(entry) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filteredLogs.length === 0" class="empty-state">
|
<div class="event-subject" v-if="entry.subject_name">
|
||||||
No activity logs yet.
|
{{ 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>
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="btn-sm" @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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useChildrenStore } from '@/stores/children'
|
import { useChildrenStore } from '@/stores/children'
|
||||||
import api from '@/composables/useApi'
|
import api from '@/composables/useApi'
|
||||||
import NavBar from '@/components/NavBar.vue'
|
import NavBar from '@/components/NavBar.vue'
|
||||||
import ChildSelector from '@/components/ChildSelector.vue'
|
import ChildSelector from '@/components/ChildSelector.vue'
|
||||||
|
|
||||||
const childrenStore = useChildrenStore()
|
const childrenStore = useChildrenStore()
|
||||||
const logs = ref([])
|
const timeline = ref([])
|
||||||
const subjects = ref([])
|
const manualLogs = ref([])
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const filterDate = ref('')
|
const filterDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const newLog = ref({ child_id: null, subject_id: null, log_date: today, notes: '', duration_minutes: null })
|
const newLog = ref({ child_id: null, log_date: today, notes: '' })
|
||||||
|
|
||||||
const filteredLogs = computed(() => {
|
const activeChildId = computed(() => childrenStore.activeChild?.id || null)
|
||||||
if (!filterDate.value) return logs.value
|
|
||||||
return logs.value.filter((l) => l.log_date === filterDate.value)
|
const editingEntry = ref(null)
|
||||||
|
const editDraft = ref({})
|
||||||
|
|
||||||
|
// Merge and sort timeline events + manual notes
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
return [...events, ...notes].sort((a, b) => b.occurred_at.localeCompare(a.occurred_at))
|
||||||
})
|
})
|
||||||
|
|
||||||
function subjectDisplay(id) {
|
// Group by session_date (events) or log_date (notes)
|
||||||
const s = subjects.value.find((s) => s.id === id)
|
const groupedByDate = computed(() => {
|
||||||
return s ? `${s.icon} ${s.name}` : ''
|
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' })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLogs() {
|
function formatTime(isoStr) {
|
||||||
const res = await api.get('/api/logs')
|
if (!isoStr) return ''
|
||||||
logs.value = res.data
|
const d = new Date(isoStr)
|
||||||
|
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_META = {
|
||||||
|
start: { icon: '▶', label: 'Started' },
|
||||||
|
pause: { icon: '⏸', label: 'Paused' },
|
||||||
|
resume: { icon: '↺', label: 'Resumed' },
|
||||||
|
complete: { icon: '✓', label: 'Completed' },
|
||||||
|
skip: { icon: '⟶', label: 'Skipped' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventIcon(entry) {
|
||||||
|
if (entry._type === 'note') return '📝'
|
||||||
|
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.event_type === 'complete' && !entry.block_label) return 'Day ended'
|
||||||
|
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') {
|
||||||
|
const d = new Date(entry.occurred_at)
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
editDraft.value = { event_type: entry.event_type, time: `${hh}:${mm}` }
|
||||||
|
} else {
|
||||||
|
editDraft.value = { notes: entry.notes || '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(entry) {
|
||||||
|
if (entry._type === 'event') {
|
||||||
|
const original = new Date(entry.occurred_at)
|
||||||
|
const [hh, mm] = editDraft.value.time.split(':').map(Number)
|
||||||
|
original.setHours(hh, mm, 0, 0)
|
||||||
|
await api.patch(`/api/logs/timeline/${entry.id}`, {
|
||||||
|
event_type: editDraft.value.event_type,
|
||||||
|
occurred_at: original.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 {
|
||||||
|
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] = await Promise.all([
|
||||||
|
api.get('/api/logs/timeline', { params }),
|
||||||
|
api.get('/api/logs', { params }),
|
||||||
|
])
|
||||||
|
timeline.value = tRes.data
|
||||||
|
manualLogs.value = lRes.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createLog() {
|
async function createLog() {
|
||||||
await api.post('/api/logs', newLog.value)
|
await api.post('/api/logs', { ...newLog.value, session_id: null, subject_id: null, duration_minutes: null })
|
||||||
newLog.value = { child_id: newLog.value.child_id, subject_id: null, log_date: today, notes: '', duration_minutes: null }
|
newLog.value = { child_id: newLog.value.child_id, log_date: today, notes: '' }
|
||||||
showForm.value = false
|
showForm.value = false
|
||||||
await loadLogs()
|
await loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteLog(id) {
|
|
||||||
if (confirm('Delete this log entry?')) {
|
|
||||||
await api.delete(`/api/logs/${id}`)
|
|
||||||
logs.value = logs.value.filter((l) => l.id !== id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await childrenStore.fetchChildren()
|
await childrenStore.fetchChildren()
|
||||||
if (childrenStore.activeChild) newLog.value.child_id = childrenStore.activeChild.id
|
if (childrenStore.activeChild) newLog.value.child_id = childrenStore.activeChild.id
|
||||||
const [sRes] = await Promise.all([api.get('/api/subjects'), loadLogs()])
|
await loadData()
|
||||||
subjects.value = sRes.data
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch([activeChildId, filterDate], loadData)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -165,20 +305,97 @@ h1 { font-size: 1.75rem; font-weight: 700; }
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
.date-heading {
|
||||||
.log-row {
|
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;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
background: #1e293b;
|
background: #1e293b;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
padding: 1rem 1.25rem;
|
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-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;
|
||||||
}
|
}
|
||||||
.log-date { font-size: 0.8rem; color: #64748b; width: 90px; flex-shrink: 0; padding-top: 0.1rem; }
|
|
||||||
.log-content { flex: 1; }
|
|
||||||
.log-subject { font-size: 0.85rem; color: #818cf8; margin-bottom: 0.2rem; }
|
|
||||||
.log-notes { font-size: 0.9rem; }
|
|
||||||
.log-meta { font-size: 0.8rem; color: #64748b; margin-top: 0.25rem; }
|
|
||||||
|
|
||||||
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
|
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user