feat: add weekly schedule management page with Drag-and-Drop support and custom context processors

This commit is contained in:
Xiao Furen 2026-08-04 07:17:23 +08:00
parent 7b5abd244f
commit 89428f773b
2 changed files with 252 additions and 214 deletions

View file

@ -1,9 +1,14 @@
from django.utils import translation
def language_processor(request): def language_processor(request):
""" """
Context processor to make current_lang available across all template renders. Context processor to make current_lang available across all template renders.
Also activates Django's gettext translation so {% trans %} tags render correctly.
Defaults to 'zh' (Traditional Chinese). Defaults to 'zh' (Traditional Chinese).
""" """
lang = request.session.get('lang', 'zh') lang = request.session.get('lang', 'zh')
target_lang = 'en' if lang == 'en' else 'zh-hant'
translation.activate(target_lang)
return { return {
'current_lang': lang 'current_lang': lang
} }

View file

@ -16,12 +16,13 @@
<i class="fas fa-clock me-1"></i>{{ m1_year }}年{{ m1_month }}月 & {{ m2_year }}年{{ m2_month }}月 <i class="fas fa-clock me-1"></i>{{ m1_year }}年{{ m1_month }}月 & {{ m2_year }}年{{ m2_month }}月
</span> </span>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<form method="post" action="{% url 'verify_schedule_rules' %}" class="d-inline"> <form method="post" action="{% url 'verify_schedule_rules' %}" class="d-inline">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-warning btn-sm shadow-sm text-dark fw-bold" onclick="return confirm('確定要校正並驗證排程規則嗎?(如為交換生將自動刪除其卡片,並清理重複或補齊缺漏排程)');"> <button type="submit" class="btn btn-warning btn-sm shadow-sm text-dark fw-bold"
<i class="fas fa-check-double me-1"></i>{% trans "校正並驗證排程規則" %} onclick="return confirm('確定要校正並驗證排程規則嗎?(如為交換生將自動刪除其卡片,並清理重複或補齊缺漏排程)');">
</button> <i class="fas fa-check-double me-1"></i>{% trans "校正並驗證排程規則" %}
</form> </button>
</form>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -59,6 +60,11 @@
</div> </div>
</div> </div>
<div class="alert alert-info small mb-4 shadow-sm rounded-3 border-start border-4 border-info">
<i class="fas fa-hand-pointer text-primary me-2"></i>
<strong>{% trans "經調整後但仍未能報告者,須準備一小時之進度簡報,當週向指導教授當面報告,次週週會補報。若連續兩次應報告而未報告,將視同解除指導關係。" %}</strong>
</div>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="alert alert-info small mb-4 shadow-sm rounded-3 border-start border-4 border-info"> <div class="alert alert-info small mb-4 shadow-sm rounded-3 border-start border-4 border-info">
<i class="fas fa-hand-pointer text-primary me-2"></i> <i class="fas fa-hand-pointer text-primary me-2"></i>
@ -78,61 +84,74 @@
</h3> </h3>
<div class="row g-4"> <div class="row g-4">
{% for item in schedule_m1 %} {% for item in schedule_m1 %}
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card shadow-sm border-0 rounded-3 h-100 monday-drop-target" data-date="{{ item.formatted }}" style="transition: all 0.2s ease-in-out;"> <div class="card shadow-sm border-0 rounded-3 h-100 monday-drop-target" data-date="{{ item.formatted }}"
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-3"> style="transition: all 0.2s ease-in-out;">
<h5 class="mb-0 fw-bold"> <div class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-3">
<i class="fas fa-calendar-check me-2"></i>{{ item.formatted }} (星期一) <h5 class="mb-0 fw-bold">
</h5> <i class="fas fa-calendar-check me-2"></i>{{ item.formatted }} (星期一)
<span class="badge bg-light text-primary fs-6">{{ item.students|length }} 位報告者</span> </h5>
</div> <span class="badge bg-light text-primary fs-6">{{ item.students|length }} 位報告者</span>
<div class="card-body p-0"> </div>
{% if item.students %} <div class="card-body p-0">
<div class="table-responsive"> {% if item.students %}
<table class="table table-hover align-middle mb-0"> <div class="table-responsive">
<thead class="table-light"> <table class="table table-hover align-middle mb-0">
<tr> <thead class="table-light">
<th scope="col" style="width: 25%;">學號</th> <tr>
<th scope="col" style="width: 25%;">姓名</th> <th scope="col" style="width: 25%;">學號</th>
<th scope="col" style="width: 25%;">學位類別</th> <th scope="col" style="width: 25%;">姓名</th>
<th scope="col" style="width: 25%;">身分類別</th> <th scope="col" style="width: 25%;">學位類別</th>
</tr> <th scope="col" style="width: 25%;">身分類別</th>
</thead> </tr>
<tbody> </thead>
{% for student in item.students %} <tbody>
<tr {% if user.is_authenticated %}draggable="true" class="draggable-student" style="cursor: grab;" data-student-id="{{ student.student_id }}" data-student-name="{{ student.name }}" data-from-date="{{ item.formatted }}"{% endif %}> {% for student in item.students %}
<td class="fw-bold"> {% if user.is_authenticated %}
{% if user.is_authenticated %}<i class="fas fa-grip-vertical text-muted me-2"></i>{% endif %} <tr draggable="true" class="draggable-student" style="cursor: grab;"
<code>{{ student.student_id }}</code> data-student-id="{{ student.student_id }}" data-student-name="{{ student.name }}"
</td> data-from-date="{{ item.formatted }}">
<td>{{ student.name }}</td> {% else %}
<td> <tr>
{% if student.degree_type == '博士生' %} {% endif %}
<span class="badge bg-danger text-white"><i class="fas fa-user-graduate me-1"></i>博士生</span> <td class="fw-bold">
{% else %} {% if user.is_authenticated %}<i
<span class="badge bg-info text-dark"><i class="fas fa-graduation-cap me-1"></i>碩士生</span> class="fas fa-grip-vertical text-muted me-2"></i>{% endif %}
{% endif %} <code>{{ student.student_id }}</code>
</td> </td>
<td> <td>{{ student.name }}</td>
{% if student.student_category == '在職生' %} <td>
<span class="badge bg-warning text-dark"><i class="fas fa-user-tie me-1"></i>在職生</span> {% if student.degree_type == '博士生' %}
{% elif student.student_category == '交換中' %} <span class="badge bg-danger text-white"><i
<span class="badge bg-secondary text-white"><i class="fas fa-exchange-alt me-1"></i>交換中</span> class="fas fa-user-graduate me-1"></i>博士生</span>
{% else %} {% else %}
<span class="badge bg-success text-white"><i class="fas fa-user me-1"></i>一般生</span> <span class="badge bg-info text-dark"><i
{% endif %} class="fas fa-graduation-cap me-1"></i>碩士生</span>
</td> {% endif %}
</tr> </td>
{% endfor %} <td>
</tbody> {% if student.student_category == '在職生' %}
</table> <span class="badge bg-warning text-dark"><i
</div> class="fas fa-user-tie me-1"></i>在職生</span>
{% else %} {% elif student.student_category == '交換中' %}
<p class="text-center text-muted py-4 mb-0">當日無排定報告者 (可拖曳學生至此)</p> <span class="badge bg-secondary text-white"><i
{% endif %} class="fas fa-exchange-alt me-1"></i>交換中</span>
{% else %}
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>一般生</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %}
<p class="text-center text-muted py-4 mb-0">當日無排定報告者 (可拖曳學生至此)</p>
{% endif %}
</div> </div>
</div> </div>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -144,61 +163,74 @@
</h3> </h3>
<div class="row g-4"> <div class="row g-4">
{% for item in schedule_m2 %} {% for item in schedule_m2 %}
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card shadow-sm border-0 rounded-3 h-100 monday-drop-target" data-date="{{ item.formatted }}" style="transition: all 0.2s ease-in-out;"> <div class="card shadow-sm border-0 rounded-3 h-100 monday-drop-target" data-date="{{ item.formatted }}"
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center py-3"> style="transition: all 0.2s ease-in-out;">
<h5 class="mb-0 fw-bold"> <div class="card-header bg-success text-white d-flex justify-content-between align-items-center py-3">
<i class="fas fa-calendar-check me-2"></i>{{ item.formatted }} (星期一) <h5 class="mb-0 fw-bold">
</h5> <i class="fas fa-calendar-check me-2"></i>{{ item.formatted }} (星期一)
<span class="badge bg-light text-success fs-6">{{ item.students|length }} 位報告者</span> </h5>
</div> <span class="badge bg-light text-success fs-6">{{ item.students|length }} 位報告者</span>
<div class="card-body p-0"> </div>
{% if item.students %} <div class="card-body p-0">
<div class="table-responsive"> {% if item.students %}
<table class="table table-hover align-middle mb-0"> <div class="table-responsive">
<thead class="table-light"> <table class="table table-hover align-middle mb-0">
<tr> <thead class="table-light">
<th scope="col" style="width: 25%;">學號</th> <tr>
<th scope="col" style="width: 25%;">姓名</th> <th scope="col" style="width: 25%;">學號</th>
<th scope="col" style="width: 25%;">學位類別</th> <th scope="col" style="width: 25%;">姓名</th>
<th scope="col" style="width: 25%;">身分類別</th> <th scope="col" style="width: 25%;">學位類別</th>
</tr> <th scope="col" style="width: 25%;">身分類別</th>
</thead> </tr>
<tbody> </thead>
{% for student in item.students %} <tbody>
<tr {% if user.is_authenticated %}draggable="true" class="draggable-student" style="cursor: grab;" data-student-id="{{ student.student_id }}" data-student-name="{{ student.name }}" data-from-date="{{ item.formatted }}"{% endif %}> {% for student in item.students %}
<td class="fw-bold"> {% if user.is_authenticated %}
{% if user.is_authenticated %}<i class="fas fa-grip-vertical text-muted me-2"></i>{% endif %} <tr draggable="true" class="draggable-student" style="cursor: grab;"
<code>{{ student.student_id }}</code> data-student-id="{{ student.student_id }}" data-student-name="{{ student.name }}"
</td> data-from-date="{{ item.formatted }}">
<td>{{ student.name }}</td> {% else %}
<td> <tr>
{% if student.degree_type == '博士生' %} {% endif %}
<span class="badge bg-danger text-white"><i class="fas fa-user-graduate me-1"></i>博士生</span> <td class="fw-bold">
{% else %} {% if user.is_authenticated %}<i
<span class="badge bg-info text-dark"><i class="fas fa-graduation-cap me-1"></i>碩士生</span> class="fas fa-grip-vertical text-muted me-2"></i>{% endif %}
{% endif %} <code>{{ student.student_id }}</code>
</td> </td>
<td> <td>{{ student.name }}</td>
{% if student.student_category == '在職生' %} <td>
<span class="badge bg-warning text-dark"><i class="fas fa-user-tie me-1"></i>在職生</span> {% if student.degree_type == '博士生' %}
{% elif student.student_category == '交換中' %} <span class="badge bg-danger text-white"><i
<span class="badge bg-secondary text-white"><i class="fas fa-exchange-alt me-1"></i>交換中</span> class="fas fa-user-graduate me-1"></i>博士生</span>
{% else %} {% else %}
<span class="badge bg-success text-white"><i class="fas fa-user me-1"></i>一般生</span> <span class="badge bg-info text-dark"><i
{% endif %} class="fas fa-graduation-cap me-1"></i>碩士生</span>
</td> {% endif %}
</tr> </td>
{% endfor %} <td>
</tbody> {% if student.student_category == '在職生' %}
</table> <span class="badge bg-warning text-dark"><i
</div> class="fas fa-user-tie me-1"></i>在職生</span>
{% else %} {% elif student.student_category == '交換中' %}
<p class="text-center text-muted py-4 mb-0">當日無排定報告者 (可拖曳學生至此)</p> <span class="badge bg-secondary text-white"><i
{% endif %} class="fas fa-exchange-alt me-1"></i>交換中</span>
{% else %}
<span class="badge bg-success text-white"><i
class="fas fa-user me-1"></i>一般生</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %}
<p class="text-center text-muted py-4 mb-0">當日無排定報告者 (可拖曳學生至此)</p>
{% endif %}
</div> </div>
</div> </div>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@ -222,31 +254,32 @@
</thead> </thead>
<tbody> <tbody>
{% for row in summary_data %} {% for row in summary_data %}
<tr> <tr>
<td class="fw-bold"><code>{{ row.student.student_id }}</code></td> <td class="fw-bold"><code>{{ row.student.student_id }}</code></td>
<td>{{ row.student.name }}</td> <td>{{ row.student.name }}</td>
<td><small class="fw-semibold text-secondary">{{ row.rule_desc }}</small></td> <td><small class="fw-semibold text-secondary">{{ row.rule_desc }}</small></td>
<td> <td>
{% if row.assigned_dates %} {% if row.assigned_dates %}
{% for d in row.assigned_dates %} {% for d in row.assigned_dates %}
<span class="badge bg-primary me-1 mb-1"><i class="far fa-calendar-alt me-1"></i>{{ d }}</span> <span class="badge bg-primary me-1 mb-1"><i class="far fa-calendar-alt me-1"></i>{{ d
{% endfor %} }}</span>
{% else %} {% endfor %}
<span class="badge bg-secondary"><i class="fas fa-minus-circle me-1"></i>免報告</span> {% else %}
{% endif %} <span class="badge bg-secondary"><i class="fas fa-minus-circle me-1"></i>免報告</span>
</td> {% endif %}
<td> </td>
{% if row.total_count > 0 %} <td>
<span class="badge bg-success fs-6">{{ row.total_count }} 次</span> {% if row.total_count > 0 %}
{% else %} <span class="badge bg-success fs-6">{{ row.total_count }} 次</span>
<span class="badge bg-secondary fs-6">0 次</span> {% else %}
{% endif %} <span class="badge bg-secondary fs-6">0 次</span>
</td> {% endif %}
</tr> </td>
</tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5" class="text-center py-4 text-muted">無在學學生資料</td> <td colspan="5" class="text-center py-4 text-muted">無在學學生資料</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -258,94 +291,94 @@
{% block scripts %} {% block scripts %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
const draggables = document.querySelectorAll('.draggable-student'); const draggables = document.querySelectorAll('.draggable-student');
const dropTargets = document.querySelectorAll('.monday-drop-target'); const dropTargets = document.querySelectorAll('.monday-drop-target');
let draggedItem = null; let draggedItem = null;
draggables.forEach(item => { draggables.forEach(item => {
item.addEventListener('dragstart', function(e) { item.addEventListener('dragstart', function (e) {
draggedItem = this; draggedItem = this;
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ e.dataTransfer.setData('text/plain', JSON.stringify({
student_id: this.dataset.studentId, student_id: this.dataset.studentId,
student_name: this.dataset.studentName, student_name: this.dataset.studentName,
from_date: this.dataset.fromDate from_date: this.dataset.fromDate
})); }));
this.classList.add('table-warning'); this.classList.add('table-warning');
}); });
item.addEventListener('dragend', function() { item.addEventListener('dragend', function () {
this.classList.remove('table-warning'); this.classList.remove('table-warning');
});
});
dropTargets.forEach(target => {
target.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
this.classList.add('border', 'border-primary', 'border-3', 'shadow');
});
target.addEventListener('dragleave', function() {
this.classList.remove('border', 'border-primary', 'border-3', 'shadow');
});
target.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('border', 'border-primary', 'border-3', 'shadow');
const toDate = this.dataset.date;
if (!draggedItem) return;
const studentId = draggedItem.dataset.studentId;
const studentName = draggedItem.dataset.studentName;
const fromDate = draggedItem.dataset.fromDate;
if (fromDate === toDate) return;
fetch('{% url "move_schedule_item" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
student_id: studentId,
from_date: fromDate,
to_date: toDate
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message);
window.location.reload();
} else {
alert('❌ 移轉失敗: ' + data.error);
}
})
.catch(err => {
alert('❌ 發生錯誤: ' + err);
}); });
}); });
});
function getCookie(name) { dropTargets.forEach(target => {
let cookieValue = null; target.addEventListener('dragover', function (e) {
if (document.cookie && document.cookie !== '') { e.preventDefault();
const cookies = document.cookie.split(';'); e.dataTransfer.dropEffect = 'move';
for (let i = 0; i < cookies.length; i++) { this.classList.add('border', 'border-primary', 'border-3', 'shadow');
const cookie = cookies[i].trim(); });
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); target.addEventListener('dragleave', function () {
break; this.classList.remove('border', 'border-primary', 'border-3', 'shadow');
});
target.addEventListener('drop', function (e) {
e.preventDefault();
this.classList.remove('border', 'border-primary', 'border-3', 'shadow');
const toDate = this.dataset.date;
if (!draggedItem) return;
const studentId = draggedItem.dataset.studentId;
const studentName = draggedItem.dataset.studentName;
const fromDate = draggedItem.dataset.fromDate;
if (fromDate === toDate) return;
fetch('{% url "move_schedule_item" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
student_id: studentId,
from_date: fromDate,
to_date: toDate
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message);
window.location.reload();
} else {
alert('❌ 移轉失敗: ' + data.error);
}
})
.catch(err => {
alert('❌ 發生錯誤: ' + err);
});
});
});
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
} }
} }
return cookieValue;
} }
return cookieValue; });
}
});
</script> </script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}