664 lines
21 KiB
PHP
664 lines
21 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Teacher Calendar</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f4efe6;
|
|
--panel: #fffaf2;
|
|
--panel-strong: #f0e3ce;
|
|
--ink: #1d2a39;
|
|
--muted: #6b7280;
|
|
--accent: #0f766e;
|
|
--accent-soft: #d4f4ef;
|
|
--line: #d8ccb8;
|
|
--shadow: 0 18px 45px rgba(38, 32, 24, 0.12);
|
|
--today: #f59e0b;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
font-family: Georgia, "Times New Roman", serif;
|
|
color: var(--ink);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(15, 118, 110, 0.15), transparent 35%),
|
|
radial-gradient(circle at top right, rgba(245, 158, 11, 0.18), transparent 28%),
|
|
linear-gradient(180deg, #f7f1e7 0%, #efe7da 100%);
|
|
}
|
|
|
|
.shell {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 32px 20px 48px;
|
|
}
|
|
|
|
.hero {
|
|
display: grid;
|
|
gap: 18px;
|
|
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr);
|
|
align-items: end;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.hero-card,
|
|
.summary-card,
|
|
.calendar-card,
|
|
.agenda-card {
|
|
background: rgba(255, 250, 242, 0.92);
|
|
border: 1px solid rgba(216, 204, 184, 0.9);
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.hero-card {
|
|
border-radius: 28px;
|
|
padding: 28px;
|
|
}
|
|
|
|
.eyebrow {
|
|
font-size: 12px;
|
|
letter-spacing: 0.18em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0 0 8px;
|
|
font-size: clamp(2rem, 4vw, 3.6rem);
|
|
line-height: 0.94;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.hero p,
|
|
.summary-meta,
|
|
.agenda-empty,
|
|
.legend span,
|
|
.calendar-note,
|
|
.month-caption {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.summary-card {
|
|
border-radius: 24px;
|
|
padding: 22px;
|
|
}
|
|
|
|
.summary-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 12px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.stat {
|
|
background: var(--panel-strong);
|
|
border-radius: 18px;
|
|
padding: 14px;
|
|
}
|
|
|
|
.stat strong {
|
|
display: block;
|
|
font-size: 1.8rem;
|
|
line-height: 1;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.layout {
|
|
display: grid;
|
|
gap: 22px;
|
|
grid-template-columns: minmax(0, 1.65fr) minmax(300px, 0.95fr);
|
|
}
|
|
|
|
.calendar-card,
|
|
.agenda-card {
|
|
border-radius: 28px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.calendar-header,
|
|
.agenda-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 22px 24px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: rgba(240, 227, 206, 0.45);
|
|
}
|
|
|
|
.calendar-header h2,
|
|
.agenda-header h2 {
|
|
margin: 0;
|
|
font-size: 1.35rem;
|
|
}
|
|
|
|
.calendar-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
button {
|
|
appearance: none;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
}
|
|
|
|
.nav-button,
|
|
.today-button {
|
|
padding: 10px 14px;
|
|
background: #e8ddcb;
|
|
color: var(--ink);
|
|
}
|
|
|
|
.today-button {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
}
|
|
|
|
.calendar-body {
|
|
padding: 18px 18px 22px;
|
|
}
|
|
|
|
.weekdays,
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
.weekdays {
|
|
padding: 0 6px 12px;
|
|
font-size: 0.82rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.day {
|
|
min-height: 132px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
padding: 12px;
|
|
border-radius: 20px;
|
|
border: 1px solid rgba(216, 204, 184, 0.85);
|
|
background: rgba(255, 255, 255, 0.74);
|
|
transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease;
|
|
}
|
|
|
|
.day:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 14px 28px rgba(54, 46, 33, 0.08);
|
|
}
|
|
|
|
.day.muted {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
.day.today {
|
|
border-color: rgba(245, 158, 11, 0.9);
|
|
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.2);
|
|
}
|
|
|
|
.day.selected {
|
|
border-color: var(--accent);
|
|
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.22);
|
|
background: #fffdfa;
|
|
}
|
|
|
|
.day-number {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 50%;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.today .day-number {
|
|
background: rgba(245, 158, 11, 0.18);
|
|
color: #9a5600;
|
|
}
|
|
|
|
.event-stack {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.event-pill {
|
|
display: block;
|
|
padding: 7px 9px;
|
|
border-radius: 12px;
|
|
font-size: 0.8rem;
|
|
line-height: 1.2;
|
|
color: #12202f;
|
|
background: #dbe7f5;
|
|
border-left: 4px solid #8aa6cf;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.event-pill.more {
|
|
background: var(--panel-strong);
|
|
border-left-color: #9c8360;
|
|
color: #5c4730;
|
|
}
|
|
|
|
.legend {
|
|
display: flex;
|
|
gap: 14px;
|
|
flex-wrap: wrap;
|
|
margin-top: 16px;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.legend-swatch {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
}
|
|
|
|
.agenda-body {
|
|
padding: 20px;
|
|
}
|
|
|
|
.agenda-date {
|
|
margin: 0 0 14px;
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
.agenda-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
.agenda-item {
|
|
padding: 16px;
|
|
border-radius: 18px;
|
|
border: 1px solid rgba(216, 204, 184, 0.85);
|
|
background: rgba(255, 255, 255, 0.7);
|
|
}
|
|
|
|
.agenda-item h3 {
|
|
margin: 0 0 8px;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.agenda-item p {
|
|
margin: 0;
|
|
color: var(--muted);
|
|
white-space: pre-line;
|
|
}
|
|
|
|
.agenda-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 0.72rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
margin-bottom: 10px;
|
|
color: #5e5c52;
|
|
}
|
|
|
|
.agenda-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 999px;
|
|
background: #8aa6cf;
|
|
}
|
|
|
|
.calendar-note {
|
|
margin-top: 10px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.hero,
|
|
.layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.shell {
|
|
padding: 16px 12px 28px;
|
|
}
|
|
|
|
.weekdays,
|
|
.grid {
|
|
gap: 6px;
|
|
}
|
|
|
|
.day {
|
|
min-height: 108px;
|
|
padding: 10px 8px;
|
|
border-radius: 16px;
|
|
}
|
|
|
|
.event-pill {
|
|
padding: 6px 7px;
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
.summary-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.calendar-header,
|
|
.agenda-header {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<section class="hero">
|
|
<div class="hero-card hero">
|
|
<div>
|
|
<div class="eyebrow">Teacher Portal</div>
|
|
<h1>School Calendar</h1>
|
|
<p>Monthly view for teacher-visible events, no-school days, and staff-only items.</p>
|
|
</div>
|
|
</div>
|
|
<aside class="summary-card">
|
|
<div class="eyebrow">Current Scope</div>
|
|
<div class="summary-meta">School Year {{ $schoolYear }} · Default Semester {{ $defaultSemester }}</div>
|
|
<div class="summary-grid">
|
|
<div class="stat">
|
|
<strong id="stat-total">0</strong>
|
|
<span>Total events</span>
|
|
</div>
|
|
<div class="stat">
|
|
<strong id="stat-no-school">0</strong>
|
|
<span>No-school days</span>
|
|
</div>
|
|
<div class="stat">
|
|
<strong id="stat-staff">0</strong>
|
|
<span>Staff-only events</span>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</section>
|
|
|
|
<section class="layout">
|
|
<article class="calendar-card">
|
|
<div class="calendar-header">
|
|
<div>
|
|
<h2 id="month-label">Month</h2>
|
|
<div class="month-caption" id="month-caption">Teacher schedule overview</div>
|
|
</div>
|
|
<div class="calendar-toolbar">
|
|
<button class="nav-button" id="prev-month" type="button">Prev</button>
|
|
<button class="today-button" id="jump-today" type="button">Today</button>
|
|
<button class="nav-button" id="next-month" type="button">Next</button>
|
|
</div>
|
|
</div>
|
|
<div class="calendar-body">
|
|
<div class="weekdays">
|
|
<div>Sun</div>
|
|
<div>Mon</div>
|
|
<div>Tue</div>
|
|
<div>Wed</div>
|
|
<div>Thu</div>
|
|
<div>Fri</div>
|
|
<div>Sat</div>
|
|
</div>
|
|
<div class="grid" id="calendar-grid"></div>
|
|
<div class="legend">
|
|
<div class="legend-item"><span class="legend-swatch" style="background:#dbe7f5"></span><span>General event</span></div>
|
|
<div class="legend-item"><span class="legend-swatch" style="background:#ffc107"></span><span>No school</span></div>
|
|
<div class="legend-item"><span class="legend-swatch" style="background:#28a745"></span><span>Staff only</span></div>
|
|
<div class="legend-item"><span class="legend-swatch" style="background:#6f42c1"></span><span>Meeting</span></div>
|
|
</div>
|
|
<div class="calendar-note">Select any day to inspect the event list for that date.</div>
|
|
</div>
|
|
</article>
|
|
|
|
<aside class="agenda-card">
|
|
<div class="agenda-header">
|
|
<h2>Day Agenda</h2>
|
|
<div class="month-caption" id="agenda-count">0 events</div>
|
|
</div>
|
|
<div class="agenda-body">
|
|
<h3 class="agenda-date" id="agenda-date"></h3>
|
|
<div class="agenda-list" id="agenda-list"></div>
|
|
<p class="agenda-empty" id="agenda-empty">No events scheduled for this day.</p>
|
|
</div>
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
const rawEvents = @json($events);
|
|
|
|
const events = rawEvents.map((event) => {
|
|
const start = String(event.start || '').slice(0, 10);
|
|
return {
|
|
...event,
|
|
start,
|
|
backgroundColor: event.backgroundColor || '#8aa6cf',
|
|
};
|
|
});
|
|
|
|
const eventsByDate = events.reduce((map, event) => {
|
|
if (!event.start) {
|
|
return map;
|
|
}
|
|
if (!map[event.start]) {
|
|
map[event.start] = [];
|
|
}
|
|
map[event.start].push(event);
|
|
return map;
|
|
}, {});
|
|
|
|
Object.values(eventsByDate).forEach((list) => {
|
|
list.sort((left, right) => String(left.title || '').localeCompare(String(right.title || '')));
|
|
});
|
|
|
|
const monthLabel = document.getElementById('month-label');
|
|
const monthCaption = document.getElementById('month-caption');
|
|
const calendarGrid = document.getElementById('calendar-grid');
|
|
const agendaDate = document.getElementById('agenda-date');
|
|
const agendaList = document.getElementById('agenda-list');
|
|
const agendaEmpty = document.getElementById('agenda-empty');
|
|
const agendaCount = document.getElementById('agenda-count');
|
|
const today = new Date();
|
|
const todayKey = toDateKey(today);
|
|
|
|
const availableDates = Object.keys(eventsByDate).sort();
|
|
const initialDate = availableDates.includes(todayKey) ? todayKey : (availableDates[0] || todayKey);
|
|
|
|
let selectedDate = initialDate;
|
|
let visibleMonth = new Date(initialDate + 'T12:00:00');
|
|
visibleMonth.setDate(1);
|
|
|
|
document.getElementById('prev-month').addEventListener('click', () => {
|
|
visibleMonth = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() - 1, 1);
|
|
renderCalendar();
|
|
});
|
|
|
|
document.getElementById('next-month').addEventListener('click', () => {
|
|
visibleMonth = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1);
|
|
renderCalendar();
|
|
});
|
|
|
|
document.getElementById('jump-today').addEventListener('click', () => {
|
|
selectedDate = todayKey;
|
|
visibleMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
renderCalendar();
|
|
renderAgenda();
|
|
});
|
|
|
|
function renderCalendar() {
|
|
calendarGrid.innerHTML = '';
|
|
|
|
const year = visibleMonth.getFullYear();
|
|
const month = visibleMonth.getMonth();
|
|
const firstDay = new Date(year, month, 1);
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
const leading = firstDay.getDay();
|
|
const totalCells = Math.ceil((leading + lastDay.getDate()) / 7) * 7;
|
|
const startDate = new Date(year, month, 1 - leading);
|
|
|
|
monthLabel.textContent = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(firstDay);
|
|
monthCaption.textContent = `${countMonthEvents(year, month)} event${countMonthEvents(year, month) === 1 ? '' : 's'} in view`;
|
|
|
|
for (let i = 0; i < totalCells; i += 1) {
|
|
const cellDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + i);
|
|
const dateKey = toDateKey(cellDate);
|
|
const dayEvents = eventsByDate[dateKey] || [];
|
|
const isCurrentMonth = cellDate.getMonth() === month;
|
|
const isToday = dateKey === todayKey;
|
|
const isSelected = dateKey === selectedDate;
|
|
|
|
const cell = document.createElement('button');
|
|
cell.type = 'button';
|
|
cell.className = `day${isCurrentMonth ? '' : ' muted'}${isToday ? ' today' : ''}${isSelected ? ' selected' : ''}`;
|
|
cell.addEventListener('click', () => {
|
|
selectedDate = dateKey;
|
|
if (!isCurrentMonth) {
|
|
visibleMonth = new Date(cellDate.getFullYear(), cellDate.getMonth(), 1);
|
|
}
|
|
renderCalendar();
|
|
renderAgenda();
|
|
});
|
|
|
|
const number = document.createElement('span');
|
|
number.className = 'day-number';
|
|
number.textContent = String(cellDate.getDate());
|
|
cell.appendChild(number);
|
|
|
|
const stack = document.createElement('div');
|
|
stack.className = 'event-stack';
|
|
|
|
dayEvents.slice(0, 3).forEach((event) => {
|
|
const pill = document.createElement('span');
|
|
pill.className = 'event-pill';
|
|
pill.textContent = event.title || 'Untitled';
|
|
pill.style.borderLeftColor = event.backgroundColor || '#8aa6cf';
|
|
pill.style.background = withAlpha(event.backgroundColor || '#dbe7f5', 0.18);
|
|
stack.appendChild(pill);
|
|
});
|
|
|
|
if (dayEvents.length > 3) {
|
|
const more = document.createElement('span');
|
|
more.className = 'event-pill more';
|
|
more.textContent = `+${dayEvents.length - 3} more`;
|
|
stack.appendChild(more);
|
|
}
|
|
|
|
cell.appendChild(stack);
|
|
calendarGrid.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
function renderAgenda() {
|
|
const dayEvents = eventsByDate[selectedDate] || [];
|
|
agendaDate.textContent = new Intl.DateTimeFormat('en-US', {
|
|
weekday: 'long',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
}).format(new Date(selectedDate + 'T12:00:00'));
|
|
agendaList.innerHTML = '';
|
|
agendaCount.textContent = `${dayEvents.length} event${dayEvents.length === 1 ? '' : 's'}`;
|
|
agendaEmpty.style.display = dayEvents.length === 0 ? 'block' : 'none';
|
|
|
|
dayEvents.forEach((event) => {
|
|
const item = document.createElement('article');
|
|
item.className = 'agenda-item';
|
|
|
|
const tag = document.createElement('div');
|
|
tag.className = 'agenda-tag';
|
|
const dot = document.createElement('span');
|
|
dot.className = 'agenda-dot';
|
|
dot.style.background = event.backgroundColor || '#8aa6cf';
|
|
tag.appendChild(dot);
|
|
tag.append(document.createTextNode(buildTagLabel(event)));
|
|
|
|
const title = document.createElement('h3');
|
|
title.textContent = event.title || 'Untitled';
|
|
|
|
const description = document.createElement('p');
|
|
description.textContent = event.description || 'No extra details provided.';
|
|
|
|
item.appendChild(tag);
|
|
item.appendChild(title);
|
|
item.appendChild(description);
|
|
agendaList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function buildTagLabel(event) {
|
|
const props = event.extendedProps || {};
|
|
if (String(event.id || '').startsWith('pm-')) {
|
|
return 'Meeting';
|
|
}
|
|
if (props.no_school) {
|
|
return 'No School';
|
|
}
|
|
if (props.notify_teacher && props.notify_admin && !props.notify_parent) {
|
|
return 'Staff Only';
|
|
}
|
|
return 'School Event';
|
|
}
|
|
|
|
function countMonthEvents(year, month) {
|
|
return events.filter((event) => {
|
|
const date = new Date(event.start + 'T12:00:00');
|
|
return date.getFullYear() === year && date.getMonth() === month;
|
|
}).length;
|
|
}
|
|
|
|
function toDateKey(date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function withAlpha(hex, alpha) {
|
|
const normalized = hex.replace('#', '');
|
|
if (normalized.length !== 6) {
|
|
return 'rgba(219, 231, 245, 0.18)';
|
|
}
|
|
const r = parseInt(normalized.slice(0, 2), 16);
|
|
const g = parseInt(normalized.slice(2, 4), 16);
|
|
const b = parseInt(normalized.slice(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
|
|
document.getElementById('stat-total').textContent = String(events.length);
|
|
document.getElementById('stat-no-school').textContent = String(events.filter((event) => event.extendedProps && event.extendedProps.no_school).length);
|
|
document.getElementById('stat-staff').textContent = String(events.filter((event) => {
|
|
const props = event.extendedProps || {};
|
|
return props.notify_teacher && props.notify_admin && !props.notify_parent;
|
|
}).length);
|
|
|
|
renderCalendar();
|
|
renderAgenda();
|
|
</script>
|
|
</body>
|
|
</html>
|