Merge Schedules into Admin, remove standalone ScheduleView

- Move schedule template management into AdminView under a new Schedules section
- Remove ScheduleView.vue and its route, drop Schedules link from NavBar
- Delete docker-compose.override.yml (dev override no longer needed)
- Fix CORS_ORIGINS default to port 8057 in docker-compose.yml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 00:59:01 -08:00
parent d43791f965
commit 462205cdc1
6 changed files with 193 additions and 267 deletions

View File

@@ -1,17 +0,0 @@
# Development overrides — hot reload for backend, Vite dev server for frontend
services:
backend:
volumes:
- ./backend/app:/app/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
target: builder
ports:
- "8054:5173"
volumes:
- ./frontend/src:/app/src
- ./frontend/index.html:/app/index.html
command: npm run dev -- --host 0.0.0.0 --port 5173

View File

@@ -28,7 +28,7 @@ services:
ALGORITHM: ${ALGORITHM:-HS256} ALGORITHM: ${ALGORITHM:-HS256}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30}
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30} REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8054} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8057}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -3,8 +3,7 @@
<RouterLink class="nav-brand" to="/dashboard">🏠 Homeschool</RouterLink> <RouterLink class="nav-brand" to="/dashboard">🏠 Homeschool</RouterLink>
<div class="nav-links"> <div class="nav-links">
<RouterLink to="/dashboard" active-class="active">Dashboard</RouterLink> <RouterLink to="/dashboard" active-class="active">Dashboard</RouterLink>
<RouterLink to="/schedules" active-class="active">Schedules</RouterLink> <RouterLink to="/logs" active-class="active">Logs</RouterLink>
<RouterLink to="/logs" active-class="active">Logs</RouterLink>
<RouterLink to="/admin" active-class="active">Admin</RouterLink> <RouterLink to="/admin" active-class="active">Admin</RouterLink>
</div> </div>
<div class="nav-user" v-if="auth.user"> <div class="nav-user" v-if="auth.user">

View File

@@ -24,13 +24,7 @@ const routes = [
component: () => import('@/views/DashboardView.vue'), component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: '/schedules',
name: 'schedules',
component: () => import('@/views/ScheduleView.vue'),
meta: { requiresAuth: true },
},
{
path: '/logs', path: '/logs',
name: 'logs', name: 'logs',
component: () => import('@/views/LogView.vue'), component: () => import('@/views/LogView.vue'),

View File

@@ -84,6 +84,86 @@
<div v-if="subjects.length === 0" class="empty-small">No subjects added yet.</div> <div v-if="subjects.length === 0" class="empty-small">No subjects added yet.</div>
</div> </div>
</section> </section>
<!-- Schedules section -->
<section class="section">
<div class="section-header">
<h2>Schedules</h2>
<button class="btn-primary btn-sm" @click="showCreateForm = !showCreateForm">+ New Template</button>
</div>
<!-- Create form -->
<div class="card" v-if="showCreateForm">
<h3>New Schedule Template</h3>
<form @submit.prevent="createTemplate">
<div class="field">
<label>Template Name</label>
<input v-model="newTemplate.name" placeholder="e.g. Monday Schedule" required />
</div>
<div class="field">
<label>Child (optional leave blank for all children)</label>
<select v-model="newTemplate.child_id">
<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="form-actions">
<button type="button" class="btn-sm" @click="showCreateForm = false">Cancel</button>
<button type="submit" class="btn-primary btn-sm">Create</button>
</div>
</form>
</div>
<!-- Template list -->
<div class="template-list">
<div v-for="template in templates" :key="template.id" class="template-card">
<div class="template-header">
<div>
<div class="template-name">{{ template.name }}</div>
<div class="template-child">
{{ template.child_id ? childName(template.child_id) : 'All children' }}
· {{ template.blocks.length }} blocks
</div>
</div>
<div class="template-actions">
<button class="btn-sm" @click="editingTemplate = editingTemplate === template.id ? null : template.id">
{{ editingTemplate === template.id ? 'Close' : 'Edit Blocks' }}
</button>
<button class="btn-sm btn-danger" @click="deleteTemplate(template.id)">Delete</button>
</div>
</div>
<!-- Block editor -->
<div v-if="editingTemplate === template.id" class="block-editor">
<div class="block-list">
<div v-for="block in template.blocks" :key="block.id" class="block-row">
<span class="block-time">{{ block.time_start }} {{ block.time_end }}</span>
<span class="block-label">{{ block.label || subjectName(block.subject_id) || 'Unnamed' }}</span>
<button class="btn-sm btn-danger" @click="deleteBlock(template.id, block.id)"></button>
</div>
<div v-if="template.blocks.length === 0" class="empty-small">No blocks yet.</div>
</div>
<!-- Add block form -->
<form @submit.prevent="addBlock(template.id)" class="add-block-form">
<select v-model="newBlock.subject_id">
<option :value="null">No subject</option>
<option v-for="s in subjects" :key="s.id" :value="s.id">{{ s.icon }} {{ s.name }}</option>
</select>
<input v-model="newBlock.time_start" type="time" required />
<span>to</span>
<input v-model="newBlock.time_end" type="time" required />
<input v-model="newBlock.label" placeholder="Label (optional)" />
<button type="submit" class="btn-primary btn-sm">Add Block</button>
</form>
</div>
</div>
<div v-if="templates.length === 0 && !showCreateForm" class="empty-small">
No schedule templates yet. Create one to get started.
</div>
</div>
</section>
</main> </main>
</div> </div>
</template> </template>
@@ -97,12 +177,10 @@ import NavBar from '@/components/NavBar.vue'
const childrenStore = useChildrenStore() const childrenStore = useChildrenStore()
const subjects = ref([]) const subjects = ref([])
// Children
const showChildForm = ref(false) const showChildForm = ref(false)
const showSubjectForm = ref(false)
const newChild = ref({ name: '', color: '#4F46E5' }) const newChild = ref({ name: '', color: '#4F46E5' })
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })
const editingChild = ref(null) const editingChild = ref(null)
const editingSubject = ref(null)
async function createChild() { async function createChild() {
await childrenStore.createChild(newChild.value) await childrenStore.createChild(newChild.value)
@@ -133,6 +211,11 @@ async function deleteChild(id) {
} }
} }
// Subjects
const showSubjectForm = ref(false)
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })
const editingSubject = ref(null)
async function loadSubjects() { async function loadSubjects() {
const res = await api.get('/api/subjects') const res = await api.get('/api/subjects')
subjects.value = res.data subjects.value = res.data
@@ -168,15 +251,64 @@ async function deleteSubject(id) {
} }
} }
// Schedules
const templates = ref([])
const showCreateForm = ref(false)
const editingTemplate = ref(null)
const newTemplate = ref({ name: '', child_id: null, is_default: false })
const newBlock = ref({ subject_id: null, time_start: '', time_end: '', label: '', order_index: 0 })
function childName(id) {
return childrenStore.children.find((c) => c.id === id)?.name || 'Unknown'
}
function subjectName(id) {
return subjects.value.find((s) => s.id === id)?.name || null
}
async function loadTemplates() {
const res = await api.get('/api/schedules')
templates.value = res.data
}
async function createTemplate() {
await api.post('/api/schedules', newTemplate.value)
newTemplate.value = { name: '', child_id: null, is_default: false }
showCreateForm.value = false
await loadTemplates()
}
async function deleteTemplate(id) {
if (confirm('Delete this template and all its blocks?')) {
await api.delete(`/api/schedules/${id}`)
await loadTemplates()
}
}
async function addBlock(templateId) {
const payload = {
...newBlock.value,
order_index: templates.value.find((t) => t.id === templateId)?.blocks.length || 0,
}
await api.post(`/api/schedules/${templateId}/blocks`, payload)
newBlock.value = { subject_id: null, time_start: '', time_end: '', label: '', order_index: 0 }
await loadTemplates()
}
async function deleteBlock(templateId, blockId) {
await api.delete(`/api/schedules/${templateId}/blocks/${blockId}`)
await loadTemplates()
}
onMounted(async () => { onMounted(async () => {
await childrenStore.fetchChildren() await childrenStore.fetchChildren()
await loadSubjects() await Promise.all([loadSubjects(), loadTemplates()])
}) })
</script> </script>
<style scoped> <style scoped>
.page { min-height: 100vh; background: #0f172a; } .page { min-height: 100vh; background: #0f172a; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem; } .container { max-width: 900px; margin: 0 auto; padding: 2rem; }
h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 2rem; } h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 2rem; }
.section { margin-bottom: 3rem; } .section { margin-bottom: 3rem; }
@@ -223,8 +355,6 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
.item-meta { font-size: 0.8rem; color: #64748b; } .item-meta { font-size: 0.8rem; color: #64748b; }
.item-actions { display: flex; gap: 0.4rem; } .item-actions { display: flex; gap: 0.4rem; }
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
.edit-input { .edit-input {
padding: 0.35rem 0.6rem; padding: 0.35rem 0.6rem;
background: #0f172a; background: #0f172a;
@@ -236,6 +366,58 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
min-width: 100px; min-width: 100px;
} }
/* Schedule styles */
.card { background: #1e293b; border-radius: 1rem; padding: 1.5rem; margin-bottom: 1.5rem; }
.card h3 { margin-bottom: 1.25rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
.field input, .field select {
width: 100%;
padding: 0.65rem 0.9rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
}
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.template-list { display: flex; flex-direction: column; gap: 1rem; }
.template-card { background: #1e293b; border-radius: 1rem; padding: 1.25rem; }
.template-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.template-name { font-size: 1.05rem; font-weight: 600; }
.template-child { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; }
.template-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
.block-editor { margin-top: 1.25rem; border-top: 1px solid #334155; padding-top: 1.25rem; }
.block-list { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1rem; }
.block-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: #0f172a; border-radius: 0.5rem; }
.block-time { font-size: 0.8rem; color: #64748b; font-variant-numeric: tabular-nums; }
.block-label { flex: 1; font-size: 0.9rem; }
.add-block-form {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
background: #0f172a;
padding: 0.75rem;
border-radius: 0.75rem;
}
.add-block-form select,
.add-block-form input {
padding: 0.4rem 0.6rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.4rem;
color: #f1f5f9;
font-size: 0.85rem;
}
.add-block-form span { color: #64748b; }
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
.btn-primary { .btn-primary {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #4f46e5; background: #4f46e5;
@@ -261,4 +443,5 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
.btn-sm:hover { background: #334155; } .btn-sm:hover { background: #334155; }
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; } .btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; } .btn-sm.btn-danger:hover { background: #7f1d1d; }
.btn-primary.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
</style> </style>

View File

@@ -1,233 +0,0 @@
<template>
<div class="page">
<NavBar />
<main class="container">
<div class="page-header">
<h1>Schedules</h1>
<button class="btn-primary" @click="showCreateForm = !showCreateForm">+ New Template</button>
</div>
<!-- Create form -->
<div class="card" v-if="showCreateForm">
<h3>New Schedule Template</h3>
<form @submit.prevent="createTemplate">
<div class="field">
<label>Template Name</label>
<input v-model="newTemplate.name" placeholder="e.g. Monday Schedule" required />
</div>
<div class="field">
<label>Child (optional leave blank for all children)</label>
<select v-model="newTemplate.child_id">
<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="form-actions">
<button type="button" class="btn-sm" @click="showCreateForm = false">Cancel</button>
<button type="submit" class="btn-primary">Create</button>
</div>
</form>
</div>
<!-- Template list -->
<div class="template-list">
<div v-for="template in templates" :key="template.id" class="template-card">
<div class="template-header">
<div>
<div class="template-name">{{ template.name }}</div>
<div class="template-child">
{{ template.child_id ? childName(template.child_id) : 'All children' }}
· {{ template.blocks.length }} blocks
</div>
</div>
<div class="template-actions">
<button class="btn-sm" @click="editingTemplate = editingTemplate === template.id ? null : template.id">
{{ editingTemplate === template.id ? 'Close' : 'Edit Blocks' }}
</button>
<button class="btn-sm btn-danger" @click="deleteTemplate(template.id)">Delete</button>
</div>
</div>
<!-- Block editor -->
<div v-if="editingTemplate === template.id" class="block-editor">
<div class="block-list">
<div v-for="block in template.blocks" :key="block.id" class="block-row">
<span class="block-time">{{ block.time_start }} {{ block.time_end }}</span>
<span class="block-label">{{ block.label || subjectName(block.subject_id) || 'Unnamed' }}</span>
<button class="btn-sm btn-danger" @click="deleteBlock(template.id, block.id)"></button>
</div>
<div v-if="template.blocks.length === 0" class="empty-small">No blocks yet.</div>
</div>
<!-- Add block form -->
<form @submit.prevent="addBlock(template.id)" class="add-block-form">
<select v-model="newBlock.subject_id">
<option :value="null">No subject</option>
<option v-for="s in subjects" :key="s.id" :value="s.id">{{ s.icon }} {{ s.name }}</option>
</select>
<input v-model="newBlock.time_start" type="time" required />
<span>to</span>
<input v-model="newBlock.time_end" type="time" required />
<input v-model="newBlock.label" placeholder="Label (optional)" />
<button type="submit" class="btn-primary btn-sm">Add Block</button>
</form>
</div>
</div>
<div v-if="templates.length === 0 && !showCreateForm" class="empty-state">
No schedule templates yet. Create one to get started.
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useChildrenStore } from '@/stores/children'
import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue'
const childrenStore = useChildrenStore()
const templates = ref([])
const subjects = ref([])
const showCreateForm = ref(false)
const editingTemplate = ref(null)
const newTemplate = ref({ name: '', child_id: null, is_default: false })
const newBlock = ref({ subject_id: null, time_start: '', time_end: '', label: '', order_index: 0 })
function childName(id) {
return childrenStore.children.find((c) => c.id === id)?.name || 'Unknown'
}
function subjectName(id) {
return subjects.value.find((s) => s.id === id)?.name || null
}
async function loadTemplates() {
const res = await api.get('/api/schedules')
templates.value = res.data
}
async function createTemplate() {
await api.post('/api/schedules', newTemplate.value)
newTemplate.value = { name: '', child_id: null, is_default: false }
showCreateForm.value = false
await loadTemplates()
}
async function deleteTemplate(id) {
if (confirm('Delete this template and all its blocks?')) {
await api.delete(`/api/schedules/${id}`)
await loadTemplates()
}
}
async function addBlock(templateId) {
const payload = {
...newBlock.value,
order_index: templates.value.find((t) => t.id === templateId)?.blocks.length || 0,
}
await api.post(`/api/schedules/${templateId}/blocks`, payload)
newBlock.value = { subject_id: null, time_start: '', time_end: '', label: '', order_index: 0 }
await loadTemplates()
}
async function deleteBlock(templateId, blockId) {
await api.delete(`/api/schedules/${templateId}/blocks/${blockId}`)
await loadTemplates()
}
onMounted(async () => {
await childrenStore.fetchChildren()
const [sRes] = await Promise.all([api.get('/api/subjects'), loadTemplates()])
subjects.value = sRes.data
})
</script>
<style scoped>
.page { min-height: 100vh; background: #0f172a; }
.container { max-width: 900px; 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 { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
.field input, .field select {
width: 100%;
padding: 0.65rem 0.9rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
}
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.template-list { display: flex; flex-direction: column; gap: 1rem; }
.template-card { background: #1e293b; border-radius: 1rem; padding: 1.25rem; }
.template-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.template-name { font-size: 1.05rem; font-weight: 600; }
.template-child { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; }
.template-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
.block-editor { margin-top: 1.25rem; border-top: 1px solid #334155; padding-top: 1.25rem; }
.block-list { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1rem; }
.block-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: #0f172a; border-radius: 0.5rem; }
.block-time { font-size: 0.8rem; color: #64748b; font-variant-numeric: tabular-nums; }
.block-label { flex: 1; font-size: 0.9rem; }
.add-block-form {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
background: #0f172a;
padding: 0.75rem;
border-radius: 0.75rem;
}
.add-block-form select,
.add-block-form input {
padding: 0.4rem 0.6rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.4rem;
color: #f1f5f9;
font-size: 0.85rem;
}
.add-block-form span { color: #64748b; }
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
.empty-small { color: #64748b; font-size: 0.85rem; padding: 0.5rem 0; }
.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-primary.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
.btn-sm {
padding: 0.35rem 0.75rem;
border: 1px solid #334155;
background: transparent;
color: #94a3b8;
border-radius: 0.4rem;
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>