Compare commits

...

4 Commits

Author SHA1 Message Date
a2nr f5ee52080c Merge branch 'dev' into master 2026-09-16 22:20:20 +07:00
a2nr ba1e7240f3 feat(offline-db): implement offline progress persistence and sync manager for lessons
- Add getProgress and getAllProgress to local IndexedDB SyncManager
- Support opportunistic background sync for lesson progress and quizzes
- Connect LessonManager to IndexedDB progress cache for offline lesson completion status
- Disable SSR for /lesson and /bab routes to ensure consistent client-side local storage
2026-09-16 22:19:53 +07:00
a2nr 53b704c7de fix(teacher): align student and teacher counters on progress tree view
- Count only distinct students for sub-chapter folder completion badge
- Separate teacher attempts from student counter on lesson header with a dedicated '+Y guru' badge
- Differentiate teacher rows with teacher icon and dashed styling
2026-09-16 20:15:20 +07:00
a2nr f95365a6e3 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
2026-09-15 04:51:35 +07:00
12 changed files with 1689 additions and 456 deletions

View File

@ -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

View File

@ -19,3 +19,20 @@ declare module '*?url' {
export default url;
}
declare namespace svelteHTML {
interface IframeHTMLAttributes {
credentialless?: boolean | string;
}
interface HTMLAttributes<T> {
credentialless?: boolean | string;
}
}
declare module 'svelte/elements' {
export interface HTMLAttributes<T> {
credentialless?: boolean | string;
}
}

View File

@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SyncManager } from './sync-manager';
import * as schemaModule from './schema';
describe('SyncManager', () => {
let syncManager: SyncManager;
let mockMyProgress: Map<string, any>;
let mockOutbox: Array<any>;
let mockQuizAttempts: Array<any>;
beforeEach(() => {
mockMyProgress = new Map();
mockOutbox = [];
mockQuizAttempts = [];
const mockDb = {
myProgress: {
put: vi.fn(async (item) => {
mockMyProgress.set(item.lessonSlug, item);
return item.lessonSlug;
}),
get: vi.fn(async (slug) => {
return mockMyProgress.get(slug) || undefined;
}),
toArray: vi.fn(async () => {
return Array.from(mockMyProgress.values());
}),
update: vi.fn(async (slug, updates) => {
const existing = mockMyProgress.get(slug);
if (existing) {
mockMyProgress.set(slug, { ...existing, ...updates });
}
})
},
outbox: {
add: vi.fn(async (entry) => {
const id = mockOutbox.length + 1;
const item = { ...entry, id };
mockOutbox.push(item);
return id;
}),
orderBy: vi.fn(() => ({
toArray: vi.fn(async () => [...mockOutbox])
})),
delete: vi.fn(async (id) => {
const idx = mockOutbox.findIndex((e) => e.id === id);
if (idx >= 0) mockOutbox.splice(idx, 1);
}),
update: vi.fn(async (id, updates) => {
const item = mockOutbox.find((e) => e.id === id);
if (item) Object.assign(item, updates);
})
},
myQuizAttempts: {
add: vi.fn(async (item) => {
mockQuizAttempts.push(item);
return mockQuizAttempts.length;
})
},
myCodeDrafts: {
where: vi.fn(() => ({
first: vi.fn(async () => null)
})),
add: vi.fn()
}
};
vi.spyOn(schemaModule, 'db', 'get').mockReturnValue(mockDb as any);
syncManager = new SyncManager();
});
it('records progress locally and enqueues sync to /lesson-progress when authenticated', async () => {
await syncManager.recordProgress('variabel', true, {
token: 'student-tok-1',
lesson_name: 'variabel',
type: 'exercise'
});
const rec = await syncManager.getProgress('variabel');
expect(rec).not.toBeNull();
expect(rec?.lessonSlug).toBe('variabel');
expect(rec?.completed).toBe(true);
expect(rec?.synced).toBe(false);
expect(mockOutbox.length).toBe(1);
expect(mockOutbox[0].endpoint).toBe('/lesson-progress');
expect(mockOutbox[0].payload.token).toBe('student-tok-1');
});
it('records progress locally without enqueueing outbox in guest mode', async () => {
await syncManager.recordProgress('percabangan', true, null);
const rec = await syncManager.getProgress('percabangan');
expect(rec).not.toBeNull();
expect(rec?.lessonSlug).toBe('percabangan');
expect(rec?.completed).toBe(true);
expect(mockOutbox.length).toBe(0);
});
it('returns all completed lessons map with getAllProgress', async () => {
await syncManager.recordProgress('lesson_a', true);
await syncManager.recordProgress('lesson_b', false);
await syncManager.recordProgress('lesson_c', true);
const map = await syncManager.getAllProgress();
expect(map).toEqual({
lesson_a: true,
lesson_b: false,
lesson_c: true
});
});
it('records quiz attempt and enqueues sync to /lesson-progress', async () => {
await syncManager.recordQuizAttempt(
{
lessonSlug: 'quiz_test',
attemptId: 'attempt-uuid-1',
answers: {},
score: 100,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString()
},
{ token: 'student-tok-1', lesson_name: 'quiz_test', type: 'quiz' }
);
expect(mockQuizAttempts.length).toBe(1);
expect(mockOutbox.length).toBe(1);
expect(mockOutbox[0].endpoint).toBe('/lesson-progress');
expect(mockOutbox[0].payload.lesson_name).toBe('quiz_test');
});
it('drains outbox queue to /api/lesson-progress and marks progress synced', async () => {
await syncManager.recordProgress('loop_test', true, {
token: 'tok-1',
lesson_name: 'loop_test',
type: 'exercise'
});
expect(mockOutbox.length).toBe(1);
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ success: true, is_done: true })
}));
(global as any).fetch = fetchMock;
Object.defineProperty(globalThis.navigator, 'onLine', { value: true, configurable: true });
await syncManager.drainQueue();
expect(fetchMock).toHaveBeenCalledWith(
'/api/lesson-progress',
expect.objectContaining({
method: 'POST'
})
);
expect(mockOutbox.length).toBe(0);
const progress = await syncManager.getProgress('loop_test');
expect(progress?.synced).toBe(true);
});
});

