setup update 3
This commit is contained in:
+2
-2
@@ -43,8 +43,8 @@ def create_app():
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(classrooms_bp)
|
||||
app.register_blueprint(schedule_bp)
|
||||
app.register_blueprint(classrooms_bp, url_prefix='/classrooms')
|
||||
app.register_blueprint(schedule_bp, url_prefix='/schedule')
|
||||
|
||||
# Create database tables
|
||||
with app.app_context():
|
||||
|
||||
+25
-39
@@ -1,58 +1,44 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, IntegerField, SelectField, TextAreaField, SubmitField
|
||||
from wtforms.validators import DataRequired, NumberRange, Length, Optional
|
||||
from wtforms import StringField, IntegerField, TextAreaField, BooleanField, SubmitField, SelectField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
|
||||
class ClassroomForm(FlaskForm):
|
||||
code = StringField('Classroom Code', validators=[
|
||||
DataRequired(),
|
||||
Length(min=2, max=20, message='Code must be between 2 and 20 characters')
|
||||
])
|
||||
name = StringField('Classroom Name', validators=[
|
||||
DataRequired(),
|
||||
Length(max=100)
|
||||
])
|
||||
# campus_id = SelectField('Campus', coerce=int, validators=[DataRequired()])
|
||||
building = StringField('Building', validators=[
|
||||
DataRequired(),
|
||||
Length(max=50)
|
||||
Length(min=2, max=100)
|
||||
])
|
||||
room_number = StringField('Room Number', validators=[
|
||||
DataRequired(),
|
||||
Length(min=1, max=50)
|
||||
])
|
||||
floor = IntegerField('Floor', validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=10)
|
||||
NumberRange(min=1, max=50)
|
||||
])
|
||||
capacity = IntegerField('Capacity', validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=200)
|
||||
NumberRange(min=1, max=500)
|
||||
])
|
||||
description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
|
||||
campus_location = SelectField('Campus Location',
|
||||
choices=[
|
||||
('Main Campus', 'Main Campus'),
|
||||
('North Campus', 'North Campus'),
|
||||
('Science & Technology Campus', 'Science & Technology Campus'),
|
||||
('Downtown Campus', 'Downtown Campus')
|
||||
],
|
||||
validators=[DataRequired()])
|
||||
description = TextAreaField('Description', validators=[
|
||||
Optional(),
|
||||
Length(max=500)
|
||||
])
|
||||
is_active = BooleanField('Active', default=True)
|
||||
submit = SubmitField('Save Classroom')
|
||||
|
||||
class ClassroomResourceForm(FlaskForm):
|
||||
resource_type = SelectField('Resource Type',
|
||||
choices=[
|
||||
resource_type = SelectField('Resource Type', choices=[
|
||||
('PROJECTOR', 'Projector'),
|
||||
('WHITEBOARD', 'Whiteboard'),
|
||||
('SMART_BOARD', 'Smart Board'),
|
||||
('SCREEN', 'Screen'),
|
||||
('AUDIO_SYSTEM', 'Audio System'),
|
||||
('COMPUTERS', 'Computers'),
|
||||
('LAB_EQUIPMENT', 'Lab Equipment'),
|
||||
('AIR_CONDITIONING', 'Air Conditioning'),
|
||||
('HEATING', 'Heating')
|
||||
],
|
||||
validators=[DataRequired()])
|
||||
('COMPUTER', 'Computer'),
|
||||
('SOUND_SYSTEM', 'Sound System'),
|
||||
('MICROPHONE', 'Microphone'),
|
||||
('AIR_CONDITIONER', 'Air Conditioner'),
|
||||
('OTHER', 'Other')
|
||||
], validators=[DataRequired()])
|
||||
brand = StringField('Brand', validators=[Optional(), Length(max=100)])
|
||||
model = StringField('Model', validators=[Optional(), Length(max=100)])
|
||||
specifications = TextAreaField('Specifications', validators=[Optional(), Length(max=1000)])
|
||||
quantity = IntegerField('Quantity', validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=50)
|
||||
], default=1)
|
||||
submit = SubmitField('Add Resource')
|
||||
specifications = TextAreaField('Specifications', validators=[Optional()])
|
||||
functional = BooleanField('Functional', default=True)
|
||||
submit = SubmitField('Save Resource')
|
||||
@@ -1,19 +1,40 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SelectField, IntegerField, TextAreaField, SubmitField, DateTimeLocalField
|
||||
from wtforms.validators import DataRequired, NumberRange, Length, Optional
|
||||
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional, ValidationError
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class ReservationForm(FlaskForm):
|
||||
classroom_id = SelectField('Classroom', coerce=int, validators=[DataRequired()])
|
||||
commission_id = SelectField('Commission', coerce=int, validators=[DataRequired()])
|
||||
commission_id = SelectField('Commission/Subject', coerce=int, validators=[DataRequired()])
|
||||
start_time = DateTimeLocalField('Start Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
||||
end_time = DateTimeLocalField('End Time', format='%Y-%m-%dT%H:%M', validators=[DataRequired()])
|
||||
purpose = StringField('Purpose', validators=[
|
||||
DataRequired(),
|
||||
Length(max=500, message='Purpose must be less than 500 characters')
|
||||
Length(min=3, max=500)
|
||||
])
|
||||
expected_attendees = IntegerField('Expected Attendees', validators=[
|
||||
DataRequired(),
|
||||
NumberRange(min=1, max=200, message='Number of attendees must be between 1 and 200')
|
||||
NumberRange(min=1, max=500)
|
||||
])
|
||||
notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)])
|
||||
submit = SubmitField('Save Reservation')
|
||||
notes = TextAreaField('Notes', validators=[
|
||||
Optional(),
|
||||
Length(max=1000)
|
||||
])
|
||||
submit = SubmitField('Make Reservation')
|
||||
|
||||
def validate_end_time(self, field):
|
||||
if field.data <= self.start_time.data:
|
||||
raise ValidationError('End time must be after start time.')
|
||||
|
||||
# Check if duration is reasonable (max 8 hours)
|
||||
if field.data > self.start_time.data + timedelta(hours=8):
|
||||
raise ValidationError('Reservation cannot exceed 8 hours.')
|
||||
|
||||
def validate_start_time(self, field):
|
||||
# Don't allow reservations in the past
|
||||
if field.data < datetime.now():
|
||||
raise ValidationError('Start time cannot be in the past.')
|
||||
|
||||
# Don't allow reservations too far in the future (max 3 months)
|
||||
if field.data > datetime.now() + timedelta(days=90):
|
||||
raise ValidationError('Reservations cannot be made more than 3 months in advance.')
|
||||
+26
-13
@@ -5,48 +5,58 @@ class Classroom(db.Model):
|
||||
__tablename__ = 'classrooms'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
campus_id = db.Column(db.Integer, db.ForeignKey('campuses.id'), nullable=False)
|
||||
# campus_id = db.Column(db.Integer, db.ForeignKey('campuses.id'), nullable=False)
|
||||
building = db.Column(db.String(100), nullable=False)
|
||||
room_number = db.Column(db.String(50), nullable=False)
|
||||
code = db.Column(db.String(50), nullable=False) # Changed from room_number
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
floor = db.Column(db.Integer, nullable=False)
|
||||
capacity = db.Column(db.Integer, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
campus_location = db.Column(db.String(100))
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Virtual field for code (combination of building and room_number)
|
||||
# Virtual field for compatibility - combines building and code
|
||||
@property
|
||||
def code(self):
|
||||
return f"{self.building}-{self.room_number}"
|
||||
def room_number(self):
|
||||
return self.code
|
||||
|
||||
# Relationships
|
||||
reservations = db.relationship('Reservation', backref='classroom', lazy=True, cascade='all, delete-orphan')
|
||||
@room_number.setter
|
||||
def room_number(self, value):
|
||||
self.code = value
|
||||
|
||||
# Resources relationship (教室资源)
|
||||
resources = db.relationship('ClassroomResource', backref='classroom', lazy=True, cascade='all, delete-orphan')
|
||||
# Override code property for consistency
|
||||
@property
|
||||
def code_display(self):
|
||||
return f"{self.building}-{self.code}"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert classroom to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'code': self.code_display,
|
||||
'building': self.building,
|
||||
'name': self.name,
|
||||
'floor': self.floor,
|
||||
'capacity': self.capacity,
|
||||
'description': self.description,
|
||||
'campus_location': self.campus_location,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'resources': [resource.to_dict() for resource in self.resources]
|
||||
'resources': [resource.to_dict() for resource in self.resources] if hasattr(self, 'resources') else []
|
||||
}
|
||||
|
||||
def get_full_location(self):
|
||||
"""Get full classroom location"""
|
||||
return f"{self.building} - Floor {self.floor}"
|
||||
location = f"{self.building} - Floor {self.floor}"
|
||||
if self.campus_location:
|
||||
location += f" ({self.campus_location})"
|
||||
return location
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Classroom {self.code}>'
|
||||
return f'<Classroom {self.code_display}>'
|
||||
|
||||
class ClassroomResource(db.Model):
|
||||
__tablename__ = 'classroom_resources'
|
||||
@@ -62,6 +72,9 @@ class ClassroomResource(db.Model):
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
classroom = db.relationship('Classroom', backref=db.backref('resources', lazy=True, cascade='all, delete-orphan'))
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert resource to dictionary"""
|
||||
return {
|
||||
|
||||
+13
-4
@@ -10,7 +10,7 @@ class Subject(db.Model):
|
||||
description = db.Column(db.Text)
|
||||
department = db.Column(db.String(100), nullable=False)
|
||||
credits = db.Column(db.Integer, nullable=False)
|
||||
duration_in_minutes = db.Column(db.Integer, nullable=False)
|
||||
# duration_in_minutes = db.Column(db.Integer, nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -27,7 +27,7 @@ class Subject(db.Model):
|
||||
'description': self.description,
|
||||
'department': self.department,
|
||||
'credits': self.credits,
|
||||
'duration_minutes': self.duration_in_minutes,
|
||||
# 'duration_minutes': self.duration_in_minutes,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
@@ -48,10 +48,19 @@ class Commission(db.Model):
|
||||
max_students = db.Column(db.Integer, nullable=False)
|
||||
current_students = db.Column(db.Integer, default=0)
|
||||
schedule = db.Column(db.String(255))
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Virtual property for compatibility
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.active
|
||||
|
||||
@is_active.setter
|
||||
def is_active(self, value):
|
||||
self.active = value
|
||||
|
||||
# Relationships
|
||||
reservations = db.relationship('Reservation', backref='commission', lazy=True, cascade='all, delete-orphan')
|
||||
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
|
||||
@@ -76,7 +85,7 @@ class Commission(db.Model):
|
||||
'max_students': self.max_students,
|
||||
'current_students': self.current_students,
|
||||
'schedule': self.schedule,
|
||||
'is_active': self.is_active,
|
||||
'active': self.active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'subject': self.subject.to_dict() if self.subject else None
|
||||
|
||||
+174
-156
@@ -1,214 +1,232 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime, date
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.user import User
|
||||
from app.forms.classroom import ClassroomForm
|
||||
from app import db
|
||||
from app.models.classroom import Classroom, ClassroomResource
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.forms.classroom import ClassroomForm, ClassroomResourceForm
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
|
||||
classrooms_bp = Blueprint('classrooms', __name__, url_prefix='/classrooms')
|
||||
|
||||
@classrooms_bp.route('/')
|
||||
@login_required
|
||||
def list_classrooms():
|
||||
"""List all classrooms"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
search = request.args.get('search', '')
|
||||
campus = request.args.get('campus', '')
|
||||
|
||||
query = Classroom.query.filter_by(is_active=True)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
Classroom.code.contains(search) |
|
||||
Classroom.name.contains(search) |
|
||||
Classroom.building.contains(search)
|
||||
)
|
||||
|
||||
if campus:
|
||||
query = query.filter(Classroom.campus_location == campus)
|
||||
|
||||
classrooms = query.paginate(
|
||||
page=page,
|
||||
per_page=20,
|
||||
error_out=False
|
||||
)
|
||||
|
||||
return render_template('classrooms/list.html',
|
||||
classrooms=classrooms,
|
||||
search=search,
|
||||
campus=campus)
|
||||
classrooms_bp = Blueprint('classrooms', __name__)
|
||||
|
||||
@classrooms_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_classroom():
|
||||
"""Add new classroom"""
|
||||
"""Add a new classroom"""
|
||||
form = ClassroomForm()
|
||||
|
||||
# TODO: Update when campus model is implemented
|
||||
# form.campus_id.choices = [(1, 'Main Campus')]
|
||||
|
||||
if form.validate_on_submit():
|
||||
classroom = Classroom(
|
||||
code=form.code.data,
|
||||
name=form.name.data,
|
||||
try:
|
||||
existing_classroom = Classroom.query.filter_by(
|
||||
building=form.building.data,
|
||||
code=form.room_number.data,
|
||||
floor=form.floor.data
|
||||
).first()
|
||||
|
||||
if existing_classroom:
|
||||
flash('Classroom with this building, room number, and floor already exists.', 'error')
|
||||
return render_template('classrooms/add.html', form=form, title='Add Classroom')
|
||||
|
||||
classroom = Classroom(
|
||||
# campus_id=form.campus_id.data,
|
||||
building=form.building.data,
|
||||
code=form.room_number.data,
|
||||
name=f"{form.building.data}-{form.room_number.data}",
|
||||
floor=form.floor.data,
|
||||
capacity=form.capacity.data,
|
||||
description=form.description.data,
|
||||
campus_location=form.campus_location.data
|
||||
is_active=form.is_active.data
|
||||
)
|
||||
|
||||
try:
|
||||
db.session.add(classroom)
|
||||
db.session.commit()
|
||||
flash(f'Classroom {classroom.code} added successfully!', 'success')
|
||||
|
||||
flash(f'Classroom {classroom.code_display} has been created successfully!', 'success')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('Error adding classroom. Classroom code might already exist.', 'error')
|
||||
flash(f'Error creating classroom: {str(e)}', 'error')
|
||||
|
||||
return render_template('classrooms/add.html', form=form)
|
||||
return render_template('classrooms/add.html', form=form, title='Add Classroom')
|
||||
|
||||
@classrooms_bp.route('/edit/<int:Id>', methods=['GET', 'POST'])
|
||||
@classrooms_bp.route('/list')
|
||||
@login_required
|
||||
def edit_classroom(Id):
|
||||
"""Edit classroom"""
|
||||
classroom = Classroom.query.get_or_404(Id)
|
||||
|
||||
if not classroom.is_active:
|
||||
flash('This classroom has been deactivated.', 'warning')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
|
||||
form = ClassroomForm(obj=classroom)
|
||||
|
||||
if form.validate_on_submit():
|
||||
def list_classrooms():
|
||||
"""List all classrooms"""
|
||||
try:
|
||||
classroom.code = form.code.data
|
||||
classroom.name = form.name.data
|
||||
classroom.building = form.building.data
|
||||
classroom.floor = form.floor.data
|
||||
classroom.capacity = form.capacity.data
|
||||
classroom.description = form.description.data
|
||||
classroom.campus_location = form.campus_location.data
|
||||
classroom.updated_at = datetime.utcnow()
|
||||
# Get filters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
active_only = request.args.get('active_only', 'true').lower() == 'true'
|
||||
search = request.args.get('search', '', type=str)
|
||||
|
||||
# Build query
|
||||
query = Classroom.query
|
||||
|
||||
if active_only:
|
||||
query = query.filter_by(is_active=True)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Classroom.building.ilike(f'%{search}%'),
|
||||
Classroom.code.ilike(f'%{search}%'),
|
||||
Classroom.description.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
# Paginate results
|
||||
classrooms = query.order_by(Classroom.building, Classroom.floor, Classroom.code).paginate(
|
||||
page=page, per_page=12, error_out=False
|
||||
)
|
||||
|
||||
return render_template('classrooms/list.html',
|
||||
classrooms=classrooms,
|
||||
active_only=active_only,
|
||||
search=search,
|
||||
title='Classrooms')
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Classroom {classroom.code} updated successfully!', 'success')
|
||||
return redirect(url_for('classrooms.view_classroom', Id=classroom.id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('Error updating classroom.', 'error')
|
||||
flash(f'Error loading classrooms: {str(e)}', 'error')
|
||||
return render_template('classrooms/list.html', classrooms=None, title='Classrooms')
|
||||
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom)
|
||||
|
||||
@classrooms_bp.route('/view/<int:Id>')
|
||||
@classrooms_bp.route('/<int:id>')
|
||||
@login_required
|
||||
def view_classroom(Id):
|
||||
def view_classroom(id):
|
||||
"""View classroom details"""
|
||||
classroom = Classroom.query.get_or_404(Id)
|
||||
try:
|
||||
classroom = Classroom.query.get_or_404(id)
|
||||
|
||||
if not classroom.is_active:
|
||||
flash('This classroom has been deactivated.', 'warning')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
# Get upcoming reservations for this classroom
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
|
||||
# Get classroom resources
|
||||
resources = ClassroomResource.query.filter_by(classroom_id=Id).all()
|
||||
|
||||
# Get upcoming reservations
|
||||
upcoming_reservations = Reservation.query.filter(
|
||||
Reservation.classroom_id == Id,
|
||||
Reservation.start_time >= datetime.utcnow(),
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
|
||||
Reservation.classroom_id == id,
|
||||
Reservation.start_time > datetime.utcnow(),
|
||||
Reservation.status == ReservationStatus.CONFIRMED
|
||||
).order_by(Reservation.start_time).limit(10).all()
|
||||
|
||||
return render_template('classrooms/view.html',
|
||||
classroom=classroom,
|
||||
resources=resources,
|
||||
upcoming_reservations=upcoming_reservations)
|
||||
upcoming_reservations=upcoming_reservations,
|
||||
title=f'Classroom {classroom.code_display}')
|
||||
|
||||
@classrooms_bp.route('/delete/<int:Id>')
|
||||
@login_required
|
||||
def delete_classroom(Id):
|
||||
"""Delete/deactivate classroom"""
|
||||
classroom = Classroom.query.get_or_404(Id)
|
||||
|
||||
# Check for active reservations
|
||||
active_reservations = Reservation.query.filter(
|
||||
Reservation.classroom_id == Id,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
|
||||
).count()
|
||||
|
||||
if active_reservations > 0:
|
||||
flash('Cannot delete classroom with active reservations.', 'error')
|
||||
return redirect(url_for('classrooms.view_classroom', Id=Id))
|
||||
|
||||
try:
|
||||
classroom.is_active = False
|
||||
classroom.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
flash(f'Classroom {classroom.code} has been deactivated.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('Error deactivating classroom.', 'error')
|
||||
|
||||
flash(f'Error loading classroom: {str(e)}', 'error')
|
||||
return redirect(url_for('classrooms.list_classrooms'))
|
||||
|
||||
@classrooms_bp.route('/<int:classroom_id>/add_resource', methods=['GET', 'POST'])
|
||||
@classrooms_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_resource(classroom_id):
|
||||
"""Add resource to classroom"""
|
||||
classroom = Classroom.query.get_or_404(classroom_id)
|
||||
form = ClassroomResourceForm()
|
||||
def edit_classroom(id):
|
||||
"""Edit classroom"""
|
||||
classroom = Classroom.query.get_or_404(id)
|
||||
form = ClassroomForm(obj=classroom)
|
||||
|
||||
# TODO: Update when campus model is implemented
|
||||
# form.campus_id.choices = [(1, 'Main Campus')]
|
||||
|
||||
if form.validate_on_submit():
|
||||
resource = ClassroomResource(
|
||||
classroom_id=classroom_id,
|
||||
resource_type=form.resource_type.data,
|
||||
brand=form.brand.data,
|
||||
model=form.model.data,
|
||||
specifications=form.specifications.data,
|
||||
quantity=form.quantity.data
|
||||
)
|
||||
|
||||
try:
|
||||
db.session.add(resource)
|
||||
# Check for duplicates (excluding current classroom)
|
||||
existing_classroom = Classroom.query.filter(
|
||||
Classroom.building == form.building.data,
|
||||
Classroom.code == form.room_number.data,
|
||||
Classroom.floor == form.floor.data,
|
||||
Classroom.id != id
|
||||
).first()
|
||||
|
||||
if existing_classroom:
|
||||
flash('Classroom with this building, room number, and floor already exists.', 'error')
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
|
||||
|
||||
# Update classroom
|
||||
# classroom.campus_id = form.campus_id.data
|
||||
classroom.building = form.building.data
|
||||
classroom.code = form.room_number.data
|
||||
classroom.name = f"{form.building.data}-{form.room_number.data}"
|
||||
classroom.floor = form.floor.data
|
||||
classroom.capacity = form.capacity.data
|
||||
classroom.description = form.description.data
|
||||
classroom.is_active = form.is_active.data
|
||||
classroom.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
flash(f'{resource.resource_type} added to {classroom.code} successfully!', 'success')
|
||||
return redirect(url_for('classrooms.view_classroom', Id=classroom_id))
|
||||
|
||||
flash(f'Classroom {classroom.code_display} has been updated successfully!', 'success')
|
||||
return redirect(url_for('classrooms.view_classroom', id=classroom.id))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('Error adding resource.', 'error')
|
||||
flash(f'Error updating classroom: {str(e)}', 'error')
|
||||
|
||||
return render_template('classrooms/add_resource.html', form=form, classroom=classroom)
|
||||
return render_template('classrooms/edit.html', form=form, classroom=classroom, title='Edit Classroom')
|
||||
|
||||
@classrooms_bp.route('/api/available')
|
||||
@classrooms_bp.route('/<int:id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
def api_available_classrooms():
|
||||
"""API endpoint to get available classrooms"""
|
||||
start_time = request.args.get('start_time')
|
||||
end_time = request.args.get('end_time')
|
||||
min_capacity = request.args.get('min_capacity', 0, type=int)
|
||||
|
||||
if not start_time or not end_time:
|
||||
return jsonify({'error': 'start_time and end_time are required'}), 400
|
||||
def toggle_classroom(id):
|
||||
"""Toggle classroom active status"""
|
||||
classroom = Classroom.query.get_or_404(id)
|
||||
|
||||
try:
|
||||
classroom.is_active = not classroom.is_active
|
||||
classroom.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
status = 'activated' if classroom.is_active else 'deactivated'
|
||||
flash(f'Classroom {classroom.code_display} has been {status} successfully!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error updating classroom status: {str(e)}', 'error')
|
||||
|
||||
return redirect(request.referrer or url_for('classrooms.list_classrooms'))
|
||||
|
||||
# API endpoints for AJAX requests
|
||||
@classrooms_bp.route('/api/available')
|
||||
@login_required
|
||||
def get_available_classrooms():
|
||||
"""Get available classrooms for a specific time slot"""
|
||||
start_time = request.args.get('start_time')
|
||||
end_time = request.args.get('end_time')
|
||||
|
||||
if not start_time or not end_time:
|
||||
return jsonify({'error': 'Start time and end time are required'}), 400
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
|
||||
start_dt = datetime.fromisoformat(start_time)
|
||||
end_dt = datetime.fromisoformat(end_time)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid datetime format'}), 400
|
||||
|
||||
# Find available classrooms (without conflicts)
|
||||
conflicting_classroom_ids = [
|
||||
r.classroom_id for r in Reservation.query.filter(
|
||||
# Find all classrooms
|
||||
all_classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
available_classrooms = []
|
||||
|
||||
for classroom in all_classrooms:
|
||||
# Check if classroom has conflicting reservations
|
||||
conflicts = Reservation.query.filter(
|
||||
Reservation.classroom_id == classroom.id,
|
||||
Reservation.status == ReservationStatus.CONFIRMED,
|
||||
Reservation.start_time < end_dt,
|
||||
Reservation.end_time > start_dt,
|
||||
Reservation.status == ReservationStatus.CONFIRMED
|
||||
).all()
|
||||
]
|
||||
|
||||
available_classrooms = Classroom.query.filter(
|
||||
Classroom.is_active == True,
|
||||
Classroom.capacity >= min_capacity,
|
||||
~Classroom.id.in_(conflicting_classroom_ids)
|
||||
Reservation.end_time > start_dt
|
||||
).all()
|
||||
|
||||
return jsonify([classroom.to_dict() for classroom in available_classrooms])
|
||||
if not conflicts:
|
||||
available_classrooms.append({
|
||||
'id': classroom.id,
|
||||
'code': classroom.code,
|
||||
'building': classroom.building,
|
||||
'room_number': classroom.code,
|
||||
'floor': classroom.floor,
|
||||
'capacity': classroom.capacity,
|
||||
'code_display': classroom.code_display
|
||||
})
|
||||
|
||||
return jsonify({'classrooms': available_classrooms})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
+270
-217
@@ -1,99 +1,52 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models.reservation import Reservation, ReservationStatus
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Subject, Commission
|
||||
from app.models.subject import Commission
|
||||
from app.forms.reservation import ReservationForm
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func, or_
|
||||
from app import db
|
||||
from datetime import datetime, timedelta, date
|
||||
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, DateTimeLocalField
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
|
||||
schedule_bp = Blueprint('schedule', __name__, url_prefix='/schedule')
|
||||
|
||||
@schedule_bp.route('/')
|
||||
@login_required
|
||||
def calendar_view():
|
||||
"""Calendar view of reservations"""
|
||||
return render_template('schedule/calendar.html')
|
||||
|
||||
@schedule_bp.route('/list')
|
||||
@login_required
|
||||
def list_reservations():
|
||||
"""List all reservations"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
status_filter = request.args.get('status', '')
|
||||
classroom_filter = request.args.get('classroom', 0, type=int)
|
||||
date_filter = request.args.get('date', '')
|
||||
|
||||
query = Reservation.query
|
||||
|
||||
# Apply filters
|
||||
if status_filter:
|
||||
query = query.filter(Reservation.status == status_filter)
|
||||
|
||||
if classroom_filter:
|
||||
query = query.filter(Reservation.classroom_id == classroom_filter)
|
||||
|
||||
if date_filter:
|
||||
try:
|
||||
filter_date = datetime.fromisoformat(date_filter).date()
|
||||
query = query.filter(func.date(Reservation.start_time) == filter_date)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
reservations = query.order_by(Reservation.start_time.desc()).paginate(
|
||||
page=page,
|
||||
per_page=20,
|
||||
error_out=False
|
||||
)
|
||||
|
||||
# Get filter options
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
return render_template('schedule/list.html',
|
||||
reservations=reservations,
|
||||
classrooms=classrooms,
|
||||
status_filter=status_filter,
|
||||
classroom_filter=classroom_filter,
|
||||
date_filter=date_filter)
|
||||
schedule_bp = Blueprint('schedule', __name__)
|
||||
|
||||
@schedule_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_reservation():
|
||||
"""Add new reservation"""
|
||||
"""Add a new reservation"""
|
||||
form = ReservationForm()
|
||||
|
||||
# Populate classroom choices
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
form.classroom_id.choices = [(c.id, f'{c.code} - {c.name} ({c.capacity} seats)') for c in classrooms]
|
||||
|
||||
# Populate commission choices
|
||||
commissions = Commission.query.filter_by(is_active=True).all()
|
||||
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.teacher_name}') for c in commissions]
|
||||
# Populate form choices
|
||||
form.classroom_id.choices = [(c.id, f"{c.code_display} (Capacity: {c.capacity})")
|
||||
for c in Classroom.query.filter_by(is_active=True).order_by(Classroom.building, Classroom.code).all()]
|
||||
form.commission_id.choices = [(c.id, c.get_full_code())
|
||||
for c in Commission.query.filter(Commission.active == True).order_by(Commission.code).all()]
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Check for conflicts
|
||||
conflicts = Reservation.find_conflicts(
|
||||
try:
|
||||
# Check for time conflicts
|
||||
conflicting_reservations = Reservation.find_conflicts(
|
||||
form.classroom_id.data,
|
||||
form.start_time.data,
|
||||
form.end_time.data
|
||||
)
|
||||
|
||||
if conflicts:
|
||||
flash('Classroom is already booked for this time slot.', 'error')
|
||||
return render_template('schedule/add.html', form=form)
|
||||
|
||||
# Check if end time is after start time
|
||||
if form.end_time.data <= form.start_time.data:
|
||||
flash('End time must be after start time.', 'error')
|
||||
return render_template('schedule/add.html', form=form)
|
||||
if conflicting_reservations:
|
||||
flash('This classroom is already reserved for the selected time slot.', 'error')
|
||||
# Get classrooms for the template
|
||||
classroom_list = Classroom.query.filter_by(is_active=True).order_by(Classroom.building, Classroom.code).all()
|
||||
return render_template('schedule/add.html', form=form, classroom_list=classroom_list, title='New Reservation')
|
||||
|
||||
# Check capacity
|
||||
classroom = Classroom.query.get(form.classroom_id.data)
|
||||
if form.expected_attendees.data > classroom.capacity:
|
||||
flash('Number of attendees exceeds classroom capacity.', 'error')
|
||||
return render_template('schedule/add.html', form=form)
|
||||
flash(f'Expected attendees ({form.expected_attendees.data}) exceeds classroom capacity ({classroom.capacity}).', 'error')
|
||||
# Get classrooms for the template
|
||||
classroom_list = Classroom.query.filter_by(is_active=True).order_by(Classroom.building, Classroom.code).all()
|
||||
return render_template('schedule/add.html', form=form, classroom_list=classroom_list, title='New Reservation')
|
||||
|
||||
# Create reservation
|
||||
reservation = Reservation(
|
||||
classroom_id=form.classroom_id.data,
|
||||
commission_id=form.commission_id.data,
|
||||
@@ -102,176 +55,276 @@ def add_reservation():
|
||||
end_time=form.end_time.data,
|
||||
purpose=form.purpose.data,
|
||||
expected_attendees=form.expected_attendees.data,
|
||||
notes=form.notes.data,
|
||||
status=ReservationStatus.PENDING
|
||||
notes=form.notes.data
|
||||
)
|
||||
|
||||
try:
|
||||
db.session.add(reservation)
|
||||
db.session.commit()
|
||||
flash('Reservation created successfully!', 'success')
|
||||
return redirect(url_for('schedule.view_reservation', Id=reservation.id))
|
||||
|
||||
flash('Reservation created successfully! It will be confirmed after review.', 'success')
|
||||
return redirect(url_for('schedule.list_reservations'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('Error creating reservation.', 'error')
|
||||
flash(f'Error creating reservation: {str(e)}', 'error')
|
||||
|
||||
return render_template('schedule/add.html', form=form)
|
||||
# Get classrooms for the template
|
||||
classroom_list = Classroom.query.filter_by(is_active=True).order_by(Classroom.building, Classroom.code).all()
|
||||
|
||||
@schedule_bp.route('/view/<int:Id>')
|
||||
return render_template('schedule/add.html', form=form, classroom_list=classroom_list, title='New Reservation')
|
||||
|
||||
@schedule_bp.route('/list')
|
||||
@login_required
|
||||
def view_reservation(Id):
|
||||
"""View reservation details"""
|
||||
reservation = Reservation.query.get_or_404(Id)
|
||||
return render_template('schedule/view.html', reservation=reservation)
|
||||
def list_reservations():
|
||||
"""List all reservations"""
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
status_filter = request.args.get('status', 'all')
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
|
||||
@schedule_bp.route('/edit/<int:Id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_reservation(Id):
|
||||
"""Edit reservation"""
|
||||
reservation = Reservation.query.get_or_404(Id)
|
||||
# Build query
|
||||
query = Reservation.query
|
||||
|
||||
# Only allow editing pending reservations
|
||||
if reservation.status != ReservationStatus.PENDING:
|
||||
flash('Only pending reservations can be edited.', 'warning')
|
||||
return redirect(url_for('schedule.view_reservation', Id=Id))
|
||||
# Apply filters
|
||||
if status_filter != 'all':
|
||||
try:
|
||||
query = query.filter_by(status=ReservationStatus[status_filter.upper()])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
form = ReservationForm(obj=reservation)
|
||||
if date_from:
|
||||
try:
|
||||
from_date = datetime.strptime(date_from, '%Y-%m-%d')
|
||||
query = query.filter(Reservation.start_time >= from_date)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Populate choices
|
||||
classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
form.classroom_id.choices = [(c.id, f'{c.code} - {c.name} ({c.capacity} seats)') for c in classrooms]
|
||||
if date_to:
|
||||
try:
|
||||
to_date = datetime.strptime(date_to, '%Y-%m-%d') + timedelta(days=1)
|
||||
query = query.filter(Reservation.start_time < to_date)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
commissions = Commission.query.filter_by(is_active=True).all()
|
||||
form.commission_id.choices = [(c.id, f'{c.get_full_code()} - {c.teacher_name}') for c in commissions]
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Check for conflicts (excluding this reservation)
|
||||
conflicts = Reservation.find_conflicts(
|
||||
form.classroom_id.data,
|
||||
form.start_time.data,
|
||||
form.end_time.data,
|
||||
exclude_reservation_id=Id
|
||||
# Order by start time descending
|
||||
reservations = query.order_by(Reservation.start_time.desc()).paginate(
|
||||
page=page, per_page=20, error_out=False
|
||||
)
|
||||
|
||||
if conflicts:
|
||||
flash('Classroom is already booked for this time slot.', 'error')
|
||||
return render_template('schedule/edit.html', form=form, reservation=reservation)
|
||||
return render_template('schedule/list.html',
|
||||
reservations=reservations,
|
||||
status_filter=status_filter,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
title='Reservations')
|
||||
|
||||
# Update reservation
|
||||
reservation.classroom_id = form.classroom_id.data
|
||||
reservation.commission_id = form.commission_id.data
|
||||
reservation.start_time = form.start_time.data
|
||||
reservation.end_time = form.end_time.data
|
||||
reservation.purpose = form.purpose.data
|
||||
reservation.expected_attendees = form.expected_attendees.data
|
||||
reservation.notes = form.notes.data
|
||||
reservation.updated_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
flash('Reservation updated successfully!', 'success')
|
||||
return redirect(url_for('schedule.view_reservation', Id=Id))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('Error updating reservation.', 'error')
|
||||
|
||||
return render_template('schedule/edit.html', form=form, reservation=reservation)
|
||||
|
||||
@schedule_bp.route('/confirm/<int:Id>')
|
||||
@login_required
|
||||
def confirm_reservation(Id):
|
||||
"""Confirm a reservation"""
|
||||
reservation = Reservation.query.get_or_404(Id)
|
||||
|
||||
if reservation.status != ReservationStatus.PENDING:
|
||||
flash('Only pending reservations can be confirmed.', 'warning')
|
||||
return redirect(url_for('schedule.view_reservation', Id=Id))
|
||||
|
||||
try:
|
||||
reservation.status = ReservationStatus.CONFIRMED
|
||||
reservation.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
flash('Reservation confirmed successfully!', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('Error confirming reservation.', 'error')
|
||||
|
||||
return redirect(url_for('schedule.view_reservation', Id=Id))
|
||||
|
||||
@schedule_bp.route('/cancel/<int:Id>')
|
||||
@login_required
|
||||
def cancel_reservation(Id):
|
||||
"""Cancel a reservation"""
|
||||
reservation = Reservation.query.get_or_404(Id)
|
||||
|
||||
if reservation.status not in [ReservationStatus.PENDING, ReservationStatus.CONFIRMED]:
|
||||
flash('Cannot cancel this reservation.', 'warning')
|
||||
return redirect(url_for('schedule.view_reservation', Id=Id))
|
||||
|
||||
try:
|
||||
reservation.status = ReservationStatus.CANCELLED
|
||||
reservation.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
flash('Reservation cancelled successfully!', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('Error cancelling reservation.', 'error')
|
||||
|
||||
return redirect(url_for('schedule.view_reservation', Id=Id))
|
||||
|
||||
@schedule_bp.route('/calendar-data')
|
||||
@login_required
|
||||
def calendar_data():
|
||||
"""API endpoint for calendar data"""
|
||||
start = request.args.get('start')
|
||||
end = request.args.get('end')
|
||||
|
||||
if not start or not end:
|
||||
return jsonify([])
|
||||
|
||||
try:
|
||||
start_date = datetime.fromisoformat(start)
|
||||
end_date = datetime.fromisoformat(end)
|
||||
except ValueError:
|
||||
return jsonify([])
|
||||
|
||||
reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= start_date,
|
||||
Reservation.start_time <= end_date,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
|
||||
).all()
|
||||
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
color = {
|
||||
ReservationStatus.CONFIRMED: '#28a745',
|
||||
ReservationStatus.PENDING: '#ffc107'
|
||||
}.get(reservation.status, '#6c757d')
|
||||
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': f'{reservation.classroom.code} - {reservation.commission.get_full_code()}',
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'url': url_for('schedule.view_reservation', Id=reservation.id),
|
||||
'backgroundColor': color,
|
||||
'borderColor': color
|
||||
})
|
||||
|
||||
return jsonify(events)
|
||||
flash(f'Error loading reservations: {str(e)}', 'error')
|
||||
return render_template('schedule/list.html', reservations=None, title='Reservations')
|
||||
|
||||
@schedule_bp.route('/today')
|
||||
@login_required
|
||||
def today_schedule():
|
||||
"""Show today's schedule"""
|
||||
today = datetime.utcnow().date()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today, datetime.max.time())
|
||||
try:
|
||||
today_start = datetime.combine(date.today(), datetime.min.time())
|
||||
today_end = datetime.combine(date.today(), datetime.max.time())
|
||||
|
||||
# Get today's reservations
|
||||
reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= today_start,
|
||||
Reservation.start_time <= today_end
|
||||
).order_by(Reservation.start_time).all()
|
||||
|
||||
# Group by time blocks for better display
|
||||
time_blocks = {}
|
||||
|
||||
for reservation in reservations:
|
||||
hour_key = reservation.start_time.hour
|
||||
|
||||
if hour_key not in time_blocks:
|
||||
time_blocks[hour_key] = []
|
||||
|
||||
time_blocks[hour_key].append({
|
||||
'reservation': reservation,
|
||||
'classroom': reservation.classroom,
|
||||
'commission': reservation.commission,
|
||||
'user': reservation.user
|
||||
})
|
||||
|
||||
# Get quick stats
|
||||
stats = {
|
||||
'total_reservations': len(reservations),
|
||||
'confirmed': len([r for r in reservations if r.status == ReservationStatus.CONFIRMED]),
|
||||
'pending': len([r for r in reservations if r.status == ReservationStatus.PENDING]),
|
||||
'cancelled': len([r for r in reservations if r.status == ReservationStatus.CANCELLED]),
|
||||
'total_classrooms': Classroom.query.filter_by(is_active=True).count(),
|
||||
'occupied_classrooms': len(set(r.classroom_id for r in reservations
|
||||
if r.status == ReservationStatus.CONFIRMED))
|
||||
}
|
||||
|
||||
return render_template('schedule/today.html',
|
||||
time_blocks=time_blocks,
|
||||
reservations=reservations,
|
||||
stats=stats,
|
||||
today=date.today(),
|
||||
title='Today\'s Schedule')
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Error loading today\'s schedule: {str(e)}', 'error')
|
||||
return render_template('schedule/today.html',
|
||||
time_blocks={},
|
||||
reservations=[],
|
||||
stats={},
|
||||
today=date.today(),
|
||||
title='Today\'s Schedule')
|
||||
|
||||
@schedule_bp.route('/<int:id>/confirm', methods=['POST'])
|
||||
@login_required
|
||||
def confirm_reservation(id):
|
||||
"""Confirm a reservation"""
|
||||
reservation = Reservation.query.get_or_404(id)
|
||||
|
||||
try:
|
||||
# Check if reservation can be confirmed
|
||||
if reservation.status != ReservationStatus.PENDING:
|
||||
flash('Only pending reservations can be confirmed.', 'error')
|
||||
return redirect(request.referrer)
|
||||
|
||||
# Check for conflicts again before confirming
|
||||
conflicts = Reservation.find_conflicts(
|
||||
reservation.classroom_id,
|
||||
reservation.start_time,
|
||||
reservation.end_time
|
||||
)
|
||||
|
||||
if conflicts:
|
||||
flash('Cannot confirm: Time slot is already occupied.', 'error')
|
||||
return redirect(request.referrer)
|
||||
|
||||
reservation.status = ReservationStatus.CONFIRMED
|
||||
db.session.commit()
|
||||
|
||||
flash('Reservation confirmed successfully!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error confirming reservation: {str(e)}', 'error')
|
||||
|
||||
return redirect(request.referrer)
|
||||
|
||||
@schedule_bp.route('/<int:id>/cancel', methods=['POST'])
|
||||
@login_required
|
||||
def cancel_reservation(id):
|
||||
"""Cancel a reservation"""
|
||||
reservation = Reservation.query.get_or_404(id)
|
||||
|
||||
try:
|
||||
# Check if reservation can be cancelled
|
||||
if not reservation.can_be_cancelled():
|
||||
flash('Reservations can only be cancelled at least 24 hours before start time.', 'error')
|
||||
return redirect(request.referrer)
|
||||
|
||||
reservation.status = ReservationStatus.CANCELLED
|
||||
db.session.commit()
|
||||
|
||||
flash('Reservation cancelled successfully!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(f'Error cancelling reservation: {str(e)}', 'error')
|
||||
|
||||
return redirect(request.referrer)
|
||||
|
||||
@schedule_bp.route('/calendar')
|
||||
@login_required
|
||||
def calendar_view():
|
||||
"""Show calendar view"""
|
||||
return render_template('schedule/calendar.html', title='Calendar View')
|
||||
|
||||
# API endpoints
|
||||
@schedule_bp.route('/calendar-data')
|
||||
@login_required
|
||||
def calendar_data():
|
||||
"""Get calendar events data for FullCalendar"""
|
||||
try:
|
||||
# Get date range from query parameters
|
||||
start = request.args.get('start')
|
||||
end = request.args.get('end')
|
||||
|
||||
if start and end:
|
||||
from datetime import datetime
|
||||
start_date = datetime.fromisoformat(start)
|
||||
end_date = datetime.fromisoformat(end)
|
||||
else:
|
||||
# Default to current month
|
||||
today = date.today()
|
||||
start_date = datetime.combine(today.replace(day=1), datetime.min.time())
|
||||
next_month = today.replace(day=28) + timedelta(days=4) # Next month
|
||||
end_date = datetime.combine(next_month.replace(day=1), datetime.max.time())
|
||||
|
||||
# Get reservations in the date range
|
||||
reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= start_date,
|
||||
Reservation.start_time <= end_date,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
|
||||
).order_by(Reservation.start_time).all()
|
||||
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': f"{reservation.commission.get_full_code() if reservation.commission else 'No Commission'} - {reservation.classroom.code_display}",
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'url': f"/schedule/{reservation.id}", # For event clicking
|
||||
'className': f'fc-event-{reservation.status.value.lower()}',
|
||||
'extendedProps': {
|
||||
'classroom': reservation.classroom.code_display if reservation.classroom else 'Unknown',
|
||||
'commission': reservation.commission.get_full_code() if reservation.commission else 'No Commission',
|
||||
'purpose': reservation.purpose or 'No purpose',
|
||||
'attendees': reservation.expected_attendees,
|
||||
'status': reservation.status.value,
|
||||
'notes': reservation.notes or ''
|
||||
}
|
||||
})
|
||||
|
||||
return jsonify(events)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@schedule_bp.route('/api/today-events')
|
||||
@login_required
|
||||
def api_today_events():
|
||||
"""Get today's events as JSON"""
|
||||
try:
|
||||
today_start = datetime.combine(date.today(), datetime.min.time())
|
||||
today_end = datetime.combine(date.today(), datetime.max.time())
|
||||
|
||||
reservations = Reservation.query.filter(
|
||||
Reservation.start_time >= today_start,
|
||||
Reservation.start_time <= today_end,
|
||||
Reservation.status.in_([ReservationStatus.CONFIRMED, ReservationStatus.PENDING])
|
||||
Reservation.status == ReservationStatus.CONFIRMED
|
||||
).order_by(Reservation.start_time).all()
|
||||
|
||||
return render_template('schedule/today.html', reservations=reservations)
|
||||
events = []
|
||||
for reservation in reservations:
|
||||
events.append({
|
||||
'id': reservation.id,
|
||||
'title': f"{reservation.commission.get_full_code()} - {reservation.classroom.code_display}",
|
||||
'start': reservation.start_time.isoformat(),
|
||||
'end': reservation.end_time.isoformat(),
|
||||
'extendedProps': {
|
||||
'classroom': reservation.classroom.code_display,
|
||||
'purpose': reservation.purpose,
|
||||
'attendees': reservation.expected_attendees,
|
||||
'status': reservation.status.value
|
||||
}
|
||||
})
|
||||
|
||||
return jsonify({'events': events})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,198 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Classroom - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-plus-circle text-primary"></i> Add Classroom
|
||||
</h1>
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Classrooms
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-door-open text-primary"></i> Classroom Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" id="classroom-form" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.building.label(class="form-label") }}
|
||||
{{ form.building(class="form-control", placeholder="A") }}
|
||||
{% if form.building.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.building.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.room_number.label(class="form-label") }}
|
||||
{{ form.room_number(class="form-control", placeholder="101") }}
|
||||
{% if form.room_number.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.room_number.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.floor.label(class="form-label") }}
|
||||
{{ form.floor(class="form-control") }}
|
||||
{% if form.floor.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.floor.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.capacity.label(class="form-label") }}
|
||||
{{ form.capacity(class="form-control", placeholder="30") }}
|
||||
{% if form.capacity.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.capacity.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.is_active.label(class="form-check-label") }}
|
||||
<div class="form-check">
|
||||
{{ form.is_active(class="form-check-input") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.description.label(class="form-label") }}
|
||||
{{ form.description(class="form-control", rows="3", placeholder="Optional: Add any additional details about this classroom...") }}
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.description.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Preview Section -->
|
||||
<div class="alert alert-info">
|
||||
<h6 class="alert-heading">
|
||||
<i class="bi bi-info-circle"></i> Preview
|
||||
</h6>
|
||||
<div id="classroom-preview">
|
||||
<p class="mb-0">Fill in the form to see a preview of the classroom code:
|
||||
<span class="fw-bold" id="preview-code">-</span>
|
||||
</p>
|
||||
<p class="mb-0">Full location:
|
||||
<span class="fw-bold" id="preview-location">-</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resources Section -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-tools text-warning"></i> Resources (Optional)
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">You can add classroom resources after creating the classroom.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
function updatePreview() {
|
||||
const building = $('#building').val();
|
||||
const roomNumber = $('#room_number').val();
|
||||
const floor = $('#floor').val();
|
||||
|
||||
if (building && roomNumber && floor) {
|
||||
$('#preview-code').text(`${building}-${roomNumber}`);
|
||||
$('#preview-location').text(`${building} - Floor ${floor}`);
|
||||
} else {
|
||||
$('#preview-code').text('-');
|
||||
$('#preview-location').text('-');
|
||||
}
|
||||
}
|
||||
|
||||
// Update preview when inputs change
|
||||
$('#building, #room_number, #floor').on('input', updatePreview);
|
||||
|
||||
// Initialize preview
|
||||
updatePreview();
|
||||
|
||||
// Form validation
|
||||
$('#classroom-form').on('submit', function(e) {
|
||||
let isValid = true;
|
||||
|
||||
// Required fields validation
|
||||
if (!$('#building').val().trim()) {
|
||||
$('#building').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#building').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#room_number').val().trim()) {
|
||||
$('#room_number').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#room_number').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#floor').val()) {
|
||||
$('#floor').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#floor').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#capacity').val() || $('#capacity').val() < 1) {
|
||||
$('#capacity').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#capacity').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,124 +3,130 @@
|
||||
{% block title %}Classrooms - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="h3 mb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-door-open text-primary"></i> Classrooms
|
||||
<small class="text-muted ms-2">Management</small>
|
||||
</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Classroom
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<!-- Filters -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" name="search"
|
||||
placeholder="Search classrooms..." value="{{ search }}">
|
||||
<input type="text" name="search" class="form-control"
|
||||
placeholder="Search by building, room, description..."
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" name="campus">
|
||||
<option value="">All Campuses</option>
|
||||
<option value="Main Campus" {% if campus == 'Main Campus' %}selected{% endif %}>Main Campus</option>
|
||||
<option value="North Campus" {% if campus == 'North Campus' %}selected{% endif %}>North Campus</option>
|
||||
<option value="Science & Technology Campus" {% if campus == 'Science & Technology Campus' %}selected{% endif %}>Science & Technology Campus</option>
|
||||
<option value="Downtown Campus" {% if campus == 'Downtown Campus' %}selected{% endif %}>Downtown Campus</option>
|
||||
</select>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="active_only"
|
||||
id="activeOnly" {% if active_only %}checked{% endif %}>
|
||||
<label class="form-check-label" for="activeOnly">
|
||||
Show active classrooms only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
<i class="bi bi-search"></i> Search
|
||||
</button>
|
||||
<a href="{{ url_for('classrooms.list_classrooms') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-clockwise"></i> Reset
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-2 text-end">
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> Add Classroom
|
||||
<i class="bi bi-x-circle"></i> Clear
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classrooms Grid -->
|
||||
<div class="row">
|
||||
{% if classrooms and classrooms.items %}
|
||||
<div class="row mt-4">
|
||||
{% for classroom in classrooms.items %}
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="col-lg-4 col-md-6 mb-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white border-0">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">{{ classroom.code }}</h5>
|
||||
<p class="text-muted small mb-0">{{ classroom.name }}</p>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.view_classroom', Id=classroom.id) }}">
|
||||
<i class="bi bi-eye"></i> View Details
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('classrooms.edit_classroom', Id=classroom.id) }}">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a></li>
|
||||
<li><a class="dropdown-item text-danger" href="{{ url_for('classrooms.delete_classroom', Id=classroom.id) }}">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-building text-primary me-2"></i>
|
||||
<span class="small">{{ classroom.building }}</span>
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">
|
||||
{{ classroom.code }}
|
||||
</h5>
|
||||
<p class="text-muted small mb-2">
|
||||
<i class="bi bi-geo-alt"></i> {{ classroom.get_full_location() }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge {% if classroom.is_active %}bg-success{% else %}bg-secondary{% endif %}">
|
||||
{% if classroom.is_active %}Active{% else %}Inactive{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row small">
|
||||
<div class="col-6">
|
||||
<span class="text-muted">Capacity:</span>
|
||||
<strong>{{ classroom.capacity }}</strong>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-layers text-info me-2"></i>
|
||||
<span class="small">Floor {{ classroom.floor }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-people text-success me-2"></i>
|
||||
<span class="small">{{ classroom.capacity }} seats</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-geo-alt text-warning me-2"></i>
|
||||
<span class="small">{{ classroom.campus_location }}</span>
|
||||
</div>
|
||||
<span class="text-muted">Building:</span>
|
||||
<strong>{{ classroom.building }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if classroom.description %}
|
||||
<div class="mt-3">
|
||||
<p class="small text-muted">{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}</p>
|
||||
</div>
|
||||
<p class="card-text small text-muted mt-2">
|
||||
{{ classroom.description[:100] }}{% if classroom.description|length > 100 %}...{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="badge bg-success">
|
||||
<i class="bi bi-check-circle"></i> Active
|
||||
</span>
|
||||
<!-- Resources indicator -->
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-tools"></i>
|
||||
{% if classroom.resources %}
|
||||
{{ classroom.resources|length }} resources
|
||||
{% else %}
|
||||
No resources registered
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-white border-0">
|
||||
<div class="d-grid">
|
||||
<a href="{{ url_for('classrooms.view_classroom', Id=classroom.id) }}"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-eye"></i> View Details
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<a href="{{ url_for('classrooms.view_classroom', id=classroom.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</a>
|
||||
<a href="{{ url_for('classrooms.edit_classroom', id=classroom.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('classrooms.toggle_classroom', id=classroom.id) }}"
|
||||
class="d-inline">
|
||||
<button type="submit" class="btn btn-sm
|
||||
{% if classroom.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
|
||||
{% if classroom.is_active %}
|
||||
<i class="bi bi-pause-circle"></i> Deactivate
|
||||
{% else %}
|
||||
<i class="bi bi-play-circle"></i> Activate
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,12 +136,12 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if classrooms.pages > 1 %}
|
||||
<nav aria-label="Classrooms pagination">
|
||||
<nav aria-label="Classroom pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if classrooms.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, campus=campus) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.prev_num, search=search, active_only=active_only) }}">
|
||||
Previous
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -144,7 +150,7 @@
|
||||
{% if page_num %}
|
||||
{% if page_num != classrooms.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, campus=campus) }}">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=page_num, search=search, active_only=active_only) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -155,15 +161,15 @@
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
<span class="page-link">…</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if classrooms.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, campus=campus) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
<a class="page-link" href="{{ url_for('classrooms.list_classrooms', page=classrooms.next_num, search=search, active_only=active_only) }}">
|
||||
Next
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
@@ -171,14 +177,25 @@
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% if not classrooms.items %}
|
||||
{% else %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-x-circle text-muted" style="font-size: 3rem;"></i>
|
||||
<h4 class="text-muted mt-3">No classrooms found</h4>
|
||||
<p class="text-muted">Try adjusting your search criteria or add a new classroom.</p>
|
||||
<i class="bi bi-door-open text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="mt-3 text-muted">No classrooms found</h4>
|
||||
<p class="text-muted">
|
||||
{% if search %}
|
||||
No classrooms match your search criteria.
|
||||
{% else %}
|
||||
No classrooms have been registered yet.
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="{{ url_for('classrooms.add_classroom') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add First Classroom
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,330 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Reservation - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-calendar-plus text-success"></i> New Reservation
|
||||
</h1>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Reservations
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-calendar3 text-success"></i> Reservation Details
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" id="reservation-form" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.classroom_id.label(class="form-label") }}
|
||||
{{ form.classroom_id(class="form-select", id="classroom-select") }}
|
||||
{% if form.classroom_id.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.classroom_id.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Select a classroom for your reservation</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.commission_id.label(class="form-label") }}
|
||||
{{ form.commission_id(class="form-select", id="commission-select") }}
|
||||
{% if form.commission_id.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.commission_id.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Select the subject/commission</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.start_time.label(class="form-label") }}
|
||||
{{ form.start_time(class="form-control", id="start-time") }}
|
||||
{% if form.start_time.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.start_time.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">When will the class start?</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.end_time.label(class="form-label") }}
|
||||
{{ form.end_time(class="form-control", id="end-time") }}
|
||||
{% if form.end_time.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.end_time.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">When will the class end?</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
{{ form.purpose.label(class="form-label") }}
|
||||
{{ form.purpose(class="form-control", placeholder="Regular class, exam, study session, etc.") }}
|
||||
{% if form.purpose.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.purpose.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
{{ form.expected_attendees.label(class="form-label") }}
|
||||
{{ form.expected_attendees(class="form-control", placeholder="30") }}
|
||||
{% if form.expected_attendees.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.expected_attendees.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">Number of expected attendees</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.notes.label(class="form-label") }}
|
||||
{{ form.notes(class="form-control", rows="3", placeholder="Any additional notes or special requirements...") }}
|
||||
{% if form.notes.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.notes.errors[0] }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Summary Section -->
|
||||
<div class="alert alert-info" id="reservation-summary">
|
||||
<h6 class="alert-heading">
|
||||
<i class="bi bi-info-circle"></i> Reservation Summary
|
||||
</h6>
|
||||
<div id="summary-content">
|
||||
<p class="mb-2">
|
||||
<strong>Selected Classroom:</strong>
|
||||
<span id="summary-classroom">Not selected</span>
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<strong>Duration:</strong>
|
||||
<span id="summary-duration">Not calculated</span>
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Commission:</strong>
|
||||
<span id="summary-commission">Not selected</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> Cancel
|
||||
</a>
|
||||
{{ form.submit(class="btn btn-success") }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions Card -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-question-circle text-info"></i> Reservation Guidelines
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-primary">📅 Booking Rules</h6>
|
||||
<ul class="small">
|
||||
<li>Reservations can be made up to 3 months in advance</li>
|
||||
<li>Cannot book in the past</li>
|
||||
<li>Maximum duration: 8 hours per reservation</li>
|
||||
<li>Cancellations must be 24 hours before start time</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-warning">🚨 Important Notes</h6>
|
||||
<ul class="small">
|
||||
<li>Check classroom capacity before booking</li>
|
||||
<li>Pending reservations require approval</li>
|
||||
<li>Time slots must not overlap</li>
|
||||
<li>System automatically detects conflicts</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Classroom data for capacity checking
|
||||
const classroomData = {};
|
||||
{% for classroom in classroom_list %}
|
||||
classroomData[{{ classroom.id }}] = {
|
||||
capacity: {{ classroom.capacity }},
|
||||
code: "{{ classroom.code }}"
|
||||
};
|
||||
{% endfor %}
|
||||
|
||||
function updateSummary() {
|
||||
const classroomId = $('#classroom-select').val();
|
||||
const commissionId = $('#commission-select').val();
|
||||
const startTime = $('#start-time').val();
|
||||
const endTime = $('#end-time').val();
|
||||
|
||||
// Update classroom summary
|
||||
if (classroomId && classroomData[classroomId]) {
|
||||
$('#summary-classroom').text(`${classroomData[classroomId].code} (Capacity: ${classroomData[classroomId].capacity})`);
|
||||
} else {
|
||||
$('#summary-classroom').text('Not selected');
|
||||
}
|
||||
|
||||
// Update commission summary
|
||||
if (commissionId) {
|
||||
const commissionText = $('#commission-select option:selected').text();
|
||||
$('#summary-commission').text(commissionText);
|
||||
} else {
|
||||
$('#summary-commission').text('Not selected');
|
||||
}
|
||||
|
||||
// Calculate duration
|
||||
if (startTime && endTime) {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
|
||||
if (end > start) {
|
||||
const diffMs = end - start;
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
let durationText = '';
|
||||
if (diffHours > 0) {
|
||||
durationText += `${diffHours} hour${diffHours > 1 ? 's' : ''}`;
|
||||
}
|
||||
if (diffMins > 0) {
|
||||
if (durationText) durationText += ' ';
|
||||
durationText += `${diffMins} minute${diffMins > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
$('#summary-duration').text(durationText);
|
||||
} else {
|
||||
$('#summary-duration').text('Invalid time range');
|
||||
}
|
||||
} else {
|
||||
$('#summary-duration').text('Not calculated');
|
||||
}
|
||||
}
|
||||
|
||||
// Check capacity vs expected attendees
|
||||
function checkCapacity() {
|
||||
const classroomId = $('#classroom-select').val();
|
||||
const attendees = parseInt($('#expected_attendees').val()) || 0;
|
||||
|
||||
if (classroomId && classroomData[classroomId] && attendees > 0) {
|
||||
const capacity = classroomData[classroomId].capacity;
|
||||
|
||||
if (attendees > capacity) {
|
||||
$('#expected_attendees').addClass('is-invalid');
|
||||
$('#expected_attendees').after(`
|
||||
<div class="invalid-feedback">
|
||||
Expected attendees (${attendees}) exceeds classroom capacity (${capacity})
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
$('#expected_attendees').removeClass('is-invalid');
|
||||
$('#expected_attendees').siblings('.invalid-feedback').remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update on change
|
||||
$('#classroom-select, #commission-select, #start-time, #end-time, #expected_attendees').on('change', updateSummary);
|
||||
$('#expected_attendees').on('input', checkCapacity);
|
||||
|
||||
// Initialize summary
|
||||
updateSummary();
|
||||
|
||||
// Form validation
|
||||
$('#reservation-form').on('submit', function(e) {
|
||||
let isValid = true;
|
||||
|
||||
// Check required fields
|
||||
if (!$('#classroom-select').val()) {
|
||||
$('#classroom-select').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#classroom-select').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#commission-select').val()) {
|
||||
$('#commission-select').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#commission-select').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#start-time').val()) {
|
||||
$('#start-time').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#start-time').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
if (!$('#end-time').val()) {
|
||||
$('#end-time').addClass('is-invalid');
|
||||
isValid = false;
|
||||
} else {
|
||||
$('#end-time').removeClass('is-invalid');
|
||||
}
|
||||
|
||||
// Validate time range
|
||||
const startTime = new Date($('#start-time').val());
|
||||
const endTime = new Date($('#end-time').val());
|
||||
|
||||
if (endTime <= startTime) {
|
||||
$('#end-time').addClass('is-invalid');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Check capacity
|
||||
checkCapacity();
|
||||
if ($('#expected_attendees').hasClass('is-invalid')) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
});
|
||||
|
||||
// Set minimum datetime to current
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
$('#start-time, #end-time').attr('min', now.toISOString().slice(0, 16));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,291 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reservations List - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-calendar-list text-primary"></i> Reservations
|
||||
<small class="text-muted ms-2">Manage & View All Reservations</small>
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('schedule.calendar_view') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-calendar3"></i> Calendar View
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-calendar-day"></i> Today
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Reservation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
<option value="all" {% if status_filter == 'all' %}selected{% endif %}>All Status</option>
|
||||
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="confirmed" {% if status_filter == 'confirmed' %}selected{% endif %}>Confirmed</option>
|
||||
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">From Date</label>
|
||||
<input type="date" name="date_from" class="form-control" value="{{ date_from or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">To Date</label>
|
||||
<input type="date" name="date_to" class="form-control" value="{{ date_to or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label"> </label>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
<i class="bi bi-funnel"></i> Filter
|
||||
</button>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reservations List -->
|
||||
{% if reservations and reservations.items %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="border-0">Classroom</th>
|
||||
<th class="border-0">Commission</th>
|
||||
<th class="border-0">Date & Time</th>
|
||||
<th class="border-0">Purpose</th>
|
||||
<th class="border-0">Attendees</th>
|
||||
<th class="border-0">Status</th>
|
||||
<th class="border-0">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reservation in reservations.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar avatar-sm bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2">
|
||||
<i class="bi bi-door-open"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">{{ reservation.classroom.code_display }}</div>
|
||||
<small class="text-muted">Capacity: {{ reservation.classroom.capacity }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div>
|
||||
<div class="fw-bold">
|
||||
{% if reservation.commission %}
|
||||
{{ reservation.commission.get_full_code() or 'N/A' }}
|
||||
{% else %}
|
||||
No Commission
|
||||
{% endif %}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{% if reservation.commission and reservation.commission.teacher_name %}
|
||||
{{ reservation.commission.teacher_name }}
|
||||
{% else %}
|
||||
No Teacher
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div>
|
||||
<div class="fw-bold">{{ reservation.start_time.strftime('%b %d, %Y') }}</div>
|
||||
<small class="text-muted">
|
||||
{{ reservation.start_time.strftime('%I:%M %p') }} - {{ reservation.end_time.strftime('%I:%M %p') }}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span class="text-truncate" style="max-width: 150px; display: inline-block;">
|
||||
{{ reservation.purpose or 'No purpose specified' }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-people text-muted me-1"></i>
|
||||
{{ reservation.expected_attendees }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if reservation.status.value == 'confirmed' %}
|
||||
<span class="badge bg-success">Confirmed</span>
|
||||
{% elif reservation.status.value == 'pending' %}
|
||||
<span class="badge bg-warning">Pending</span>
|
||||
{% elif reservation.status.value == 'cancelled' %}
|
||||
<span class="badge bg-danger">Cancelled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ reservation.status.value }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if reservation.status.value == 'pending' %}
|
||||
<form method="POST" action="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-outline-success btn-sm" title="Confirm">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if reservation.status.value in ['pending', 'confirmed'] %}
|
||||
<form method="POST" action="{{ url_for('schedule.cancel_reservation', id=reservation.id) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="Cancel">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if reservation.notes %}
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
title="{{ reservation.notes }}" data-bs-toggle="tooltip">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if reservations.pages > 1 %}
|
||||
<div class="card-footer bg-white border-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted small">
|
||||
Showing {{ reservations.items|length }} of {{ reservations.total }} reservations
|
||||
</span>
|
||||
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
{% if reservations.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.prev_num, status=status_filter, date_from=date_from, date_to=date_to) }}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in reservations.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != reservations.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=page_num, status=status_filter, date_from=date_from, date_to=date_to) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">…</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if reservations.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('schedule.list_reservations', page=reservations.next_num, status=status_filter, date_from=date_from, date_to=date_to) }}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-calendar-x display-1 text-muted"></i>
|
||||
</div>
|
||||
<h5 class="text-muted">No reservations found</h5>
|
||||
<p class="text-muted">
|
||||
{% if status_filter != 'all' or date_from or date_to %}
|
||||
No reservations match your current filters.
|
||||
<a href="{{ url_for('schedule.list_reservations') }}">Clear filters</a> to see all reservations.
|
||||
{% else %}
|
||||
There are no reservations in the system yet.
|
||||
<a href="{{ url_for('schedule.add_reservation') }}">Create your first reservation</a>.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="d-flex justify-content-center gap-2 mt-3">
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Add Reservation
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.today_schedule') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-calendar-day"></i> Today's Schedule
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Initialize tooltips
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
});
|
||||
|
||||
// Form submission confirmation
|
||||
document.querySelectorAll('form[action*="cancel"]').forEach(function(form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!confirm('Are you sure you want to cancel this reservation?')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('form[action*="confirm"]').forEach(function(form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!confirm('Are you sure you want to confirm this reservation?')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,397 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Today's Schedule - Edu-Space Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid pt-5 mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">
|
||||
<i class="bi bi-calendar-today text-info"></i> Today's Schedule
|
||||
<small class="text-muted ms-2">{{ today.strftime('%B %d, %Y') }}</small>
|
||||
</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> New Reservation
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2">
|
||||
<i class="bi bi-list"></i> All Reservations
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Total Reservations</h6>
|
||||
<h2 class="mb-0">{{ stats.total_reservations }}</h2>
|
||||
</div>
|
||||
<div class="bg-info bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-calendar3 text-info fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Confirmed</h6>
|
||||
<h2 class="mb-0">{{ stats.confirmed }}</h2>
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-check-circle text-success fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Pending</h6>
|
||||
<h2 class="mb-0">{{ stats.pending }}</h2>
|
||||
</div>
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-clock text-warning fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-muted mb-2">Active Classrooms</h6>
|
||||
<h2 class="mb-0">{{ stats.occupied_classrooms }}/{{ stats.total_classrooms }}</h2>
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
|
||||
<i class="bi bi-door-open text-primary fs-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Schedule -->
|
||||
{% if time_blocks %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-clock-history text-info"></i> Timeline View
|
||||
</h5>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary" id="refresh-btn">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" id="toggle-view-btn">
|
||||
<i class="bi bi-grid"></i> Grid View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="timeline-view">
|
||||
{% for hour in range(7, 22) %}
|
||||
<div class="row border-bottom py-3 align-items-start">
|
||||
<div class="col-md-2 col-3">
|
||||
<div class="text-center">
|
||||
<strong>{{ "%02d:00"|format(hour) }}</strong>
|
||||
<br>
|
||||
<small class="text-muted">{{ hour+1 }}:00</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-10 col-9">
|
||||
{% if time_blocks[hour] %}
|
||||
<div class="row">
|
||||
{% for item in time_blocks[hour] %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h6 class="card-title mb-1">
|
||||
{{ item.commission.get_full_code() }}
|
||||
</h6>
|
||||
<p class="text-muted small mb-2 mb-md-0">
|
||||
<i class="bi bi-geo-alt"></i> {{ item.classroom.code }}
|
||||
<br class="d-md-none">
|
||||
<i class="bi bi-person ms-md-2"></i> {{ item.user.first_name }} {{ item.user.last_name }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge bg-{% if item.reservation.status.value == 'CONFIRMED' %}success{% elif item.reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}">
|
||||
{{ item.reservation.status.value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row small">
|
||||
<div class="col-md-6">
|
||||
<i class="bi bi-clock"></i>
|
||||
{{ item.reservation.start_time.strftime('%H:%M') }} - {{ item.reservation.end_time.strftime('%H:%M') }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<i class="bi bi-people"></i>
|
||||
{{ item.reservation.expected_attendees }} attendees
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if item.reservation.purpose %}
|
||||
<p class="card-text small text-muted mt-2">
|
||||
{{ item.reservation.purpose }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('schedule.view_reservation', id=item.reservation.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> Details
|
||||
</a>
|
||||
{% if item.reservation.status.value == 'PENDING' %}
|
||||
<a href="{{ url_for('schedule.confirm_reservation', id=item.reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-success ms-1"
|
||||
onclick="return confirm('Confirm this reservation?')">
|
||||
<i class="bi bi-check-circle"></i> Confirm
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-3">
|
||||
<i class="bi bi-circle"></i> No reservations scheduled
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Grid View (hidden by default) -->
|
||||
<div id="grid-view" style="display: none;">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Classroom</th>
|
||||
<th>Subject</th>
|
||||
<th>Teacher</th>
|
||||
<th>Purpose</th>
|
||||
<th>Attendees</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reservation in reservations %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ reservation.start_time.strftime('%H:%M') }} -
|
||||
{{ reservation.end_time.strftime('%H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
<i class="bi bi-geo-alt"></i>
|
||||
{{ reservation.classroom.code }}
|
||||
</td>
|
||||
<td>{{ reservation.commission.get_full_code() }}</td>
|
||||
<td>{{ reservation.user.first_name }} {{ reservation.user.last_name }}</td>
|
||||
<td>{{ reservation.purpose }}</td>
|
||||
<td>{{ reservation.expected_attendees }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if reservation.status.value == 'CONFIRMED' %}success{% elif reservation.status.value == 'PENDING' %}warning{% else %}secondary{% endif %}">
|
||||
{{ reservation.status.value }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('schedule.view_reservation', id=reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if reservation.status.value == 'PENDING' %}
|
||||
<a href="{{ url_for('schedule.confirm_reservation', id=reservation.id) }}"
|
||||
class="btn btn-sm btn-outline-success ms-1"
|
||||
onclick="return confirm('Confirm this reservation?')">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Summary -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-graph-up text-warning"></i> Daily Analytics
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<i class="bi bi-door-open-fill text-success" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
<h6>Most Active Hours</h6>
|
||||
<!-- Calculate peak hours from data -->
|
||||
{% set peak_hours = [] %}
|
||||
{% for item in reservations %}
|
||||
{% set slot = item.reservation.start_time.hour %}
|
||||
{% if slot not in peak_hours %}
|
||||
{% set _ = peak_hours.append(slot) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<p class="text-muted">
|
||||
{% if peak_hours %}
|
||||
{% for hour in peak_hours[:3]|sort %}
|
||||
{{ "%02d:00"|format(hour) }}{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
No data available
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<i class="bi bi-building text-info" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
<h6>Busiest Classrooms</h6>
|
||||
{% set usage_counter = {} %}
|
||||
{% for item in reservations %}
|
||||
{% set code = item.classroom.code %}
|
||||
{% if code not in usage_counter %}
|
||||
{% set _ = usage_counter.update({code: 1}) %}
|
||||
{% else %}
|
||||
{% set _ = usage_counter.update({code: usage_counter[code] + 1}) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<p class="text-muted">
|
||||
{% if usage_counter %}
|
||||
{% for code, count in usage_counter.items()|sort(attribute='1', reverse=true)|slice(3) %}
|
||||
{{ code }} ({{ count }}){% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
No data available
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<i class="bi bi-clock-fill text-primary" style="font-size: 2rem;"></i>
|
||||
</div>
|
||||
<h6>Total Hours Booked</h6>
|
||||
{% set total_minutes = 0 %}
|
||||
{% for item in reservations %}
|
||||
{% set duration = (item.reservation.end_time - item.reservation.start_time).total_seconds() / 60 %}
|
||||
{% set _ = total_minutes.__iadd__(duration) %}
|
||||
{% endfor %}
|
||||
<p class="text-muted">
|
||||
{{ (total_minutes / 60)|round(1) }} hours total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-calendar-x text-muted" style="font-size: 4rem;"></i>
|
||||
<h4 class="mt-3 text-muted">No reservations scheduled for today</h4>
|
||||
<p class="text-muted">
|
||||
There are no classroom reservations for {{ today.strftime('%B %d, %Y') }}.
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('schedule.add_reservation') }}" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> Schedule First Class
|
||||
</a>
|
||||
<a href="{{ url_for('schedule.list_reservations') }}" class="btn btn-outline-primary ms-2">
|
||||
<i class="bi bi-calendar-week"></i>View Week Schedule
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Toggle between timeline and grid view
|
||||
$('#toggle-view-btn').on('click', function() {
|
||||
const timelineView = $('#timeline-view');
|
||||
const gridView = $('#grid-view');
|
||||
const btn = $(this);
|
||||
|
||||
if (timelineView.is(':visible')) {
|
||||
timelineView.hide();
|
||||
gridView.show();
|
||||
btn.html('<i class="bi bi-list"></i> Timeline View');
|
||||
} else {
|
||||
timelineView.show();
|
||||
gridView.hide();
|
||||
btn.html('<i class="bi bi-grid"></i> Grid View');
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh the page
|
||||
$('#refresh-btn').on('click', function() {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// Auto-refresh every 5 minutes
|
||||
setInterval(function() {
|
||||
location.reload();
|
||||
}, 300000); // 5 minutes = 300000 milliseconds
|
||||
|
||||
// Highlight current time
|
||||
const currentHour = new Date().getHours();
|
||||
if (currentHour >= 7 && currentHour < 22) {
|
||||
const timeBlockIndex = currentHour - 7;
|
||||
$('.row.border-bottom').eq(timeBlockIndex).addClass('bg-primary bg-opacity-10');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user