improvement Quiz Interactive
This commit is contained in:
parent
d0e5332563
commit
bcb9aabc35
|
|
@ -0,0 +1,355 @@
|
|||
# 🧪 Design Review: UAT Testing Strategy untuk LMS
|
||||
**Epic**: LMS System
|
||||
**Story**: UAT Design Testing
|
||||
**Topic**: User Acceptance Testing
|
||||
**Date**: 2025-01-27
|
||||
**Test Architect**: Quinn - BMad Method
|
||||
|
||||
---
|
||||
|
||||
## 📋 **EXECUTIVE SUMMARY**
|
||||
|
||||
Dokumen ini menyajikan design review khusus untuk User Acceptance Testing (UAT) pada sistem Learning Management System (LMS). Fokus utama adalah memastikan sistem siap untuk pengujian oleh end-user dengan skenario yang komprehensif dan realistis.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **UAT SCOPE & OBJECTIVES**
|
||||
|
||||
### **Primary Objectives:**
|
||||
- ✅ Validasi user experience untuk pengguna Indonesia
|
||||
- ✅ Verifikasi business requirements telah terpenuhi
|
||||
- ✅ Konfirmasi sistem dapat digunakan dalam kondisi real-world
|
||||
- ✅ Identifikasi gap antara ekspektasi user dan implementasi
|
||||
|
||||
### **UAT Coverage Areas:**
|
||||
1. **Authentication & Authorization** (Login/Register/Role Management)
|
||||
2. **Course Management** (Browse, Enroll, Progress Tracking)
|
||||
3. **Assessment System** (Quiz, Exam, Interactive Elements)
|
||||
4. **Certificate Management** (Generation, Verification, Download)
|
||||
5. **Dashboard & Analytics** (Student/Admin/Instructor Views)
|
||||
6. **Payroll Reward System** (Learning Hours, Performance Bonus)
|
||||
7. **AI Assistant** (Learning Support, Recommendations)
|
||||
8. **Mobile Responsiveness** (Cross-device compatibility)
|
||||
|
||||
---
|
||||
|
||||
## 👥 **USER PERSONAS FOR UAT**
|
||||
|
||||
### **1. Peserta/Student (Primary User)**
|
||||
- **Profile**: Karyawan perusahaan, usia 25-45 tahun
|
||||
- **Tech Literacy**: Menengah (familiar dengan smartphone, basic computer)
|
||||
- **Goals**: Menyelesaikan pelatihan, mendapat sertifikat, earning rewards
|
||||
- **Pain Points**: Waktu terbatas, perlu interface yang intuitif
|
||||
|
||||
### **2. Instruktur/Trainer**
|
||||
- **Profile**: Professional trainer, usia 30-50 tahun
|
||||
- **Tech Literacy**: Menengah-Tinggi
|
||||
- **Goals**: Mengelola kursus, monitor progress siswa, evaluasi hasil
|
||||
- **Pain Points**: Butuh tools yang efisien untuk manajemen konten
|
||||
|
||||
### **3. Admin/HR**
|
||||
- **Profile**: HR Manager atau Training Coordinator
|
||||
- **Tech Literacy**: Menengah-Tinggi
|
||||
- **Goals**: Oversight training program, generate reports, manage users
|
||||
- **Pain Points**: Butuh dashboard yang comprehensive dan mudah dipahami
|
||||
|
||||
### **4. Super Admin/System Administrator**
|
||||
- **Profile**: IT Professional
|
||||
- **Tech Literacy**: Tinggi
|
||||
- **Goals**: System maintenance, user management, security oversight
|
||||
- **Pain Points**: Butuh control panel yang powerful namun user-friendly
|
||||
|
||||
---
|
||||
|
||||
## 🧪 **UAT TEST SCENARIOS**
|
||||
|
||||
### **Scenario 1: Onboarding Journey (Critical Path)**
|
||||
```
|
||||
User Story: "Sebagai karyawan baru, saya ingin dapat mendaftar dan mulai belajar dengan mudah"
|
||||
|
||||
Test Steps:
|
||||
1. Akses halaman registrasi
|
||||
2. Isi form registrasi dengan data valid
|
||||
3. Verifikasi email (jika ada)
|
||||
4. Login pertama kali
|
||||
5. Complete profile setup
|
||||
6. Browse available courses
|
||||
7. Enroll ke course pertama
|
||||
8. Start learning module pertama
|
||||
|
||||
Expected Results:
|
||||
- Proses registrasi < 3 menit
|
||||
- Interface dalam Bahasa Indonesia yang jelas
|
||||
- Guidance yang membantu untuk first-time user
|
||||
- Smooth transition antar step
|
||||
```
|
||||
|
||||
### **Scenario 2: Learning Experience (Core Functionality)**
|
||||
```
|
||||
User Story: "Sebagai peserta, saya ingin pengalaman belajar yang engaging dan mudah diikuti"
|
||||
|
||||
Test Steps:
|
||||
1. Login ke dashboard
|
||||
2. Continue course yang sedang berjalan
|
||||
3. Watch video pembelajaran
|
||||
4. Complete interactive quiz
|
||||
5. Submit assignment (jika ada)
|
||||
6. Check progress tracking
|
||||
7. Earn learning hours untuk payroll system
|
||||
8. Receive notifications/feedback
|
||||
|
||||
Expected Results:
|
||||
- Video player berfungsi smooth di berbagai device
|
||||
- Quiz interface intuitif dan responsive
|
||||
- Progress tracking akurat dan real-time
|
||||
- Payroll hours tercatat dengan benar
|
||||
```
|
||||
|
||||
### **Scenario 3: Assessment & Certification (High Stakes)**
|
||||
```
|
||||
User Story: "Sebagai peserta, saya ingin dapat mengikuti ujian dan mendapat sertifikat dengan confidence"
|
||||
|
||||
Test Steps:
|
||||
1. Access exam session
|
||||
2. Review exam instructions
|
||||
3. Complete exam dengan timer
|
||||
4. Submit exam answers
|
||||
5. Receive immediate feedback (jika applicable)
|
||||
6. Check exam results
|
||||
7. Download digital certificate
|
||||
8. Verify certificate authenticity
|
||||
|
||||
Expected Results:
|
||||
- Exam interface stable dan tidak crash
|
||||
- Timer berfungsi akurat
|
||||
- Auto-save answers berfungsi
|
||||
- Certificate generation berhasil
|
||||
- QR code verification works
|
||||
```
|
||||
|
||||
### **Scenario 4: Admin Management (Power User)**
|
||||
```
|
||||
User Story: "Sebagai admin, saya ingin dapat mengelola sistem dengan efisien"
|
||||
|
||||
Test Steps:
|
||||
1. Login ke admin panel
|
||||
2. Create new course
|
||||
3. Upload learning materials
|
||||
4. Set up quiz/exam
|
||||
5. Manage user enrollments
|
||||
6. Generate analytics reports
|
||||
7. Configure payroll reward settings
|
||||
8. Monitor system performance
|
||||
|
||||
Expected Results:
|
||||
- Admin interface responsive dan intuitive
|
||||
- Bulk operations berfungsi dengan baik
|
||||
- Reports generated accurately
|
||||
- System performance metrics visible
|
||||
```
|
||||
|
||||
### **Scenario 5: Mobile Experience (Cross-Platform)**
|
||||
```
|
||||
User Story: "Sebagai mobile user, saya ingin dapat belajar dengan nyaman di smartphone"
|
||||
|
||||
Test Steps:
|
||||
1. Access LMS via mobile browser
|
||||
2. Login dan navigate dashboard
|
||||
3. Watch video di mobile
|
||||
4. Complete quiz di mobile
|
||||
5. Check progress dan notifications
|
||||
6. Download certificate di mobile
|
||||
7. Test offline capabilities (jika ada)
|
||||
|
||||
Expected Results:
|
||||
- Responsive design works seamlessly
|
||||
- Touch interactions smooth
|
||||
- Video playback optimized for mobile
|
||||
- Text readable tanpa zoom
|
||||
- Fast loading times
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 **UAT TEST DESIGN FRAMEWORK**
|
||||
|
||||
### **Testing Approach:**
|
||||
- **Exploratory Testing**: User bebas explore sistem secara natural
|
||||
- **Scenario-Based Testing**: Guided scenarios berdasarkan real use cases
|
||||
- **Usability Testing**: Focus pada ease of use dan user satisfaction
|
||||
- **Acceptance Criteria Validation**: Verify business requirements terpenuhi
|
||||
|
||||
### **Test Environment:**
|
||||
- **Staging Environment**: Mirror production dengan test data
|
||||
- **Multiple Devices**: Desktop, tablet, smartphone (Android/iOS)
|
||||
- **Multiple Browsers**: Chrome, Firefox, Safari, Edge
|
||||
- **Network Conditions**: Fast WiFi, slow 3G, intermittent connection
|
||||
|
||||
### **Success Metrics:**
|
||||
- **Task Completion Rate**: > 90% untuk critical paths
|
||||
- **Time to Complete**: Sesuai dengan target yang ditetapkan
|
||||
- **Error Rate**: < 5% untuk user-induced errors
|
||||
- **User Satisfaction**: Rating > 4/5 pada post-test survey
|
||||
- **Accessibility**: WCAG 2.1 AA compliance
|
||||
|
||||
---
|
||||
|
||||
## 📊 **UAT EXECUTION PLAN**
|
||||
|
||||
### **Phase 1: Internal UAT (Week 1)**
|
||||
- **Participants**: Internal team members (5-8 orang)
|
||||
- **Focus**: Basic functionality dan critical bugs
|
||||
- **Duration**: 3 hari
|
||||
- **Deliverable**: Bug report dan initial feedback
|
||||
|
||||
### **Phase 2: Stakeholder UAT (Week 2)**
|
||||
- **Participants**: Key stakeholders dan power users (8-12 orang)
|
||||
- **Focus**: Business requirements validation
|
||||
- **Duration**: 5 hari
|
||||
- **Deliverable**: Acceptance criteria validation report
|
||||
|
||||
### **Phase 3: End-User UAT (Week 3)**
|
||||
- **Participants**: Representative end users (15-20 orang)
|
||||
- **Focus**: Real-world usage scenarios
|
||||
- **Duration**: 1 minggu
|
||||
- **Deliverable**: User experience report dan recommendations
|
||||
|
||||
### **Phase 4: Performance UAT (Week 4)**
|
||||
- **Participants**: Mixed user groups dengan concurrent access
|
||||
- **Focus**: System performance under load
|
||||
- **Duration**: 2 hari
|
||||
- **Deliverable**: Performance validation report
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **UAT SUCCESS CRITERIA**
|
||||
|
||||
### **Functional Criteria:**
|
||||
- ✅ Semua critical user journeys dapat diselesaikan tanpa blocker
|
||||
- ✅ Authentication dan authorization berfungsi sesuai role
|
||||
- ✅ Course management dan learning experience smooth
|
||||
- ✅ Assessment system reliable dan secure
|
||||
- ✅ Certificate generation dan verification works
|
||||
- ✅ Payroll reward system calculate accurately
|
||||
- ✅ Admin functions accessible dan efficient
|
||||
|
||||
### **Non-Functional Criteria:**
|
||||
- ✅ Page load time < 3 detik untuk 95% requests
|
||||
- ✅ System available 99.5% selama UAT period
|
||||
- ✅ Mobile responsiveness works pada semua target devices
|
||||
- ✅ Accessibility standards met (WCAG 2.1 AA)
|
||||
- ✅ Security vulnerabilities addressed
|
||||
- ✅ Data integrity maintained throughout testing
|
||||
|
||||
### **User Experience Criteria:**
|
||||
- ✅ Interface dalam Bahasa Indonesia yang natural
|
||||
- ✅ Navigation intuitive untuk Indonesian users
|
||||
- ✅ Error messages helpful dan actionable
|
||||
- ✅ Feedback mechanisms responsive
|
||||
- ✅ Help documentation accessible dan comprehensive
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **RISK ASSESSMENT**
|
||||
|
||||
### **High Risk Areas:**
|
||||
1. **Exam System Stability**: Critical untuk certification process
|
||||
2. **Mobile Performance**: Majority users akan akses via mobile
|
||||
3. **Payroll Calculation**: Financial implications jika salah
|
||||
4. **Certificate Verification**: Legal compliance requirements
|
||||
5. **User Data Security**: Privacy dan GDPR compliance
|
||||
|
||||
### **Mitigation Strategies:**
|
||||
- **Comprehensive Test Data**: Cover edge cases dan boundary conditions
|
||||
- **Rollback Plan**: Ready jika critical issues ditemukan
|
||||
- **Performance Monitoring**: Real-time monitoring selama UAT
|
||||
- **Security Review**: Penetration testing sebelum UAT
|
||||
- **User Training**: Provide clear documentation dan support
|
||||
|
||||
---
|
||||
|
||||
## 📋 **UAT DELIVERABLES**
|
||||
|
||||
### **Test Documentation:**
|
||||
- [ ] UAT Test Plan (This document)
|
||||
- [ ] Test Scenarios dan Test Cases
|
||||
- [ ] User Personas dan Journey Maps
|
||||
- [ ] Test Data Requirements
|
||||
- [ ] Environment Setup Guide
|
||||
|
||||
### **Execution Artifacts:**
|
||||
- [ ] Daily Test Execution Reports
|
||||
- [ ] Bug Reports dengan severity classification
|
||||
- [ ] User Feedback Compilation
|
||||
- [ ] Performance Test Results
|
||||
- [ ] Accessibility Audit Report
|
||||
|
||||
### **Final Reports:**
|
||||
- [ ] UAT Summary Report
|
||||
- [ ] Business Requirements Traceability Matrix
|
||||
- [ ] User Acceptance Sign-off Document
|
||||
- [ ] Go-Live Readiness Assessment
|
||||
- [ ] Post-UAT Recommendations
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **RECOMMENDATIONS**
|
||||
|
||||
### **Pre-UAT Preparation:**
|
||||
1. **Test Data Setup**: Prepare realistic test data yang represent production scenarios
|
||||
2. **User Training**: Brief UAT participants tentang objectives dan expectations
|
||||
3. **Environment Validation**: Ensure staging environment stable dan representative
|
||||
4. **Communication Plan**: Clear channels untuk reporting issues dan feedback
|
||||
|
||||
### **During UAT Execution:**
|
||||
1. **Daily Standups**: Track progress dan address blockers immediately
|
||||
2. **Real-time Monitoring**: Monitor system performance dan user behavior
|
||||
3. **Feedback Collection**: Multiple channels untuk user input (forms, interviews, observations)
|
||||
4. **Issue Triage**: Rapid classification dan resolution untuk critical issues
|
||||
|
||||
### **Post-UAT Actions:**
|
||||
1. **Lessons Learned**: Document insights untuk future UAT cycles
|
||||
2. **User Training Materials**: Update based pada UAT feedback
|
||||
3. **Performance Optimization**: Address performance issues identified
|
||||
4. **Go-Live Planning**: Finalize deployment strategy based pada UAT results
|
||||
|
||||
---
|
||||
|
||||
## 🏁 **GATE ASSESSMENT**
|
||||
|
||||
### **Current Status**: READY FOR UAT EXECUTION
|
||||
|
||||
### **Gate Decision**: **PASS** ✅
|
||||
|
||||
**Justification:**
|
||||
- ✅ Comprehensive UAT strategy telah didefinisikan
|
||||
- ✅ Test scenarios cover semua critical user journeys
|
||||
- ✅ Success criteria jelas dan measurable
|
||||
- ✅ Risk mitigation strategies in place
|
||||
- ✅ Execution plan realistic dan achievable
|
||||
- ✅ Deliverables clearly defined
|
||||
|
||||
### **Conditions for Success:**
|
||||
1. Staging environment harus stable sebelum UAT dimulai
|
||||
2. Test data harus representative dan comprehensive
|
||||
3. UAT participants harus properly briefed
|
||||
4. Issue resolution process harus efficient
|
||||
5. Performance monitoring harus real-time
|
||||
|
||||
### **Next Steps:**
|
||||
1. Finalize UAT participant selection
|
||||
2. Setup staging environment dengan production-like data
|
||||
3. Conduct UAT kickoff meeting
|
||||
4. Begin Phase 1: Internal UAT execution
|
||||
5. Monitor progress dan adjust plan as needed
|
||||
|
||||
---
|
||||
|
||||
**Document Prepared By**: Quinn - Test Architect, BMad Method
|
||||
**Review Date**: 2025-01-27
|
||||
**Next Review**: Post-UAT Completion
|
||||
**Approval Status**: Ready for Stakeholder Review
|
||||
|
||||
---
|
||||
|
||||
*"Quality is not an act, it is a habit. UAT adalah kesempatan terakhir untuk memastikan sistem benar-benar siap melayani user dengan excellence."* - Quinn, BMad Method QA Team
|
||||
|
|
@ -6,7 +6,7 @@ import { cn } from '@/utils/cn';
|
|||
import Link from 'next/link';
|
||||
import { ExamProvider, useExam } from '@/contexts/ExamContext';
|
||||
import { useExamPersistence } from '@/hooks/useExamPersistence';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { InteractiveQuestion, EnhancedExamData, Question, PuzzleData } from '@/types';
|
||||
|
||||
// Feature Flags
|
||||
|
|
@ -23,6 +23,9 @@ import AnswerFeedback from '@/components/quiz/AnswerFeedback';
|
|||
|
||||
// Interactive Quiz Components
|
||||
import InteractiveQuizRenderer from '@/components/quiz/InteractiveQuizRenderer';
|
||||
import VideoScenario from '@/components/quiz/VideoScenario';
|
||||
import InteractiveImageHotspot from '@/components/quiz/InteractiveImageHotspot';
|
||||
import MediaGallery from '@/components/quiz/MediaGallery';
|
||||
|
||||
// Legacy Components (fallback)
|
||||
import QuestionNavigator from '@/components/exam/QuestionNavigator';
|
||||
|
|
@ -141,6 +144,235 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
}
|
||||
]
|
||||
},
|
||||
// Video Scenario Question - NEW REALISTIC SCENARIO
|
||||
{
|
||||
id: 'video-scenario-1',
|
||||
question: 'Analisis Situasi Dapur: Identifikasi Masalah Keamanan Pangan',
|
||||
type: 'video_scenario' as const,
|
||||
options: [],
|
||||
correctAnswer: 0,
|
||||
content: {
|
||||
question: 'Tonton video situasi dapur berikut dan identifikasi masalah keamanan pangan yang terjadi',
|
||||
instructions: 'Video akan berhenti pada titik-titik kritis. Jawab pertanyaan yang muncul untuk melanjutkan.',
|
||||
media: {
|
||||
type: 'video' as const,
|
||||
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||||
alt: 'Simulasi Dapur Komersial - Analisis Keamanan Pangan',
|
||||
caption: 'Amati aktivitas dapur selama shift pagi dan identifikasi potensi masalah keamanan pangan'
|
||||
}
|
||||
},
|
||||
difficulty: 'medium' as const,
|
||||
timeLimit: 600,
|
||||
scenarios: [
|
||||
{
|
||||
id: 'step1',
|
||||
timestamp: 15,
|
||||
title: 'Persiapan Bahan Makanan',
|
||||
question: 'Berdasarkan simulasi video, apa masalah utama yang dapat terjadi dalam persiapan bahan makanan di dapur komersial?',
|
||||
options: [
|
||||
{ id: 'opt1', text: 'Daging mentah dan sayuran disiapkan di area yang sama tanpa pemisahan', consequence: 'Risiko kontaminasi silang sangat tinggi - dapat menyebabkan foodborne illness' },
|
||||
{ id: 'opt2', text: 'Suhu ruangan tidak terkontrol dengan baik', consequence: 'Dapat mempercepat pertumbuhan bakteri patogen pada makanan' },
|
||||
{ id: 'opt3', text: 'Peralatan masak tidak dibersihkan secara proper antara penggunaan', consequence: 'Menjadi sumber kontaminasi utama dan breeding ground bakteri' },
|
||||
{ id: 'opt4', text: 'Semua masalah di atas dapat terjadi bersamaan', consequence: 'Analisis komprehensif - multiple hazards memerlukan sistem HACCP yang ketat' }
|
||||
],
|
||||
correctAnswer: 3,
|
||||
explanation: 'Dalam operasional dapur komersial, multiple food safety hazards sering terjadi bersamaan: kontaminasi silang, temperature abuse, dan poor sanitation. Sistem HACCP (Hazard Analysis Critical Control Points) diperlukan untuk mengidentifikasi dan mengontrol semua critical control points.',
|
||||
pauseVideo: true
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
timestamp: 45,
|
||||
title: 'Kontrol Suhu dan Penyimpanan',
|
||||
question: 'Dalam konteks keamanan pangan, apa yang harus diperhatikan dalam kontrol suhu?',
|
||||
options: [
|
||||
{ id: 'opt1', text: 'Danger Zone (4°C - 60°C) harus dihindari', consequence: 'Benar - bakteri berkembang pesat di suhu ini' },
|
||||
{ id: 'opt2', text: 'Cold chain harus dijaga dari supplier hingga serving', consequence: 'Kritis untuk mencegah temperature abuse' },
|
||||
{ id: 'opt3', text: 'Monitoring suhu harus dilakukan secara berkala', consequence: 'Dokumentasi suhu adalah requirement legal' },
|
||||
{ id: 'opt4', text: 'Semua aspek kontrol suhu di atas penting', consequence: 'Temperature control adalah fundamental dalam food safety' }
|
||||
],
|
||||
correctAnswer: 3,
|
||||
explanation: 'Temperature control adalah salah satu pilar utama food safety. Danger zone, cold chain management, dan monitoring berkelanjutan harus diimplementasikan secara sistematis.',
|
||||
pauseVideo: true
|
||||
}
|
||||
],
|
||||
interactiveElements: [
|
||||
{
|
||||
type: 'hint',
|
||||
content: 'Fokus pada prinsip-prinsip HACCP: identifikasi hazards, critical control points, monitoring procedures, dan corrective actions'
|
||||
},
|
||||
{
|
||||
type: 'learning_objective',
|
||||
content: 'Setelah menyelesaikan scenario ini, Anda akan mampu mengidentifikasi critical control points dalam operasional dapur dan menerapkan prinsip food safety management'
|
||||
}
|
||||
]
|
||||
},
|
||||
// Image Hotspot Question - NEW REALISTIC SCENARIO
|
||||
{
|
||||
id: 'hotspot-scenario-1',
|
||||
question: 'Identifikasi Area Bermasalah dalam Tata Letak Dapur',
|
||||
type: 'image_hotspot' as const,
|
||||
options: [],
|
||||
correctAnswer: 0,
|
||||
content: {
|
||||
question: 'Klik pada area-area dalam gambar dapur yang menunjukkan pelanggaran keamanan pangan',
|
||||
instructions: 'Temukan dan klik pada 5 area bermasalah dalam tata letak dapur. Setiap area yang benar akan memberikan poin.',
|
||||
media: {
|
||||
type: 'image' as const,
|
||||
src: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&h=600',
|
||||
alt: 'Tata letak dapur dengan berbagai area bermasalah',
|
||||
caption: 'Analisis Tata Letak Dapur MBG - Identifikasi masalah keamanan pangan'
|
||||
}
|
||||
},
|
||||
difficulty: 'hard' as const,
|
||||
timeLimit: 300,
|
||||
hotspots: [
|
||||
{
|
||||
id: 'hotspot1',
|
||||
x: 15, y: 25, width: 12, height: 15,
|
||||
type: 'incorrect',
|
||||
title: 'Area Penyimpanan Daging',
|
||||
description: 'Daging mentah disimpan di rak atas',
|
||||
feedback: 'SALAH! Daging mentah harus disimpan di rak paling bawah untuk mencegah tetesan ke makanan lain.',
|
||||
points: 10,
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
id: 'hotspot2',
|
||||
x: 45, y: 35, width: 15, height: 12,
|
||||
type: 'incorrect',
|
||||
title: 'Area Pencucian Sayuran',
|
||||
description: 'Sayuran dicuci bersamaan dengan peralatan kotor',
|
||||
feedback: 'SALAH! Sayuran harus dicuci di area terpisah dari peralatan kotor untuk mencegah kontaminasi silang.',
|
||||
points: 10,
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
id: 'hotspot3',
|
||||
x: 70, y: 20, width: 18, height: 10,
|
||||
type: 'incorrect',
|
||||
title: 'Area Penyimpanan Suhu Ruang',
|
||||
description: 'Makanan mudah rusak disimpan pada suhu ruang',
|
||||
feedback: 'SALAH! Makanan mudah rusak harus disimpan dalam refrigerator pada suhu 4°C atau lebih rendah.',
|
||||
points: 10,
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
id: 'hotspot4',
|
||||
x: 25, y: 60, width: 20, height: 8,
|
||||
type: 'incorrect',
|
||||
title: 'Area Persiapan Makanan',
|
||||
description: 'Talenan untuk daging dan sayuran tidak dipisah',
|
||||
feedback: 'SALAH! Harus menggunakan talenan terpisah untuk daging mentah dan sayuran untuk mencegah kontaminasi silang.',
|
||||
points: 10,
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
id: 'hotspot5',
|
||||
x: 60, y: 70, width: 15, height: 12,
|
||||
type: 'incorrect',
|
||||
title: 'Area Pembuangan Sampah',
|
||||
description: 'Tempat sampah terbuka dekat area persiapan makanan',
|
||||
feedback: 'SALAH! Tempat sampah harus tertutup rapat dan dijauhkan dari area persiapan makanan.',
|
||||
points: 10,
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
id: 'hotspot6',
|
||||
x: 80, y: 45, width: 12, height: 18,
|
||||
type: 'correct',
|
||||
title: 'Area Hand Washing Station',
|
||||
description: 'Stasiun cuci tangan yang tepat',
|
||||
feedback: 'BENAR! Stasiun cuci tangan sudah ditempatkan dengan benar dan mudah diakses oleh staff.',
|
||||
points: 15,
|
||||
isRequired: false
|
||||
}
|
||||
],
|
||||
interactiveElements: [
|
||||
{
|
||||
type: 'hint',
|
||||
content: 'Fokus pada prinsip pemisahan: daging mentah, area pencucian, kontrol suhu, dan kebersihan. Cari area yang melanggar prinsip HACCP.'
|
||||
}
|
||||
]
|
||||
},
|
||||
// Media Gallery Question - NEW REALISTIC SCENARIO
|
||||
{
|
||||
id: 'media-gallery-1',
|
||||
question: 'Pelajari Prosedur HACCP Melalui Media Pembelajaran',
|
||||
type: 'media_gallery' as const,
|
||||
options: [],
|
||||
correctAnswer: 0,
|
||||
content: {
|
||||
question: 'Tinjau semua media pembelajaran tentang implementasi HACCP di Dapur MBG',
|
||||
instructions: 'Pelajari setiap media untuk memahami prosedur HACCP secara komprehensif.',
|
||||
description: 'Koleksi media pembelajaran untuk implementasi sistem HACCP'
|
||||
},
|
||||
difficulty: 'medium' as const,
|
||||
timeLimit: 480,
|
||||
mediaItems: [
|
||||
{
|
||||
id: 'video1',
|
||||
type: 'video',
|
||||
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
|
||||
title: 'Pengenalan Sistem HACCP',
|
||||
description: 'Video pengenalan konsep dasar HACCP dan implementasinya di industri makanan - 7 prinsip fundamental untuk keamanan pangan',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400&h=300&fit=crop',
|
||||
duration: 180
|
||||
},
|
||||
{
|
||||
id: 'image1',
|
||||
type: 'image',
|
||||
src: 'https://images.unsplash.com/photo-1577308856961-8e4e0d5b63b8?w=800&h=600&fit=crop',
|
||||
title: 'Diagram Alur HACCP',
|
||||
description: 'Flowchart lengkap implementasi 7 prinsip HACCP: Analisis Bahaya, Identifikasi CCP, Penetapan Batas Kritis, Monitoring, Tindakan Koreksi, Verifikasi, dan Dokumentasi'
|
||||
},
|
||||
{
|
||||
id: 'video2',
|
||||
type: 'video',
|
||||
src: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
|
||||
title: 'Critical Control Points (CCP)',
|
||||
description: 'Panduan identifikasi dan monitoring Critical Control Points dalam operasional dapur komersial',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=400&h=300&fit=crop',
|
||||
duration: 240
|
||||
},
|
||||
{
|
||||
id: 'image2',
|
||||
type: 'image',
|
||||
src: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=800&h=600&fit=crop',
|
||||
title: 'Temperature Control Chart',
|
||||
description: 'Grafik kontrol suhu untuk berbagai jenis makanan - Danger Zone, Safe Storage, dan Cooking Temperatures'
|
||||
},
|
||||
{
|
||||
id: 'document1',
|
||||
type: 'document',
|
||||
src: 'https://www.fda.gov/media/99016/download',
|
||||
title: 'HACCP Implementation Guide',
|
||||
description: 'Panduan resmi implementasi HACCP dari FDA - dokumen referensi lengkap untuk industri makanan',
|
||||
fileType: 'PDF',
|
||||
fileSize: '2.5 MB'
|
||||
},
|
||||
{
|
||||
id: 'image3',
|
||||
type: 'image',
|
||||
src: 'https://images.unsplash.com/photo-1583394838336-acd977736f90?w=800&h=600&fit=crop',
|
||||
title: 'Food Safety Monitoring Log',
|
||||
description: 'Contoh form monitoring harian untuk dokumentasi HACCP - temperature logs, cleaning schedules, dan corrective actions'
|
||||
}
|
||||
],
|
||||
interactiveElements: [
|
||||
{
|
||||
type: 'hint',
|
||||
content: 'Pelajari setiap media secara berurutan: Video → Diagram → Dokumentasi. Fokus pada 7 prinsip HACCP dan implementasi praktisnya'
|
||||
},
|
||||
{
|
||||
type: 'learning_objective',
|
||||
content: 'Setelah mempelajari semua media, Anda akan memahami: (1) 7 Prinsip HACCP, (2) Identifikasi CCP, (3) Sistem Monitoring, (4) Dokumentasi yang diperlukan'
|
||||
},
|
||||
{
|
||||
type: 'assessment_criteria',
|
||||
content: 'Pemahaman dinilai berdasarkan: kemampuan mengidentifikasi hazards, menetapkan CCP, merancang monitoring system, dan membuat dokumentasi HACCP'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
question: 'Identifikasi Suara Peralatan Dapur yang Bermasalah',
|
||||
|
|
@ -278,7 +510,8 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
correctAnswer: 0,
|
||||
content: {
|
||||
question: 'Seret setiap istilah untuk mencocokkan dengan definisi yang benar',
|
||||
instructions: 'Seret setiap istilah untuk mencocokkan dengan definisi yang benar.'
|
||||
instructions: 'Seret setiap istilah untuk mencocokkan dengan definisi yang benar.',
|
||||
puzzleType: 'matching' as const
|
||||
},
|
||||
difficulty: 'medium' as const,
|
||||
puzzleData: {
|
||||
|
|
@ -288,27 +521,33 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
{
|
||||
id: 'ccp',
|
||||
content: 'Titik Kendali Kritis (CCP)',
|
||||
correctPosition: 0,
|
||||
correctPosition: 'def1',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'haccp',
|
||||
content: 'Analisis Bahaya dan Titik Kendali Kritis',
|
||||
correctPosition: 1,
|
||||
correctPosition: 'def2',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'sanitasi',
|
||||
content: 'Proses pembersihan dan desinfeksi',
|
||||
correctPosition: 2,
|
||||
correctPosition: 'def3',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'kontaminasi',
|
||||
content: 'Pencemaran makanan oleh zat berbahaya',
|
||||
correctPosition: 3,
|
||||
correctPosition: 'def4',
|
||||
isLocked: false
|
||||
}
|
||||
],
|
||||
targetAreas: [
|
||||
{ id: 'def1', label: 'Titik dalam proses produksi makanan di mana bahaya dapat dicegah, dieliminasi, atau dikurangi' },
|
||||
{ id: 'def2', label: 'Sistem manajemen keamanan pangan yang mengidentifikasi, mengevaluasi, dan mengendalikan bahaya' },
|
||||
{ id: 'def3', label: 'Tindakan untuk menghilangkan kotoran dan mikroorganisme dari permukaan' },
|
||||
{ id: 'def4', label: 'Masuknya bahan kimia, fisik, atau biologis yang dapat membahayakan kesehatan' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -383,18 +622,51 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
correctAnswer: 0,
|
||||
content: {
|
||||
question: 'Susun langkah mencuci tangan dalam urutan yang benar',
|
||||
instructions: 'Susun langkah mencuci tangan dalam urutan yang benar.'
|
||||
instructions: 'Seret setiap langkah ke urutan yang benar (1-5).',
|
||||
puzzleType: 'matching' as const
|
||||
},
|
||||
difficulty: 'easy' as const,
|
||||
puzzleData: {
|
||||
type: 'sequence' as const,
|
||||
type: 'jigsaw' as const,
|
||||
instructions: 'Urutkan langkah-langkah mencuci tangan sesuai standar Dapur MBG.',
|
||||
sequence: [
|
||||
'Basahi tangan dengan air bersih',
|
||||
'Gunakan sabun dan gosok hingga berbusa',
|
||||
'Gosok selama minimal 20 detik',
|
||||
'Bilas hingga bersih',
|
||||
'Keringkan dengan handuk bersih'
|
||||
pieces: [
|
||||
{
|
||||
id: 'step1',
|
||||
content: 'Basahi tangan dengan air bersih',
|
||||
correctPosition: 'pos1',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
content: 'Gunakan sabun dan gosok hingga berbusa',
|
||||
correctPosition: 'pos2',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
content: 'Gosok selama minimal 20 detik',
|
||||
correctPosition: 'pos3',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'step4',
|
||||
content: 'Bilas hingga bersih',
|
||||
correctPosition: 'pos4',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'step5',
|
||||
content: 'Keringkan dengan handuk bersih',
|
||||
correctPosition: 'pos5',
|
||||
isLocked: false
|
||||
}
|
||||
],
|
||||
targetAreas: [
|
||||
{ id: 'pos1', label: 'Langkah 1' },
|
||||
{ id: 'pos2', label: 'Langkah 2' },
|
||||
{ id: 'pos3', label: 'Langkah 3' },
|
||||
{ id: 'pos4', label: 'Langkah 4' },
|
||||
{ id: 'pos5', label: 'Langkah 5' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -406,18 +678,51 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
correctAnswer: 0,
|
||||
content: {
|
||||
question: 'Urutkan tahapan persiapan makanan dari awal hingga penyajian',
|
||||
instructions: 'Seret dan lepas untuk mengurutkan tahapan persiapan makanan.'
|
||||
instructions: 'Seret setiap tahapan ke urutan yang benar (1-5).',
|
||||
puzzleType: 'matching' as const
|
||||
},
|
||||
difficulty: 'hard' as const,
|
||||
puzzleData: {
|
||||
type: 'sequence' as const,
|
||||
type: 'jigsaw' as const,
|
||||
instructions: 'Urutkan tahapan persiapan makanan bergizi gratis sesuai standar MBG.',
|
||||
sequence: [
|
||||
'Perencanaan menu bergizi seimbang',
|
||||
'Pemilihan dan pemeriksaan bahan baku',
|
||||
'Persiapan dan pengolahan makanan',
|
||||
'Kontrol kualitas dan suhu',
|
||||
'Penyajian dan distribusi'
|
||||
pieces: [
|
||||
{
|
||||
id: 'stage1',
|
||||
content: 'Perencanaan menu bergizi seimbang',
|
||||
correctPosition: 'order1',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'stage2',
|
||||
content: 'Pemilihan dan pemeriksaan bahan baku',
|
||||
correctPosition: 'order2',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'stage3',
|
||||
content: 'Persiapan dan pengolahan makanan',
|
||||
correctPosition: 'order3',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'stage4',
|
||||
content: 'Kontrol kualitas dan suhu',
|
||||
correctPosition: 'order4',
|
||||
isLocked: false
|
||||
},
|
||||
{
|
||||
id: 'stage5',
|
||||
content: 'Penyajian dan distribusi',
|
||||
correctPosition: 'order5',
|
||||
isLocked: false
|
||||
}
|
||||
],
|
||||
targetAreas: [
|
||||
{ id: 'order1', label: 'Tahap 1' },
|
||||
{ id: 'order2', label: 'Tahap 2' },
|
||||
{ id: 'order3', label: 'Tahap 3' },
|
||||
{ id: 'order4', label: 'Tahap 4' },
|
||||
{ id: 'order5', label: 'Tahap 5' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -659,33 +964,6 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
question: 'Puzzle Slider: Pengaturan Suhu Optimal',
|
||||
type: 'puzzle' as const,
|
||||
content: {
|
||||
question: 'Atur suhu yang tepat untuk berbagai jenis penyimpanan makanan',
|
||||
instructions: 'Geser slider untuk mengatur suhu optimal setiap area penyimpanan',
|
||||
puzzleType: 'slider' as const
|
||||
},
|
||||
difficulty: 'hard' as const,
|
||||
timeLimit: 120,
|
||||
puzzleData: {
|
||||
type: 'jigsaw' as const,
|
||||
sliders: [
|
||||
{ id: 's1', label: 'Freezer', minValue: -25, maxValue: 0, correctValue: -18, unit: '°C' },
|
||||
{ id: 's2', label: 'Kulkas Sayuran', minValue: 0, maxValue: 10, correctValue: 4, unit: '°C' },
|
||||
{ id: 's3', label: 'Kulkas Daging', minValue: -5, maxValue: 5, correctValue: 2, unit: '°C' },
|
||||
{ id: 's4', label: 'Display Makanan Panas', minValue: 50, maxValue: 80, correctValue: 65, unit: '°C' }
|
||||
]
|
||||
},
|
||||
interactiveElements: [
|
||||
{
|
||||
type: 'explanation',
|
||||
content: 'Suhu optimal: Freezer -18°C, Sayuran 4°C, Daging 2°C, Display Panas 65°C'
|
||||
}
|
||||
]
|
||||
},
|
||||
// New Quiz Type 1: Puzzle Gambar/Icon Match
|
||||
{
|
||||
id: '15',
|
||||
|
|
@ -706,7 +984,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
leftItem: {
|
||||
id: 'img1',
|
||||
content: 'Termometer Digital',
|
||||
image: '/images/thermometer-digital.png'
|
||||
image: 'https://cdn-icons-png.flaticon.com/512/2913/2913465.png'
|
||||
},
|
||||
rightItem: {
|
||||
id: 'area1',
|
||||
|
|
@ -718,7 +996,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
leftItem: {
|
||||
id: 'img2',
|
||||
content: 'Cutting Board Berwarna',
|
||||
image: '/images/cutting-board-colored.png'
|
||||
image: 'https://cdn-icons-png.flaticon.com/512/2515/2515183.png'
|
||||
},
|
||||
rightItem: {
|
||||
id: 'area2',
|
||||
|
|
@ -730,7 +1008,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
leftItem: {
|
||||
id: 'img3',
|
||||
content: 'Timer Dapur',
|
||||
image: '/images/kitchen-timer.png'
|
||||
image: 'https://cdn-icons-png.flaticon.com/512/2921/2921222.png'
|
||||
},
|
||||
rightItem: {
|
||||
id: 'area3',
|
||||
|
|
@ -742,12 +1020,36 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
leftItem: {
|
||||
id: 'img4',
|
||||
content: 'Sarung Tangan Sekali Pakai',
|
||||
image: '/images/disposable-gloves.png'
|
||||
image: 'https://cdn-icons-png.flaticon.com/512/2913/2913423.png'
|
||||
},
|
||||
rightItem: {
|
||||
id: 'area4',
|
||||
content: 'Melindungi dari kontaminasi'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'pair5',
|
||||
leftItem: {
|
||||
id: 'img5',
|
||||
content: 'Topi Chef',
|
||||
image: 'https://cdn-icons-png.flaticon.com/512/2515/2515191.png'
|
||||
},
|
||||
rightItem: {
|
||||
id: 'area5',
|
||||
content: 'Menjaga kebersihan rambut'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'pair6',
|
||||
leftItem: {
|
||||
id: 'img6',
|
||||
content: 'Apron/Celemek',
|
||||
image: 'https://cdn-icons-png.flaticon.com/512/2515/2515189.png'
|
||||
},
|
||||
rightItem: {
|
||||
id: 'area6',
|
||||
content: 'Melindungi pakaian dari kontaminasi'
|
||||
}
|
||||
}
|
||||
],
|
||||
allowDragDrop: true,
|
||||
|
|
@ -775,18 +1077,18 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
correctAnswer: 0,
|
||||
content: {
|
||||
question: 'Daging mentah boleh disimpan pada suhu 8°C selama 2 hari',
|
||||
instructions: 'Jawab dengan cepat! Anda memiliki waktu terbatas untuk menjawab.'
|
||||
instructions: 'Jawab sesuai kecepatan Anda untuk testing.'
|
||||
},
|
||||
difficulty: 'easy' as const,
|
||||
timeLimit: 10,
|
||||
timeLimit: 300, // 5 minutes - very long time limit
|
||||
reactionTimeConfig: {
|
||||
timeLimit: 10,
|
||||
showCountdown: true,
|
||||
trackReactionTime: true,
|
||||
timeLimit: 300000, // 5 minutes in milliseconds
|
||||
showCountdown: false, // Hide countdown timer
|
||||
trackReactionTime: true, // Still track reaction time for testing
|
||||
instantFeedback: true,
|
||||
penaltyForWrongAnswer: 2,
|
||||
penaltyForWrongAnswer: 0, // No penalty since this is for testing
|
||||
bonusForQuickAnswer: 5,
|
||||
quickAnswerThreshold: 5
|
||||
quickAnswerThreshold: 3000 // 3 seconds threshold
|
||||
},
|
||||
interactiveElements: [
|
||||
{
|
||||
|
|
@ -818,29 +1120,33 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
problemSolving: 60,
|
||||
communication: 75
|
||||
},
|
||||
avatar: '/images/kitchen-manager-avatar.png'
|
||||
avatar: 'https://cdn-icons-png.flaticon.com/512/3135/3135715.png'
|
||||
},
|
||||
scenario: 'Anda adalah manajer dapur MBG yang menghadapi situasi krisis keracunan makanan. Setiap keputusan yang Anda buat akan mempengaruhi reputasi dapur dan keselamatan pelanggan.',
|
||||
maxSteps: 3,
|
||||
decisions: [
|
||||
{
|
||||
id: 'decision1',
|
||||
step: 1,
|
||||
step: 0,
|
||||
situation: 'Laporan keracunan makanan masuk. Apa langkah pertama Anda?',
|
||||
options: [
|
||||
{
|
||||
id: 'opt1',
|
||||
text: 'Segera hentikan semua operasi dapur',
|
||||
difficulty: 'medium'
|
||||
difficulty: 'medium',
|
||||
icon: '🛑'
|
||||
},
|
||||
{
|
||||
id: 'opt2',
|
||||
text: 'Investigasi sumber makanan yang dicurigai',
|
||||
difficulty: 'easy'
|
||||
difficulty: 'easy',
|
||||
icon: '🔍'
|
||||
},
|
||||
{
|
||||
id: 'opt3',
|
||||
text: 'Hubungi manajemen dan otoritas kesehatan',
|
||||
difficulty: 'medium'
|
||||
difficulty: 'medium',
|
||||
icon: '📞'
|
||||
}
|
||||
],
|
||||
consequences: {
|
||||
|
|
@ -875,9 +1181,125 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
|||
nextStep: 'decision2c'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'decision2',
|
||||
step: 1,
|
||||
situation: 'Tim dapur mulai panik. Bagaimana Anda mengelola situasi ini?',
|
||||
options: [
|
||||
{
|
||||
id: 'opt1',
|
||||
text: 'Berikan instruksi tegas dan jelas kepada semua staff',
|
||||
difficulty: 'medium',
|
||||
icon: '👨💼'
|
||||
},
|
||||
{
|
||||
id: 'opt2',
|
||||
text: 'Tenangkan tim dan jelaskan situasi dengan empati',
|
||||
difficulty: 'easy',
|
||||
icon: '🤝'
|
||||
},
|
||||
{
|
||||
id: 'opt3',
|
||||
text: 'Fokus pada dokumentasi dan bukti',
|
||||
difficulty: 'hard',
|
||||
icon: '📋'
|
||||
}
|
||||
],
|
||||
consequences: {
|
||||
'opt1': {
|
||||
message: 'Tim mengikuti instruksi dengan baik',
|
||||
statChanges: {
|
||||
leadership: 10,
|
||||
empathy: -5,
|
||||
problemSolving: 5,
|
||||
communication: 5
|
||||
},
|
||||
isEndingPath: false
|
||||
},
|
||||
'opt2': {
|
||||
message: 'Tim merasa didukung dan bekerja lebih tenang',
|
||||
statChanges: {
|
||||
leadership: 5,
|
||||
empathy: 15,
|
||||
problemSolving: 0,
|
||||
communication: 10
|
||||
},
|
||||
isEndingPath: false
|
||||
},
|
||||
'opt3': {
|
||||
message: 'Dokumentasi lengkap membantu investigasi',
|
||||
statChanges: {
|
||||
leadership: 0,
|
||||
empathy: -5,
|
||||
problemSolving: 20,
|
||||
communication: -5
|
||||
},
|
||||
isEndingPath: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'decision3',
|
||||
step: 2,
|
||||
situation: 'Media mulai meliput kasus ini. Bagaimana respons Anda?',
|
||||
options: [
|
||||
{
|
||||
id: 'opt1',
|
||||
text: 'Berikan pernyataan transparan kepada media',
|
||||
difficulty: 'hard',
|
||||
icon: '📺'
|
||||
},
|
||||
{
|
||||
id: 'opt2',
|
||||
text: 'Hindari media dan fokus pada penyelesaian internal',
|
||||
difficulty: 'medium',
|
||||
icon: '🚫'
|
||||
},
|
||||
{
|
||||
id: 'opt3',
|
||||
text: 'Koordinasi dengan tim PR perusahaan',
|
||||
difficulty: 'easy',
|
||||
icon: '💼'
|
||||
}
|
||||
],
|
||||
consequences: {
|
||||
'opt1': {
|
||||
message: 'Transparansi meningkatkan kepercayaan publik',
|
||||
statChanges: {
|
||||
leadership: 15,
|
||||
empathy: 10,
|
||||
problemSolving: 5,
|
||||
communication: 20
|
||||
},
|
||||
isEndingPath: true,
|
||||
endingType: 'success'
|
||||
},
|
||||
'opt2': {
|
||||
message: 'Spekulasi media meningkat, reputasi terpengaruh',
|
||||
statChanges: {
|
||||
leadership: -10,
|
||||
empathy: -5,
|
||||
problemSolving: 10,
|
||||
communication: -15
|
||||
},
|
||||
isEndingPath: true,
|
||||
endingType: 'failure'
|
||||
},
|
||||
'opt3': {
|
||||
message: 'Respons terkoordinasi dengan baik',
|
||||
statChanges: {
|
||||
leadership: 5,
|
||||
empathy: 5,
|
||||
problemSolving: 10,
|
||||
communication: 15
|
||||
},
|
||||
isEndingPath: true,
|
||||
endingType: 'neutral'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
maxSteps: 5,
|
||||
scoringSystem: {
|
||||
empathy: 10,
|
||||
leadership: 15,
|
||||
|
|
@ -1041,14 +1463,21 @@ const sampleExamData = {
|
|||
};
|
||||
|
||||
function InteractiveQuizWrapper() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
state,
|
||||
dispatch,
|
||||
setExamData,
|
||||
setAdminConfig,
|
||||
setInteractiveState
|
||||
setInteractiveState,
|
||||
submitExam
|
||||
} = useExam();
|
||||
|
||||
const handleSubmitExam = () => {
|
||||
submitExam();
|
||||
router.push('/exam-summary');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Set up interactive exam data
|
||||
setExamData(sampleInteractiveExamData);
|
||||
|
|
@ -1108,13 +1537,33 @@ function InteractiveQuizWrapper() {
|
|||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'SET_CURRENT_QUESTION', payload: state.currentQuestion + 1 })}
|
||||
disabled={state.currentQuestion >= (state.examData.interactiveData?.questions.length || state.examData.questions.length) - 1}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
{state.currentQuestion >= (state.examData.interactiveData?.questions.length || state.examData.questions.length) - 1 ? (
|
||||
<button
|
||||
onClick={handleSubmitExam}
|
||||
disabled={state.answers[state.currentQuestion] === undefined}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-lg transition-colors",
|
||||
state.answers[state.currentQuestion] !== undefined
|
||||
? "bg-green-600 text-white hover:bg-green-700"
|
||||
: "bg-gray-300 text-gray-500 cursor-not-allowed disabled:opacity-50"
|
||||
)}
|
||||
>
|
||||
Finish
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'SET_CURRENT_QUESTION', payload: state.currentQuestion + 1 })}
|
||||
disabled={state.answers[state.currentQuestion] === undefined}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-lg transition-colors",
|
||||
state.answers[state.currentQuestion] !== undefined
|
||||
? "bg-blue-600 text-white hover:bg-blue-700"
|
||||
: "bg-gray-300 text-gray-500 cursor-not-allowed disabled:opacity-50"
|
||||
)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1124,6 +1573,7 @@ function InteractiveQuizWrapper() {
|
|||
|
||||
function ExamSessionContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const isInteractive = searchParams.get('type') === 'interactive';
|
||||
|
||||
// If this is an interactive quiz, render the InteractiveQuizWrapper
|
||||
|
|
@ -1152,6 +1602,11 @@ function ExamSessionContent() {
|
|||
hasUnsavedChanges
|
||||
} = useExam();
|
||||
|
||||
const handleSubmitExam = () => {
|
||||
submitExam();
|
||||
router.push('/exam-summary');
|
||||
};
|
||||
|
||||
const [showIntroModal, setShowIntroModal] = useState(true);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [lastAnsweredQuestion, setLastAnsweredQuestion] = useState<number | null>(null);
|
||||
|
|
@ -1201,10 +1656,6 @@ function ExamSessionContent() {
|
|||
// Remove the invalid START_EXAM dispatch - not needed as the exam starts when intro modal closes
|
||||
};
|
||||
|
||||
const handleSubmitExam = () => {
|
||||
submitExam();
|
||||
};
|
||||
|
||||
// Don't render main content if intro modal is showing and feature is enabled
|
||||
if (showIntroModal && isFeatureEnabled('quizIntroductionModal')) {
|
||||
return (
|
||||
|
|
@ -1465,10 +1916,10 @@ function ExamSessionContent() {
|
|||
{isLastQuestion ? (
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'SHOW_SUBMIT_MODAL', payload: true })}
|
||||
disabled={!canSubmit}
|
||||
disabled={state.answers[state.currentQuestion] === undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-6 py-3 rounded-lg transition-colors font-medium",
|
||||
canSubmit
|
||||
state.answers[state.currentQuestion] !== undefined
|
||||
? "bg-green-600 text-white hover:bg-green-700"
|
||||
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
|
|
@ -1479,7 +1930,13 @@ function ExamSessionContent() {
|
|||
) : (
|
||||
<button
|
||||
onClick={handleNextQuestion}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
disabled={state.answers[state.currentQuestion] === undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-6 py-3 rounded-lg transition-colors font-medium",
|
||||
state.answers[state.currentQuestion] !== undefined
|
||||
? "bg-blue-600 text-white hover:bg-blue-700"
|
||||
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
Selanjutnya
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { CheckCircle, XCircle, Clock, Award, BarChart3, Home, FileText } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface ExamResult {
|
||||
examId: string;
|
||||
examTitle: string;
|
||||
totalQuestions: number;
|
||||
correctAnswers: number;
|
||||
score: number;
|
||||
timeSpent: string;
|
||||
completedAt: string;
|
||||
answers: Array<{
|
||||
questionId: string;
|
||||
question: string;
|
||||
userAnswer: any;
|
||||
correctAnswer: any;
|
||||
isCorrect: boolean;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function ExamSummaryPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [examResult, setExamResult] = useState<ExamResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate loading exam results
|
||||
// In real app, this would fetch from API or localStorage
|
||||
const mockResult: ExamResult = {
|
||||
examId: 'exam-1',
|
||||
examTitle: 'Ujian Keamanan Pangan MBG',
|
||||
totalQuestions: 10,
|
||||
correctAnswers: 8,
|
||||
score: 80,
|
||||
timeSpent: '25 menit 30 detik',
|
||||
completedAt: new Date().toLocaleString('id-ID'),
|
||||
answers: [
|
||||
{
|
||||
questionId: '1',
|
||||
question: 'Suhu ideal untuk menyimpan daging segar adalah...',
|
||||
userAnswer: 'Di bawah 4°C',
|
||||
correctAnswer: 'Di bawah 4°C',
|
||||
isCorrect: true,
|
||||
type: 'multiple_choice'
|
||||
},
|
||||
{
|
||||
questionId: '2',
|
||||
question: 'Langkah pertama dalam sistem HACCP adalah...',
|
||||
userAnswer: 'Analisis bahaya',
|
||||
correctAnswer: 'Analisis bahaya',
|
||||
isCorrect: true,
|
||||
type: 'multiple_choice'
|
||||
},
|
||||
// Add more mock answers as needed
|
||||
]
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
setExamResult(mockResult);
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-green-600';
|
||||
if (score >= 60) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getScoreBgColor = (score: number) => {
|
||||
if (score >= 80) return 'bg-green-100 border-green-200';
|
||||
if (score >= 60) return 'bg-yellow-100 border-yellow-200';
|
||||
return 'bg-red-100 border-red-200';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Memproses hasil ujian...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!examResult) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Hasil ujian tidak ditemukan</h2>
|
||||
<button
|
||||
onClick={() => router.push('/exams')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Kembali ke Daftar Ujian
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
{examResult.score >= 80 ? (
|
||||
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||
) : examResult.score >= 60 ? (
|
||||
<Award className="w-16 h-16 text-yellow-500" />
|
||||
) : (
|
||||
<XCircle className="w-16 h-16 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
{examResult.score >= 80 ? 'Selamat!' : examResult.score >= 60 ? 'Cukup Baik!' : 'Perlu Perbaikan'}
|
||||
</h1>
|
||||
<p className="text-gray-600">Anda telah menyelesaikan ujian</p>
|
||||
</div>
|
||||
|
||||
{/* Score Summary */}
|
||||
<div className={cn(
|
||||
"bg-white rounded-xl shadow-lg p-6 mb-8 border-2",
|
||||
getScoreBgColor(examResult.score)
|
||||
)}>
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{examResult.examTitle}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="text-center">
|
||||
<div className={cn("text-4xl font-bold mb-2", getScoreColor(examResult.score))}>
|
||||
{examResult.score}%
|
||||
</div>
|
||||
<p className="text-gray-600">Skor Akhir</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{examResult.correctAnswers}/{examResult.totalQuestions}
|
||||
</div>
|
||||
<p className="text-gray-600">Jawaban Benar</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 mb-2 flex items-center justify-center gap-2">
|
||||
<Clock className="w-6 h-6" />
|
||||
{examResult.timeSpent}
|
||||
</div>
|
||||
<p className="text-gray-600">Waktu Pengerjaan</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{examResult.completedAt}
|
||||
</div>
|
||||
<p className="text-gray-600">Selesai Pada</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Results */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-8">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
|
||||
<BarChart3 className="w-6 h-6" />
|
||||
Detail Jawaban
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{examResult.answers.map((answer, index) => (
|
||||
<div
|
||||
key={answer.questionId}
|
||||
className={cn(
|
||||
"p-4 rounded-lg border-2",
|
||||
answer.isCorrect
|
||||
? "bg-green-50 border-green-200"
|
||||
: "bg-red-50 border-red-200"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
{answer.isCorrect ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 mb-2">
|
||||
Soal {index + 1}: {answer.question}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Jawaban Anda:</p>
|
||||
<p className={cn(
|
||||
"font-medium",
|
||||
answer.isCorrect ? "text-green-700" : "text-red-700"
|
||||
)}>
|
||||
{typeof answer.userAnswer === 'string' ? answer.userAnswer : 'Tidak dijawab'}
|
||||
</p>
|
||||
</div>
|
||||
{!answer.isCorrect && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Jawaban Benar:</p>
|
||||
<p className="font-medium text-green-700">
|
||||
{typeof answer.correctAnswer === 'string' ? answer.correctAnswer : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => router.push('/exams')}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
<FileText className="w-5 h-5" />
|
||||
Lihat Ujian Lain
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
Kembali ke Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '../../features/payroll-reward-system/components';
|
||||
import { PayrollRewardSystemModule } from '../../features/payroll-reward-system';
|
||||
import { setupGlobalErrorHandling } from '../../core/errors';
|
||||
import { container } from '../../core/di/DIContainer';
|
||||
import { container as globalContainer } from '../../core/di/DIContainer';
|
||||
|
||||
export default function PayrollDemoPage() {
|
||||
const [moduleLoaded, setModuleLoaded] = useState(false);
|
||||
|
|
@ -33,23 +33,43 @@ export default function PayrollDemoPage() {
|
|||
// Create module instance
|
||||
const moduleInstance = new PayrollRewardSystemModule();
|
||||
|
||||
// Register services in DI container
|
||||
const services = moduleInstance.getServices();
|
||||
|
||||
// Activate the module
|
||||
// Activate the module first (this initializes the services)
|
||||
await moduleInstance.activate();
|
||||
|
||||
// Register services with container
|
||||
// Get services after activation
|
||||
const services = moduleInstance.getServices();
|
||||
|
||||
// Register services with container using lazy initialization to avoid circular dependency
|
||||
for (const serviceConfig of services) {
|
||||
const serviceInstance = serviceConfig.factory();
|
||||
container.registerInstance(serviceConfig.token, serviceInstance);
|
||||
try {
|
||||
console.log(`Registering service: ${serviceConfig.token}`);
|
||||
|
||||
// Check if service is already registered to prevent duplicates
|
||||
if (globalContainer.has(serviceConfig.token)) {
|
||||
console.log(`Service ${serviceConfig.token} already registered, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Register as singleton with lazy factory to avoid circular dependency
|
||||
globalContainer.singleton(serviceConfig.token, () => {
|
||||
const serviceInstance = serviceConfig.factory();
|
||||
console.log(`Created service instance: ${serviceConfig.token}`);
|
||||
return serviceInstance;
|
||||
});
|
||||
|
||||
console.log(`✅ Registered service: ${serviceConfig.token}`);
|
||||
} catch (serviceError) {
|
||||
console.error(`❌ Failed to register service ${serviceConfig.token}:`, serviceError);
|
||||
throw new Error(`Failed to register ${serviceConfig.token}: ${serviceError instanceof Error ? serviceError.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
setModuleLoaded(true);
|
||||
console.log('Payroll Reward System module loaded successfully');
|
||||
console.log('🎉 Payroll Reward System module loaded successfully');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to initialize module');
|
||||
console.error('Failed to initialize module:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize module';
|
||||
setError(errorMessage);
|
||||
console.error('❌ Failed to initialize module:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -235,9 +235,13 @@ export default function AdminTopBar({ onMenuClick, sidebarCollapsed }: AdminTopB
|
|||
<div className="border-t border-gray-100">
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
onClick={async () => {
|
||||
try {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
} catch (error) {
|
||||
console.error('Logout button error:', error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Keluar
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ export interface InteractiveQuestionData {
|
|||
interactiveConfig?: {
|
||||
// Enhanced MCQ
|
||||
allowMultipleAnswers?: boolean;
|
||||
showConfidenceScale?: boolean;
|
||||
enableHints?: boolean;
|
||||
hints?: string[];
|
||||
|
||||
|
|
@ -156,7 +155,6 @@ export const EnhancedQuestionBuilder: React.FC<EnhancedQuestionBuilderProps> = (
|
|||
multimedia: [],
|
||||
interactiveConfig: {
|
||||
allowMultipleAnswers: false,
|
||||
showConfidenceScale: false,
|
||||
enableHints: false,
|
||||
hints: [],
|
||||
timeLimit: 0,
|
||||
|
|
@ -575,16 +573,6 @@ export const EnhancedQuestionBuilder: React.FC<EnhancedQuestionBuilderProps> = (
|
|||
<span className="ml-2">Izinkan Multiple Answers</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={questionData.interactiveConfig?.showConfidenceScale || false}
|
||||
onChange={(e) => handleInteractiveConfigChange('showConfidenceScale', e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2">Tampilkan Skala Confidence</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -852,19 +840,6 @@ export const EnhancedQuestionBuilder: React.FC<EnhancedQuestionBuilderProps> = (
|
|||
)}
|
||||
</label>
|
||||
))}
|
||||
|
||||
{questionData.interactiveConfig?.showConfidenceScale && (
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm font-medium text-blue-900 mb-2">Seberapa yakin Anda dengan jawaban ini?</p>
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map(level => (
|
||||
<button key={level} className="px-3 py-1 bg-white border rounded text-sm">
|
||||
{level}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ interface QuizPreviewProps {
|
|||
interface PreviewState {
|
||||
currentIndex: number;
|
||||
selectedAnswers: { [questionId: string]: any };
|
||||
confidenceScores: { [questionId: string]: number };
|
||||
timeSpent: { [questionId: string]: number };
|
||||
startTime: number;
|
||||
isPlaying: boolean;
|
||||
|
|
@ -67,7 +66,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
|
|||
const [previewState, setPreviewState] = useState<PreviewState>({
|
||||
currentIndex: currentQuestionIndex,
|
||||
selectedAnswers: {},
|
||||
confidenceScores: {},
|
||||
timeSpent: {},
|
||||
startTime: Date.now(),
|
||||
isPlaying: false,
|
||||
|
|
@ -162,16 +160,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
|
|||
}));
|
||||
};
|
||||
|
||||
const handleConfidenceChange = (questionId: string, confidence: number) => {
|
||||
setPreviewState(prev => ({
|
||||
...prev,
|
||||
confidenceScores: {
|
||||
...prev.confidenceScores,
|
||||
[questionId]: confidence
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleHints = (questionId: string) => {
|
||||
setPreviewState(prev => ({
|
||||
...prev,
|
||||
|
|
@ -204,7 +192,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
|
|||
setPreviewState({
|
||||
currentIndex: 0,
|
||||
selectedAnswers: {},
|
||||
confidenceScores: {},
|
||||
timeSpent: {},
|
||||
startTime: Date.now(),
|
||||
isPlaying: false,
|
||||
|
|
@ -359,34 +346,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
|
|||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Confidence Scale */}
|
||||
{currentQuestion.interactiveConfig?.showConfidenceScale && (
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm font-medium text-blue-900 mb-3">
|
||||
Seberapa yakin Anda dengan jawaban ini?
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map(level => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => handleConfidenceChange(questionId, level)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
previewState.confidenceScores[questionId] === level
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-blue-600 border border-blue-200 hover:bg-blue-100'
|
||||
}`}
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-blue-700 mt-1">
|
||||
<span>Tidak yakin</span>
|
||||
<span>Sangat yakin</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,358 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Eye, Target, CheckCircle, XCircle, AlertTriangle, Lightbulb, Zap } from 'lucide-react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
interface InteractiveImageHotspotProps {
|
||||
imageSrc: string;
|
||||
title: string;
|
||||
description: string;
|
||||
hotspots: ImageHotspot[];
|
||||
onComplete: (results: HotspotResult[]) => void;
|
||||
className?: string;
|
||||
showHints?: boolean;
|
||||
allowMultipleAttempts?: boolean;
|
||||
}
|
||||
|
||||
interface ImageHotspot {
|
||||
id: string;
|
||||
x: number; // percentage (0-100)
|
||||
y: number; // percentage (0-100)
|
||||
width: number; // percentage
|
||||
height: number; // percentage
|
||||
type: 'correct' | 'incorrect' | 'neutral' | 'warning';
|
||||
title: string;
|
||||
description: string;
|
||||
feedback: string;
|
||||
points: number;
|
||||
hint?: string;
|
||||
consequence?: string;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
interface HotspotResult {
|
||||
hotspotId: string;
|
||||
clicked: boolean;
|
||||
timestamp: number;
|
||||
attempts: number;
|
||||
isCorrect: boolean;
|
||||
points: number;
|
||||
}
|
||||
|
||||
const InteractiveImageHotspot: React.FC<InteractiveImageHotspotProps> = ({
|
||||
imageSrc,
|
||||
title,
|
||||
description,
|
||||
hotspots,
|
||||
onComplete,
|
||||
className,
|
||||
showHints = true,
|
||||
allowMultipleAttempts = true
|
||||
}) => {
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [clickedHotspots, setClickedHotspots] = useState<Set<string>>(new Set());
|
||||
const [hotspotResults, setHotspotResults] = useState<Map<string, HotspotResult>>(new Map());
|
||||
const [showFeedback, setShowFeedback] = useState<string | null>(null);
|
||||
const [currentFeedback, setCurrentFeedback] = useState<ImageHotspot | null>(null);
|
||||
const [hintsVisible, setHintsVisible] = useState(false);
|
||||
const [attempts, setAttempts] = useState<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
// Check if all required hotspots are clicked
|
||||
const requiredHotspots = hotspots.filter(h => h.isRequired);
|
||||
const clickedRequired = requiredHotspots.filter(h => clickedHotspots.has(h.id));
|
||||
|
||||
if (requiredHotspots.length > 0 && clickedRequired.length === requiredHotspots.length) {
|
||||
const results = Array.from(hotspotResults.values());
|
||||
onComplete(results);
|
||||
}
|
||||
}, [clickedHotspots, hotspots, hotspotResults, onComplete]);
|
||||
|
||||
const handleHotspotClick = (hotspot: ImageHotspot, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const currentAttempts = attempts.get(hotspot.id) || 0;
|
||||
const newAttempts = currentAttempts + 1;
|
||||
|
||||
// Update attempts
|
||||
setAttempts(prev => new Map(prev.set(hotspot.id, newAttempts)));
|
||||
|
||||
// Check if already clicked and multiple attempts not allowed
|
||||
if (clickedHotspots.has(hotspot.id) && !allowMultipleAttempts) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to clicked hotspots
|
||||
setClickedHotspots(prev => new Set([...Array.from(prev), hotspot.id]));
|
||||
|
||||
// Calculate points (reduce points for multiple attempts)
|
||||
let points = hotspot.points;
|
||||
if (newAttempts > 1) {
|
||||
points = Math.max(0, hotspot.points - (newAttempts - 1) * 2);
|
||||
}
|
||||
|
||||
// Create result
|
||||
const result: HotspotResult = {
|
||||
hotspotId: hotspot.id,
|
||||
clicked: true,
|
||||
timestamp: Date.now(),
|
||||
attempts: newAttempts,
|
||||
isCorrect: hotspot.type === 'correct',
|
||||
points: hotspot.type === 'correct' ? points : 0
|
||||
};
|
||||
|
||||
// Update results
|
||||
setHotspotResults(prev => new Map(prev.set(hotspot.id, result)));
|
||||
|
||||
// Show feedback
|
||||
setCurrentFeedback(hotspot);
|
||||
setShowFeedback(hotspot.id);
|
||||
|
||||
// Auto-hide feedback after 3 seconds
|
||||
setTimeout(() => {
|
||||
setShowFeedback(null);
|
||||
setCurrentFeedback(null);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const getHotspotIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'correct':
|
||||
return <CheckCircle className="text-green-600" size={16} />;
|
||||
case 'incorrect':
|
||||
return <XCircle className="text-red-600" size={16} />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="text-yellow-600" size={16} />;
|
||||
default:
|
||||
return <Target className="text-blue-600" size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getHotspotColor = (hotspot: ImageHotspot) => {
|
||||
const isClicked = clickedHotspots.has(hotspot.id);
|
||||
|
||||
if (isClicked) {
|
||||
switch (hotspot.type) {
|
||||
case 'correct':
|
||||
return 'border-green-500 bg-green-500/20';
|
||||
case 'incorrect':
|
||||
return 'border-red-500 bg-red-500/20';
|
||||
case 'warning':
|
||||
return 'border-yellow-500 bg-yellow-500/20';
|
||||
default:
|
||||
return 'border-blue-500 bg-blue-500/20';
|
||||
}
|
||||
}
|
||||
|
||||
return showHints
|
||||
? 'border-blue-400 bg-blue-400/10 animate-pulse'
|
||||
: 'border-transparent bg-transparent hover:border-blue-400 hover:bg-blue-400/10';
|
||||
};
|
||||
|
||||
const totalPoints = Array.from(hotspotResults.values()).reduce((sum, result) => sum + result.points, 0);
|
||||
const maxPoints = hotspots.reduce((sum, hotspot) => sum + hotspot.points, 0);
|
||||
const requiredHotspots = hotspots.filter(h => h.isRequired);
|
||||
const clickedRequired = requiredHotspots.filter(h => clickedHotspots.has(h.id));
|
||||
const progress = requiredHotspots.length > 0 ? (clickedRequired.length / requiredHotspots.length) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className={cn("bg-white rounded-lg shadow-lg overflow-hidden", className)}>
|
||||
{/* Header */}
|
||||
<div className="p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="text-blue-100 text-sm mt-1">{description}</p>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mt-3 p-3 bg-white/10 rounded-lg">
|
||||
<p className="text-sm text-blue-50">
|
||||
📍 <strong>Instruksi:</strong> Klik pada area-area bermasalah dalam gambar.
|
||||
Area yang wajib ditemukan: <span className="font-bold">{requiredHotspots.length}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Target size={16} />
|
||||
{clickedRequired.length}/{requiredHotspots.length} Wajib
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye size={16} />
|
||||
{clickedHotspots.size}/{hotspots.length} Total
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap size={16} />
|
||||
{totalPoints}/{maxPoints} Poin
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHintsVisible(!hintsVisible)}
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-xs font-medium transition-colors",
|
||||
hintsVisible
|
||||
? "bg-yellow-500 text-white"
|
||||
: "bg-white/20 text-white hover:bg-white/30"
|
||||
)}
|
||||
>
|
||||
<Lightbulb size={14} className="inline mr-1" />
|
||||
{hintsVisible ? 'Sembunyikan Hint' : 'Tampilkan Hint'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="px-4 py-2 bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-medium text-gray-700">Progress Area Wajib</span>
|
||||
<span className="text-xs text-gray-600">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 rounded-full transition-all duration-300",
|
||||
progress === 100 ? "bg-green-500" : "bg-blue-500"
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progress === 100 && (
|
||||
<p className="text-xs text-green-600 mt-1 font-medium">
|
||||
✅ Semua area wajib telah ditemukan!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Image */}
|
||||
<div className="relative">
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={imageSrc}
|
||||
alt={title}
|
||||
className="w-full h-auto"
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
/>
|
||||
|
||||
{/* Hotspots Overlay */}
|
||||
{imageLoaded && (
|
||||
<div className="absolute inset-0">
|
||||
{hotspots.map((hotspot) => (
|
||||
<button
|
||||
key={hotspot.id}
|
||||
className={cn(
|
||||
"absolute border-2 rounded-lg transition-all duration-200 cursor-pointer",
|
||||
"flex items-center justify-center group hover:scale-105",
|
||||
getHotspotColor(hotspot),
|
||||
clickedHotspots.has(hotspot.id) && "animate-pulse"
|
||||
)}
|
||||
style={{
|
||||
left: `${hotspot.x}%`,
|
||||
top: `${hotspot.y}%`,
|
||||
width: `${hotspot.width}%`,
|
||||
height: `${hotspot.height}%`,
|
||||
}}
|
||||
onClick={(e) => handleHotspotClick(hotspot, e)}
|
||||
title={hintsVisible ? hotspot.hint || hotspot.title : 'Klik untuk memeriksa area ini'}
|
||||
>
|
||||
{clickedHotspots.has(hotspot.id) && getHotspotIcon(hotspot.type)}
|
||||
|
||||
{/* Hotspot Number */}
|
||||
<span className={cn(
|
||||
"absolute -top-2 -right-2 w-6 h-6 rounded-full text-xs font-bold",
|
||||
"flex items-center justify-center shadow-lg",
|
||||
clickedHotspots.has(hotspot.id)
|
||||
? hotspot.type === 'correct'
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-red-500 text-white"
|
||||
: hotspot.isRequired
|
||||
? "bg-orange-500 text-white animate-bounce"
|
||||
: "bg-blue-500 text-white"
|
||||
)}>
|
||||
{hotspots.indexOf(hotspot) + 1}
|
||||
</span>
|
||||
|
||||
{/* Required indicator */}
|
||||
{hotspot.isRequired && !clickedHotspots.has(hotspot.id) && (
|
||||
<span className="absolute -top-1 -left-1 w-3 h-3 bg-red-500 rounded-full animate-ping" />
|
||||
)}
|
||||
|
||||
{/* Hover tooltip */}
|
||||
{!clickedHotspots.has(hotspot.id) && (
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
||||
{hotspot.isRequired ? '⚠️ Area Wajib' : '💡 Area Opsional'}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback Overlay */}
|
||||
{showFeedback && currentFeedback && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{getHotspotIcon(currentFeedback.type)}
|
||||
<h4 className="text-lg font-semibold text-gray-800">
|
||||
{currentFeedback.title}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-4">{currentFeedback.feedback}</p>
|
||||
|
||||
{currentFeedback.consequence && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Konsekuensi:</strong> {currentFeedback.consequence}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={cn(
|
||||
"px-3 py-1 rounded-full text-sm font-medium",
|
||||
currentFeedback.type === 'correct'
|
||||
? "bg-green-100 text-green-800"
|
||||
: currentFeedback.type === 'incorrect'
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
)}>
|
||||
{currentFeedback.type === 'correct' ? '+' : ''}{currentFeedback.points} poin
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFeedback(null);
|
||||
setCurrentFeedback(null);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Lanjutkan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="p-4 bg-gray-50">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">Area Ditemukan:</span>
|
||||
<span className="ml-2 font-semibold text-gray-800">
|
||||
{clickedHotspots.size}/{hotspots.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Total Poin:</span>
|
||||
<span className="ml-2 font-semibold text-green-600">
|
||||
{totalPoints}/{maxPoints}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InteractiveImageHotspot;
|
||||
|
|
@ -26,6 +26,9 @@ import PuzzleGambarIconMatch from './PuzzleGambarIconMatch';
|
|||
import TrueFalseCepatTepat from './TrueFalseCepatTepat';
|
||||
import MiniSimulationRPG from './MiniSimulationRPG';
|
||||
import MiniSurveyReflection from './MiniSurveyReflection';
|
||||
import VideoScenario from './VideoScenario';
|
||||
import InteractiveImageHotspot from './InteractiveImageHotspot';
|
||||
import MediaGallery from './MediaGallery';
|
||||
|
||||
interface InteractiveQuizRendererProps {
|
||||
question: InteractiveQuestion;
|
||||
|
|
@ -56,8 +59,7 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
|
|||
|
||||
const [startTime] = useState(Date.now());
|
||||
const [lastInteraction, setLastInteraction] = useState(Date.now());
|
||||
const [confidenceLevel, setConfidenceLevel] = useState<number>(3);
|
||||
const [showConfidenceRating, setShowConfidenceRating] = useState(false);
|
||||
|
||||
|
||||
// Guard clause untuk question yang undefined
|
||||
if (!question) {
|
||||
|
|
@ -98,19 +100,9 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
|
|||
onAnswerChange?.(answer);
|
||||
};
|
||||
|
||||
// Effect untuk menampilkan confidence rating
|
||||
useEffect(() => {
|
||||
if (question?.content?.instructions && state.answers[questionIndex]) {
|
||||
setShowConfidenceRating(true);
|
||||
}
|
||||
}, [question?.content?.instructions, state.answers, questionIndex]);
|
||||
|
||||
// Handle confidence rating
|
||||
const handleConfidenceRating = (rating: number) => {
|
||||
setConfidenceLevel(rating);
|
||||
setConfidenceRating(questionIndex, rating);
|
||||
setShowConfidenceRating(false);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Render ikon berdasarkan tipe pertanyaan
|
||||
const renderQuestionTypeIcon = () => {
|
||||
|
|
@ -131,6 +123,12 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
|
|||
return <Gamepad2 {...iconProps} />;
|
||||
case 'mini_survey_reflection':
|
||||
return <Heart {...iconProps} />;
|
||||
case 'video_scenario':
|
||||
return <ChefHat {...iconProps} />;
|
||||
case 'image_hotspot':
|
||||
return <MousePointer {...iconProps} />;
|
||||
case 'media_gallery':
|
||||
return <Image {...iconProps} />;
|
||||
default:
|
||||
return <HelpCircle {...iconProps} />;
|
||||
}
|
||||
|
|
@ -167,6 +165,41 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
|
|||
case 'mini_survey_reflection':
|
||||
return <MiniSurveyReflection {...commonProps} />;
|
||||
|
||||
case 'video_scenario':
|
||||
return (
|
||||
<VideoScenario
|
||||
videoSrc={question.content?.media?.src || ''}
|
||||
title={question.question || ''}
|
||||
description={question.content?.instructions || ''}
|
||||
scenarios={question.scenarios || []}
|
||||
onScenarioComplete={(results) => {
|
||||
if (onAnswerChange) {
|
||||
onAnswerChange(results);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'image_hotspot':
|
||||
return (
|
||||
<InteractiveImageHotspot
|
||||
imageSrc={question.content?.media?.src || ''}
|
||||
title={question.question || ''}
|
||||
description={question.content?.instructions || ''}
|
||||
hotspots={question.hotspots || []}
|
||||
onComplete={(results) => {
|
||||
if (onAnswerChange) {
|
||||
onAnswerChange(results);
|
||||
}
|
||||
}}
|
||||
showHints={true}
|
||||
allowMultipleAttempts={true}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'media_gallery':
|
||||
return <MediaGallery {...commonProps} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-6 text-center text-gray-500">
|
||||
|
|
@ -275,48 +308,6 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
|
|||
{/* Render komponen pertanyaan */}
|
||||
{renderQuestionComponent()}
|
||||
</div>
|
||||
|
||||
{/* Confidence Rating Modal */}
|
||||
{showConfidenceRating && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{adminLanguage === 'indonesia'
|
||||
? 'Seberapa yakin Anda dengan jawaban ini?'
|
||||
: 'How confident are you with this answer?'}
|
||||
</h3>
|
||||
|
||||
<div className="flex justify-between mb-4">
|
||||
{[1, 2, 3, 4, 5].map((rating) => (
|
||||
<button
|
||||
key={rating}
|
||||
onClick={() => handleConfidenceRating(rating)}
|
||||
className={cn(
|
||||
'w-12 h-12 rounded-full border-2 flex items-center justify-center font-semibold transition-colors',
|
||||
confidenceLevel === rating
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
|
||||
)}
|
||||
>
|
||||
{rating}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-4">
|
||||
<span>{adminLanguage === 'indonesia' ? 'Tidak yakin' : 'Not sure'}</span>
|
||||
<span>{adminLanguage === 'indonesia' ? 'Sangat yakin' : 'Very sure'}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowConfidenceRating(false)}
|
||||
className="w-full py-2 px-4 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
{adminLanguage === 'indonesia' ? 'Lewati' : 'Skip'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,409 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { InteractiveQuestion, MediaItem } from '@/types';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Download,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
Maximize,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface MediaGalleryProps {
|
||||
question: InteractiveQuestion;
|
||||
questionIndex: number;
|
||||
onAnswerChange: (answer: any) => void;
|
||||
adminLanguage: string;
|
||||
}
|
||||
|
||||
export default function MediaGallery({
|
||||
question,
|
||||
questionIndex,
|
||||
onAnswerChange,
|
||||
adminLanguage
|
||||
}: MediaGalleryProps) {
|
||||
const [currentMediaIndex, setCurrentMediaIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [completedMedia, setCompletedMedia] = useState<Set<string>>(new Set());
|
||||
const [progress, setProgress] = useState<{ [key: string]: number }>({});
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const mediaItems = question.mediaItems || [];
|
||||
const currentMedia = mediaItems[currentMediaIndex];
|
||||
|
||||
const handleMediaComplete = (mediaId: string) => {
|
||||
setCompletedMedia(prev => new Set([...Array.from(prev), mediaId]));
|
||||
|
||||
// Update progress
|
||||
const newProgress = { ...progress };
|
||||
newProgress[mediaId] = 100;
|
||||
setProgress(newProgress);
|
||||
|
||||
// Report completion to parent
|
||||
onAnswerChange({
|
||||
completedMedia: Array.from(new Set([...Array.from(completedMedia), mediaId])),
|
||||
currentMediaIndex,
|
||||
totalMedia: mediaItems.length,
|
||||
progress: newProgress
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (currentMedia?.type === 'video' && videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
} else if (currentMedia?.type === 'audio' && audioRef.current) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMuteToggle = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted;
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = !isMuted;
|
||||
}
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
const handleDownload = (media: MediaItem) => {
|
||||
// Create download link
|
||||
const link = document.createElement('a');
|
||||
link.href = media.src;
|
||||
link.download = media.title || 'media-file';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const navigateMedia = (direction: 'prev' | 'next') => {
|
||||
if (direction === 'prev' && currentMediaIndex > 0) {
|
||||
setCurrentMediaIndex(currentMediaIndex - 1);
|
||||
} else if (direction === 'next' && currentMediaIndex < mediaItems.length - 1) {
|
||||
setCurrentMediaIndex(currentMediaIndex + 1);
|
||||
}
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const getMediaIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'video': return <Video className="w-5 h-5" />;
|
||||
case 'audio': return <Music className="w-5 h-5" />;
|
||||
case 'image': return <ImageIcon className="w-5 h-5" />;
|
||||
case 'document': return <FileText className="w-5 h-5" />;
|
||||
default: return <FileText className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderMediaContent = () => {
|
||||
if (!currentMedia) return null;
|
||||
|
||||
switch (currentMedia.type) {
|
||||
case 'video':
|
||||
return (
|
||||
<div className="relative bg-black rounded-lg overflow-hidden">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={currentMedia.src}
|
||||
className="w-full h-64 object-contain"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onEnded={() => handleMediaComplete(currentMedia.id)}
|
||||
controls={false}
|
||||
/>
|
||||
|
||||
{/* Video Controls */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4">
|
||||
<div className="flex items-center justify-between text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handlePlayPause}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleMuteToggle}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
{isMuted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<Maximize className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-6">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={currentMedia.src}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onEnded={() => handleMediaComplete(currentMedia.id)}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<Music className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg">{currentMedia.title}</h3>
|
||||
<p className="text-gray-600 text-sm">{currentMedia.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handlePlayPause} variant="outline">
|
||||
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleMuteToggle} variant="outline" size="sm">
|
||||
{isMuted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'image':
|
||||
return (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={currentMedia.src}
|
||||
alt={currentMedia.title}
|
||||
className="w-full h-64 object-cover rounded-lg"
|
||||
onLoad={() => handleMediaComplete(currentMedia.id)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="absolute top-2 right-2"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
>
|
||||
<Maximize className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'document':
|
||||
return (
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
|
||||
<FileText className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="font-semibold text-lg mb-2">{currentMedia.title}</h3>
|
||||
<p className="text-gray-600 mb-4">{currentMedia.description}</p>
|
||||
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
onClick={() => window.open(currentMedia.src, '_blank')}
|
||||
variant="outline"
|
||||
>
|
||||
Buka Dokumen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleDownload(currentMedia)}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-center text-gray-500 p-8">
|
||||
<p>Tipe media tidak didukung</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-4xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
📚 {question.question || 'Media Gallery'}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{currentMediaIndex + 1} / {mediaItems.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
|
||||
{question.content?.description && (
|
||||
<p className="text-gray-600">{question.content.description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Media Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
onClick={() => navigateMedia('prev')}
|
||||
disabled={currentMediaIndex === 0}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Sebelumnya
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{getMediaIcon(currentMedia?.type || 'document')}
|
||||
<span className="font-medium">{currentMedia?.title}</span>
|
||||
{completedMedia.has(currentMedia?.id || '') && (
|
||||
<Badge className="bg-green-500">✓ Selesai</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => navigateMedia('next')}
|
||||
disabled={currentMediaIndex === mediaItems.length - 1}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Selanjutnya
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Media Content */}
|
||||
<div className="space-y-4">
|
||||
{renderMediaContent()}
|
||||
|
||||
{/* Media Description */}
|
||||
{currentMedia?.description && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-gray-700">{currentMedia.description}</p>
|
||||
{currentMedia.duration && (
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Durasi: {Math.floor(currentMedia.duration / 60)}:{(currentMedia.duration % 60).toString().padStart(2, '0')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>Progress Media</span>
|
||||
<span>{completedMedia.size} / {mediaItems.length} selesai</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(completedMedia.size / mediaItems.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media List */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{mediaItems.map((media, index) => (
|
||||
<button
|
||||
key={media.id}
|
||||
onClick={() => setCurrentMediaIndex(index)}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
index === currentMediaIndex
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{getMediaIcon(media.type)}
|
||||
<span className="text-sm font-medium truncate">{media.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 capitalize">{media.type}</span>
|
||||
{completedMedia.has(media.id) && (
|
||||
<Badge className="bg-green-500 text-xs">✓</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Fullscreen Modal */}
|
||||
{isFullscreen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50">
|
||||
<div className="relative max-w-6xl max-h-full p-4">
|
||||
<Button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="absolute top-2 right-2 z-10"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{currentMedia?.type === 'image' ? (
|
||||
<img
|
||||
src={currentMedia.src}
|
||||
alt={currentMedia.title}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : currentMedia?.type === 'video' ? (
|
||||
<video
|
||||
src={currentMedia.src}
|
||||
controls
|
||||
className="max-w-full max-h-full"
|
||||
autoPlay
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -386,17 +386,43 @@ export default function MiniSurveyReflection({
|
|||
Sebelumnya
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
{Object.keys(answers).length} dari {config.questions.length} pertanyaan dijawab
|
||||
<div className="text-center text-sm">
|
||||
<div className="text-gray-500 mb-1">
|
||||
{Object.keys(answers).length} dari {config.questions.length} pertanyaan dijawab
|
||||
</div>
|
||||
{isLastQuestion && !isCompleted && (
|
||||
<div className="text-red-500 text-xs">
|
||||
Selesaikan semua pertanyaan wajib untuk melanjutkan
|
||||
</div>
|
||||
)}
|
||||
{isLastQuestion && isCompleted && (
|
||||
<div className="text-green-600 text-xs">
|
||||
✓ Siap untuk menyelesaikan refleksi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentQuestion.required && answers[currentQuestion.id] === undefined}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isLastQuestion ? 'Selesai' : 'Selanjutnya'}
|
||||
</Button>
|
||||
{isLastQuestion ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!isCompleted}
|
||||
className={`${
|
||||
isCompleted
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? '✓ Selesai' : 'Jawab Pertanyaan Wajib'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentQuestion.required && answers[currentQuestion.id] === undefined}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Selanjutnya
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ interface PuzzlePiece {
|
|||
id: string;
|
||||
content: string;
|
||||
correctPosition: number | string;
|
||||
currentPosition: number;
|
||||
currentPosition: number | string;
|
||||
isLocked?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -43,24 +43,20 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
question,
|
||||
questionIndex,
|
||||
onAnswerChange,
|
||||
adminLanguage
|
||||
adminLanguage = 'id'
|
||||
}) => {
|
||||
const { state, setPuzzleState } = useExam();
|
||||
const [puzzleState, setLocalPuzzleState] = useState<PuzzleState>({
|
||||
pieces: [],
|
||||
isComplete: false,
|
||||
moves: 0,
|
||||
startTime: Date.now(),
|
||||
hints: 0
|
||||
});
|
||||
const [localPuzzleState, setLocalPuzzleState] = useState<PuzzleState | null>(null);
|
||||
const [draggedPiece, setDraggedPiece] = useState<string | null>(null);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [selectedPieces, setSelectedPieces] = useState<string[]>([]);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [hintMessage, setHintMessage] = useState('');
|
||||
const [hoveredDropZone, setHoveredDropZone] = useState<string | null>(null); // Add hover state
|
||||
const puzzleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Initialize puzzle
|
||||
useEffect(() => {
|
||||
if (question.puzzleData) {
|
||||
if (question.puzzleData && !localPuzzleState) {
|
||||
const savedState = state.puzzleStates?.[questionIndex];
|
||||
if (savedState) {
|
||||
setLocalPuzzleState(savedState);
|
||||
|
|
@ -122,6 +118,18 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'slider':
|
||||
if (question.puzzleData.sliders) {
|
||||
pieces = question.puzzleData.sliders.map((slider, index) => ({
|
||||
id: slider.id,
|
||||
content: slider.label,
|
||||
correctPosition: slider.correctValue,
|
||||
currentPosition: Math.floor((slider.minValue + slider.maxValue) / 2), // Start at middle value
|
||||
isLocked: false
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const newState: PuzzleState = {
|
||||
|
|
@ -148,7 +156,9 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
|
||||
// Handle piece movement
|
||||
const movePiece = (fromIndex: number, toIndex: number) => {
|
||||
const newPieces = [...puzzleState.pieces];
|
||||
if (!localPuzzleState) return;
|
||||
|
||||
const newPieces = [...localPuzzleState.pieces];
|
||||
const piece = newPieces[fromIndex];
|
||||
|
||||
if (piece.isLocked) return;
|
||||
|
|
@ -163,10 +173,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
const isComplete = checkCompletion(newPieces);
|
||||
|
||||
const newState: PuzzleState = {
|
||||
...puzzleState,
|
||||
...localPuzzleState,
|
||||
pieces: newPieces,
|
||||
isComplete,
|
||||
moves: puzzleState.moves + 1
|
||||
moves: localPuzzleState.moves + 1
|
||||
};
|
||||
|
||||
setLocalPuzzleState(newState);
|
||||
|
|
@ -176,32 +186,54 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
|
||||
// Check if puzzle is complete
|
||||
const checkCompletion = (pieces: PuzzlePiece[]): boolean => {
|
||||
const puzzleType = question.puzzleData?.type;
|
||||
|
||||
if (puzzleType === 'slider') {
|
||||
const sliders = question.puzzleData?.sliders || [];
|
||||
return pieces.every(piece => {
|
||||
const slider = sliders.find(s => s.id === piece.id);
|
||||
if (!slider) return false;
|
||||
|
||||
// Ensure both positions are numbers for arithmetic operations
|
||||
const currentPos = typeof piece.currentPosition === 'number' ? piece.currentPosition : 0;
|
||||
const correctPos = typeof piece.correctPosition === 'number' ? piece.correctPosition : 0;
|
||||
|
||||
// Allow tolerance of ±1 for correct value
|
||||
const tolerance = 1;
|
||||
return Math.abs(currentPos - correctPos) <= tolerance;
|
||||
});
|
||||
}
|
||||
|
||||
return pieces.every(piece => piece.currentPosition === piece.correctPosition);
|
||||
};
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = (e: React.DragEvent, pieceId: string) => {
|
||||
console.log('🚀 DRAG START:', {
|
||||
pieceId,
|
||||
draggedPiece: draggedPiece,
|
||||
localPuzzleState: localPuzzleState?.pieces?.length || 0,
|
||||
targetAreas: question.puzzleData?.targetAreas?.length || 0
|
||||
});
|
||||
setDraggedPiece(pieceId);
|
||||
e.dataTransfer.setData('text/plain', pieceId);
|
||||
};
|
||||
|
||||
// Handle drag over
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
// Handle drop in sequence puzzle
|
||||
const handleDropInSequence = (e: React.DragEvent, targetPosition: number) => {
|
||||
e.preventDefault();
|
||||
if (!localPuzzleState) return;
|
||||
|
||||
const pieceId = e.dataTransfer.getData('text/plain');
|
||||
const piece = puzzleState.pieces.find(p => p.id === pieceId);
|
||||
const piece = localPuzzleState.pieces.find(p => p.id === pieceId);
|
||||
|
||||
if (!piece || piece.isLocked) return;
|
||||
|
||||
let newPieces = [...puzzleState.pieces];
|
||||
let newPieces = [...localPuzzleState.pieces];
|
||||
|
||||
// Find if there's already a piece in the target position
|
||||
const existingPiece = puzzleState.pieces.find(p => p.correctPosition === targetPosition);
|
||||
const existingPiece = localPuzzleState.pieces.find(p => p.correctPosition === targetPosition);
|
||||
|
||||
if (existingPiece && existingPiece.currentPosition === targetPosition) {
|
||||
// Swap positions
|
||||
|
|
@ -219,10 +251,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
const isComplete = checkCompletion(newPieces);
|
||||
|
||||
const newState: PuzzleState = {
|
||||
...puzzleState,
|
||||
...localPuzzleState,
|
||||
pieces: newPieces,
|
||||
isComplete,
|
||||
moves: puzzleState.moves + 1
|
||||
moves: localPuzzleState.moves + 1
|
||||
};
|
||||
|
||||
setLocalPuzzleState(newState);
|
||||
|
|
@ -234,22 +266,46 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
// Handle drop in matching puzzle
|
||||
const handleDropInMatching = (e: React.DragEvent, targetPosition: number) => {
|
||||
e.preventDefault();
|
||||
setHoveredDropZone(null); // Clear hover state
|
||||
if (!localPuzzleState) return;
|
||||
|
||||
const pieceId = e.dataTransfer.getData('text/plain');
|
||||
const piece = puzzleState.pieces.find(p => p.id === pieceId);
|
||||
const piece = localPuzzleState.pieces.find(p => p.id === pieceId);
|
||||
|
||||
if (!piece || piece.isLocked) return;
|
||||
|
||||
const newPieces = [...puzzleState.pieces];
|
||||
// Get the target area ID from puzzleData
|
||||
const targetAreaId = question.puzzleData?.targetAreas?.[targetPosition]?.id;
|
||||
if (!targetAreaId) return;
|
||||
|
||||
const newPieces = [...localPuzzleState.pieces];
|
||||
const pieceIndex = newPieces.findIndex(p => p.id === pieceId);
|
||||
newPieces[pieceIndex].currentPosition = targetPosition;
|
||||
|
||||
// Clear any previous placement of this piece in other positions
|
||||
newPieces.forEach((p, idx) => {
|
||||
if (p.id === pieceId) {
|
||||
// Reset this piece's position
|
||||
p.currentPosition = p.id; // Reset to original position (piece ID)
|
||||
}
|
||||
});
|
||||
|
||||
// Clear any other piece that might be in this target position
|
||||
newPieces.forEach((p, idx) => {
|
||||
if (p.currentPosition === targetAreaId && p.id !== pieceId) {
|
||||
p.currentPosition = p.id; // Reset to original position
|
||||
}
|
||||
});
|
||||
|
||||
// Set the new position using the target area ID
|
||||
newPieces[pieceIndex].currentPosition = targetAreaId;
|
||||
|
||||
const isComplete = checkCompletion(newPieces);
|
||||
|
||||
const newState: PuzzleState = {
|
||||
...puzzleState,
|
||||
...localPuzzleState,
|
||||
pieces: newPieces,
|
||||
isComplete,
|
||||
moves: puzzleState.moves + 1
|
||||
moves: localPuzzleState.moves + 1
|
||||
};
|
||||
|
||||
setLocalPuzzleState(newState);
|
||||
|
|
@ -258,11 +314,39 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
setDraggedPiece(null);
|
||||
};
|
||||
|
||||
// Handle drag over with hover state
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Handle drag enter for hover effect
|
||||
const handleDragEnter = (e: React.DragEvent, targetId: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedPiece) {
|
||||
setHoveredDropZone(targetId);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drag leave for hover effect
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
// Only clear hover if we're actually leaving the drop zone
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
setHoveredDropZone(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drop
|
||||
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
if (!localPuzzleState) return;
|
||||
|
||||
const pieceId = e.dataTransfer.getData('text/plain');
|
||||
const sourceIndex = puzzleState.pieces.findIndex(p => p.id === pieceId);
|
||||
const sourceIndex = localPuzzleState.pieces.findIndex(p => p.id === pieceId);
|
||||
|
||||
if (sourceIndex !== -1 && sourceIndex !== targetIndex) {
|
||||
movePiece(sourceIndex, targetIndex);
|
||||
|
|
@ -273,11 +357,13 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
|
||||
// Handle piece selection (for touch devices)
|
||||
const handlePieceSelection = (pieceId: string) => {
|
||||
if (!localPuzzleState) return;
|
||||
|
||||
if (selectedPieces.length === 0) {
|
||||
setSelectedPieces([pieceId]);
|
||||
} else if (selectedPieces.length === 1) {
|
||||
const firstPieceIndex = puzzleState.pieces.findIndex(p => p.id === selectedPieces[0]);
|
||||
const secondPieceIndex = puzzleState.pieces.findIndex(p => p.id === pieceId);
|
||||
const firstPieceIndex = localPuzzleState.pieces.findIndex(p => p.id === selectedPieces[0]);
|
||||
const secondPieceIndex = localPuzzleState.pieces.findIndex(p => p.id === pieceId);
|
||||
|
||||
if (firstPieceIndex !== secondPieceIndex) {
|
||||
movePiece(firstPieceIndex, secondPieceIndex);
|
||||
|
|
@ -294,20 +380,22 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
|
||||
// Shuffle puzzle
|
||||
const shufflePuzzle = () => {
|
||||
const unlockedPieces = puzzleState.pieces.filter(p => !p.isLocked);
|
||||
if (!localPuzzleState) return;
|
||||
|
||||
const unlockedPieces = localPuzzleState.pieces.filter(p => !p.isLocked);
|
||||
const shuffledPositions = shuffleArray(unlockedPieces.map(p => p.currentPosition));
|
||||
|
||||
const newPieces = [...puzzleState.pieces];
|
||||
const newPieces = [...localPuzzleState.pieces];
|
||||
unlockedPieces.forEach((piece, index) => {
|
||||
const pieceIndex = newPieces.findIndex(p => p.id === piece.id);
|
||||
newPieces[pieceIndex].currentPosition = shuffledPositions[index];
|
||||
});
|
||||
|
||||
const newState: PuzzleState = {
|
||||
...puzzleState,
|
||||
...localPuzzleState,
|
||||
pieces: newPieces,
|
||||
isComplete: false,
|
||||
moves: puzzleState.moves + 1
|
||||
moves: localPuzzleState.moves + 1
|
||||
};
|
||||
|
||||
setLocalPuzzleState(newState);
|
||||
|
|
@ -316,23 +404,26 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
|
||||
// Show hint
|
||||
const showPuzzleHint = () => {
|
||||
if (puzzleState.hints >= 3) return; // Maksimal 3 hint
|
||||
if (!localPuzzleState || localPuzzleState.hints >= 3) return; // Maksimal 3 hint
|
||||
|
||||
const incorrectPieces = puzzleState.pieces.filter(
|
||||
const incorrectPieces = localPuzzleState.pieces.filter(
|
||||
piece => piece.currentPosition !== piece.correctPosition && !piece.isLocked
|
||||
);
|
||||
|
||||
if (incorrectPieces.length > 0) {
|
||||
const randomPiece = incorrectPieces[Math.floor(Math.random() * incorrectPieces.length)];
|
||||
const correctIndex = puzzleState.pieces.findIndex(p => p.correctPosition === randomPiece.correctPosition);
|
||||
const correctIndex = localPuzzleState.pieces.findIndex(p => p.correctPosition === randomPiece.correctPosition);
|
||||
|
||||
if (correctIndex !== -1) {
|
||||
movePiece(puzzleState.pieces.findIndex(p => p.id === randomPiece.id), correctIndex);
|
||||
movePiece(localPuzzleState.pieces.findIndex(p => p.id === randomPiece.id), correctIndex);
|
||||
|
||||
setLocalPuzzleState(prev => ({
|
||||
...prev,
|
||||
hints: prev.hints + 1
|
||||
}));
|
||||
setLocalPuzzleState(prev => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
hints: prev.hints + 1
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -359,10 +450,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
isDragging && 'opacity-30 scale-110 rotate-2 shadow-2xl z-10',
|
||||
isSelected && 'ring-4 ring-blue-500 ring-offset-2 shadow-lg scale-105',
|
||||
isDropTarget && 'border-dashed border-blue-500 bg-blue-50 scale-105',
|
||||
isCorrect && puzzleState.isComplete && 'bg-green-100 border-green-500 shadow-md',
|
||||
isCorrect && localPuzzleState?.isComplete && 'bg-green-100 border-green-500 shadow-md',
|
||||
piece.isLocked && 'bg-gray-100 border-gray-300 cursor-not-allowed opacity-60',
|
||||
!piece.isLocked && !isCorrect && 'bg-white border-gray-300 hover:border-blue-400 hover:shadow-lg hover:bg-blue-50',
|
||||
!piece.isLocked && isCorrect && !puzzleState.isComplete && 'bg-green-50 border-green-400 shadow-sm'
|
||||
!piece.isLocked && isCorrect && !localPuzzleState?.isComplete && 'bg-green-50 border-green-400 shadow-sm'
|
||||
)}
|
||||
style={{
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
|
|
@ -404,7 +495,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
)}
|
||||
|
||||
{/* Correct indicator */}
|
||||
{isCorrect && puzzleState.isComplete && (
|
||||
{isCorrect && localPuzzleState?.isComplete && (
|
||||
<div className="absolute top-1 right-1 bg-green-500 rounded-full p-1 animate-pulse">
|
||||
<Check size={12} className="text-white" />
|
||||
</div>
|
||||
|
|
@ -450,17 +541,29 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
|
||||
// Render puzzle grid with proper drop zones
|
||||
const renderPuzzleGrid = () => {
|
||||
if (!localPuzzleState) return null;
|
||||
|
||||
const puzzleType = question.puzzleData?.type;
|
||||
const contentPuzzleType = (question.content as any)?.puzzleType;
|
||||
|
||||
console.log('🎯 PUZZLE TYPE CHECK:', {
|
||||
puzzleDataType: puzzleType,
|
||||
contentPuzzleType: contentPuzzleType,
|
||||
hasTargetAreas: !!question.puzzleData?.targetAreas?.length,
|
||||
targetAreasCount: question.puzzleData?.targetAreas?.length || 0
|
||||
});
|
||||
|
||||
if (puzzleType === 'sequence') {
|
||||
return renderSequencePuzzle();
|
||||
} else if (puzzleType === 'jigsaw') {
|
||||
} else if (puzzleType === 'jigsaw' || contentPuzzleType === 'matching') {
|
||||
return renderMatchingPuzzle();
|
||||
} else if (puzzleType === 'slider') {
|
||||
return renderSliderPuzzle();
|
||||
}
|
||||
|
||||
// Fallback to original grid
|
||||
const gridCols = question.puzzleData?.gridSize?.cols || Math.ceil(Math.sqrt(puzzleState.pieces.length));
|
||||
const gridRows = question.puzzleData?.gridSize?.rows || Math.ceil(puzzleState.pieces.length / gridCols);
|
||||
const gridCols = question.puzzleData?.gridSize?.cols || Math.ceil(Math.sqrt(localPuzzleState.pieces.length));
|
||||
const gridRows = question.puzzleData?.gridSize?.rows || Math.ceil(localPuzzleState.pieces.length / gridCols);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -471,14 +574,20 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
gridTemplateRows: `repeat(${gridRows}, 1fr)`
|
||||
}}
|
||||
>
|
||||
{puzzleState.pieces.map((piece, index) => renderPuzzlePiece(piece, index))}
|
||||
{localPuzzleState.pieces.map((piece, index) => renderPuzzlePiece(piece, index))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render sequence puzzle with clear drop zones
|
||||
const renderSequencePuzzle = () => {
|
||||
const sortedPieces = [...puzzleState.pieces].sort((a, b) => a.currentPosition - b.currentPosition);
|
||||
if (!localPuzzleState) return null;
|
||||
|
||||
const sortedPieces = [...localPuzzleState.pieces].sort((a, b) => {
|
||||
const aPos = typeof a.currentPosition === 'number' ? a.currentPosition : 0;
|
||||
const bPos = typeof b.currentPosition === 'number' ? b.currentPosition : 0;
|
||||
return aPos - bPos;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -519,9 +628,9 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
<Target className="mr-2" size={16} />
|
||||
Urutan yang benar:
|
||||
</h4>
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${puzzleState.pieces.length}, 1fr)` }}>
|
||||
{Array.from({ length: puzzleState.pieces.length }, (_, index) => {
|
||||
const pieceInPosition = puzzleState.pieces.find(p => p.correctPosition === index);
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${localPuzzleState.pieces.length}, 1fr)` }}>
|
||||
{Array.from({ length: localPuzzleState.pieces.length }, (_, index) => {
|
||||
const pieceInPosition = localPuzzleState.pieces.find(p => p.correctPosition === index);
|
||||
const isCorrectlyPlaced = pieceInPosition && pieceInPosition.currentPosition === index;
|
||||
|
||||
return (
|
||||
|
|
@ -565,12 +674,28 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
|
||||
// Render matching puzzle with clear areas
|
||||
const renderMatchingPuzzle = () => {
|
||||
const items = puzzleState.pieces;
|
||||
const definitions = question.puzzleData?.pieces?.map((_, index) => ({
|
||||
id: index,
|
||||
label: `Definisi ${index + 1}`,
|
||||
correctPieceId: question.puzzleData?.pieces?.[index]?.id
|
||||
})) || [];
|
||||
if (!localPuzzleState) return null;
|
||||
|
||||
const items = localPuzzleState.pieces;
|
||||
const targetAreas = question.puzzleData?.targetAreas || [];
|
||||
|
||||
console.log('🎮 RENDER MATCHING PUZZLE:', {
|
||||
questionId: question.id,
|
||||
questionContent: question.content,
|
||||
itemsCount: items?.length || 0,
|
||||
targetAreasCount: targetAreas?.length || 0,
|
||||
items: items?.map(item => ({
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
correctPosition: item.correctPosition,
|
||||
currentPosition: item.currentPosition,
|
||||
isLocked: item.isLocked
|
||||
})),
|
||||
targetAreas: targetAreas?.map(area => ({ id: area.id, label: area.label })),
|
||||
draggedPiece,
|
||||
hoveredDropZone,
|
||||
fullPuzzleData: question.puzzleData
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
|
|
@ -608,31 +733,103 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
<Target className="mr-2" size={16} />
|
||||
Cocokkan dengan definisi:
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{definitions.map((def, index) => {
|
||||
const matchedPiece = items.find(p => p.correctPosition === index && p.currentPosition === index);
|
||||
const isCorrect = matchedPiece !== undefined;
|
||||
<div className="space-y-6">
|
||||
{targetAreas.map((targetArea, index) => {
|
||||
console.log('🎯 RENDERING TARGET AREA:', {
|
||||
targetArea: targetArea.id,
|
||||
index,
|
||||
label: targetArea.label,
|
||||
hoveredDropZone,
|
||||
isHovered: hoveredDropZone === targetArea.id
|
||||
});
|
||||
const targetAreaId = targetArea.id;
|
||||
const matchedPiece = items.find(p => p.currentPosition === targetAreaId);
|
||||
const isCorrect = matchedPiece && matchedPiece.correctPosition === targetAreaId;
|
||||
const isHovered = hoveredDropZone === targetAreaId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={def.id}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDropInMatching(e, index)}
|
||||
key={targetAreaId}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('📋 DRAG OVER:', { targetAreaId, draggedPiece, hoveredDropZone });
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('📥 DRAG ENTER:', { targetAreaId, draggedPiece });
|
||||
if (draggedPiece) {
|
||||
setHoveredDropZone(targetAreaId);
|
||||
console.log('✅ SET HOVERED ZONE:', targetAreaId);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('📤 DRAG LEAVE:', { targetAreaId, hoveredDropZone });
|
||||
// Check if we're really leaving the drop zone
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
setHoveredDropZone(null);
|
||||
console.log('❌ CLEAR HOVERED ZONE');
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('🎯 DROP EVENT:', {
|
||||
targetAreaId,
|
||||
index,
|
||||
draggedPiece,
|
||||
dataTransfer: e.dataTransfer.getData('text/plain')
|
||||
});
|
||||
handleDropInMatching(e, index);
|
||||
setHoveredDropZone(null);
|
||||
}}
|
||||
onClick={() => {
|
||||
// Handle click-to-place for mobile devices
|
||||
if (selectedPieces.length === 1) {
|
||||
const selectedPieceId = selectedPieces[0];
|
||||
const piece = items.find(p => p.id === selectedPieceId);
|
||||
if (piece && !piece.isLocked) {
|
||||
// Simulate drop event
|
||||
const fakeEvent = {
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
dataTransfer: {
|
||||
getData: () => selectedPieceId
|
||||
}
|
||||
} as unknown as React.DragEvent;
|
||||
console.log('Click-to-place triggered for:', selectedPieceId, 'to target:', targetAreaId);
|
||||
handleDropInMatching(fakeEvent, index);
|
||||
setSelectedPieces([]); // Clear selection
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'min-h-16 p-3 border-2 border-dashed rounded-lg transition-all duration-200',
|
||||
'flex items-center justify-between relative',
|
||||
draggedPiece && 'border-green-400 bg-green-100 scale-102',
|
||||
isCorrect && 'bg-green-100 border-green-400 border-solid',
|
||||
!isCorrect && !draggedPiece && 'border-gray-300 bg-gray-50',
|
||||
!isCorrect && draggedPiece && 'hover:border-green-500 hover:bg-green-100'
|
||||
'min-h-20 p-4 border-3 border-dashed rounded-xl transition-all duration-300',
|
||||
'flex items-center justify-between relative cursor-pointer',
|
||||
'bg-white shadow-lg hover:shadow-xl',
|
||||
// Hover state when dragging
|
||||
isHovered && draggedPiece && 'border-green-500 bg-green-100 scale-105 shadow-2xl ring-2 ring-green-300',
|
||||
// Correct match state
|
||||
isCorrect && 'bg-green-100 border-green-500 border-solid shadow-xl ring-2 ring-green-200',
|
||||
// Default state
|
||||
!isCorrect && !draggedPiece && selectedPieces.length === 0 && 'border-gray-500 bg-gray-50 hover:border-blue-400 hover:bg-blue-50',
|
||||
// Dragging but not hovering this zone
|
||||
!isCorrect && draggedPiece && !isHovered && 'border-gray-400 bg-gray-100 opacity-75',
|
||||
// Selected piece ready to place
|
||||
!isCorrect && selectedPieces.length === 1 && 'border-blue-500 bg-blue-100 hover:border-blue-600 hover:bg-blue-200 shadow-xl ring-2 ring-blue-300 animate-pulse'
|
||||
)}
|
||||
>
|
||||
{/* Definition label */}
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-gray-600 mb-1">Definisi {index + 1}:</div>
|
||||
<div className="text-xs text-gray-600 mb-1">Target {index + 1}:</div>
|
||||
<div className="text-sm text-gray-800">
|
||||
{/* You can add actual definitions here based on your data structure */}
|
||||
Definisi untuk item {index + 1}
|
||||
{targetArea.content || targetArea.label || `Target area ${index + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -644,10 +841,28 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
<Check size={16} className="text-green-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 border-2 border-dashed border-gray-400 rounded text-center">
|
||||
<div className="text-xs text-gray-500">
|
||||
{draggedPiece ? 'Lepas di sini' : 'Kosong'}
|
||||
<div className={cn(
|
||||
"p-4 border-2 border-dashed rounded-xl text-center transition-all duration-300 min-h-16",
|
||||
"bg-gradient-to-br from-white to-gray-50 shadow-inner",
|
||||
isHovered && draggedPiece ? "border-green-500 bg-gradient-to-br from-green-50 to-green-100 shadow-lg animate-bounce" :
|
||||
selectedPieces.length === 1 ? "border-blue-500 bg-gradient-to-br from-blue-50 to-blue-100 shadow-lg animate-pulse" :
|
||||
"border-gray-500 bg-gradient-to-br from-gray-50 to-gray-100 hover:border-blue-400 hover:from-blue-50 hover:to-blue-100"
|
||||
)}>
|
||||
<div className="text-sm font-bold text-gray-700 mb-1">
|
||||
{isHovered && draggedPiece ? '🎯 LEPAS DI SINI!' :
|
||||
selectedPieces.length === 1 ? '👆 KLIK UNTUK MENEMPATKAN' :
|
||||
'📋 AREA DROP'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isHovered && draggedPiece ? 'Lepaskan item yang sedang diseret' :
|
||||
selectedPieces.length === 1 ? 'Klik untuk menempatkan item terpilih' :
|
||||
'Seret item ke sini atau klik setelah memilih'}
|
||||
</div>
|
||||
{matchedPiece && !isCorrect && (
|
||||
<div className="text-sm text-orange-700 mt-2 font-medium bg-orange-100 p-2 rounded border border-orange-300">
|
||||
❌ {matchedPiece.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -660,9 +875,125 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// Render slider puzzle
|
||||
const renderSliderPuzzle = () => {
|
||||
if (!localPuzzleState) return null;
|
||||
|
||||
const sliders = question.puzzleData?.sliders || [];
|
||||
|
||||
const handleSliderChange = (sliderId: string, value: number) => {
|
||||
const newPieces = [...localPuzzleState.pieces];
|
||||
const pieceIndex = newPieces.findIndex(p => p.id === sliderId);
|
||||
|
||||
if (pieceIndex !== -1) {
|
||||
newPieces[pieceIndex].currentPosition = value;
|
||||
|
||||
const isComplete = checkSliderCompletion(newPieces);
|
||||
|
||||
const newState: PuzzleState = {
|
||||
...localPuzzleState,
|
||||
pieces: newPieces,
|
||||
isComplete,
|
||||
moves: localPuzzleState.moves + 1
|
||||
};
|
||||
|
||||
setLocalPuzzleState(newState);
|
||||
setPuzzleState(questionIndex.toString(), newState);
|
||||
onAnswerChange(newState);
|
||||
}
|
||||
};
|
||||
|
||||
const checkSliderCompletion = (pieces: PuzzlePiece[]) => {
|
||||
return pieces.every(piece => {
|
||||
const slider = sliders.find(s => s.id === piece.id);
|
||||
if (!slider) return false;
|
||||
|
||||
// Ensure both positions are numbers for arithmetic operations
|
||||
const currentPos = typeof piece.currentPosition === 'number' ? piece.currentPosition : 0;
|
||||
const correctPos = typeof piece.correctPosition === 'number' ? piece.correctPosition : 0;
|
||||
|
||||
// Allow tolerance of ±1 for correct value
|
||||
const tolerance = 1;
|
||||
return Math.abs(currentPos - correctPos) <= tolerance;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 bg-blue-50 border-2 border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-4 flex items-center">
|
||||
<Target className="mr-2" size={16} />
|
||||
Atur nilai yang tepat untuk setiap parameter:
|
||||
</h4>
|
||||
|
||||
<div className="space-y-6">
|
||||
{sliders.map((slider, index) => {
|
||||
const piece = localPuzzleState.pieces.find(p => p.id === slider.id);
|
||||
const currentValue = typeof piece?.currentPosition === 'number' ? piece.currentPosition : slider.minValue;
|
||||
const isCorrect = piece && Math.abs(currentValue - slider.correctValue) <= 1;
|
||||
|
||||
return (
|
||||
<div key={slider.id} className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{slider.label}
|
||||
</label>
|
||||
<div className={cn(
|
||||
"px-3 py-1 rounded-full text-sm font-bold",
|
||||
isCorrect ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||
)}>
|
||||
{currentValue}{slider.unit}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="range"
|
||||
min={slider.minValue}
|
||||
max={slider.maxValue}
|
||||
value={currentValue}
|
||||
onChange={(e) => handleSliderChange(slider.id, parseInt(e.target.value))}
|
||||
className={cn(
|
||||
"w-full h-2 rounded-lg appearance-none cursor-pointer",
|
||||
"bg-gray-200 slider-thumb",
|
||||
isCorrect ? "accent-green-500" : "accent-blue-500"
|
||||
)}
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${isCorrect ? '#10b981' : '#3b82f6'} 0%, ${isCorrect ? '#10b981' : '#3b82f6'} ${((currentValue - slider.minValue) / (slider.maxValue - slider.minValue)) * 100}%, #e5e7eb ${((currentValue - slider.minValue) / (slider.maxValue - slider.minValue)) * 100}%, #e5e7eb 100%)`
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Range labels */}
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>{slider.minValue}{slider.unit}</span>
|
||||
<span className="text-green-600 font-medium">
|
||||
Target: {slider.correctValue}{slider.unit}
|
||||
</span>
|
||||
<span>{slider.maxValue}{slider.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correctness indicator */}
|
||||
{isCorrect && (
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<Check size={16} />
|
||||
<span className="text-sm font-medium">Nilai sudah tepat!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render puzzle stats
|
||||
const renderPuzzleStats = () => {
|
||||
const timeElapsed = Math.floor((Date.now() - puzzleState.startTime) / 1000);
|
||||
if (!localPuzzleState) return null;
|
||||
|
||||
const timeElapsed = Math.floor((Date.now() - localPuzzleState.startTime) / 1000);
|
||||
const minutes = Math.floor(timeElapsed / 60);
|
||||
const seconds = timeElapsed % 60;
|
||||
|
||||
|
|
@ -676,19 +1007,19 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Target size={16} />
|
||||
<span>{puzzleState.moves} {adminLanguage === 'id' ? 'gerakan' : 'moves'}</span>
|
||||
<span>{localPuzzleState.moves} {adminLanguage === 'id' ? 'gerakan' : 'moves'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Lightbulb size={16} />
|
||||
<span>{puzzleState.hints}/3 {adminLanguage === 'id' ? 'petunjuk' : 'hints'}</span>
|
||||
<span>{localPuzzleState.hints}/3 {adminLanguage === 'id' ? 'petunjuk' : 'hints'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={showPuzzleHint}
|
||||
disabled={puzzleState.hints >= 3 || puzzleState.isComplete}
|
||||
disabled={localPuzzleState.hints >= 3 || localPuzzleState.isComplete}
|
||||
className="px-3 py-1 text-xs bg-yellow-100 text-yellow-800 rounded hover:bg-yellow-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{adminLanguage === 'id' ? 'Petunjuk' : 'Hint'}
|
||||
|
|
@ -696,7 +1027,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
|
||||
<button
|
||||
onClick={shufflePuzzle}
|
||||
disabled={puzzleState.isComplete}
|
||||
disabled={localPuzzleState.isComplete}
|
||||
className="px-3 py-1 text-xs bg-blue-100 text-blue-800 rounded hover:bg-blue-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Shuffle size={12} />
|
||||
|
|
@ -722,10 +1053,19 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (!localPuzzleState) {
|
||||
return (
|
||||
<div className="p-6 text-center text-gray-500">
|
||||
<Puzzle className="mx-auto mb-2" size={48} />
|
||||
<p>{adminLanguage === 'id' ? 'Memuat puzzle...' : 'Loading puzzle...'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tutorial untuk pertama kali */}
|
||||
{puzzleState.moves === 0 && (
|
||||
{localPuzzleState.moves === 0 && (
|
||||
<div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
|
|
@ -769,19 +1109,19 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
)}
|
||||
|
||||
{/* Progress indicator */}
|
||||
{puzzleState.pieces.length > 0 && (
|
||||
{localPuzzleState && localPuzzleState.pieces.length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Progress</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{puzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length} / {puzzleState.pieces.length}
|
||||
{localPuzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length} / {localPuzzleState.pieces.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-green-400 to-green-600 h-2 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${(puzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length / puzzleState.pieces.length) * 100}%`
|
||||
width: `${(localPuzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length / localPuzzleState.pieces.length) * 100}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
|
|
@ -795,7 +1135,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
{renderPuzzleGrid()}
|
||||
|
||||
{/* Completion message */}
|
||||
{puzzleState.isComplete && (
|
||||
{localPuzzleState?.isComplete && (
|
||||
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
|
|
@ -808,7 +1148,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
|||
🎉 Selamat! Puzzle berhasil diselesaikan!
|
||||
</p>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Diselesaikan dalam <strong>{puzzleState.moves} gerakan</strong> dengan <strong>{puzzleState.hints} petunjuk</strong>.
|
||||
Diselesaikan dalam <strong>{localPuzzleState.moves} gerakan</strong> dengan <strong>{localPuzzleState.hints} petunjuk</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,14 +19,10 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
|||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
|
||||
const isIndonesian = adminLanguage === 'id';
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: isIndonesian ? 'Selamat Datang di Simulasi Skenario!' : 'Welcome to Scenario Simulation!',
|
||||
content: isIndonesian
|
||||
? 'Anda akan menghadapi situasi nyata di dapur dan harus membuat keputusan yang tepat. Setiap pilihan akan mempengaruhi skor dan kondisi dapur Anda.'
|
||||
: 'You will face real kitchen situations and must make the right decisions. Each choice will affect your score and kitchen conditions.',
|
||||
title: 'Selamat Datang di Simulasi Skenario!',
|
||||
content: 'Anda akan menghadapi situasi nyata di dapur dan harus membuat keputusan yang tepat. Setiap pilihan akan mempengaruhi skor dan kondisi dapur Anda.',
|
||||
icon: <ChefHat className="text-blue-600" size={48} />,
|
||||
visual: (
|
||||
<div className="bg-gradient-to-br from-blue-100 to-indigo-100 p-6 rounded-lg">
|
||||
|
|
@ -43,82 +39,76 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
|||
)
|
||||
},
|
||||
{
|
||||
title: isIndonesian ? 'Cara Bermain' : 'How to Play',
|
||||
content: isIndonesian
|
||||
? 'Baca situasi dengan cermat, pilih tindakan terbaik dari opsi yang tersedia, lalu klik "Jalankan Tindakan" untuk melihat hasilnya.'
|
||||
: 'Read the situation carefully, choose the best action from available options, then click "Execute Action" to see the results.',
|
||||
title: 'Cara Bermain',
|
||||
content: 'Baca situasi dengan cermat, pilih tindakan terbaik dari opsi yang tersedia, lalu klik "Jalankan Tindakan" untuk melihat hasilnya.',
|
||||
icon: <Play className="text-green-600" size={48} />,
|
||||
visual: (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-blue-50 border-l-4 border-blue-400 rounded-r">
|
||||
<p className="text-sm text-blue-800">
|
||||
{isIndonesian ? '📖 1. Baca situasi' : '📖 1. Read situation'}
|
||||
📖 1. Baca situasi
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-50 border-l-4 border-yellow-400 rounded-r">
|
||||
<p className="text-sm text-yellow-800">
|
||||
{isIndonesian ? '🎯 2. Pilih tindakan' : '🎯 2. Choose action'}
|
||||
🎯 2. Pilih tindakan
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-50 border-l-4 border-green-400 rounded-r">
|
||||
<p className="text-sm text-green-800">
|
||||
{isIndonesian ? '⚡ 3. Jalankan & lihat hasil' : '⚡ 3. Execute & see results'}
|
||||
⚡ 3. Jalankan & lihat hasil
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: isIndonesian ? 'Memahami Indikator' : 'Understanding Indicators',
|
||||
content: isIndonesian
|
||||
? 'Perhatikan skor, waktu, kepuasan pelanggan, dan efisiensi. Indikator ini menunjukkan performa Anda dalam mengelola dapur.'
|
||||
: 'Pay attention to score, time, customer satisfaction, and efficiency. These indicators show your performance in managing the kitchen.',
|
||||
title: 'Memahami Indikator',
|
||||
content: 'Perhatikan skor, waktu, kepuasan pelanggan, dan efisiensi. Indikator ini menunjukkan performa Anda dalam mengelola dapur.',
|
||||
icon: <Target className="text-purple-600" size={48} />,
|
||||
visual: (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-red-50 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-red-600">85</div>
|
||||
<div className="text-xs text-red-700">{isIndonesian ? 'Skor' : 'Score'}</div>
|
||||
<div className="text-xs text-red-700">Skor</div>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-50 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">2:30</div>
|
||||
<div className="text-xs text-blue-700">{isIndonesian ? 'Waktu' : 'Time'}</div>
|
||||
<div className="text-xs text-blue-700">Waktu</div>
|
||||
</div>
|
||||
<div className="p-3 bg-green-50 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-green-600">92%</div>
|
||||
<div className="text-xs text-green-700">{isIndonesian ? 'Kepuasan' : 'Satisfaction'}</div>
|
||||
<div className="text-xs text-green-700">Kepuasan</div>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-50 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">78%</div>
|
||||
<div className="text-xs text-yellow-700">{isIndonesian ? 'Efisiensi' : 'Efficiency'}</div>
|
||||
<div className="text-xs text-yellow-700">Efisiensi</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: isIndonesian ? 'Tips Sukses' : 'Success Tips',
|
||||
content: isIndonesian
|
||||
? 'Prioritaskan keamanan pangan, kelola waktu dengan baik, dan pertimbangkan dampak jangka panjang dari setiap keputusan Anda.'
|
||||
: 'Prioritize food safety, manage time well, and consider the long-term impact of each decision you make.',
|
||||
title: 'Tips Sukses',
|
||||
content: 'Prioritaskan keamanan pangan, kelola waktu dengan baik, dan pertimbangkan dampak jangka panjang dari setiap keputusan Anda.',
|
||||
icon: <Lightbulb className="text-yellow-600" size={48} />,
|
||||
visual: (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 p-2 bg-green-50 rounded">
|
||||
<CheckCircle className="text-green-600" size={16} />
|
||||
<span className="text-sm text-green-800">
|
||||
{isIndonesian ? 'Keamanan pangan adalah prioritas utama' : 'Food safety is top priority'}
|
||||
Keamanan pangan adalah prioritas utama
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 p-2 bg-blue-50 rounded">
|
||||
<CheckCircle className="text-blue-600" size={16} />
|
||||
<span className="text-sm text-blue-800">
|
||||
{isIndonesian ? 'Kelola waktu dengan efisien' : 'Manage time efficiently'}
|
||||
Kelola waktu dengan efisien
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 p-2 bg-purple-50 rounded">
|
||||
<CheckCircle className="text-purple-600" size={16} />
|
||||
<span className="text-sm text-purple-800">
|
||||
{isIndonesian ? 'Pikirkan dampak jangka panjang' : 'Think long-term impact'}
|
||||
Pikirkan dampak jangka panjang
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -154,7 +144,7 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
|||
<div>
|
||||
<h2 className="text-xl font-bold">{currentStepData.title}</h2>
|
||||
<p className="text-blue-100 text-sm">
|
||||
{isIndonesian ? 'Langkah' : 'Step'} {currentStep + 1} {isIndonesian ? 'dari' : 'of'} {steps.length}
|
||||
Langkah {currentStep + 1} dari {steps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -197,13 +187,10 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
|||
<ChefHat className="text-orange-600 mt-1" size={20} />
|
||||
<div>
|
||||
<h4 className="font-semibold text-orange-900 mb-1">
|
||||
{isIndonesian ? 'Khusus Simulasi Dapur MBG' : 'MBG Kitchen Simulation'}
|
||||
Simulasi Dapur MBG
|
||||
</h4>
|
||||
<p className="text-orange-800 text-sm">
|
||||
{isIndonesian
|
||||
? 'Simulasi ini dirancang khusus untuk menguji kemampuan Anda dalam mengelola dapur sesuai standar keamanan pangan MBG.'
|
||||
: 'This simulation is specifically designed to test your ability to manage a kitchen according to MBG food safety standards.'
|
||||
}
|
||||
Simulasi ini dibuat khusus untuk melatih kemampuan Anda mengelola dapur dengan standar kebersihan dan keamanan makanan yang baik sesuai aturan MBG.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -223,8 +210,8 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
|||
: 'bg-gray-300 text-gray-700 hover:bg-gray-400'
|
||||
)}
|
||||
>
|
||||
{isIndonesian ? 'Sebelumnya' : 'Previous'}
|
||||
</button>
|
||||
Sebelumnya
|
||||
</button>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{steps.map((_, index) => (
|
||||
|
|
@ -239,14 +226,14 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
|||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="px-6 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-medium hover:from-blue-700 hover:to-indigo-700 transition-colors"
|
||||
>
|
||||
{currentStep === steps.length - 1
|
||||
? (isIndonesian ? 'Mulai Simulasi' : 'Start Simulation')
|
||||
: (isIndonesian ? 'Selanjutnya' : 'Next')
|
||||
}
|
||||
</button>
|
||||
onClick={handleNext}
|
||||
className="px-6 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg font-medium hover:from-blue-700 hover:to-indigo-700 transition-colors"
|
||||
>
|
||||
{currentStep === steps.length - 1
|
||||
? 'Mulai Bermain'
|
||||
: 'Lanjut'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Play, Pause, RotateCcw, Volume2, VolumeX, Maximize, Clock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
interface VideoScenarioProps {
|
||||
videoSrc: string;
|
||||
title: string;
|
||||
description: string;
|
||||
scenarios: VideoScenarioStep[];
|
||||
onScenarioComplete: (results: ScenarioResult[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface VideoScenarioStep {
|
||||
id: string;
|
||||
timestamp: number; // detik
|
||||
title: string;
|
||||
question: string;
|
||||
options: VideoOption[];
|
||||
correctAnswer: number;
|
||||
explanation: string;
|
||||
pauseVideo?: boolean;
|
||||
}
|
||||
|
||||
interface VideoOption {
|
||||
id: string;
|
||||
text: string;
|
||||
consequence?: string;
|
||||
isCorrect?: boolean;
|
||||
}
|
||||
|
||||
interface ScenarioResult {
|
||||
stepId: string;
|
||||
selectedOption: number;
|
||||
isCorrect: boolean;
|
||||
timestamp: number;
|
||||
reactionTime: number;
|
||||
}
|
||||
|
||||
const VideoScenario: React.FC<VideoScenarioProps> = ({
|
||||
videoSrc,
|
||||
title,
|
||||
description,
|
||||
scenarios,
|
||||
onScenarioComplete,
|
||||
className
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [currentScenario, setCurrentScenario] = useState<VideoScenarioStep | null>(null);
|
||||
const [scenarioResults, setScenarioResults] = useState<ScenarioResult[]>([]);
|
||||
const [completedScenarios, setCompletedScenarios] = useState<Set<string>>(new Set());
|
||||
const [showQuestion, setShowQuestion] = useState(false);
|
||||
const [questionStartTime, setQuestionStartTime] = useState(0);
|
||||
|
||||
// Monitor video progress
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const time = video.currentTime;
|
||||
setCurrentTime(time);
|
||||
|
||||
// Check for scenario triggers
|
||||
const activeScenario = scenarios.find(
|
||||
scenario =>
|
||||
Math.abs(time - scenario.timestamp) < 0.5 &&
|
||||
!completedScenarios.has(scenario.id)
|
||||
);
|
||||
|
||||
if (activeScenario && !showQuestion) {
|
||||
setCurrentScenario(activeScenario);
|
||||
setShowQuestion(true);
|
||||
setQuestionStartTime(Date.now());
|
||||
|
||||
if (activeScenario.pauseVideo) {
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
};
|
||||
}, [scenarios, completedScenarios, showQuestion]);
|
||||
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
setScenarioResults([]);
|
||||
setCompletedScenarios(new Set());
|
||||
setCurrentScenario(null);
|
||||
setShowQuestion(false);
|
||||
};
|
||||
|
||||
const handleAnswerSelect = (optionIndex: number) => {
|
||||
if (!currentScenario) return;
|
||||
|
||||
const reactionTime = Date.now() - questionStartTime;
|
||||
const isCorrect = optionIndex === currentScenario.correctAnswer;
|
||||
|
||||
const result: ScenarioResult = {
|
||||
stepId: currentScenario.id,
|
||||
selectedOption: optionIndex,
|
||||
isCorrect,
|
||||
timestamp: currentTime,
|
||||
reactionTime
|
||||
};
|
||||
|
||||
const newResults = [...scenarioResults, result];
|
||||
setScenarioResults(newResults);
|
||||
setCompletedScenarios(prev => new Set([...Array.from(prev), currentScenario.id]));
|
||||
setShowQuestion(false);
|
||||
setCurrentScenario(null);
|
||||
|
||||
// Resume video if it was paused
|
||||
const video = videoRef.current;
|
||||
if (video && video.paused && currentScenario.pauseVideo) {
|
||||
video.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
|
||||
// Check if all scenarios completed
|
||||
if (newResults.length === scenarios.length) {
|
||||
onScenarioComplete(newResults);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className={cn("bg-white rounded-lg shadow-lg overflow-hidden", className)}>
|
||||
{/* Header */}
|
||||
<div className="p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="text-blue-100 text-sm mt-1">{description}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={16} />
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle size={16} />
|
||||
{completedScenarios.size}/{scenarios.length} Scenario
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Container */}
|
||||
<div className="relative bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
className="w-full aspect-video"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
/>
|
||||
|
||||
{/* Video Controls Overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div className="w-full bg-white/20 rounded-full h-1">
|
||||
<div
|
||||
className="bg-blue-500 h-1 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Scenario Markers */}
|
||||
{scenarios.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className={cn(
|
||||
"absolute top-0 w-2 h-2 rounded-full transform -translate-x-1 -translate-y-1",
|
||||
completedScenarios.has(scenario.id)
|
||||
? "bg-green-400"
|
||||
: "bg-yellow-400"
|
||||
)}
|
||||
style={{
|
||||
left: `${duration > 0 ? (scenario.timestamp / duration) * 100 : 0}%`
|
||||
}}
|
||||
title={`Scenario: ${scenario.title}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-2 bg-white/20 hover:bg-white/30 rounded-full transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={restart}
|
||||
className="p-2 bg-white/20 hover:bg-white/30 rounded-full transition-colors"
|
||||
>
|
||||
<RotateCcw size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-2 bg-white/20 hover:bg-white/30 rounded-full transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-white text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Overlay */}
|
||||
{showQuestion && currentScenario && (
|
||||
<div className="absolute inset-0 bg-black/80 flex items-center justify-center p-6">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertCircle className="text-blue-600" size={24} />
|
||||
<h4 className="text-lg font-semibold text-gray-800">
|
||||
{currentScenario.title}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-6">{currentScenario.question}</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{currentScenario.options.map((option, index) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => handleAnswerSelect(index)}
|
||||
className="w-full p-4 text-left border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{option.text}</p>
|
||||
{option.consequence && (
|
||||
<p className="text-sm text-gray-600 mt-1">{option.consequence}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scenario Progress */}
|
||||
<div className="p-4 bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Progress Scenario</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{completedScenarios.size}/{scenarios.length} selesai
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{scenarios.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className={cn(
|
||||
"flex-1 h-2 rounded-full",
|
||||
completedScenarios.has(scenario.id)
|
||||
? "bg-green-400"
|
||||
: currentScenario?.id === scenario.id
|
||||
? "bg-yellow-400"
|
||||
: "bg-gray-200"
|
||||
)}
|
||||
title={scenario.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoScenario;
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ComponentType<{ error?: Error; resetError: () => void }>;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
resetError = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
const FallbackComponent = this.props.fallback;
|
||||
return <FallbackComponent error={this.state.error} resetError={this.resetError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
|
||||
<div className="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full">
|
||||
<svg
|
||||
className="w-6 h-6 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Terjadi Kesalahan
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Maaf, terjadi kesalahan yang tidak terduga. Silakan coba lagi atau hubungi administrator.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<details className="mt-4 text-left">
|
||||
<summary className="text-sm text-gray-600 cursor-pointer">
|
||||
Detail Error (untuk developer)
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-red-600 bg-red-50 p-2 rounded overflow-auto">
|
||||
{this.state.error.message}
|
||||
{'\n'}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="mt-6 flex space-x-3">
|
||||
<button
|
||||
onClick={this.resetError}
|
||||
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Coba Lagi
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex-1 bg-gray-300 text-gray-700 px-4 py-2 rounded-md text-sm font-medium hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Refresh Halaman
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
|
@ -47,10 +47,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Public routes that don't require authentication
|
||||
const publicRoutes = ['/login', '/register', '/forgot-password'];
|
||||
const isPublicRoute = publicRoutes.includes(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing session on mount
|
||||
const savedUser = localStorage.getItem('lms_user');
|
||||
|
|
@ -72,6 +68,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
useEffect(() => {
|
||||
// Redirect logic
|
||||
if (!isLoading) {
|
||||
// Public routes that don't require authentication
|
||||
const publicRoutes = ['/login', '/register', '/forgot-password'];
|
||||
const isPublicRoute = publicRoutes.includes(pathname);
|
||||
|
||||
if (!user && !isPublicRoute && pathname !== '/login') {
|
||||
// User not logged in and trying to access protected route
|
||||
router.push('/login');
|
||||
|
|
@ -83,7 +83,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
router.push(dashboardPath);
|
||||
}
|
||||
}
|
||||
}, [user, isLoading, pathname, isPublicRoute, router]);
|
||||
}, [user, isLoading, pathname, router]);
|
||||
|
||||
const login = async (email: string, password: string): Promise<boolean> => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -111,11 +111,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('lms_user');
|
||||
// Also clear cookie
|
||||
document.cookie = 'lms_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
router.push('/login');
|
||||
try {
|
||||
setUser(null);
|
||||
|
||||
// Safely remove from localStorage
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
localStorage.removeItem('lms_user');
|
||||
}
|
||||
|
||||
// Safely clear cookie
|
||||
if (typeof document !== 'undefined') {
|
||||
document.cookie = 'lms_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
}
|
||||
|
||||
// Use setTimeout to avoid race conditions with state updates
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Fallback: still try to redirect even if cleanup fails
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ interface ExamState {
|
|||
|
||||
// Interactive Quiz Enhancement: Interactive Elements State
|
||||
interactiveState: {
|
||||
currentQuestionType: 'mcq' | 'enhanced_mcq' | 'puzzle' | 'scenario' | 'simulation' | 'randomizer' | 'drag_drop' | 'puzzle_gambar_icon' | 'true_false_cepat' | 'mini_simulation_rpg' | 'mini_survey_reflection';
|
||||
currentQuestionType: 'mcq' | 'enhanced_mcq' | 'puzzle' | 'scenario' | 'simulation' | 'randomizer' | 'drag_drop' | 'puzzle_gambar_icon' | 'true_false_cepat' | 'mini_simulation_rpg' | 'mini_survey_reflection' | 'video_scenario' | 'image_hotspot' | 'media_gallery';
|
||||
dragDropState: Record<string, any>;
|
||||
puzzleState: Record<string, any>;
|
||||
scenarioState: Record<string, any>;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export class DIContainer {
|
|||
private services = new Map<string, ServiceRegistration>();
|
||||
private singletons = new Map<string, any>();
|
||||
private resolving = new Set<string>();
|
||||
// Track pending resolutions to allow concurrent callers to await the same promise
|
||||
private pendingResolutions = new Map<string, Promise<any>>();
|
||||
|
||||
/**
|
||||
* Register a service with the container
|
||||
|
|
@ -60,16 +62,21 @@ export class DIContainer {
|
|||
* Resolve a service from the container
|
||||
*/
|
||||
async resolve<T>(token: string): Promise<T> {
|
||||
// Check for circular dependencies
|
||||
if (this.resolving.has(token)) {
|
||||
throw new Error(`Circular dependency detected: ${token}`);
|
||||
}
|
||||
|
||||
// Return singleton if already created
|
||||
// Return singleton if already created (prefer fast path)
|
||||
if (this.singletons.has(token)) {
|
||||
return this.singletons.get(token);
|
||||
}
|
||||
|
||||
// If a resolution is already in progress for this token, share the promise
|
||||
if (this.pendingResolutions.has(token)) {
|
||||
return await this.pendingResolutions.get(token) as T;
|
||||
}
|
||||
|
||||
// Check for circular dependencies (true recursion within a single resolution chain)
|
||||
if (this.resolving.has(token)) {
|
||||
throw new Error(`Circular dependency detected: ${token}`);
|
||||
}
|
||||
|
||||
const registration = this.services.get(token);
|
||||
if (!registration) {
|
||||
throw new Error(`Service not found: ${token}`);
|
||||
|
|
@ -77,32 +84,40 @@ export class DIContainer {
|
|||
|
||||
this.resolving.add(token);
|
||||
|
||||
try {
|
||||
// Resolve dependencies first
|
||||
const dependencies: any[] = [];
|
||||
if (registration.dependencies) {
|
||||
for (const dep of registration.dependencies) {
|
||||
dependencies.push(await this.resolve(dep));
|
||||
// Create a shared promise for this resolution so concurrent callers can await it
|
||||
const resolutionPromise = (async () => {
|
||||
try {
|
||||
// Resolve dependencies first
|
||||
const dependencies: any[] = [];
|
||||
if (registration.dependencies) {
|
||||
for (const dep of registration.dependencies) {
|
||||
dependencies.push(await this.resolve(dep));
|
||||
}
|
||||
}
|
||||
|
||||
// Create service instance
|
||||
const instance = await registration.factory();
|
||||
|
||||
// Initialize service if it implements IService
|
||||
if (this.isService(instance)) {
|
||||
await instance.initialize?.();
|
||||
}
|
||||
|
||||
// Store singleton
|
||||
if (registration.singleton) {
|
||||
this.singletons.set(token, instance);
|
||||
}
|
||||
|
||||
return instance as T;
|
||||
} finally {
|
||||
this.resolving.delete(token);
|
||||
this.pendingResolutions.delete(token);
|
||||
}
|
||||
})();
|
||||
|
||||
// Create service instance
|
||||
const instance = await registration.factory();
|
||||
this.pendingResolutions.set(token, resolutionPromise);
|
||||
|
||||
// Initialize service if it implements IService
|
||||
if (this.isService(instance)) {
|
||||
await instance.initialize?.();
|
||||
}
|
||||
|
||||
// Store singleton
|
||||
if (registration.singleton) {
|
||||
this.singletons.set(token, instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
} finally {
|
||||
this.resolving.delete(token);
|
||||
}
|
||||
return await resolutionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -34,8 +34,10 @@ export const PayrollManagement: React.FC<PayrollManagementProps> = ({
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadPayrollData();
|
||||
}, []);
|
||||
if (payrollCalculator) {
|
||||
loadPayrollData();
|
||||
}
|
||||
}, [payrollCalculator]);
|
||||
|
||||
const loadPayrollData = async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -27,10 +27,21 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
|
|||
const [selectedPeriod, setSelectedPeriod] = useState<'week' | 'month' | 'quarter'>('month');
|
||||
|
||||
useEffect(() => {
|
||||
loadRewardData();
|
||||
}, [userId, selectedPeriod]);
|
||||
if (rewardService) {
|
||||
loadRewardData();
|
||||
} else {
|
||||
// Reset loading state if service is not available
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId, selectedPeriod, rewardService]);
|
||||
|
||||
const loadRewardData = async () => {
|
||||
if (!rewardService) {
|
||||
setError('Reward service not available');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -52,10 +63,6 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
|
|||
}
|
||||
|
||||
// Load rewards for the period
|
||||
if (!rewardService) {
|
||||
throw new Error('Reward service not available');
|
||||
}
|
||||
|
||||
const userRewards = await rewardService.getRewardsByUserId(userId, {
|
||||
dateFrom: startDate,
|
||||
dateTo: endDate
|
||||
|
|
|
|||
|
|
@ -29,8 +29,10 @@ export const WalletBalance: React.FC<WalletBalanceProps> = ({
|
|||
const [showAllTransactions, setShowAllTransactions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadWalletData();
|
||||
}, [userId]);
|
||||
if (walletService) {
|
||||
loadWalletData();
|
||||
}
|
||||
}, [userId, walletService]);
|
||||
|
||||
const loadWalletData = async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { EventBus } from '../../core/events/EventBus';
|
|||
import { RewardDashboard } from './components/RewardDashboard';
|
||||
import { WalletBalance } from './components/WalletBalance';
|
||||
import { PayrollManagement } from './components/PayrollManagement';
|
||||
import { container as globalContainer } from '../../core/di/DIContainer';
|
||||
|
||||
export class PayrollRewardSystemModule implements IPlugin {
|
||||
readonly metadata: PluginMetadata = {
|
||||
|
|
@ -29,7 +30,7 @@ export class PayrollRewardSystemModule implements IPlugin {
|
|||
private rewardRepository: MockRewardRepository;
|
||||
private rewardService: RewardService;
|
||||
private walletService: WalletService;
|
||||
private payrollCalculator: PayrollCalculator;
|
||||
private payrollCalculator: PayrollCalculator | undefined;
|
||||
private services: Map<string, any> = new Map();
|
||||
|
||||
constructor() {
|
||||
|
|
@ -37,7 +38,8 @@ export class PayrollRewardSystemModule implements IPlugin {
|
|||
this.rewardRepository = new MockRewardRepository();
|
||||
this.rewardService = new RewardService(this.rewardRepository, this.eventBus);
|
||||
this.walletService = new WalletService();
|
||||
this.payrollCalculator = new PayrollCalculator(this.rewardService);
|
||||
this.payrollCalculator = undefined;
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
|
|
@ -46,10 +48,17 @@ export class PayrollRewardSystemModule implements IPlugin {
|
|||
// Initialize services
|
||||
await this.rewardService.initialize();
|
||||
await this.walletService.initialize();
|
||||
await this.payrollCalculator.initialize();
|
||||
|
||||
// Register reward-service to DIContainer first
|
||||
this.services.set('reward-service', this.rewardService);
|
||||
this.services.set('wallet-service', this.walletService);
|
||||
// Register the already-initialized instances to the global DI container
|
||||
globalContainer.registerInstance('reward-service', this.rewardService);
|
||||
globalContainer.registerInstance('wallet-service', this.walletService);
|
||||
// Baru inisialisasi PayrollCalculator setelah reward-service terdaftar
|
||||
// Use resolveSync because reward-service is already registered as an instance
|
||||
this.payrollCalculator = new PayrollCalculator(() => globalContainer.resolveSync('reward-service'));
|
||||
await this.payrollCalculator.initialize();
|
||||
this.services.set('payroll-calculator', this.payrollCalculator);
|
||||
|
||||
this.isActive = true;
|
||||
|
|
@ -100,6 +109,11 @@ export class PayrollRewardSystemModule implements IPlugin {
|
|||
}
|
||||
|
||||
getServices(): ServiceConfig[] {
|
||||
if (!this.isActive) {
|
||||
console.warn('PayrollRewardSystemModule is not active. Call activate() first.');
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
token: 'reward-service',
|
||||
|
|
@ -113,7 +127,12 @@ export class PayrollRewardSystemModule implements IPlugin {
|
|||
},
|
||||
{
|
||||
token: 'payroll-calculator',
|
||||
factory: () => this.payrollCalculator,
|
||||
factory: () => {
|
||||
if (!this.payrollCalculator) {
|
||||
throw new Error('PayrollCalculator not initialized. Make sure to call activate() first.');
|
||||
}
|
||||
return this.payrollCalculator;
|
||||
},
|
||||
singleton: true
|
||||
}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
|
|||
private entries: Map<string, PayrollEntry> = new Map();
|
||||
private config: PayrollConfig = { ...defaultPayrollConfig };
|
||||
|
||||
constructor(private rewardService: IRewardService) {
|
||||
constructor(private rewardServiceFactory: () => IRewardService) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
|
|||
}
|
||||
|
||||
// Get all approved rewards for the period
|
||||
const rewards = await this.rewardService.getTotalRewardsByPeriod(
|
||||
const rewards = await this.rewardServiceFactory().getTotalRewardsByPeriod(
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
RewardStatus.APPROVED
|
||||
|
|
@ -64,7 +64,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
|
|||
let totalAmount = 0;
|
||||
|
||||
for (const userId of simulatedUsers) {
|
||||
const userRewardData = await this.rewardService.getRewardsByUserId(userId, {
|
||||
const userRewardData = await this.rewardServiceFactory().getRewardsByUserId(userId, {
|
||||
status: RewardStatus.APPROVED,
|
||||
dateFrom: input.startDate,
|
||||
dateTo: input.endDate
|
||||
|
|
@ -287,7 +287,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
|
|||
for (const entry of batch.entries) {
|
||||
for (const breakdown of entry.rewardBreakdown) {
|
||||
for (const rewardId of breakdown.rewardIds) {
|
||||
await this.rewardService.markRewardAsPaid(rewardId, `payroll_${batchId}`);
|
||||
await this.rewardServiceFactory().markRewardAsPaid(rewardId, `payroll_${batchId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,9 +185,13 @@ export function TopBar({ onMenuClick, sidebarCollapsed }: TopBarProps) {
|
|||
<div className="border-t border-gray-100">
|
||||
<button
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
onClick={async () => {
|
||||
try {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
} catch (error) {
|
||||
console.error('Logout button error:', error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Keluar
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ export interface LoadingState {
|
|||
// Enhanced Question Types for Interactive Quiz
|
||||
export interface InteractiveQuestion {
|
||||
id: string;
|
||||
type: 'mcq' | 'enhanced_mcq' | 'puzzle' | 'scenario' | 'simulation' | 'randomizer' | 'drag_drop' | 'puzzle_gambar_icon' | 'true_false_cepat' | 'mini_simulation_rpg' | 'mini_survey_reflection';
|
||||
type: 'mcq' | 'enhanced_mcq' | 'puzzle' | 'scenario' | 'simulation' | 'randomizer' | 'drag_drop' | 'puzzle_gambar_icon' | 'true_false_cepat' | 'mini_simulation_rpg' | 'mini_survey_reflection' | 'video_scenario' | 'image_hotspot' | 'media_gallery';
|
||||
content: QuestionContent;
|
||||
interactiveElements?: InteractiveElement[];
|
||||
behaviorMetrics?: BehaviorMetric[];
|
||||
|
|
@ -425,12 +425,21 @@ export interface InteractiveQuestion {
|
|||
// Scenario-specific data
|
||||
scenarioData?: ScenarioData;
|
||||
|
||||
// Video scenario specific data
|
||||
scenarios?: VideoScenarioStep[];
|
||||
|
||||
// Image hotspot specific data
|
||||
hotspots?: ImageHotspot[];
|
||||
|
||||
// New quiz type properties
|
||||
visualElements?: VisualMatchingConfig;
|
||||
reactionTimeConfig?: ReactionTimeConfig;
|
||||
rpgElements?: RPGConfig;
|
||||
reflectionConfig?: ReflectionConfig;
|
||||
|
||||
// Media gallery specific
|
||||
mediaItems?: MediaItem[];
|
||||
|
||||
// Legacy properties for backward compatibility
|
||||
text?: string;
|
||||
question?: string;
|
||||
|
|
@ -489,8 +498,27 @@ export interface MultimediaContent {
|
|||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
type: 'image' | 'video' | 'audio' | 'document';
|
||||
src: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
duration?: number; // in seconds for video/audio
|
||||
size?: number; // file size in bytes
|
||||
fileType?: string; // for document type (e.g., 'PDF', 'DOC', 'XLS')
|
||||
fileSize?: string; // human readable file size (e.g., '2.5 MB')
|
||||
thumbnail?: string;
|
||||
metadata?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
quality?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InteractiveElement {
|
||||
type: 'drag_drop' | 'matching' | 'sequencing' | 'slider' | 'hotspot' | 'timeline' | 'hint' | 'explanation';
|
||||
type: 'drag_drop' | 'matching' | 'sequencing' | 'slider' | 'hotspot' | 'timeline' | 'hint' | 'explanation' | 'learning_objective' | 'assessment_criteria';
|
||||
config?: any; // Flexible configuration object
|
||||
content?: string; // For hint and explanation content
|
||||
validation?: ValidationRule[];
|
||||
|
|
@ -502,15 +530,24 @@ export interface ValidationRule {
|
|||
message: string;
|
||||
}
|
||||
|
||||
export interface SliderConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
correctValue: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
// Puzzle Data Types
|
||||
export interface PuzzleData {
|
||||
type: 'jigsaw' | 'sequence' | 'word_puzzle';
|
||||
type: 'jigsaw' | 'sequence' | 'word_puzzle' | 'slider';
|
||||
instructions?: string;
|
||||
pieces?: PuzzlePiece[];
|
||||
sequence?: string[];
|
||||
words?: string[];
|
||||
correctOrder?: number[];
|
||||
sliders?: any[];
|
||||
sliders?: SliderConfig[];
|
||||
targetAreas?: any[];
|
||||
gridSize?: {
|
||||
rows: number;
|
||||
|
|
@ -1049,6 +1086,42 @@ export interface EmojiScaleConfig {
|
|||
allowHalfSteps: boolean;
|
||||
}
|
||||
|
||||
// Video Scenario Types
|
||||
export interface VideoScenarioStep {
|
||||
id: string;
|
||||
timestamp: number; // detik
|
||||
title: string;
|
||||
question: string;
|
||||
options: VideoOption[];
|
||||
correctAnswer: number;
|
||||
explanation: string;
|
||||
pauseVideo?: boolean;
|
||||
}
|
||||
|
||||
export interface VideoOption {
|
||||
id: string;
|
||||
text: string;
|
||||
consequence?: string;
|
||||
isCorrect?: boolean;
|
||||
}
|
||||
|
||||
// Image Hotspot Types
|
||||
export interface ImageHotspot {
|
||||
id: string;
|
||||
x: number; // percentage (0-100)
|
||||
y: number; // percentage (0-100)
|
||||
width: number; // percentage
|
||||
height: number; // percentage
|
||||
type: 'correct' | 'incorrect' | 'neutral' | 'warning';
|
||||
title: string;
|
||||
description: string;
|
||||
feedback: string;
|
||||
points: number;
|
||||
hint?: string;
|
||||
consequence?: string;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface EmojiOption {
|
||||
value: number;
|
||||
emoji: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue