feat(teacher): refactor progress into tree view and separate classroom management
- Redesign /teacher/progress into a folder-like tree view (sub-bab -> lesson -> completed students) - Create /teacher/classroom for student management (import/export CSV, add, bulk delete) - Enrich /progress-report.json API response with sub_bab, sub_bab_title, and sub_bab_list - Add Classroom navigation item to teacher navbar menu
This commit is contained in:
parent
c765399715
commit
f95365a6e3
|
|
@ -67,6 +67,10 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z"></path></svg>
|
||||
Edit Content
|
||||
</a>
|
||||
<a href="/teacher/classroom" class="dropdown-item" onclick={() => (showDropdown = false)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
Kelas
|
||||
</a>
|
||||
<a href="/teacher/progress" class="dropdown-item" onclick={() => (showDropdown = false)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></svg>
|
||||
Progress
|
||||
|
|
|
|||
|
|
@ -0,0 +1,480 @@
|
|||
<script lang="ts">
|
||||
import { get } from 'svelte/store';
|
||||
import { authIsTeacher, authToken } from '$stores/auth';
|
||||
import { studentSelection } from '$stores/studentSelection';
|
||||
import { exportStudentsCsv, triggerBlobDownload } from '$services/studentManagement';
|
||||
import StudentImportDialog from '$lib/components/StudentImportDialog.svelte';
|
||||
import BulkDeleteStudentsDialog from '$lib/components/BulkDeleteStudentsDialog.svelte';
|
||||
import AddStudentDialog from '$lib/components/AddStudentDialog.svelte';
|
||||
|
||||
interface StudentItem {
|
||||
id: string;
|
||||
nama_siswa: string;
|
||||
role: 'teacher' | 'student';
|
||||
completed_count: number;
|
||||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
let students = $state<StudentItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let exportBusy = $state(false);
|
||||
let exportError = $state('');
|
||||
let importOpen = $state(false);
|
||||
let deleteOpen = $state(false);
|
||||
let addOpen = $state(false);
|
||||
let headerCheckbox = $state<HTMLInputElement | undefined>();
|
||||
|
||||
// Reactively load data when auth is ready
|
||||
$effect(() => {
|
||||
if ($authIsTeacher && $authToken) {
|
||||
loadData();
|
||||
} else if (!$authIsTeacher) {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
if (!$authIsTeacher || !$authToken) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/progress-report.json?token=${encodeURIComponent($authToken)}&_t=${Date.now()}`, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
const data = await res.json();
|
||||
students = data.students ?? [];
|
||||
studentSelection.setAvailable(
|
||||
students.filter((s) => s.role === 'student').map((s) => s.id)
|
||||
);
|
||||
} catch {
|
||||
// API not available
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (exportBusy) return;
|
||||
exportBusy = true;
|
||||
exportError = '';
|
||||
try {
|
||||
const { blob, filename } = await exportStudentsCsv(get(studentSelection.selected), fetch);
|
||||
triggerBlobDownload(blob, filename);
|
||||
} catch (err) {
|
||||
exportError = err instanceof Error ? err.message : 'Gagal mengekspor CSV.';
|
||||
} finally {
|
||||
exportBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleImported() {
|
||||
importOpen = false;
|
||||
studentSelection.clear();
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handleDeleted() {
|
||||
deleteOpen = false;
|
||||
studentSelection.clear();
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handleAdded() {
|
||||
addOpen = false;
|
||||
studentSelection.clear();
|
||||
loadData();
|
||||
}
|
||||
|
||||
function handleHeaderCheckbox() {
|
||||
if (get(studentSelection.allSelected)) {
|
||||
studentSelection.clear();
|
||||
} else {
|
||||
studentSelection.selectAll();
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
selected: selectedStore,
|
||||
count: countStore,
|
||||
allSelected: allSelectedStore,
|
||||
someSelected: someSelectedStore
|
||||
} = studentSelection;
|
||||
const selectedIds = $derived($selectedStore);
|
||||
const selectionCount = $derived($countStore);
|
||||
const allSelected = $derived($allSelectedStore);
|
||||
const someSelected = $derived($someSelectedStore);
|
||||
|
||||
$effect(() => {
|
||||
if (headerCheckbox) {
|
||||
headerCheckbox.indeterminate = someSelected && !allSelected;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedStudents = $derived(students.filter((s) => selectedIds.includes(s.id)));
|
||||
const studentRows = $derived(students.filter((s) => s.role === 'student'));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Manajemen Kelas - Elemes LMS</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="header-section">
|
||||
<div>
|
||||
<h1>Manajemen Kelas & Siswa</h1>
|
||||
<p class="subtitle">Kelola akun siswa, impor/ekspor data kelas, dan pantau ringkasan siswa.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="/teacher/progress" class="btn btn-sm btn-outline">
|
||||
Lihat Perkembangan Siswa →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="loading">Memuat data kelas...</p>
|
||||
{:else if !$authIsTeacher}
|
||||
<p class="empty">Anda tidak memiliki akses ke halaman ini.</p>
|
||||
{:else}
|
||||
<div class="summary-bar">
|
||||
<span><strong>{studentRows.length}</strong> siswa terdaftar</span>
|
||||
|
||||
{#if selectionCount > 0}
|
||||
<span class="selection-count">
|
||||
<strong>{selectionCount}</strong> siswa dipilih
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
{#if exportError}
|
||||
<span class="error-inline">{exportError}</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
onclick={handleExport}
|
||||
disabled={exportBusy}
|
||||
title="Unduh CSV siswa (token kosong) untuk diedit dan diimpor ulang"
|
||||
>
|
||||
{#if exportBusy}
|
||||
Mengekspor…
|
||||
{:else if selectionCount > 0}
|
||||
↓ Export {selectionCount} Siswa
|
||||
{:else}
|
||||
↓ Export CSV Siswa
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
onclick={() => (addOpen = true)}
|
||||
title="Tambah satu siswa baru secara manual"
|
||||
>
|
||||
+ Tambah Siswa
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
onclick={() => (importOpen = true)}
|
||||
title="Import data siswa dari berkas CSV"
|
||||
>
|
||||
↑ Import CSV
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
onclick={() => (deleteOpen = true)}
|
||||
disabled={selectionCount === 0}
|
||||
title="Hapus siswa terpilih beserta token dan progresnya"
|
||||
>
|
||||
🗑 Hapus {selectionCount} Siswa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if studentRows.length === 0}
|
||||
<div class="empty-box">
|
||||
<p class="empty-title">Belum ada data siswa</p>
|
||||
<p class="empty-desc">Gunakan tombol <strong>Import CSV</strong> untuk mengimpor daftar siswa atau <strong>Tambah Siswa</strong> untuk memasukkan siswa satu per satu.</p>
|
||||
<div class="empty-actions">
|
||||
<button class="btn btn-primary" onclick={() => (importOpen = true)}>↑ Import CSV</button>
|
||||
<button class="btn btn-secondary" onclick={() => (addOpen = true)}>+ Tambah Siswa</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:this={headerCheckbox}
|
||||
checked={allSelected}
|
||||
onchange={handleHeaderCheckbox}
|
||||
aria-label="Pilih semua siswa"
|
||||
/>
|
||||
</th>
|
||||
<th class="text-left">Nama Siswa</th>
|
||||
<th class="text-center">Peran</th>
|
||||
<th class="text-center">Materi Diselesaikan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each students as student}
|
||||
{@const isChecked = selectedIds.includes(student.id as string)}
|
||||
{@const isTeacher = student.role === 'teacher'}
|
||||
<tr class:selected={isChecked}>
|
||||
<td class="checkbox-col">
|
||||
{#if isTeacher}
|
||||
<span class="teacher-no-select" aria-label="Guru tidak dapat dipilih" title="Guru tidak dapat dipilih untuk aksi bulk">—</span>
|
||||
{:else}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onchange={() => studentSelection.toggle(student.id as string)}
|
||||
aria-label={`Pilih ${student.nama_siswa}`}
|
||||
/>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-left student-name-cell">
|
||||
<span class="student-name">{student.nama_siswa}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{#if isTeacher}
|
||||
<span class="badge teacher-badge">Guru</span>
|
||||
{:else}
|
||||
<span class="badge student-badge">Siswa</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-center completion-count">
|
||||
<strong>{student.completed_count}</strong> materi
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<StudentImportDialog
|
||||
open={importOpen}
|
||||
onClose={() => (importOpen = false)}
|
||||
onImported={handleImported}
|
||||
/>
|
||||
<AddStudentDialog
|
||||
open={addOpen}
|
||||
onClose={() => (addOpen = false)}
|
||||
onAdded={handleAdded}
|
||||
/>
|
||||
<BulkDeleteStudentsDialog
|
||||
open={deleteOpen}
|
||||
studentNames={selectedStudents.map((s) => ({ id: s.id as string, nama_siswa: s.nama_siswa }))}
|
||||
onClose={() => (deleteOpen = false)}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.selection-count {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.error-inline {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
border: 1px solid var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-outline:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-danger {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-box {
|
||||
text-align: center;
|
||||
padding: 3rem 1.5rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
.empty-desc {
|
||||
color: var(--color-text-muted);
|
||||
max-width: 450px;
|
||||
margin: 0 auto 1.5rem auto;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
th, td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
th {
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.checkbox-col {
|
||||
width: 2.5rem;
|
||||
text-align: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
.checkbox-col input {
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
tr.selected td {
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg));
|
||||
}
|
||||
.student-name-cell {
|
||||
font-weight: 500;
|
||||
}
|
||||
.student-name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.teacher-badge {
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg));
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 40%, var(--color-bg));
|
||||
}
|
||||
.badge.student-badge {
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.teacher-no-select {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.completion-count {
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,7 +13,13 @@ from services.token_service import (
|
|||
reset_student_progress,
|
||||
get_all_students_progress,
|
||||
)
|
||||
from services.lesson_service import get_lessons_with_learning_objectives
|
||||
from services.lesson_service import (
|
||||
get_lessons_with_learning_objectives,
|
||||
find_lesson_file,
|
||||
find_sub_home_for_lesson,
|
||||
get_sub_home_data,
|
||||
find_all_sub_home_folders,
|
||||
)
|
||||
|
||||
progress_bp = Blueprint('progress', __name__)
|
||||
|
||||
|
|
@ -85,9 +91,31 @@ def api_progress_report():
|
|||
get_lessons_with_learning_objectives,
|
||||
)
|
||||
|
||||
# Petakan sub-bab dari sub-home.md yang terdaftar
|
||||
sub_home_folders = find_all_sub_home_folders()
|
||||
sub_bab_map = {}
|
||||
for folder in sub_home_folders:
|
||||
data = get_sub_home_data(folder)
|
||||
if data and data.get('title'):
|
||||
sub_bab_map[folder] = data['title']
|
||||
|
||||
def _lesson_to_dict(lesson):
|
||||
d = {'filename': lesson['filename'], 'title': lesson['title']}
|
||||
file_path = lesson.get('path') or find_lesson_file(lesson['filename'])
|
||||
if file_path:
|
||||
_sub_home_path, folder_name = find_sub_home_for_lesson(file_path)
|
||||
if folder_name and folder_name in sub_bab_map:
|
||||
d['sub_bab'] = folder_name
|
||||
d['sub_bab_title'] = sub_bab_map[folder_name]
|
||||
return d
|
||||
|
||||
resp = jsonify({
|
||||
'students': all_students_progress,
|
||||
'lessons': [{'filename': l['filename'], 'title': l['title']} for l in ordered_lessons],
|
||||
'lessons': [_lesson_to_dict(l) for l in ordered_lessons],
|
||||
'sub_bab_list': [
|
||||
{'folder': f, 'title': sub_bab_map[f]}
|
||||
for f in sub_home_folders if f in sub_bab_map
|
||||
],
|
||||
})
|
||||
resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
return resp
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ def test_teacher_can_access_report(client):
|
|||
# Guru + 2 siswa harus muncul sebagai row
|
||||
assert len(data["students"]) == 3
|
||||
assert data["lessons"]
|
||||
assert "sub_bab_list" in data
|
||||
|
||||
|
||||
def test_teacher_appears_in_progress_report(client):
|
||||
|
|
|
|||
Loading…
Reference in New Issue