View File

@ -32,9 +32,9 @@ export class SyncManager {
/**
* Save progress in local IndexedDB mirror immediately,
* then opportunistically sync to server.
* then opportunistically sync to server if authenticated payload is provided.
*/
async recordProgress(lessonSlug: string, completed: boolean, payload: any): Promise<void> {
async recordProgress(lessonSlug: string, completed: boolean, payload?: any): Promise<void> {
if (!db) return;
const progress: MyProgress = {
@ -45,13 +45,37 @@ export class SyncManager {
};
await db.myProgress.put(progress);
await this.enqueueAndTrySync('/progress', payload);
if (payload && payload.token) {
await this.enqueueAndTrySync('/lesson-progress', payload);
}
}
/**
* Get progress for a specific lesson from local IndexedDB mirror.
*/
async getProgress(lessonSlug: string): Promise<MyProgress | null> {
if (!db) return null;
const record = await db.myProgress.get(lessonSlug);
return record ?? null;
}
/**
* Get all recorded completed lesson slugs from local IndexedDB mirror.
*/
async getAllProgress(): Promise<Record<string, boolean>> {
if (!db) return {};
const all = await db.myProgress.toArray();
const map: Record<string, boolean> = {};
for (const item of all) {
map[item.lessonSlug] = item.completed;
}
return map;
}
/**
* Record a completed quiz attempt locally, then sync.
*/
async recordQuizAttempt(attempt: Omit<MyQuizAttempt, 'id' | 'synced'>, syncPayload: any): Promise<void> {
async recordQuizAttempt(attempt: Omit<MyQuizAttempt, 'id' | 'synced'>, syncPayload?: any): Promise<void> {
if (!db) return;
await db.myQuizAttempts.add({
@ -59,7 +83,9 @@ export class SyncManager {
synced: false
});
await this.enqueueAndTrySync('/progress', syncPayload);
if (syncPayload && syncPayload.token) {
await this.enqueueAndTrySync('/lesson-progress', syncPayload);
}
}
/**
@ -135,6 +161,9 @@ export class SyncManager {
if (entry.id) {
await db.outbox.delete(entry.id);
}
if (entry.payload?.lesson_name) {
await db.myProgress.update(entry.payload.lesson_name, { synced: true }).catch(() => {});
}
} else if (entry.retries < 5 && entry.id) {
await db.outbox.update(entry.id, { retries: entry.retries + 1 });
}

View File

@ -2,6 +2,8 @@ import type { PageLoad } from './$types';
import type { Lesson } from '$types/lesson';
import { error } from '@sveltejs/kit';
export const ssr = false;
export const load: PageLoad = async ({ params, fetch }) => {
const token = typeof window !== 'undefined'
? localStorage.getItem('student_token') ?? ''

View File

@ -200,6 +200,10 @@ import QuizQuestionView from './QuizQuestionView.svelte';
mgr.velxioBridge.destroy();
mgr.velxioBridge = null;
}
if (nav.type !== 'leave') {
mgr.reset();
}
});
onMount(() => {

View File

@ -1,6 +1,8 @@
import { getLesson } from '$services/api';
import type { PageLoad } from './$types';
export const ssr = false;
export const load: PageLoad = async ({ params, fetch }) => {
const token = typeof window !== 'undefined'
? localStorage.getItem('student_token') ?? ''

View File

@ -196,20 +196,60 @@ export class LessonManager {
});
}
reset() {
this.data = null;
this.lessonCompleted = false;
this.isQuizMode = false;
this.quizSession = null;
this.quizCurrentIndex = 0;
this.quizCurrentQuestion = null;
this.quizResult = null;
this.quizAttemptId = null;
this.quizStartedAt = null;
this.quizFinalized = false;
this.quizTerminationReason = null;
this.visibilityEventCount = 0;
this.currentCode = '';
this.currentLanguage = 'c';
this.cCode = '';
this.pythonCode = '';
this.cOut = this.freshOutput();
this.pyOut = this.freshOutput();
this.circuitOut = this.freshOutput();
this.velxioOut = this.freshOutput();
this.flowchartOut = this.freshOutput();
this.cPassed = false;
this.pythonPassed = false;
this.circuitPassed = false;
this.flowchartPassed = false;
this.showSolution = false;
this.showCelebration = false;
this.velxioSaving = false;
this.velxioReady = false;
this.velxioError = false;
if (this.velxioCleanup) { this.velxioCleanup(); this.velxioCleanup = null; }
if (this.velxioBridge) { this.velxioBridge.destroy(); this.velxioBridge = null; }
this.deployState = 'idle';
this.mobileMode = 'hidden';
this.editor = null;
this.circuitEditor = null;
this.flowchartTab = null;
this.velxioIframe = null;
}
init(lesson: LessonContent) {
untrack(() => {
this.reset();
this.data = lesson;
this.lessonCompleted = lesson.lesson_completed;
this.isQuizMode = false;
this.quizSession = null;
this.quizCurrentIndex = 0;
this.quizCurrentQuestion = null;
this.quizResult = null;
this.quizAttemptId = null;
this.quizStartedAt = null;
this.quizFinalized = false;
this.quizTerminationReason = null;
this.visibilityEventCount = 0;
this.lessonCompleted = Boolean(lesson.lesson_completed);
this.cCode = lesson.initial_code_c || '';
this.pythonCode = lesson.initial_python || '';
@ -220,40 +260,61 @@ export class LessonManager {
this.currentLanguage = initLang;
this.currentCode = initLang === 'python' ? this.pythonCode : (this.cCode || lesson.initial_code || '');
this.cOut = this.freshOutput();
this.pyOut = this.freshOutput();
this.circuitOut = this.freshOutput();
this.velxioOut = this.freshOutput();
this.cPassed = false;
this.pythonPassed = false;
this.circuitPassed = false;
this.showSolution = false;
if (this.velxioCleanup) { this.velxioCleanup(); this.velxioCleanup = null; }
if (this.velxioBridge) { this.velxioBridge.destroy(); this.velxioBridge = null; }
this.velxioReady = false;
this.velxioError = false;
this.activeTab = pickDefaultTab(lesson);
this.deployState = 'idle';
this.mobileMode = 'hidden';
lessonContext.set({
title: lesson.lesson_title,
completed: lesson.lesson_completed,
completed: this.lessonCompleted,
prevLesson: lesson.prev_lesson,
nextLesson: lesson.next_lesson
});
if (typeof window !== 'undefined') {
const lessonSlug = this.slug.replace('.md', '');
syncManager.getProgress(lessonSlug).then((localProg) => {
if (localProg && localProg.completed && !this.lessonCompleted) {
this.lessonCompleted = true;
lessonContext.update((ctx) => (ctx ? { ...ctx, completed: true } : ctx));
if (this.data?.ordered_lessons) {
const current = this.data.ordered_lessons.find(
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')
);
if (current) current.completed = true;
}
}
}).catch(() => {});
syncManager.getAllProgress().then((allProg) => {
if (this.data?.ordered_lessons) {
let updated = false;
for (const item of this.data.ordered_lessons) {
const s = item.filename.replace('.md', '');
if (allProg[s] && !item.completed) {
item.completed = true;
updated = true;
}
}
if (updated && this.slug) {
const curr = this.data.ordered_lessons.find(
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')
);
if (curr?.completed && !this.lessonCompleted) {
this.lessonCompleted = true;
lessonContext.update((ctx) => (ctx ? { ...ctx, completed: true } : ctx));
}
}
}
}).catch(() => {});
}
});
}
async completeLesson(status = 'completed') {
if (this.lessonCompleted && status === 'completed') return;
const lessonName = this.slug.replace('.md', '');
if (get(authLoggedIn)) {
const lessonName = this.slug.replace('.md', '');
let isDone = true;
try {
const res = await submitLessonProgress({ token: auth.token, lesson_name: lessonName, type: 'exercise' });
@ -277,6 +338,7 @@ export class LessonManager {
} else {
this.showCelebration = true;
this.lessonCompleted = true;
await syncManager.recordProgress(lessonName, true, null);
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
if (this.data?.ordered_lessons) {
const current = this.data.ordered_lessons.find(
@ -480,6 +542,15 @@ export class LessonManager {
this.lessonCompleted = passed;
this.showCelebration = passed;
lessonContext.update((ctx) => (ctx ? { ...ctx, completed: passed } : ctx));
const lessonName = this.slug.replace('.md', '');
if (passed) {
const token = get(authToken) || (typeof window !== 'undefined' ? localStorage.getItem('student_token') : '') || '';
syncManager.recordProgress(
lessonName,
true,
token ? { token, lesson_name: lessonName, type: 'quiz' } : null
).catch(() => {});
}
if (this.data?.ordered_lessons) {
const current = this.data.ordered_lessons.find(
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')

View File

@ -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 &rarr;
</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}
&#8595; Export {selectionCount} Siswa
{:else}
&#8595; 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"
>
&#8593; Import CSV
</button>
<button
class="btn btn-sm btn-danger"
onclick={() => (deleteOpen = true)}
disabled={selectionCount === 0}
title="Hapus siswa terpilih beserta token dan progresnya"
>
&#128465; 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)}>&#8593; 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

View File

@ -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

View File

@ -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):