feat(admin): CourseForm with upload, duration spinner, color picker; refactor create/edit pages
feat(backend): add courses CRUD with validation; add GET /api/instructors feat(api): add /api/uploads route chore(proxy): BASE fallback -> http://localhost:8001
This commit is contained in:
parent
bd6e2ebb07
commit
4f06277903
|
|
@ -0,0 +1,577 @@
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 8000;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// In-memory stores
|
||||||
|
const idempotencyStore = new Map();
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
// Instructors list (simple directory)
|
||||||
|
const instructors = [
|
||||||
|
{ id: 'i1', name: 'Chef A', email: 'chef.a@example.com' },
|
||||||
|
{ id: 'i2', name: 'Chef B', email: 'chef.b@example.com' },
|
||||||
|
{ id: 'i3', name: 'Chef C', email: 'chef.c@example.com' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sample data aligned with OpenAPI examples
|
||||||
|
const courses = [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
title: 'Basic Knife Skills',
|
||||||
|
instructor: 'Chef A',
|
||||||
|
progress: 30,
|
||||||
|
status: 'in_progress',
|
||||||
|
color: '#FF9800',
|
||||||
|
thumbnail: '/images/knife-skills.jpg',
|
||||||
|
duration: '6h',
|
||||||
|
modules: 12,
|
||||||
|
enrolled: '1.2k'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c2',
|
||||||
|
title: 'Bread Baking 101',
|
||||||
|
instructor: 'Chef B',
|
||||||
|
progress: 0,
|
||||||
|
status: 'not_started',
|
||||||
|
color: '#3F51B5',
|
||||||
|
thumbnail: '/images/bread.jpg',
|
||||||
|
duration: '4h',
|
||||||
|
modules: 8,
|
||||||
|
enrolled: '860'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const courseDetails = {
|
||||||
|
c1: {
|
||||||
|
id: 'c1',
|
||||||
|
title: 'Basic Knife Skills',
|
||||||
|
description: 'Learn core knife handling techniques.',
|
||||||
|
instructor: 'Chef A',
|
||||||
|
duration: '6h',
|
||||||
|
totalModules: 12,
|
||||||
|
completedModules: 3,
|
||||||
|
progress: 25,
|
||||||
|
thumbnail: '/images/knife-skills.jpg',
|
||||||
|
color: '#FF9800',
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
title: 'Intro',
|
||||||
|
description: 'Overview',
|
||||||
|
duration: '15m',
|
||||||
|
isUnlocked: true,
|
||||||
|
isCompleted: false,
|
||||||
|
progress: 0,
|
||||||
|
type: 'reading',
|
||||||
|
hasQuiz: false,
|
||||||
|
hasAssignment: false,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
c2: {
|
||||||
|
id: 'c2',
|
||||||
|
title: 'Bread Baking 101',
|
||||||
|
description: 'Learn how to bake bread.',
|
||||||
|
instructor: 'Chef B',
|
||||||
|
duration: '4h',
|
||||||
|
totalModules: 8,
|
||||||
|
completedModules: 0,
|
||||||
|
progress: 0,
|
||||||
|
thumbnail: '/images/bread.jpg',
|
||||||
|
color: '#3F51B5',
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: 'm21',
|
||||||
|
title: 'Starter',
|
||||||
|
description: 'Basics',
|
||||||
|
duration: '10m',
|
||||||
|
isUnlocked: true,
|
||||||
|
isCompleted: false,
|
||||||
|
progress: 0,
|
||||||
|
type: 'video',
|
||||||
|
hasQuiz: false,
|
||||||
|
hasAssignment: false,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exams = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Kuis Interaktif - Keamanan Dapur BGN',
|
||||||
|
course: 'Keamanan Pangan dan Gizi - Badan Gizi Nasional',
|
||||||
|
type: 'Interactive Quiz',
|
||||||
|
date: '2024-01-26',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '10:00',
|
||||||
|
duration: 60,
|
||||||
|
questions: 10,
|
||||||
|
status: 'available',
|
||||||
|
attempts: 0,
|
||||||
|
maxAttempts: 3,
|
||||||
|
passingScore: 70,
|
||||||
|
description: 'Kuis interaktif tentang keamanan pangan dan praktik dapur yang baik di lingkungan BGN',
|
||||||
|
instructions: [
|
||||||
|
'Pastikan koneksi internet stabil',
|
||||||
|
'Siapkan alat tulis',
|
||||||
|
'Baca soal dengan teliti'
|
||||||
|
],
|
||||||
|
isInteractive: true,
|
||||||
|
score: null,
|
||||||
|
grade: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Ujian Tengah Semester - HACCP',
|
||||||
|
course: 'HACCP - Hazard Analysis Critical Control Point',
|
||||||
|
type: 'Midterm Exam',
|
||||||
|
date: '2024-01-28',
|
||||||
|
startTime: '10:00',
|
||||||
|
endTime: '12:00',
|
||||||
|
duration: 120,
|
||||||
|
questions: 50,
|
||||||
|
status: 'upcoming',
|
||||||
|
attempts: 0,
|
||||||
|
maxAttempts: 1,
|
||||||
|
passingScore: 75,
|
||||||
|
description: 'Ujian tengah semester mencakup materi HACCP dari modul 1-6',
|
||||||
|
instructions: [
|
||||||
|
'Pastikan koneksi internet stabil',
|
||||||
|
'Siapkan alat tulis',
|
||||||
|
'Baca soal dengan teliti'
|
||||||
|
],
|
||||||
|
isInteractive: false,
|
||||||
|
score: null,
|
||||||
|
grade: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Answer key for scoring (simple)
|
||||||
|
const examAnswerKey = {
|
||||||
|
'exam-001': {
|
||||||
|
q1: 0,
|
||||||
|
q2: 2,
|
||||||
|
q3: 1,
|
||||||
|
q4: 3,
|
||||||
|
q5: 0,
|
||||||
|
q6: 1,
|
||||||
|
q7: 2,
|
||||||
|
q8: 1,
|
||||||
|
q9: 0,
|
||||||
|
q10: 3,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', service: 'lms-bgn-backend', time: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Epik 1 — Courses APIs
|
||||||
|
// =====================
|
||||||
|
app.get('/api/courses', (req, res) => {
|
||||||
|
const page = parseInt(req.query.page || '1', 10);
|
||||||
|
const pageSize = parseInt(req.query.pageSize || '20', 10);
|
||||||
|
if (isNaN(page) || isNaN(pageSize) || page < 1 || pageSize < 1 || pageSize > 100) {
|
||||||
|
return res.status(400).json({ error: 'Invalid query parameters' });
|
||||||
|
}
|
||||||
|
const q = (req.query.q || '').toString().toLowerCase();
|
||||||
|
const filtered = courses.filter(c => !q || c.title.toLowerCase().includes(q));
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const items = filtered.slice(start, start + pageSize);
|
||||||
|
res.json({ page, pageSize, total: filtered.length, items });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/courses/:courseId', (req, res) => {
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const detail = courseDetails[courseId];
|
||||||
|
if (!detail) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
res.json(detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instructors directory
|
||||||
|
app.get('/api/instructors', (req, res) => {
|
||||||
|
const q = (req.query.q || '').toString().toLowerCase();
|
||||||
|
const items = instructors.filter(i => !q || i.name.toLowerCase().includes(q) || i.email.toLowerCase().includes(q));
|
||||||
|
res.json({ items, total: items.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Course
|
||||||
|
app.post('/api/courses', (req, res) => {
|
||||||
|
const { title, description, instructor, duration, thumbnail, color, status } = req.body || {};
|
||||||
|
const errors = {};
|
||||||
|
if (!title || String(title).trim().length < 3) {
|
||||||
|
errors.title = 'Judul minimal 3 karakter';
|
||||||
|
}
|
||||||
|
if (!instructor || String(instructor).trim().length < 3) {
|
||||||
|
errors.instructor = 'Instruktur minimal 3 karakter';
|
||||||
|
}
|
||||||
|
if (!duration || typeof duration !== 'string') {
|
||||||
|
errors.duration = 'Durasi wajib';
|
||||||
|
} else {
|
||||||
|
const okDur = /^(\d+h|\d+m|\d+h\s\d+m)$/.test(duration.trim());
|
||||||
|
if (!okDur) errors.duration = 'Durasi harus format seperti 6h, 30m, atau 1h 30m';
|
||||||
|
}
|
||||||
|
if (!thumbnail || typeof thumbnail !== 'string') {
|
||||||
|
errors.thumbnail = 'Thumbnail wajib';
|
||||||
|
} else {
|
||||||
|
const t = thumbnail.trim();
|
||||||
|
const okT = t.startsWith('/') || /^https?:\/\//.test(t);
|
||||||
|
if (!okT) errors.thumbnail = 'Thumbnail harus URL http(s) atau path dimulai /';
|
||||||
|
}
|
||||||
|
if (color) {
|
||||||
|
const okColor = /^#([A-Fa-f0-9]{6})$/.test(String(color).trim());
|
||||||
|
if (!okColor) errors.color = 'Warna harus hex contoh #FF9800';
|
||||||
|
}
|
||||||
|
if (status && !['not_started', 'in_progress', 'completed'].includes(status)) {
|
||||||
|
errors.status = 'Status tidak valid';
|
||||||
|
}
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Invalid request', details: errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newId = `c${Date.now()}`;
|
||||||
|
const summary = {
|
||||||
|
id: newId,
|
||||||
|
title: String(title),
|
||||||
|
instructor: String(instructor),
|
||||||
|
progress: 0,
|
||||||
|
status: status || 'not_started',
|
||||||
|
color: color || '#3F51B5',
|
||||||
|
thumbnail: String(thumbnail),
|
||||||
|
duration: String(duration),
|
||||||
|
modules: 0,
|
||||||
|
enrolled: '0'
|
||||||
|
};
|
||||||
|
courses.unshift(summary);
|
||||||
|
courseDetails[newId] = {
|
||||||
|
id: newId,
|
||||||
|
title: String(title),
|
||||||
|
description: description || '',
|
||||||
|
instructor: String(instructor),
|
||||||
|
duration: String(duration),
|
||||||
|
totalModules: 0,
|
||||||
|
completedModules: 0,
|
||||||
|
progress: 0,
|
||||||
|
thumbnail: String(thumbnail),
|
||||||
|
color: color || '#3F51B5',
|
||||||
|
modules: [],
|
||||||
|
};
|
||||||
|
res.status(201).json({ id: newId, status: 'created' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Course
|
||||||
|
app.put('/api/courses/:courseId', (req, res) => {
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const existingDetail = courseDetails[courseId];
|
||||||
|
if (!existingDetail) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
|
||||||
|
const { title, description, instructor, duration, thumbnail, color, status } = req.body || {};
|
||||||
|
const errors = {};
|
||||||
|
if (title !== undefined && String(title).trim().length < 3) {
|
||||||
|
errors.title = 'Judul minimal 3 karakter';
|
||||||
|
}
|
||||||
|
if (instructor !== undefined && String(instructor).trim().length < 3) {
|
||||||
|
errors.instructor = 'Instruktur minimal 3 karakter';
|
||||||
|
}
|
||||||
|
if (duration !== undefined) {
|
||||||
|
const okDur = /^(\d+h|\d+m|\d+h\s\d+m)$/.test(String(duration).trim());
|
||||||
|
if (!okDur) errors.duration = 'Durasi harus format seperti 6h, 30m, atau 1h 30m';
|
||||||
|
}
|
||||||
|
if (thumbnail !== undefined) {
|
||||||
|
const t = String(thumbnail).trim();
|
||||||
|
const okT = t.startsWith('/') || /^https?:\/\//.test(t);
|
||||||
|
if (!okT) errors.thumbnail = 'Thumbnail harus URL http(s) atau path dimulai /';
|
||||||
|
}
|
||||||
|
if (color !== undefined) {
|
||||||
|
const okColor = /^#([A-Fa-f0-9]{6})$/.test(String(color).trim());
|
||||||
|
if (!okColor) errors.color = 'Warna harus hex contoh #FF9800';
|
||||||
|
}
|
||||||
|
if (status !== undefined && !['not_started', 'in_progress', 'completed'].includes(status)) {
|
||||||
|
errors.status = 'Status tidak valid';
|
||||||
|
}
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
return res.status(400).json({ error: 'Invalid request', details: errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update detail
|
||||||
|
const updatedDetail = { ...existingDetail };
|
||||||
|
if (title !== undefined) updatedDetail.title = String(title);
|
||||||
|
if (description !== undefined) updatedDetail.description = String(description);
|
||||||
|
if (instructor !== undefined) updatedDetail.instructor = String(instructor);
|
||||||
|
if (duration !== undefined) updatedDetail.duration = String(duration);
|
||||||
|
if (thumbnail !== undefined) updatedDetail.thumbnail = String(thumbnail);
|
||||||
|
if (color !== undefined) updatedDetail.color = String(color);
|
||||||
|
if (status !== undefined) updatedDetail.status = status;
|
||||||
|
courseDetails[courseId] = updatedDetail;
|
||||||
|
|
||||||
|
// Update summary
|
||||||
|
const idx = courses.findIndex(c => c.id === courseId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const updatedSummary = { ...courses[idx] };
|
||||||
|
if (title !== undefined) updatedSummary.title = String(title);
|
||||||
|
if (instructor !== undefined) updatedSummary.instructor = String(instructor);
|
||||||
|
if (duration !== undefined) updatedSummary.duration = String(duration);
|
||||||
|
if (thumbnail !== undefined) updatedSummary.thumbnail = String(thumbnail);
|
||||||
|
if (color !== undefined) updatedSummary.color = String(color);
|
||||||
|
if (status !== undefined) updatedSummary.status = status;
|
||||||
|
courses[idx] = updatedSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ id: courseId, status: 'updated' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete Course
|
||||||
|
app.delete('/api/courses/:courseId', (req, res) => {
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const idx = courses.findIndex(c => c.id === courseId);
|
||||||
|
if (idx < 0) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
courses.splice(idx, 1);
|
||||||
|
delete courseDetails[courseId];
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/modules/:moduleId/progress', (req, res) => {
|
||||||
|
const { moduleId } = req.params;
|
||||||
|
const { progressPercent, updatedAt } = req.body || {};
|
||||||
|
if (typeof progressPercent !== 'number' || progressPercent < 0 || progressPercent > 100) {
|
||||||
|
return res.status(400).json({ error: 'Invalid progressPercent' });
|
||||||
|
}
|
||||||
|
// Accept and simulate async processing
|
||||||
|
res.status(202).json({ status: 'accepted', moduleId, updatedAt: updatedAt || new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/assignments/:assignmentId/submission', (req, res) => {
|
||||||
|
const { assignmentId } = req.params;
|
||||||
|
const { content, attachments, idempotencyKey } = req.body || {};
|
||||||
|
if (!idempotencyKey) return res.status(400).json({ error: 'Missing idempotencyKey' });
|
||||||
|
const key = `assignment-${idempotencyKey}`;
|
||||||
|
if (idempotencyStore.has(key)) {
|
||||||
|
return res.status(409).json({ error: 'Conflict (duplicate idempotency key)' });
|
||||||
|
}
|
||||||
|
idempotencyStore.set(key, { assignmentId, at: Date.now() });
|
||||||
|
const submissionId = `sub-${Date.now()}`;
|
||||||
|
res.status(201).json({ submissionId, status: 'created' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Epik 2 — Exams APIs
|
||||||
|
// =====================
|
||||||
|
const AUTH_TOKEN = process.env.TEST_BEARER_TOKEN || 'test-token';
|
||||||
|
const requireAuth = (req, res, next) => {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
if (!auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
const token = auth.slice('Bearer '.length);
|
||||||
|
if (token !== AUTH_TOKEN) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
app.post('/api/exam-session', requireAuth, (req, res) => {
|
||||||
|
const { examId, userId } = req.body || {};
|
||||||
|
if (!examId || !userId) return res.status(400).json({ error: 'Missing examId or userId' });
|
||||||
|
const sessionId = `sess-${Date.now()}`;
|
||||||
|
const startTime = new Date().toISOString();
|
||||||
|
sessions.set(sessionId, { examId, userId, startTime });
|
||||||
|
res.status(201).json({ sessionId, status: 'created', startTime });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/exam-session/:sessionId/score', requireAuth, (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
||||||
|
const { idempotencyKey, answers, mode } = req.body || {};
|
||||||
|
if (!idempotencyKey) return res.status(400).json({ error: 'Missing idempotencyKey' });
|
||||||
|
const key = `score-${idempotencyKey}`;
|
||||||
|
if (idempotencyStore.has(key)) {
|
||||||
|
return res.status(409).json({ error: 'Conflict (duplicate idempotency key)' });
|
||||||
|
}
|
||||||
|
idempotencyStore.set(key, { sessionId, at: Date.now() });
|
||||||
|
|
||||||
|
const perQuestion = (answers || []).map(a => scoreAnswer(session.examId, a));
|
||||||
|
const totalScore = perQuestion.reduce((sum, p) => sum + p.earnedPoints, 0);
|
||||||
|
const totalQuestions = perQuestion.length;
|
||||||
|
const correctAnswers = perQuestion.filter(p => p.isCorrect).length;
|
||||||
|
const result = { totalScore, totalQuestions, correctAnswers, perQuestion };
|
||||||
|
sessions.set(sessionId, { ...session, lastScore: result, lastAnswers: perQuestion, completedAt: new Date().toISOString() });
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/exam-session/:sessionId/summary', requireAuth, (req, res) => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
||||||
|
const { lastScore, lastAnswers } = session;
|
||||||
|
const summary = {
|
||||||
|
examId: session.examId || 'exam-001',
|
||||||
|
examTitle: 'Kuis Interaktif - Keamanan Dapur BGN',
|
||||||
|
totalQuestions: lastScore ? lastScore.totalQuestions : 0,
|
||||||
|
correctAnswers: lastScore ? lastScore.correctAnswers : 0,
|
||||||
|
score: lastScore ? lastScore.totalScore : 0,
|
||||||
|
timeSpent: '25 menit 30 detik',
|
||||||
|
completedAt: session.completedAt || new Date().toISOString(),
|
||||||
|
answers: (lastAnswers || []).map(a => ({
|
||||||
|
questionId: a.questionId,
|
||||||
|
isCorrect: a.isCorrect,
|
||||||
|
type: a.type,
|
||||||
|
earnedPoints: a.earnedPoints,
|
||||||
|
maxPoints: a.maxPoints,
|
||||||
|
details: a.details,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
res.json(summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/exams', (req, res) => {
|
||||||
|
const page = parseInt(req.query.page || '1', 10);
|
||||||
|
const pageSize = parseInt(req.query.pageSize || '20', 10);
|
||||||
|
if (isNaN(page) || isNaN(pageSize) || page < 1 || pageSize < 1 || pageSize > 100) {
|
||||||
|
return res.status(400).json({ error: 'Invalid query parameters' });
|
||||||
|
}
|
||||||
|
const q = (req.query.q || '').toString().toLowerCase();
|
||||||
|
const status = (req.query.status || '').toString();
|
||||||
|
const filtered = exams.filter(e => {
|
||||||
|
const okQ = !q || e.title.toLowerCase().includes(q) || e.course.toLowerCase().includes(q);
|
||||||
|
const okStatus = !status || e.status === status;
|
||||||
|
return okQ && okStatus;
|
||||||
|
});
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const items = filtered.slice(start, start + pageSize);
|
||||||
|
res.json({ page, pageSize, total: filtered.length, items });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/certificates/:certificateId/resend', (req, res) => {
|
||||||
|
const { certificateId } = req.params;
|
||||||
|
const { recipientEmail, idempotencyKey } = req.body || {};
|
||||||
|
if (!recipientEmail) return res.status(400).json({ error: 'Missing recipientEmail' });
|
||||||
|
if (!idempotencyKey) return res.status(400).json({ error: 'Missing idempotencyKey' });
|
||||||
|
const key = `cert-${idempotencyKey}`;
|
||||||
|
const prev = idempotencyStore.get(key);
|
||||||
|
if (prev && Date.now() - prev.at < 30000) {
|
||||||
|
return res.status(429).json({ error: 'Too Many Requests' });
|
||||||
|
}
|
||||||
|
idempotencyStore.set(key, { certificateId, at: Date.now() });
|
||||||
|
const jobId = `job-${Date.now()}`;
|
||||||
|
res.status(202).json({ jobId, status: 'accepted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global error handler (fallback)
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`LMS-BGN backend running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
|
|
||||||
|
const interactiveAnswerKey = {
|
||||||
|
'exam-001': {
|
||||||
|
'mcq-1': { type: 'enhanced_mcq', correctChoiceIndex: 2, maxPoints: 10 },
|
||||||
|
'video-1': { type: 'video_scenario', steps: [ { stepId: 's1', correctOptionId: 'optA' }, { stepId: 's2', correctOptionId: 'optB' } ], maxPoints: 10 },
|
||||||
|
'hotspot-1': { type: 'image_hotspot', correctHotspots: ['spot1','spot3'], maxPoints: 10 },
|
||||||
|
'gallery-1': { type: 'media_gallery', minViewed: 3, maxPoints: 10 },
|
||||||
|
'puzzle-1': { type: 'puzzle', matches: [ { pieceId: 'p1', targetId: 't1' }, { pieceId: 'p2', targetId: 't2' } ], maxPoints: 10 },
|
||||||
|
'scenario-1': { type: 'scenario', correctOptionId: 'optX', maxPoints: 10 },
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function scoreAnswer(sessionExamId, a) {
|
||||||
|
const maxPointsDefault = 10;
|
||||||
|
const type = a.type || 'multiple-choice';
|
||||||
|
const ik = interactiveAnswerKey[sessionExamId] || {};
|
||||||
|
const key = ik[a.questionId];
|
||||||
|
let earnedPoints = 0;
|
||||||
|
let maxPoints = (key && key.maxPoints) || maxPointsDefault;
|
||||||
|
let isCorrect = false;
|
||||||
|
let details = {};
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'enhanced_mcq':
|
||||||
|
case 'multiple-choice': {
|
||||||
|
const ak = examAnswerKey[sessionExamId] || {};
|
||||||
|
const correctIndex = key && key.correctChoiceIndex !== undefined ? key.correctChoiceIndex : ak[a.questionId];
|
||||||
|
isCorrect = typeof correctIndex === 'number' && a.choiceIndex === correctIndex;
|
||||||
|
earnedPoints = isCorrect ? maxPoints : 0;
|
||||||
|
details = { choiceIndex: a.choiceIndex, correctIndex };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'video_scenario': {
|
||||||
|
const expected = (key && key.steps) || [];
|
||||||
|
const steps = Array.isArray(a.scenarioAnswers) ? a.scenarioAnswers : [];
|
||||||
|
let correctCount = 0;
|
||||||
|
const stepDetails = steps.map(s => {
|
||||||
|
const exp = expected.find(e => e.stepId === s.stepId);
|
||||||
|
const ok = !!exp && s.selectedOptionId === exp.correctOptionId;
|
||||||
|
if (ok) correctCount++;
|
||||||
|
return { stepId: s.stepId, selectedOptionId: s.selectedOptionId, correctOptionId: exp ? exp.correctOptionId : null, isCorrect: ok };
|
||||||
|
});
|
||||||
|
const total = expected.length || steps.length || 1;
|
||||||
|
earnedPoints = Math.round((correctCount / total) * maxPoints);
|
||||||
|
isCorrect = correctCount === total && total > 0;
|
||||||
|
details = { steps: stepDetails, correctCount, totalSteps: total };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'image_hotspot': {
|
||||||
|
const selected = Array.isArray(a.selectedHotspots) ? a.selectedHotspots : [];
|
||||||
|
const expected = (key && key.correctHotspots) || [];
|
||||||
|
const setExp = new Set(expected);
|
||||||
|
let matchCount = 0;
|
||||||
|
selected.forEach(h => { if (setExp.has(h)) matchCount++; });
|
||||||
|
const totalRequired = expected.length || 1;
|
||||||
|
earnedPoints = Math.round((matchCount / totalRequired) * maxPoints);
|
||||||
|
isCorrect = matchCount === totalRequired && totalRequired > 0;
|
||||||
|
details = { selectedHotspots: selected, correctHotspots: expected, matchCount, totalRequired };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'media_gallery': {
|
||||||
|
const viewed = Array.isArray(a.viewedItems) ? a.viewedItems : [];
|
||||||
|
const minViewed = (key && key.minViewed) || 3;
|
||||||
|
const ratio = Math.min(viewed.length, minViewed) / minViewed;
|
||||||
|
earnedPoints = Math.round(ratio * maxPoints);
|
||||||
|
isCorrect = viewed.length >= minViewed;
|
||||||
|
details = { viewedCount: viewed.length, minViewed };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'puzzle': {
|
||||||
|
const matches = Array.isArray(a.matches || a.solvedSteps) ? (a.matches || a.solvedSteps) : [];
|
||||||
|
const expected = (key && key.matches) || [];
|
||||||
|
let correctCount = 0;
|
||||||
|
const matchDetails = matches.map(m => {
|
||||||
|
const ok = expected.some(e => e.pieceId === m.pieceId && e.targetId === m.targetId);
|
||||||
|
if (ok) correctCount++;
|
||||||
|
return { pieceId: m.pieceId, targetId: m.targetId, isCorrect: ok };
|
||||||
|
});
|
||||||
|
const totalPairs = expected.length || matches.length || 1;
|
||||||
|
earnedPoints = Math.round((correctCount / totalPairs) * maxPoints);
|
||||||
|
isCorrect = correctCount === totalPairs && totalPairs > 0;
|
||||||
|
details = { solvedSteps: matchDetails, correctCount, totalPairs };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'scenario': {
|
||||||
|
const correctOptionId = key && key.correctOptionId;
|
||||||
|
isCorrect = !!correctOptionId && a.selectedOptionId === correctOptionId;
|
||||||
|
earnedPoints = isCorrect ? maxPoints : 0;
|
||||||
|
details = { selectedOptionId: a.selectedOptionId, correctOptionId };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const ak = examAnswerKey[sessionExamId] || {};
|
||||||
|
const correctIndex = ak[a.questionId];
|
||||||
|
isCorrect = typeof correctIndex === 'number' && a.choiceIndex === correctIndex;
|
||||||
|
earnedPoints = isCorrect ? maxPoints : 0;
|
||||||
|
details = { choiceIndex: a.choiceIndex, correctIndex };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { questionId: a.questionId, type, isCorrect, earnedPoints, maxPoints, details };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import CourseForm, { CourseFormValues } from '@/components/admin/CourseForm';
|
||||||
|
import { showToast } from '@/lib/toast';
|
||||||
|
|
||||||
|
export default function AdminCourseEditPage() {
|
||||||
|
const params = useParams() as { id: string };
|
||||||
|
const courseId = params?.id;
|
||||||
|
const router = useRouter();
|
||||||
|
const [initial, setInitial] = useState<CourseFormValues | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/courses/${encodeURIComponent(courseId)}`, { signal: ctrl.signal });
|
||||||
|
if (!res.ok) throw new Error(`Gagal memuat (${res.status})`);
|
||||||
|
const json = await res.json();
|
||||||
|
setInitial({
|
||||||
|
title: String(json?.title ?? ''),
|
||||||
|
description: json?.description ?? '',
|
||||||
|
instructor: json?.instructor ?? '',
|
||||||
|
duration: json?.duration ?? '',
|
||||||
|
thumbnail: json?.thumbnail ?? '',
|
||||||
|
color: json?.color ?? '#3F51B5',
|
||||||
|
status: json?.status ?? 'not_started',
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(e?.message || 'Gagal memuat data kursus', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => ctrl.abort();
|
||||||
|
}, [courseId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Edit Kursus</h1>
|
||||||
|
<p className="text-gray-600">ID: {courseId}</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/admin/courses/${courseId}`} className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Kembali</Link>
|
||||||
|
</div>
|
||||||
|
{loading && <div className="p-4 bg-white border rounded-lg">Memuat data kursus...</div>}
|
||||||
|
{!loading && initial && (
|
||||||
|
<CourseForm mode="edit" courseId={courseId} initial={initial} onDone={() => setTimeout(() => router.push(`/admin/courses/${courseId}`), 600)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import CourseForm from '@/components/admin/CourseForm';
|
||||||
|
|
||||||
|
export default function AdminCourseCreatePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Buat Kursus Baru</h1>
|
||||||
|
<p className="text-gray-600">Isi detail kursus kemudian simpan</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin/courses" className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Kembali</Link>
|
||||||
|
</div>
|
||||||
|
<CourseForm mode="create" onDone={() => setTimeout(() => router.push('/admin/courses'), 600)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const BASE = process.env.BACKEND_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8001';
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: { courseId: string } }) {
|
||||||
|
const { courseId } = params;
|
||||||
|
const upstreamUrl = `${BASE}/api/courses/${encodeURIComponent(courseId)}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(upstreamUrl, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: 'Upstream error', status: res.status }, { status: res.status });
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
return NextResponse.json(json);
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, { params }: { params: { courseId: string } }) {
|
||||||
|
const { courseId } = params;
|
||||||
|
try {
|
||||||
|
const payload = await req.json();
|
||||||
|
const upstreamUrl = `${BASE}/api/courses/${encodeURIComponent(courseId)}`;
|
||||||
|
const res = await fetch(upstreamUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json: any = null;
|
||||||
|
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: 'Upstream error', status: res.status, data: json }, { status: res.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json(json, { status: res.status });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: NextRequest, { params }: { params: { courseId: string } }) {
|
||||||
|
const { courseId } = params;
|
||||||
|
const upstreamUrl = `${BASE}/api/courses/${encodeURIComponent(courseId)}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(upstreamUrl, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json: any = null;
|
||||||
|
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: 'Upstream error', status: res.status, data: json }, { status: res.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json(json, { status: res.status });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const BASE = process.env.BACKEND_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8001';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const page = url.searchParams.get('page') || '1';
|
||||||
|
const limit = url.searchParams.get('limit') || '20';
|
||||||
|
const q = url.searchParams.get('q') || '';
|
||||||
|
|
||||||
|
const upstreamUrl = `${BASE}/api/courses?page=${encodeURIComponent(page)}&pageSize=${encodeURIComponent(limit)}${q ? `&q=${encodeURIComponent(q)}` : ''}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(upstreamUrl, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: 'Upstream error', status: res.status }, { status: res.status });
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
return NextResponse.json({
|
||||||
|
data: json.items || [],
|
||||||
|
total: json.total ?? 0,
|
||||||
|
page: json.page ?? Number(page),
|
||||||
|
limit: json.pageSize ?? Number(limit),
|
||||||
|
q,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const payload = await req.json();
|
||||||
|
const upstreamUrl = `${BASE}/api/courses`;
|
||||||
|
const res = await fetch(upstreamUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let json: any = null;
|
||||||
|
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: 'Upstream error', status: res.status, data: json }, { status: res.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json(json, { status: res.status });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const BASE = process.env.BACKEND_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8001';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const q = url.searchParams.get('q') || '';
|
||||||
|
const upstreamUrl = `${BASE}/api/instructors${q ? `?q=${encodeURIComponent(q)}` : ''}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(upstreamUrl, { headers: { Accept: 'application/json' } });
|
||||||
|
const text = await res.text();
|
||||||
|
let json: any = null;
|
||||||
|
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||||
|
if (!res.ok) return NextResponse.json({ error: 'Upstream error', status: res.status, data: json }, { status: res.status });
|
||||||
|
return NextResponse.json(json);
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get('file') as File | null;
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'File tidak ditemukan' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const type = file.type || '';
|
||||||
|
if (!type.startsWith('image/')) {
|
||||||
|
return NextResponse.json({ error: 'Hanya gambar yang diperbolehkan' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
if (buffer.length > 5 * 1024 * 1024) {
|
||||||
|
return NextResponse.json({ error: 'Ukuran gambar maksimal 5MB' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'public', 'uploads');
|
||||||
|
await fs.promises.mkdir(uploadsDir, { recursive: true });
|
||||||
|
const ext = path.extname(file.name) || (type.includes('png') ? '.png' : type.includes('jpeg') ? '.jpg' : '');
|
||||||
|
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;
|
||||||
|
const filePath = path.join(uploadsDir, filename);
|
||||||
|
await fs.promises.writeFile(filePath, buffer);
|
||||||
|
const publicPath = `/uploads/${filename}`;
|
||||||
|
return NextResponse.json({ path: publicPath });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Upload gagal', message: err?.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { showToast } from '@/lib/toast';
|
||||||
|
|
||||||
|
type Status = 'not_started' | 'in_progress' | 'completed';
|
||||||
|
|
||||||
|
export type CourseFormValues = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
instructor?: string; // name string per backend schema
|
||||||
|
duration?: string; // e.g., "6h", "30m", "1h 30m"
|
||||||
|
thumbnail?: string; // public path / URL
|
||||||
|
color?: string; // hex
|
||||||
|
status?: Status;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
courseId?: string;
|
||||||
|
initial?: CourseFormValues;
|
||||||
|
onDone?: (result: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Instructor = { id: string; name: string; email?: string };
|
||||||
|
|
||||||
|
export default function CourseForm({ mode, courseId, initial, onDone }: Props) {
|
||||||
|
const [title, setTitle] = useState(initial?.title || '');
|
||||||
|
const [description, setDescription] = useState(initial?.description || '');
|
||||||
|
const [instructor, setInstructor] = useState(initial?.instructor || '');
|
||||||
|
const [hours, setHours] = useState(() => {
|
||||||
|
if (!initial?.duration) return 0;
|
||||||
|
const hMatch = /(?<h>\d+)h/.exec(initial.duration || '');
|
||||||
|
return hMatch ? Number(hMatch.groups?.h) : 0;
|
||||||
|
});
|
||||||
|
const [minutes, setMinutes] = useState(() => {
|
||||||
|
if (!initial?.duration) return 0;
|
||||||
|
const mMatch = /(?<m>\d+)m/.exec(initial.duration || '');
|
||||||
|
return mMatch ? Number(mMatch.groups?.m) : 0;
|
||||||
|
});
|
||||||
|
const [color, setColor] = useState(initial?.color || '#3F51B5');
|
||||||
|
const [status, setStatus] = useState<Status>(initial?.status || 'not_started');
|
||||||
|
|
||||||
|
const [thumbPath, setThumbPath] = useState<string | undefined>(initial?.thumbnail || undefined);
|
||||||
|
const [thumbPreview, setThumbPreview] = useState<string | undefined>(undefined);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string> | null>(null);
|
||||||
|
|
||||||
|
const [instructorQuery, setInstructorQuery] = useState('');
|
||||||
|
const [instructorOptions, setInstructorOptions] = useState<Instructor[]>([]);
|
||||||
|
const [loadingInstructors, setLoadingInstructors] = useState(false);
|
||||||
|
|
||||||
|
const durationString = useMemo(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (hours && hours > 0) parts.push(`${hours}h`);
|
||||||
|
if (minutes && minutes > 0) parts.push(`${minutes}m`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}, [hours, minutes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
setLoadingInstructors(true);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/instructors${instructorQuery ? `?q=${encodeURIComponent(instructorQuery)}` : ''}`, { signal: ctrl.signal });
|
||||||
|
const json = await res.json();
|
||||||
|
setInstructorOptions(json?.items || []);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingInstructors(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => ctrl.abort();
|
||||||
|
}, [instructorQuery]);
|
||||||
|
|
||||||
|
function validate(): Record<string, string> {
|
||||||
|
const errs: Record<string, string> = {};
|
||||||
|
if (!title || title.trim().length < 3) errs.title = 'Judul minimal 3 karakter';
|
||||||
|
if (!instructor || instructor.trim().length < 3) errs.instructor = 'Instruktur wajib dipilih';
|
||||||
|
if (!durationString) errs.duration = 'Durasi wajib';
|
||||||
|
else {
|
||||||
|
const ok = /^(\d+h|\d+m|\d+h\s\d+m)$/.test(durationString);
|
||||||
|
if (!ok) errs.duration = 'Durasi harus format seperti 6h, 30m, atau 1h 30m';
|
||||||
|
}
|
||||||
|
if (!thumbPath) errs.thumbnail = 'Thumbnail wajib';
|
||||||
|
if (color) {
|
||||||
|
const ok = /^#([A-Fa-f0-9]{6})$/.test(color.trim());
|
||||||
|
if (!ok) errs.color = 'Warna harus hex contoh #FF9800';
|
||||||
|
}
|
||||||
|
if (status && !['not_started', 'in_progress', 'completed'].includes(status)) {
|
||||||
|
errs.status = 'Status tidak valid';
|
||||||
|
}
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
setUploadError(null);
|
||||||
|
if (!file) return;
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
setThumbPreview(previewUrl);
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const res = await fetch('/api/uploads', { method: 'POST', body: fd });
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = json?.error || 'Upload gagal';
|
||||||
|
setUploadError(String(msg));
|
||||||
|
showToast(String(msg), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setThumbPath(json?.path);
|
||||||
|
showToast('Thumbnail berhasil diunggah', 'success');
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message || 'Upload gagal';
|
||||||
|
setUploadError(String(msg));
|
||||||
|
showToast(String(msg), 'error');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const errs = validate();
|
||||||
|
if (Object.keys(errs).length > 0) {
|
||||||
|
setErrors(errs);
|
||||||
|
showToast('Periksa kembali input Anda', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrors(null);
|
||||||
|
|
||||||
|
const payload: CourseFormValues = {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description || '',
|
||||||
|
instructor: instructor.trim(),
|
||||||
|
duration: durationString,
|
||||||
|
thumbnail: thumbPath,
|
||||||
|
color,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res: Response;
|
||||||
|
let json: any;
|
||||||
|
if (mode === 'create') {
|
||||||
|
res = await fetch('/api/courses', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch(`/api/courses/${encodeURIComponent(String(courseId))}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
json = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = json?.error || 'Gagal menyimpan kursus';
|
||||||
|
showToast(String(msg), 'error');
|
||||||
|
setErrors({ general: String(msg) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast(mode === 'create' ? 'Kursus berhasil dibuat' : 'Perubahan berhasil disimpan', 'success');
|
||||||
|
onDone?.(json);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message || 'Gagal menyimpan kursus';
|
||||||
|
showToast(String(msg), 'error');
|
||||||
|
setErrors({ general: String(msg) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-4 max-w-2xl">
|
||||||
|
{errors && (
|
||||||
|
<div className="p-4 bg-yellow-50 text-yellow-700 border border-yellow-200 rounded-lg">
|
||||||
|
<ul className="list-disc ml-5 text-sm">
|
||||||
|
{Object.entries(errors).map(([k, v]) => (
|
||||||
|
<li key={k}>{v}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Judul</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Deskripsi</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Instruktur</label>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari instruktur..."
|
||||||
|
value={instructorQuery}
|
||||||
|
onChange={e => setInstructorQuery(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
{loadingInstructors && <span className="text-sm text-gray-500">Memuat...</span>}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={instructor}
|
||||||
|
onChange={e => setInstructor(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>Pilih instruktur</option>
|
||||||
|
{instructorOptions.map((i) => (
|
||||||
|
<option key={i.id} value={i.name}>{i.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors?.instructor && <p className="text-xs text-red-600 mt-1">{errors.instructor}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Durasi</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<input type="number" min={0} max={24} value={hours} onChange={e => setHours(Number(e.target.value))} className="w-24 px-3 py-2 border border-gray-300 rounded-lg" />
|
||||||
|
<span className="text-xs text-gray-500">Jam (0–24)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<input type="number" min={0} max={59} value={minutes} onChange={e => setMinutes(Number(e.target.value))} className="w-24 px-3 py-2 border border-gray-300 rounded-lg" />
|
||||||
|
<span className="text-xs text-gray-500">Menit (0–59)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{errors?.duration && <p className="text-xs text-red-600 mt-1">{errors.duration}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Warna Tema</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input type="color" value={color} onChange={e => setColor(e.target.value)} className="h-10 w-16 p-1 border rounded" />
|
||||||
|
<input type="text" value={color} onChange={e => setColor(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
{errors?.color && <p className="text-xs text-red-600 mt-1">{errors.color}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Thumbnail</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input type="file" accept="image/*" onChange={handleFileChange} className="flex-1" />
|
||||||
|
{uploading && <span className="text-sm text-gray-500">Mengunggah...</span>}
|
||||||
|
</div>
|
||||||
|
{(thumbPreview || thumbPath) && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<img src={thumbPreview || thumbPath} alt="preview" className="w-48 h-28 object-cover rounded border" />
|
||||||
|
{thumbPath && <p className="text-xs text-gray-500 mt-1">Disimpan: {thumbPath}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uploadError && <p className="text-xs text-red-600 mt-1">{uploadError}</p>}
|
||||||
|
{errors?.thumbnail && <p className="text-xs text-red-600 mt-1">{errors.thumbnail}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status Kursus</label>
|
||||||
|
<select value={status} onChange={e => setStatus(e.target.value as Status)} className="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
<option value="not_started">Belum Mulai</option>
|
||||||
|
<option value="in_progress">Berlangsung</option>
|
||||||
|
<option value="completed">Selesai</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
|
{mode === 'create' ? 'Buat Kursus' : 'Simpan'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue