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 Link from 'next/link';
|
||||||
import { ExamProvider, useExam } from '@/contexts/ExamContext';
|
import { ExamProvider, useExam } from '@/contexts/ExamContext';
|
||||||
import { useExamPersistence } from '@/hooks/useExamPersistence';
|
import { useExamPersistence } from '@/hooks/useExamPersistence';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { InteractiveQuestion, EnhancedExamData, Question, PuzzleData } from '@/types';
|
import { InteractiveQuestion, EnhancedExamData, Question, PuzzleData } from '@/types';
|
||||||
|
|
||||||
// Feature Flags
|
// Feature Flags
|
||||||
|
|
@ -23,6 +23,9 @@ import AnswerFeedback from '@/components/quiz/AnswerFeedback';
|
||||||
|
|
||||||
// Interactive Quiz Components
|
// Interactive Quiz Components
|
||||||
import InteractiveQuizRenderer from '@/components/quiz/InteractiveQuizRenderer';
|
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)
|
// Legacy Components (fallback)
|
||||||
import QuestionNavigator from '@/components/exam/QuestionNavigator';
|
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',
|
id: '12',
|
||||||
question: 'Identifikasi Suara Peralatan Dapur yang Bermasalah',
|
question: 'Identifikasi Suara Peralatan Dapur yang Bermasalah',
|
||||||
|
|
@ -278,7 +510,8 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
correctAnswer: 0,
|
correctAnswer: 0,
|
||||||
content: {
|
content: {
|
||||||
question: 'Seret setiap istilah untuk mencocokkan dengan definisi yang benar',
|
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,
|
difficulty: 'medium' as const,
|
||||||
puzzleData: {
|
puzzleData: {
|
||||||
|
|
@ -288,27 +521,33 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
{
|
{
|
||||||
id: 'ccp',
|
id: 'ccp',
|
||||||
content: 'Titik Kendali Kritis (CCP)',
|
content: 'Titik Kendali Kritis (CCP)',
|
||||||
correctPosition: 0,
|
correctPosition: 'def1',
|
||||||
isLocked: false
|
isLocked: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'haccp',
|
id: 'haccp',
|
||||||
content: 'Analisis Bahaya dan Titik Kendali Kritis',
|
content: 'Analisis Bahaya dan Titik Kendali Kritis',
|
||||||
correctPosition: 1,
|
correctPosition: 'def2',
|
||||||
isLocked: false
|
isLocked: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sanitasi',
|
id: 'sanitasi',
|
||||||
content: 'Proses pembersihan dan desinfeksi',
|
content: 'Proses pembersihan dan desinfeksi',
|
||||||
correctPosition: 2,
|
correctPosition: 'def3',
|
||||||
isLocked: false
|
isLocked: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'kontaminasi',
|
id: 'kontaminasi',
|
||||||
content: 'Pencemaran makanan oleh zat berbahaya',
|
content: 'Pencemaran makanan oleh zat berbahaya',
|
||||||
correctPosition: 3,
|
correctPosition: 'def4',
|
||||||
isLocked: false
|
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,
|
correctAnswer: 0,
|
||||||
content: {
|
content: {
|
||||||
question: 'Susun langkah mencuci tangan dalam urutan yang benar',
|
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,
|
difficulty: 'easy' as const,
|
||||||
puzzleData: {
|
puzzleData: {
|
||||||
type: 'sequence' as const,
|
type: 'jigsaw' as const,
|
||||||
instructions: 'Urutkan langkah-langkah mencuci tangan sesuai standar Dapur MBG.',
|
instructions: 'Urutkan langkah-langkah mencuci tangan sesuai standar Dapur MBG.',
|
||||||
sequence: [
|
pieces: [
|
||||||
'Basahi tangan dengan air bersih',
|
{
|
||||||
'Gunakan sabun dan gosok hingga berbusa',
|
id: 'step1',
|
||||||
'Gosok selama minimal 20 detik',
|
content: 'Basahi tangan dengan air bersih',
|
||||||
'Bilas hingga bersih',
|
correctPosition: 'pos1',
|
||||||
'Keringkan dengan handuk bersih'
|
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,
|
correctAnswer: 0,
|
||||||
content: {
|
content: {
|
||||||
question: 'Urutkan tahapan persiapan makanan dari awal hingga penyajian',
|
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,
|
difficulty: 'hard' as const,
|
||||||
puzzleData: {
|
puzzleData: {
|
||||||
type: 'sequence' as const,
|
type: 'jigsaw' as const,
|
||||||
instructions: 'Urutkan tahapan persiapan makanan bergizi gratis sesuai standar MBG.',
|
instructions: 'Urutkan tahapan persiapan makanan bergizi gratis sesuai standar MBG.',
|
||||||
sequence: [
|
pieces: [
|
||||||
'Perencanaan menu bergizi seimbang',
|
{
|
||||||
'Pemilihan dan pemeriksaan bahan baku',
|
id: 'stage1',
|
||||||
'Persiapan dan pengolahan makanan',
|
content: 'Perencanaan menu bergizi seimbang',
|
||||||
'Kontrol kualitas dan suhu',
|
correctPosition: 'order1',
|
||||||
'Penyajian dan distribusi'
|
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
|
// New Quiz Type 1: Puzzle Gambar/Icon Match
|
||||||
{
|
{
|
||||||
id: '15',
|
id: '15',
|
||||||
|
|
@ -706,7 +984,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
leftItem: {
|
leftItem: {
|
||||||
id: 'img1',
|
id: 'img1',
|
||||||
content: 'Termometer Digital',
|
content: 'Termometer Digital',
|
||||||
image: '/images/thermometer-digital.png'
|
image: 'https://cdn-icons-png.flaticon.com/512/2913/2913465.png'
|
||||||
},
|
},
|
||||||
rightItem: {
|
rightItem: {
|
||||||
id: 'area1',
|
id: 'area1',
|
||||||
|
|
@ -718,7 +996,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
leftItem: {
|
leftItem: {
|
||||||
id: 'img2',
|
id: 'img2',
|
||||||
content: 'Cutting Board Berwarna',
|
content: 'Cutting Board Berwarna',
|
||||||
image: '/images/cutting-board-colored.png'
|
image: 'https://cdn-icons-png.flaticon.com/512/2515/2515183.png'
|
||||||
},
|
},
|
||||||
rightItem: {
|
rightItem: {
|
||||||
id: 'area2',
|
id: 'area2',
|
||||||
|
|
@ -730,7 +1008,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
leftItem: {
|
leftItem: {
|
||||||
id: 'img3',
|
id: 'img3',
|
||||||
content: 'Timer Dapur',
|
content: 'Timer Dapur',
|
||||||
image: '/images/kitchen-timer.png'
|
image: 'https://cdn-icons-png.flaticon.com/512/2921/2921222.png'
|
||||||
},
|
},
|
||||||
rightItem: {
|
rightItem: {
|
||||||
id: 'area3',
|
id: 'area3',
|
||||||
|
|
@ -742,12 +1020,36 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
leftItem: {
|
leftItem: {
|
||||||
id: 'img4',
|
id: 'img4',
|
||||||
content: 'Sarung Tangan Sekali Pakai',
|
content: 'Sarung Tangan Sekali Pakai',
|
||||||
image: '/images/disposable-gloves.png'
|
image: 'https://cdn-icons-png.flaticon.com/512/2913/2913423.png'
|
||||||
},
|
},
|
||||||
rightItem: {
|
rightItem: {
|
||||||
id: 'area4',
|
id: 'area4',
|
||||||
content: 'Melindungi dari kontaminasi'
|
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,
|
allowDragDrop: true,
|
||||||
|
|
@ -775,18 +1077,18 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
correctAnswer: 0,
|
correctAnswer: 0,
|
||||||
content: {
|
content: {
|
||||||
question: 'Daging mentah boleh disimpan pada suhu 8°C selama 2 hari',
|
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,
|
difficulty: 'easy' as const,
|
||||||
timeLimit: 10,
|
timeLimit: 300, // 5 minutes - very long time limit
|
||||||
reactionTimeConfig: {
|
reactionTimeConfig: {
|
||||||
timeLimit: 10,
|
timeLimit: 300000, // 5 minutes in milliseconds
|
||||||
showCountdown: true,
|
showCountdown: false, // Hide countdown timer
|
||||||
trackReactionTime: true,
|
trackReactionTime: true, // Still track reaction time for testing
|
||||||
instantFeedback: true,
|
instantFeedback: true,
|
||||||
penaltyForWrongAnswer: 2,
|
penaltyForWrongAnswer: 0, // No penalty since this is for testing
|
||||||
bonusForQuickAnswer: 5,
|
bonusForQuickAnswer: 5,
|
||||||
quickAnswerThreshold: 5
|
quickAnswerThreshold: 3000 // 3 seconds threshold
|
||||||
},
|
},
|
||||||
interactiveElements: [
|
interactiveElements: [
|
||||||
{
|
{
|
||||||
|
|
@ -818,29 +1120,33 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
problemSolving: 60,
|
problemSolving: 60,
|
||||||
communication: 75
|
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.',
|
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: [
|
decisions: [
|
||||||
{
|
{
|
||||||
id: 'decision1',
|
id: 'decision1',
|
||||||
step: 1,
|
step: 0,
|
||||||
situation: 'Laporan keracunan makanan masuk. Apa langkah pertama Anda?',
|
situation: 'Laporan keracunan makanan masuk. Apa langkah pertama Anda?',
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
id: 'opt1',
|
id: 'opt1',
|
||||||
text: 'Segera hentikan semua operasi dapur',
|
text: 'Segera hentikan semua operasi dapur',
|
||||||
difficulty: 'medium'
|
difficulty: 'medium',
|
||||||
|
icon: '🛑'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'opt2',
|
id: 'opt2',
|
||||||
text: 'Investigasi sumber makanan yang dicurigai',
|
text: 'Investigasi sumber makanan yang dicurigai',
|
||||||
difficulty: 'easy'
|
difficulty: 'easy',
|
||||||
|
icon: '🔍'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'opt3',
|
id: 'opt3',
|
||||||
text: 'Hubungi manajemen dan otoritas kesehatan',
|
text: 'Hubungi manajemen dan otoritas kesehatan',
|
||||||
difficulty: 'medium'
|
difficulty: 'medium',
|
||||||
|
icon: '📞'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
consequences: {
|
consequences: {
|
||||||
|
|
@ -875,9 +1181,125 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
nextStep: 'decision2c'
|
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: {
|
scoringSystem: {
|
||||||
empathy: 10,
|
empathy: 10,
|
||||||
leadership: 15,
|
leadership: 15,
|
||||||
|
|
@ -1041,14 +1463,21 @@ const sampleExamData = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function InteractiveQuizWrapper() {
|
function InteractiveQuizWrapper() {
|
||||||
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
dispatch,
|
dispatch,
|
||||||
setExamData,
|
setExamData,
|
||||||
setAdminConfig,
|
setAdminConfig,
|
||||||
setInteractiveState
|
setInteractiveState,
|
||||||
|
submitExam
|
||||||
} = useExam();
|
} = useExam();
|
||||||
|
|
||||||
|
const handleSubmitExam = () => {
|
||||||
|
submitExam();
|
||||||
|
router.push('/exam-summary');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set up interactive exam data
|
// Set up interactive exam data
|
||||||
setExamData(sampleInteractiveExamData);
|
setExamData(sampleInteractiveExamData);
|
||||||
|
|
@ -1108,13 +1537,33 @@ function InteractiveQuizWrapper() {
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<button
|
{state.currentQuestion >= (state.examData.interactiveData?.questions.length || state.examData.questions.length) - 1 ? (
|
||||||
onClick={() => dispatch({ type: 'SET_CURRENT_QUESTION', payload: state.currentQuestion + 1 })}
|
<button
|
||||||
disabled={state.currentQuestion >= (state.examData.interactiveData?.questions.length || state.examData.questions.length) - 1}
|
onClick={handleSubmitExam}
|
||||||
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"
|
disabled={state.answers[state.currentQuestion] === undefined}
|
||||||
>
|
className={cn(
|
||||||
Next
|
"px-4 py-2 rounded-lg transition-colors",
|
||||||
</button>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1124,6 +1573,7 @@ function InteractiveQuizWrapper() {
|
||||||
|
|
||||||
function ExamSessionContent() {
|
function ExamSessionContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
const isInteractive = searchParams.get('type') === 'interactive';
|
const isInteractive = searchParams.get('type') === 'interactive';
|
||||||
|
|
||||||
// If this is an interactive quiz, render the InteractiveQuizWrapper
|
// If this is an interactive quiz, render the InteractiveQuizWrapper
|
||||||
|
|
@ -1151,6 +1601,11 @@ function ExamSessionContent() {
|
||||||
canSubmit,
|
canSubmit,
|
||||||
hasUnsavedChanges
|
hasUnsavedChanges
|
||||||
} = useExam();
|
} = useExam();
|
||||||
|
|
||||||
|
const handleSubmitExam = () => {
|
||||||
|
submitExam();
|
||||||
|
router.push('/exam-summary');
|
||||||
|
};
|
||||||
|
|
||||||
const [showIntroModal, setShowIntroModal] = useState(true);
|
const [showIntroModal, setShowIntroModal] = useState(true);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
@ -1201,10 +1656,6 @@ function ExamSessionContent() {
|
||||||
// Remove the invalid START_EXAM dispatch - not needed as the exam starts when intro modal closes
|
// 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
|
// Don't render main content if intro modal is showing and feature is enabled
|
||||||
if (showIntroModal && isFeatureEnabled('quizIntroductionModal')) {
|
if (showIntroModal && isFeatureEnabled('quizIntroductionModal')) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -1465,10 +1916,10 @@ function ExamSessionContent() {
|
||||||
{isLastQuestion ? (
|
{isLastQuestion ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => dispatch({ type: 'SHOW_SUBMIT_MODAL', payload: true })}
|
onClick={() => dispatch({ type: 'SHOW_SUBMIT_MODAL', payload: true })}
|
||||||
disabled={!canSubmit}
|
disabled={state.answers[state.currentQuestion] === undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-6 py-3 rounded-lg transition-colors font-medium",
|
"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-green-600 text-white hover:bg-green-700"
|
||||||
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
|
|
@ -1479,7 +1930,13 @@ function ExamSessionContent() {
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleNextQuestion}
|
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
|
Selanjutnya
|
||||||
<ChevronRight className="w-4 h-4" />
|
<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';
|
} from '../../features/payroll-reward-system/components';
|
||||||
import { PayrollRewardSystemModule } from '../../features/payroll-reward-system';
|
import { PayrollRewardSystemModule } from '../../features/payroll-reward-system';
|
||||||
import { setupGlobalErrorHandling } from '../../core/errors';
|
import { setupGlobalErrorHandling } from '../../core/errors';
|
||||||
import { container } from '../../core/di/DIContainer';
|
import { container as globalContainer } from '../../core/di/DIContainer';
|
||||||
|
|
||||||
export default function PayrollDemoPage() {
|
export default function PayrollDemoPage() {
|
||||||
const [moduleLoaded, setModuleLoaded] = useState(false);
|
const [moduleLoaded, setModuleLoaded] = useState(false);
|
||||||
|
|
@ -33,23 +33,43 @@ export default function PayrollDemoPage() {
|
||||||
// Create module instance
|
// Create module instance
|
||||||
const moduleInstance = new PayrollRewardSystemModule();
|
const moduleInstance = new PayrollRewardSystemModule();
|
||||||
|
|
||||||
// Register services in DI container
|
// Activate the module first (this initializes the services)
|
||||||
const services = moduleInstance.getServices();
|
|
||||||
|
|
||||||
// Activate the module
|
|
||||||
await moduleInstance.activate();
|
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) {
|
for (const serviceConfig of services) {
|
||||||
const serviceInstance = serviceConfig.factory();
|
try {
|
||||||
container.registerInstance(serviceConfig.token, serviceInstance);
|
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);
|
setModuleLoaded(true);
|
||||||
console.log('Payroll Reward System module loaded successfully');
|
console.log('🎉 Payroll Reward System module loaded successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to initialize module');
|
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize module';
|
||||||
console.error('Failed to initialize module:', err);
|
setError(errorMessage);
|
||||||
|
console.error('❌ Failed to initialize module:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,9 +235,13 @@ export default function AdminTopBar({ onMenuClick, sidebarCollapsed }: AdminTopB
|
||||||
<div className="border-t border-gray-100">
|
<div className="border-t border-gray-100">
|
||||||
<button
|
<button
|
||||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
setShowUserMenu(false);
|
try {
|
||||||
logout();
|
setShowUserMenu(false);
|
||||||
|
logout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout button error:', error);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Keluar
|
Keluar
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@ export interface InteractiveQuestionData {
|
||||||
interactiveConfig?: {
|
interactiveConfig?: {
|
||||||
// Enhanced MCQ
|
// Enhanced MCQ
|
||||||
allowMultipleAnswers?: boolean;
|
allowMultipleAnswers?: boolean;
|
||||||
showConfidenceScale?: boolean;
|
|
||||||
enableHints?: boolean;
|
enableHints?: boolean;
|
||||||
hints?: string[];
|
hints?: string[];
|
||||||
|
|
||||||
|
|
@ -156,7 +155,6 @@ export const EnhancedQuestionBuilder: React.FC<EnhancedQuestionBuilderProps> = (
|
||||||
multimedia: [],
|
multimedia: [],
|
||||||
interactiveConfig: {
|
interactiveConfig: {
|
||||||
allowMultipleAnswers: false,
|
allowMultipleAnswers: false,
|
||||||
showConfidenceScale: false,
|
|
||||||
enableHints: false,
|
enableHints: false,
|
||||||
hints: [],
|
hints: [],
|
||||||
timeLimit: 0,
|
timeLimit: 0,
|
||||||
|
|
@ -575,16 +573,6 @@ export const EnhancedQuestionBuilder: React.FC<EnhancedQuestionBuilderProps> = (
|
||||||
<span className="ml-2">Izinkan Multiple Answers</span>
|
<span className="ml-2">Izinkan Multiple Answers</span>
|
||||||
</label>
|
</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">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -852,19 +840,6 @@ export const EnhancedQuestionBuilder: React.FC<EnhancedQuestionBuilderProps> = (
|
||||||
)}
|
)}
|
||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ interface QuizPreviewProps {
|
||||||
interface PreviewState {
|
interface PreviewState {
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
selectedAnswers: { [questionId: string]: any };
|
selectedAnswers: { [questionId: string]: any };
|
||||||
confidenceScores: { [questionId: string]: number };
|
|
||||||
timeSpent: { [questionId: string]: number };
|
timeSpent: { [questionId: string]: number };
|
||||||
startTime: number;
|
startTime: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
|
|
@ -67,7 +66,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
|
||||||
const [previewState, setPreviewState] = useState<PreviewState>({
|
const [previewState, setPreviewState] = useState<PreviewState>({
|
||||||
currentIndex: currentQuestionIndex,
|
currentIndex: currentQuestionIndex,
|
||||||
selectedAnswers: {},
|
selectedAnswers: {},
|
||||||
confidenceScores: {},
|
|
||||||
timeSpent: {},
|
timeSpent: {},
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
isPlaying: false,
|
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) => {
|
const toggleHints = (questionId: string) => {
|
||||||
setPreviewState(prev => ({
|
setPreviewState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -204,7 +192,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
|
||||||
setPreviewState({
|
setPreviewState({
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
selectedAnswers: {},
|
selectedAnswers: {},
|
||||||
confidenceScores: {},
|
|
||||||
timeSpent: {},
|
timeSpent: {},
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
|
|
@ -359,34 +346,6 @@ export const QuizPreview: React.FC<QuizPreviewProps> = ({
|
||||||
</label>
|
</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>
|
</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 TrueFalseCepatTepat from './TrueFalseCepatTepat';
|
||||||
import MiniSimulationRPG from './MiniSimulationRPG';
|
import MiniSimulationRPG from './MiniSimulationRPG';
|
||||||
import MiniSurveyReflection from './MiniSurveyReflection';
|
import MiniSurveyReflection from './MiniSurveyReflection';
|
||||||
|
import VideoScenario from './VideoScenario';
|
||||||
|
import InteractiveImageHotspot from './InteractiveImageHotspot';
|
||||||
|
import MediaGallery from './MediaGallery';
|
||||||
|
|
||||||
interface InteractiveQuizRendererProps {
|
interface InteractiveQuizRendererProps {
|
||||||
question: InteractiveQuestion;
|
question: InteractiveQuestion;
|
||||||
|
|
@ -56,8 +59,7 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
|
||||||
|
|
||||||
const [startTime] = useState(Date.now());
|
const [startTime] = useState(Date.now());
|
||||||
const [lastInteraction, setLastInteraction] = 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
|
// Guard clause untuk question yang undefined
|
||||||
if (!question) {
|
if (!question) {
|
||||||
|
|
@ -98,19 +100,9 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
|
||||||
onAnswerChange?.(answer);
|
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
|
// Render ikon berdasarkan tipe pertanyaan
|
||||||
const renderQuestionTypeIcon = () => {
|
const renderQuestionTypeIcon = () => {
|
||||||
|
|
@ -131,6 +123,12 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
|
||||||
return <Gamepad2 {...iconProps} />;
|
return <Gamepad2 {...iconProps} />;
|
||||||
case 'mini_survey_reflection':
|
case 'mini_survey_reflection':
|
||||||
return <Heart {...iconProps} />;
|
return <Heart {...iconProps} />;
|
||||||
|
case 'video_scenario':
|
||||||
|
return <ChefHat {...iconProps} />;
|
||||||
|
case 'image_hotspot':
|
||||||
|
return <MousePointer {...iconProps} />;
|
||||||
|
case 'media_gallery':
|
||||||
|
return <Image {...iconProps} />;
|
||||||
default:
|
default:
|
||||||
return <HelpCircle {...iconProps} />;
|
return <HelpCircle {...iconProps} />;
|
||||||
}
|
}
|
||||||
|
|
@ -167,6 +165,41 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
|
||||||
case 'mini_survey_reflection':
|
case 'mini_survey_reflection':
|
||||||
return <MiniSurveyReflection {...commonProps} />;
|
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:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="p-6 text-center text-gray-500">
|
<div className="p-6 text-center text-gray-500">
|
||||||
|
|
@ -275,48 +308,6 @@ const InteractiveQuizRenderer: React.FC<InteractiveQuizRendererProps> = ({
|
||||||
{/* Render komponen pertanyaan */}
|
{/* Render komponen pertanyaan */}
|
||||||
{renderQuestionComponent()}
|
{renderQuestionComponent()}
|
||||||
</div>
|
</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>
|
</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
|
Sebelumnya
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="text-center text-sm text-gray-500">
|
<div className="text-center text-sm">
|
||||||
{Object.keys(answers).length} dari {config.questions.length} pertanyaan dijawab
|
<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>
|
</div>
|
||||||
|
|
||||||
<Button
|
{isLastQuestion ? (
|
||||||
onClick={handleNext}
|
<Button
|
||||||
disabled={currentQuestion.required && answers[currentQuestion.id] === undefined}
|
onClick={handleNext}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
disabled={!isCompleted}
|
||||||
>
|
className={`${
|
||||||
{isLastQuestion ? 'Selesai' : 'Selanjutnya'}
|
isCompleted
|
||||||
</Button>
|
? '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>
|
</div>
|
||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ interface PuzzlePiece {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
correctPosition: number | string;
|
correctPosition: number | string;
|
||||||
currentPosition: number;
|
currentPosition: number | string;
|
||||||
isLocked?: boolean;
|
isLocked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,24 +43,20 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
question,
|
question,
|
||||||
questionIndex,
|
questionIndex,
|
||||||
onAnswerChange,
|
onAnswerChange,
|
||||||
adminLanguage
|
adminLanguage = 'id'
|
||||||
}) => {
|
}) => {
|
||||||
const { state, setPuzzleState } = useExam();
|
const { state, setPuzzleState } = useExam();
|
||||||
const [puzzleState, setLocalPuzzleState] = useState<PuzzleState>({
|
const [localPuzzleState, setLocalPuzzleState] = useState<PuzzleState | null>(null);
|
||||||
pieces: [],
|
|
||||||
isComplete: false,
|
|
||||||
moves: 0,
|
|
||||||
startTime: Date.now(),
|
|
||||||
hints: 0
|
|
||||||
});
|
|
||||||
const [draggedPiece, setDraggedPiece] = useState<string | null>(null);
|
const [draggedPiece, setDraggedPiece] = useState<string | null>(null);
|
||||||
const [showHint, setShowHint] = useState(false);
|
|
||||||
const [selectedPieces, setSelectedPieces] = useState<string[]>([]);
|
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);
|
const puzzleRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Initialize puzzle
|
// Initialize puzzle
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (question.puzzleData) {
|
if (question.puzzleData && !localPuzzleState) {
|
||||||
const savedState = state.puzzleStates?.[questionIndex];
|
const savedState = state.puzzleStates?.[questionIndex];
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
setLocalPuzzleState(savedState);
|
setLocalPuzzleState(savedState);
|
||||||
|
|
@ -122,6 +118,18 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
break;
|
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 = {
|
const newState: PuzzleState = {
|
||||||
|
|
@ -148,7 +156,9 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Handle piece movement
|
// Handle piece movement
|
||||||
const movePiece = (fromIndex: number, toIndex: number) => {
|
const movePiece = (fromIndex: number, toIndex: number) => {
|
||||||
const newPieces = [...puzzleState.pieces];
|
if (!localPuzzleState) return;
|
||||||
|
|
||||||
|
const newPieces = [...localPuzzleState.pieces];
|
||||||
const piece = newPieces[fromIndex];
|
const piece = newPieces[fromIndex];
|
||||||
|
|
||||||
if (piece.isLocked) return;
|
if (piece.isLocked) return;
|
||||||
|
|
@ -163,10 +173,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
const isComplete = checkCompletion(newPieces);
|
const isComplete = checkCompletion(newPieces);
|
||||||
|
|
||||||
const newState: PuzzleState = {
|
const newState: PuzzleState = {
|
||||||
...puzzleState,
|
...localPuzzleState,
|
||||||
pieces: newPieces,
|
pieces: newPieces,
|
||||||
isComplete,
|
isComplete,
|
||||||
moves: puzzleState.moves + 1
|
moves: localPuzzleState.moves + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
setLocalPuzzleState(newState);
|
setLocalPuzzleState(newState);
|
||||||
|
|
@ -176,32 +186,54 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Check if puzzle is complete
|
// Check if puzzle is complete
|
||||||
const checkCompletion = (pieces: PuzzlePiece[]): boolean => {
|
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);
|
return pieces.every(piece => piece.currentPosition === piece.correctPosition);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag start
|
// Handle drag start
|
||||||
const handleDragStart = (e: React.DragEvent, pieceId: string) => {
|
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);
|
setDraggedPiece(pieceId);
|
||||||
e.dataTransfer.setData('text/plain', pieceId);
|
e.dataTransfer.setData('text/plain', pieceId);
|
||||||
};
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
|
||||||
// Handle drag over
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drop in sequence puzzle
|
// Handle drop in sequence puzzle
|
||||||
const handleDropInSequence = (e: React.DragEvent, targetPosition: number) => {
|
const handleDropInSequence = (e: React.DragEvent, targetPosition: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!localPuzzleState) return;
|
||||||
|
|
||||||
const pieceId = e.dataTransfer.getData('text/plain');
|
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;
|
if (!piece || piece.isLocked) return;
|
||||||
|
|
||||||
let newPieces = [...puzzleState.pieces];
|
let newPieces = [...localPuzzleState.pieces];
|
||||||
|
|
||||||
// Find if there's already a piece in the target position
|
// 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) {
|
if (existingPiece && existingPiece.currentPosition === targetPosition) {
|
||||||
// Swap positions
|
// Swap positions
|
||||||
|
|
@ -219,10 +251,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
const isComplete = checkCompletion(newPieces);
|
const isComplete = checkCompletion(newPieces);
|
||||||
|
|
||||||
const newState: PuzzleState = {
|
const newState: PuzzleState = {
|
||||||
...puzzleState,
|
...localPuzzleState,
|
||||||
pieces: newPieces,
|
pieces: newPieces,
|
||||||
isComplete,
|
isComplete,
|
||||||
moves: puzzleState.moves + 1
|
moves: localPuzzleState.moves + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
setLocalPuzzleState(newState);
|
setLocalPuzzleState(newState);
|
||||||
|
|
@ -234,22 +266,46 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
// Handle drop in matching puzzle
|
// Handle drop in matching puzzle
|
||||||
const handleDropInMatching = (e: React.DragEvent, targetPosition: number) => {
|
const handleDropInMatching = (e: React.DragEvent, targetPosition: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setHoveredDropZone(null); // Clear hover state
|
||||||
|
if (!localPuzzleState) return;
|
||||||
|
|
||||||
const pieceId = e.dataTransfer.getData('text/plain');
|
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;
|
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);
|
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 isComplete = checkCompletion(newPieces);
|
||||||
|
|
||||||
const newState: PuzzleState = {
|
const newState: PuzzleState = {
|
||||||
...puzzleState,
|
...localPuzzleState,
|
||||||
pieces: newPieces,
|
pieces: newPieces,
|
||||||
isComplete,
|
isComplete,
|
||||||
moves: puzzleState.moves + 1
|
moves: localPuzzleState.moves + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
setLocalPuzzleState(newState);
|
setLocalPuzzleState(newState);
|
||||||
|
|
@ -258,11 +314,39 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
setDraggedPiece(null);
|
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
|
// Handle drop
|
||||||
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!localPuzzleState) return;
|
||||||
|
|
||||||
const pieceId = e.dataTransfer.getData('text/plain');
|
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) {
|
if (sourceIndex !== -1 && sourceIndex !== targetIndex) {
|
||||||
movePiece(sourceIndex, targetIndex);
|
movePiece(sourceIndex, targetIndex);
|
||||||
|
|
@ -273,11 +357,13 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Handle piece selection (for touch devices)
|
// Handle piece selection (for touch devices)
|
||||||
const handlePieceSelection = (pieceId: string) => {
|
const handlePieceSelection = (pieceId: string) => {
|
||||||
|
if (!localPuzzleState) return;
|
||||||
|
|
||||||
if (selectedPieces.length === 0) {
|
if (selectedPieces.length === 0) {
|
||||||
setSelectedPieces([pieceId]);
|
setSelectedPieces([pieceId]);
|
||||||
} else if (selectedPieces.length === 1) {
|
} else if (selectedPieces.length === 1) {
|
||||||
const firstPieceIndex = puzzleState.pieces.findIndex(p => p.id === selectedPieces[0]);
|
const firstPieceIndex = localPuzzleState.pieces.findIndex(p => p.id === selectedPieces[0]);
|
||||||
const secondPieceIndex = puzzleState.pieces.findIndex(p => p.id === pieceId);
|
const secondPieceIndex = localPuzzleState.pieces.findIndex(p => p.id === pieceId);
|
||||||
|
|
||||||
if (firstPieceIndex !== secondPieceIndex) {
|
if (firstPieceIndex !== secondPieceIndex) {
|
||||||
movePiece(firstPieceIndex, secondPieceIndex);
|
movePiece(firstPieceIndex, secondPieceIndex);
|
||||||
|
|
@ -294,20 +380,22 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Shuffle puzzle
|
// Shuffle puzzle
|
||||||
const shufflePuzzle = () => {
|
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 shuffledPositions = shuffleArray(unlockedPieces.map(p => p.currentPosition));
|
||||||
|
|
||||||
const newPieces = [...puzzleState.pieces];
|
const newPieces = [...localPuzzleState.pieces];
|
||||||
unlockedPieces.forEach((piece, index) => {
|
unlockedPieces.forEach((piece, index) => {
|
||||||
const pieceIndex = newPieces.findIndex(p => p.id === piece.id);
|
const pieceIndex = newPieces.findIndex(p => p.id === piece.id);
|
||||||
newPieces[pieceIndex].currentPosition = shuffledPositions[index];
|
newPieces[pieceIndex].currentPosition = shuffledPositions[index];
|
||||||
});
|
});
|
||||||
|
|
||||||
const newState: PuzzleState = {
|
const newState: PuzzleState = {
|
||||||
...puzzleState,
|
...localPuzzleState,
|
||||||
pieces: newPieces,
|
pieces: newPieces,
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
moves: puzzleState.moves + 1
|
moves: localPuzzleState.moves + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
setLocalPuzzleState(newState);
|
setLocalPuzzleState(newState);
|
||||||
|
|
@ -316,23 +404,26 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Show hint
|
// Show hint
|
||||||
const showPuzzleHint = () => {
|
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
|
piece => piece.currentPosition !== piece.correctPosition && !piece.isLocked
|
||||||
);
|
);
|
||||||
|
|
||||||
if (incorrectPieces.length > 0) {
|
if (incorrectPieces.length > 0) {
|
||||||
const randomPiece = incorrectPieces[Math.floor(Math.random() * incorrectPieces.length)];
|
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) {
|
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 => ({
|
setLocalPuzzleState(prev => {
|
||||||
...prev,
|
if (!prev) return null;
|
||||||
hints: prev.hints + 1
|
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',
|
isDragging && 'opacity-30 scale-110 rotate-2 shadow-2xl z-10',
|
||||||
isSelected && 'ring-4 ring-blue-500 ring-offset-2 shadow-lg scale-105',
|
isSelected && 'ring-4 ring-blue-500 ring-offset-2 shadow-lg scale-105',
|
||||||
isDropTarget && 'border-dashed border-blue-500 bg-blue-50 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 && '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 && '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={{
|
style={{
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
|
@ -404,7 +495,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Correct indicator */}
|
{/* Correct indicator */}
|
||||||
{isCorrect && puzzleState.isComplete && (
|
{isCorrect && localPuzzleState?.isComplete && (
|
||||||
<div className="absolute top-1 right-1 bg-green-500 rounded-full p-1 animate-pulse">
|
<div className="absolute top-1 right-1 bg-green-500 rounded-full p-1 animate-pulse">
|
||||||
<Check size={12} className="text-white" />
|
<Check size={12} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -450,17 +541,29 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Render puzzle grid with proper drop zones
|
// Render puzzle grid with proper drop zones
|
||||||
const renderPuzzleGrid = () => {
|
const renderPuzzleGrid = () => {
|
||||||
|
if (!localPuzzleState) return null;
|
||||||
|
|
||||||
const puzzleType = question.puzzleData?.type;
|
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') {
|
if (puzzleType === 'sequence') {
|
||||||
return renderSequencePuzzle();
|
return renderSequencePuzzle();
|
||||||
} else if (puzzleType === 'jigsaw') {
|
} else if (puzzleType === 'jigsaw' || contentPuzzleType === 'matching') {
|
||||||
return renderMatchingPuzzle();
|
return renderMatchingPuzzle();
|
||||||
|
} else if (puzzleType === 'slider') {
|
||||||
|
return renderSliderPuzzle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to original grid
|
// Fallback to original grid
|
||||||
const gridCols = question.puzzleData?.gridSize?.cols || Math.ceil(Math.sqrt(puzzleState.pieces.length));
|
const gridCols = question.puzzleData?.gridSize?.cols || Math.ceil(Math.sqrt(localPuzzleState.pieces.length));
|
||||||
const gridRows = question.puzzleData?.gridSize?.rows || Math.ceil(puzzleState.pieces.length / gridCols);
|
const gridRows = question.puzzleData?.gridSize?.rows || Math.ceil(localPuzzleState.pieces.length / gridCols);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -471,14 +574,20 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
gridTemplateRows: `repeat(${gridRows}, 1fr)`
|
gridTemplateRows: `repeat(${gridRows}, 1fr)`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{puzzleState.pieces.map((piece, index) => renderPuzzlePiece(piece, index))}
|
{localPuzzleState.pieces.map((piece, index) => renderPuzzlePiece(piece, index))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render sequence puzzle with clear drop zones
|
// Render sequence puzzle with clear drop zones
|
||||||
const renderSequencePuzzle = () => {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -519,9 +628,9 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
<Target className="mr-2" size={16} />
|
<Target className="mr-2" size={16} />
|
||||||
Urutan yang benar:
|
Urutan yang benar:
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${puzzleState.pieces.length}, 1fr)` }}>
|
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${localPuzzleState.pieces.length}, 1fr)` }}>
|
||||||
{Array.from({ length: puzzleState.pieces.length }, (_, index) => {
|
{Array.from({ length: localPuzzleState.pieces.length }, (_, index) => {
|
||||||
const pieceInPosition = puzzleState.pieces.find(p => p.correctPosition === index);
|
const pieceInPosition = localPuzzleState.pieces.find(p => p.correctPosition === index);
|
||||||
const isCorrectlyPlaced = pieceInPosition && pieceInPosition.currentPosition === index;
|
const isCorrectlyPlaced = pieceInPosition && pieceInPosition.currentPosition === index;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -565,12 +674,28 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Render matching puzzle with clear areas
|
// Render matching puzzle with clear areas
|
||||||
const renderMatchingPuzzle = () => {
|
const renderMatchingPuzzle = () => {
|
||||||
const items = puzzleState.pieces;
|
if (!localPuzzleState) return null;
|
||||||
const definitions = question.puzzleData?.pieces?.map((_, index) => ({
|
|
||||||
id: index,
|
const items = localPuzzleState.pieces;
|
||||||
label: `Definisi ${index + 1}`,
|
const targetAreas = question.puzzleData?.targetAreas || [];
|
||||||
correctPieceId: question.puzzleData?.pieces?.[index]?.id
|
|
||||||
})) || [];
|
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 (
|
return (
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
|
@ -608,31 +733,103 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
<Target className="mr-2" size={16} />
|
<Target className="mr-2" size={16} />
|
||||||
Cocokkan dengan definisi:
|
Cocokkan dengan definisi:
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-6">
|
||||||
{definitions.map((def, index) => {
|
{targetAreas.map((targetArea, index) => {
|
||||||
const matchedPiece = items.find(p => p.correctPosition === index && p.currentPosition === index);
|
console.log('🎯 RENDERING TARGET AREA:', {
|
||||||
const isCorrect = matchedPiece !== undefined;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={def.id}
|
key={targetAreaId}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={(e) => {
|
||||||
onDrop={(e) => handleDropInMatching(e, index)}
|
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(
|
className={cn(
|
||||||
'min-h-16 p-3 border-2 border-dashed rounded-lg transition-all duration-200',
|
'min-h-20 p-4 border-3 border-dashed rounded-xl transition-all duration-300',
|
||||||
'flex items-center justify-between relative',
|
'flex items-center justify-between relative cursor-pointer',
|
||||||
draggedPiece && 'border-green-400 bg-green-100 scale-102',
|
'bg-white shadow-lg hover:shadow-xl',
|
||||||
isCorrect && 'bg-green-100 border-green-400 border-solid',
|
// Hover state when dragging
|
||||||
!isCorrect && !draggedPiece && 'border-gray-300 bg-gray-50',
|
isHovered && draggedPiece && 'border-green-500 bg-green-100 scale-105 shadow-2xl ring-2 ring-green-300',
|
||||||
!isCorrect && draggedPiece && 'hover:border-green-500 hover:bg-green-100'
|
// 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 */}
|
{/* Definition label */}
|
||||||
<div className="flex-1">
|
<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">
|
<div className="text-sm text-gray-800">
|
||||||
{/* You can add actual definitions here based on your data structure */}
|
{targetArea.content || targetArea.label || `Target area ${index + 1}`}
|
||||||
Definisi untuk item {index + 1}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -644,10 +841,28 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
<Check size={16} className="text-green-600" />
|
<Check size={16} className="text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 border-2 border-dashed border-gray-400 rounded text-center">
|
<div className={cn(
|
||||||
<div className="text-xs text-gray-500">
|
"p-4 border-2 border-dashed rounded-xl text-center transition-all duration-300 min-h-16",
|
||||||
{draggedPiece ? 'Lepas di sini' : 'Kosong'}
|
"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>
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
</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
|
// Render puzzle stats
|
||||||
const renderPuzzleStats = () => {
|
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 minutes = Math.floor(timeElapsed / 60);
|
||||||
const seconds = timeElapsed % 60;
|
const seconds = timeElapsed % 60;
|
||||||
|
|
||||||
|
|
@ -676,19 +1007,19 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<Target size={16} />
|
<Target size={16} />
|
||||||
<span>{puzzleState.moves} {adminLanguage === 'id' ? 'gerakan' : 'moves'}</span>
|
<span>{localPuzzleState.moves} {adminLanguage === 'id' ? 'gerakan' : 'moves'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<Lightbulb size={16} />
|
<Lightbulb size={16} />
|
||||||
<span>{puzzleState.hints}/3 {adminLanguage === 'id' ? 'petunjuk' : 'hints'}</span>
|
<span>{localPuzzleState.hints}/3 {adminLanguage === 'id' ? 'petunjuk' : 'hints'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={showPuzzleHint}
|
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"
|
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'}
|
{adminLanguage === 'id' ? 'Petunjuk' : 'Hint'}
|
||||||
|
|
@ -696,7 +1027,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={shufflePuzzle}
|
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"
|
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} />
|
<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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Tutorial untuk pertama kali */}
|
{/* 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="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 items-start space-x-3">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
|
@ -769,19 +1109,19 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress indicator */}
|
{/* 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="bg-white border border-gray-200 rounded-lg p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm font-medium text-gray-700">Progress</span>
|
<span className="text-sm font-medium text-gray-700">Progress</span>
|
||||||
<span className="text-sm text-gray-600">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-gradient-to-r from-green-400 to-green-600 h-2 rounded-full transition-all duration-500"
|
className="bg-gradient-to-r from-green-400 to-green-600 h-2 rounded-full transition-all duration-500"
|
||||||
style={{
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -795,7 +1135,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
{renderPuzzleGrid()}
|
{renderPuzzleGrid()}
|
||||||
|
|
||||||
{/* Completion message */}
|
{/* 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="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 items-center space-x-3">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
|
@ -808,7 +1148,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
🎉 Selamat! Puzzle berhasil diselesaikan!
|
🎉 Selamat! Puzzle berhasil diselesaikan!
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-green-700 mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,10 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
const isIndonesian = adminLanguage === 'id';
|
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
title: isIndonesian ? 'Selamat Datang di Simulasi Skenario!' : 'Welcome to Scenario Simulation!',
|
title: 'Selamat Datang di Simulasi Skenario!',
|
||||||
content: isIndonesian
|
content: 'Anda akan menghadapi situasi nyata di dapur dan harus membuat keputusan yang tepat. Setiap pilihan akan mempengaruhi skor dan kondisi dapur Anda.',
|
||||||
? '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.',
|
|
||||||
icon: <ChefHat className="text-blue-600" size={48} />,
|
icon: <ChefHat className="text-blue-600" size={48} />,
|
||||||
visual: (
|
visual: (
|
||||||
<div className="bg-gradient-to-br from-blue-100 to-indigo-100 p-6 rounded-lg">
|
<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',
|
title: 'Cara Bermain',
|
||||||
content: isIndonesian
|
content: 'Baca situasi dengan cermat, pilih tindakan terbaik dari opsi yang tersedia, lalu klik "Jalankan Tindakan" untuk melihat hasilnya.',
|
||||||
? '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.',
|
|
||||||
icon: <Play className="text-green-600" size={48} />,
|
icon: <Play className="text-green-600" size={48} />,
|
||||||
visual: (
|
visual: (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="p-3 bg-blue-50 border-l-4 border-blue-400 rounded-r">
|
<div className="p-3 bg-blue-50 border-l-4 border-blue-400 rounded-r">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">
|
||||||
{isIndonesian ? '📖 1. Baca situasi' : '📖 1. Read situation'}
|
📖 1. Baca situasi
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-yellow-50 border-l-4 border-yellow-400 rounded-r">
|
<div className="p-3 bg-yellow-50 border-l-4 border-yellow-400 rounded-r">
|
||||||
<p className="text-sm text-yellow-800">
|
<p className="text-sm text-yellow-800">
|
||||||
{isIndonesian ? '🎯 2. Pilih tindakan' : '🎯 2. Choose action'}
|
🎯 2. Pilih tindakan
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-green-50 border-l-4 border-green-400 rounded-r">
|
<div className="p-3 bg-green-50 border-l-4 border-green-400 rounded-r">
|
||||||
<p className="text-sm text-green-800">
|
<p className="text-sm text-green-800">
|
||||||
{isIndonesian ? '⚡ 3. Jalankan & lihat hasil' : '⚡ 3. Execute & see results'}
|
⚡ 3. Jalankan & lihat hasil
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: isIndonesian ? 'Memahami Indikator' : 'Understanding Indicators',
|
title: 'Memahami Indikator',
|
||||||
content: isIndonesian
|
content: 'Perhatikan skor, waktu, kepuasan pelanggan, dan efisiensi. Indikator ini menunjukkan performa Anda dalam mengelola dapur.',
|
||||||
? '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.',
|
|
||||||
icon: <Target className="text-purple-600" size={48} />,
|
icon: <Target className="text-purple-600" size={48} />,
|
||||||
visual: (
|
visual: (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="p-3 bg-red-50 rounded-lg text-center">
|
<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-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>
|
||||||
<div className="p-3 bg-blue-50 rounded-lg text-center">
|
<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-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>
|
||||||
<div className="p-3 bg-green-50 rounded-lg text-center">
|
<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-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>
|
||||||
<div className="p-3 bg-yellow-50 rounded-lg text-center">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: isIndonesian ? 'Tips Sukses' : 'Success Tips',
|
title: 'Tips Sukses',
|
||||||
content: isIndonesian
|
content: 'Prioritaskan keamanan pangan, kelola waktu dengan baik, dan pertimbangkan dampak jangka panjang dari setiap keputusan Anda.',
|
||||||
? '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.',
|
|
||||||
icon: <Lightbulb className="text-yellow-600" size={48} />,
|
icon: <Lightbulb className="text-yellow-600" size={48} />,
|
||||||
visual: (
|
visual: (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-2 p-2 bg-green-50 rounded">
|
<div className="flex items-center space-x-2 p-2 bg-green-50 rounded">
|
||||||
<CheckCircle className="text-green-600" size={16} />
|
<CheckCircle className="text-green-600" size={16} />
|
||||||
<span className="text-sm text-green-800">
|
<span className="text-sm text-green-800">
|
||||||
{isIndonesian ? 'Keamanan pangan adalah prioritas utama' : 'Food safety is top priority'}
|
Keamanan pangan adalah prioritas utama
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2 p-2 bg-blue-50 rounded">
|
<div className="flex items-center space-x-2 p-2 bg-blue-50 rounded">
|
||||||
<CheckCircle className="text-blue-600" size={16} />
|
<CheckCircle className="text-blue-600" size={16} />
|
||||||
<span className="text-sm text-blue-800">
|
<span className="text-sm text-blue-800">
|
||||||
{isIndonesian ? 'Kelola waktu dengan efisien' : 'Manage time efficiently'}
|
Kelola waktu dengan efisien
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2 p-2 bg-purple-50 rounded">
|
<div className="flex items-center space-x-2 p-2 bg-purple-50 rounded">
|
||||||
<CheckCircle className="text-purple-600" size={16} />
|
<CheckCircle className="text-purple-600" size={16} />
|
||||||
<span className="text-sm text-purple-800">
|
<span className="text-sm text-purple-800">
|
||||||
{isIndonesian ? 'Pikirkan dampak jangka panjang' : 'Think long-term impact'}
|
Pikirkan dampak jangka panjang
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,7 +144,7 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold">{currentStepData.title}</h2>
|
<h2 className="text-xl font-bold">{currentStepData.title}</h2>
|
||||||
<p className="text-blue-100 text-sm">
|
<p className="text-blue-100 text-sm">
|
||||||
{isIndonesian ? 'Langkah' : 'Step'} {currentStep + 1} {isIndonesian ? 'dari' : 'of'} {steps.length}
|
Langkah {currentStep + 1} dari {steps.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -197,13 +187,10 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
||||||
<ChefHat className="text-orange-600 mt-1" size={20} />
|
<ChefHat className="text-orange-600 mt-1" size={20} />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-orange-900 mb-1">
|
<h4 className="font-semibold text-orange-900 mb-1">
|
||||||
{isIndonesian ? 'Khusus Simulasi Dapur MBG' : 'MBG Kitchen Simulation'}
|
Simulasi Dapur MBG
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-orange-800 text-sm">
|
<p className="text-orange-800 text-sm">
|
||||||
{isIndonesian
|
Simulasi ini dibuat khusus untuk melatih kemampuan Anda mengelola dapur dengan standar kebersihan dan keamanan makanan yang baik sesuai aturan MBG.
|
||||||
? '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.'
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -223,8 +210,8 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
||||||
: 'bg-gray-300 text-gray-700 hover:bg-gray-400'
|
: 'bg-gray-300 text-gray-700 hover:bg-gray-400'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isIndonesian ? 'Sebelumnya' : 'Previous'}
|
Sebelumnya
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{steps.map((_, index) => (
|
{steps.map((_, index) => (
|
||||||
|
|
@ -239,14 +226,14 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
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"
|
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
|
{currentStep === steps.length - 1
|
||||||
? (isIndonesian ? 'Mulai Simulasi' : 'Start Simulation')
|
? 'Mulai Bermain'
|
||||||
: (isIndonesian ? 'Selanjutnya' : 'Next')
|
: 'Lanjut'
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Public routes that don't require authentication
|
|
||||||
const publicRoutes = ['/login', '/register', '/forgot-password'];
|
|
||||||
const isPublicRoute = publicRoutes.includes(pathname);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for existing session on mount
|
// Check for existing session on mount
|
||||||
const savedUser = localStorage.getItem('lms_user');
|
const savedUser = localStorage.getItem('lms_user');
|
||||||
|
|
@ -72,6 +68,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect logic
|
// Redirect logic
|
||||||
if (!isLoading) {
|
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') {
|
if (!user && !isPublicRoute && pathname !== '/login') {
|
||||||
// User not logged in and trying to access protected route
|
// User not logged in and trying to access protected route
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
|
|
@ -83,7 +83,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
router.push(dashboardPath);
|
router.push(dashboardPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [user, isLoading, pathname, isPublicRoute, router]);
|
}, [user, isLoading, pathname, router]);
|
||||||
|
|
||||||
const login = async (email: string, password: string): Promise<boolean> => {
|
const login = async (email: string, password: string): Promise<boolean> => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
@ -111,11 +111,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
setUser(null);
|
try {
|
||||||
localStorage.removeItem('lms_user');
|
setUser(null);
|
||||||
// Also clear cookie
|
|
||||||
document.cookie = 'lms_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
// Safely remove from localStorage
|
||||||
router.push('/login');
|
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 = {
|
const value = {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ interface ExamState {
|
||||||
|
|
||||||
// Interactive Quiz Enhancement: Interactive Elements State
|
// Interactive Quiz Enhancement: Interactive Elements State
|
||||||
interactiveState: {
|
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>;
|
dragDropState: Record<string, any>;
|
||||||
puzzleState: Record<string, any>;
|
puzzleState: Record<string, any>;
|
||||||
scenarioState: Record<string, any>;
|
scenarioState: Record<string, any>;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export class DIContainer {
|
||||||
private services = new Map<string, ServiceRegistration>();
|
private services = new Map<string, ServiceRegistration>();
|
||||||
private singletons = new Map<string, any>();
|
private singletons = new Map<string, any>();
|
||||||
private resolving = new Set<string>();
|
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
|
* Register a service with the container
|
||||||
|
|
@ -60,16 +62,21 @@ export class DIContainer {
|
||||||
* Resolve a service from the container
|
* Resolve a service from the container
|
||||||
*/
|
*/
|
||||||
async resolve<T>(token: string): Promise<T> {
|
async resolve<T>(token: string): Promise<T> {
|
||||||
// Check for circular dependencies
|
// Return singleton if already created (prefer fast path)
|
||||||
if (this.resolving.has(token)) {
|
|
||||||
throw new Error(`Circular dependency detected: ${token}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return singleton if already created
|
|
||||||
if (this.singletons.has(token)) {
|
if (this.singletons.has(token)) {
|
||||||
return this.singletons.get(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);
|
const registration = this.services.get(token);
|
||||||
if (!registration) {
|
if (!registration) {
|
||||||
throw new Error(`Service not found: ${token}`);
|
throw new Error(`Service not found: ${token}`);
|
||||||
|
|
@ -77,32 +84,40 @@ export class DIContainer {
|
||||||
|
|
||||||
this.resolving.add(token);
|
this.resolving.add(token);
|
||||||
|
|
||||||
try {
|
// Create a shared promise for this resolution so concurrent callers can await it
|
||||||
// Resolve dependencies first
|
const resolutionPromise = (async () => {
|
||||||
const dependencies: any[] = [];
|
try {
|
||||||
if (registration.dependencies) {
|
// Resolve dependencies first
|
||||||
for (const dep of registration.dependencies) {
|
const dependencies: any[] = [];
|
||||||
dependencies.push(await this.resolve(dep));
|
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
|
this.pendingResolutions.set(token, resolutionPromise);
|
||||||
const instance = await registration.factory();
|
|
||||||
|
|
||||||
// Initialize service if it implements IService
|
return await resolutionPromise;
|
||||||
if (this.isService(instance)) {
|
|
||||||
await instance.initialize?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store singleton
|
|
||||||
if (registration.singleton) {
|
|
||||||
this.singletons.set(token, instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
} finally {
|
|
||||||
this.resolving.delete(token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,10 @@ export const PayrollManagement: React.FC<PayrollManagementProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPayrollData();
|
if (payrollCalculator) {
|
||||||
}, []);
|
loadPayrollData();
|
||||||
|
}
|
||||||
|
}, [payrollCalculator]);
|
||||||
|
|
||||||
const loadPayrollData = async () => {
|
const loadPayrollData = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,21 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<'week' | 'month' | 'quarter'>('month');
|
const [selectedPeriod, setSelectedPeriod] = useState<'week' | 'month' | 'quarter'>('month');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRewardData();
|
if (rewardService) {
|
||||||
}, [userId, selectedPeriod]);
|
loadRewardData();
|
||||||
|
} else {
|
||||||
|
// Reset loading state if service is not available
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [userId, selectedPeriod, rewardService]);
|
||||||
|
|
||||||
const loadRewardData = async () => {
|
const loadRewardData = async () => {
|
||||||
|
if (!rewardService) {
|
||||||
|
setError('Reward service not available');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -52,10 +63,6 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load rewards for the period
|
// Load rewards for the period
|
||||||
if (!rewardService) {
|
|
||||||
throw new Error('Reward service not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRewards = await rewardService.getRewardsByUserId(userId, {
|
const userRewards = await rewardService.getRewardsByUserId(userId, {
|
||||||
dateFrom: startDate,
|
dateFrom: startDate,
|
||||||
dateTo: endDate
|
dateTo: endDate
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,10 @@ export const WalletBalance: React.FC<WalletBalanceProps> = ({
|
||||||
const [showAllTransactions, setShowAllTransactions] = useState(false);
|
const [showAllTransactions, setShowAllTransactions] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWalletData();
|
if (walletService) {
|
||||||
}, [userId]);
|
loadWalletData();
|
||||||
|
}
|
||||||
|
}, [userId, walletService]);
|
||||||
|
|
||||||
const loadWalletData = async () => {
|
const loadWalletData = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { EventBus } from '../../core/events/EventBus';
|
||||||
import { RewardDashboard } from './components/RewardDashboard';
|
import { RewardDashboard } from './components/RewardDashboard';
|
||||||
import { WalletBalance } from './components/WalletBalance';
|
import { WalletBalance } from './components/WalletBalance';
|
||||||
import { PayrollManagement } from './components/PayrollManagement';
|
import { PayrollManagement } from './components/PayrollManagement';
|
||||||
|
import { container as globalContainer } from '../../core/di/DIContainer';
|
||||||
|
|
||||||
export class PayrollRewardSystemModule implements IPlugin {
|
export class PayrollRewardSystemModule implements IPlugin {
|
||||||
readonly metadata: PluginMetadata = {
|
readonly metadata: PluginMetadata = {
|
||||||
|
|
@ -29,7 +30,7 @@ export class PayrollRewardSystemModule implements IPlugin {
|
||||||
private rewardRepository: MockRewardRepository;
|
private rewardRepository: MockRewardRepository;
|
||||||
private rewardService: RewardService;
|
private rewardService: RewardService;
|
||||||
private walletService: WalletService;
|
private walletService: WalletService;
|
||||||
private payrollCalculator: PayrollCalculator;
|
private payrollCalculator: PayrollCalculator | undefined;
|
||||||
private services: Map<string, any> = new Map();
|
private services: Map<string, any> = new Map();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -37,21 +38,29 @@ export class PayrollRewardSystemModule implements IPlugin {
|
||||||
this.rewardRepository = new MockRewardRepository();
|
this.rewardRepository = new MockRewardRepository();
|
||||||
this.rewardService = new RewardService(this.rewardRepository, this.eventBus);
|
this.rewardService = new RewardService(this.rewardRepository, this.eventBus);
|
||||||
this.walletService = new WalletService();
|
this.walletService = new WalletService();
|
||||||
this.payrollCalculator = new PayrollCalculator(this.rewardService);
|
this.payrollCalculator = undefined;
|
||||||
|
this.isActive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async activate(): Promise<void> {
|
async activate(): Promise<void> {
|
||||||
if (this.isActive) return;
|
if (this.isActive) return;
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
await this.rewardService.initialize();
|
await this.rewardService.initialize();
|
||||||
await this.walletService.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('reward-service', this.rewardService);
|
||||||
this.services.set('wallet-service', this.walletService);
|
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.services.set('payroll-calculator', this.payrollCalculator);
|
||||||
|
|
||||||
this.isActive = true;
|
this.isActive = true;
|
||||||
console.log(`${this.metadata.name} module activated`);
|
console.log(`${this.metadata.name} module activated`);
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +109,11 @@ export class PayrollRewardSystemModule implements IPlugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
getServices(): ServiceConfig[] {
|
getServices(): ServiceConfig[] {
|
||||||
|
if (!this.isActive) {
|
||||||
|
console.warn('PayrollRewardSystemModule is not active. Call activate() first.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
token: 'reward-service',
|
token: 'reward-service',
|
||||||
|
|
@ -113,7 +127,12 @@ export class PayrollRewardSystemModule implements IPlugin {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
token: 'payroll-calculator',
|
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
|
singleton: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
|
||||||
private entries: Map<string, PayrollEntry> = new Map();
|
private entries: Map<string, PayrollEntry> = new Map();
|
||||||
private config: PayrollConfig = { ...defaultPayrollConfig };
|
private config: PayrollConfig = { ...defaultPayrollConfig };
|
||||||
|
|
||||||
constructor(private rewardService: IRewardService) {
|
constructor(private rewardServiceFactory: () => IRewardService) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all approved rewards for the period
|
// Get all approved rewards for the period
|
||||||
const rewards = await this.rewardService.getTotalRewardsByPeriod(
|
const rewards = await this.rewardServiceFactory().getTotalRewardsByPeriod(
|
||||||
input.startDate,
|
input.startDate,
|
||||||
input.endDate,
|
input.endDate,
|
||||||
RewardStatus.APPROVED
|
RewardStatus.APPROVED
|
||||||
|
|
@ -64,7 +64,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
|
||||||
let totalAmount = 0;
|
let totalAmount = 0;
|
||||||
|
|
||||||
for (const userId of simulatedUsers) {
|
for (const userId of simulatedUsers) {
|
||||||
const userRewardData = await this.rewardService.getRewardsByUserId(userId, {
|
const userRewardData = await this.rewardServiceFactory().getRewardsByUserId(userId, {
|
||||||
status: RewardStatus.APPROVED,
|
status: RewardStatus.APPROVED,
|
||||||
dateFrom: input.startDate,
|
dateFrom: input.startDate,
|
||||||
dateTo: input.endDate
|
dateTo: input.endDate
|
||||||
|
|
@ -287,7 +287,7 @@ export class PayrollCalculator extends BaseService implements IPayrollCalculator
|
||||||
for (const entry of batch.entries) {
|
for (const entry of batch.entries) {
|
||||||
for (const breakdown of entry.rewardBreakdown) {
|
for (const breakdown of entry.rewardBreakdown) {
|
||||||
for (const rewardId of breakdown.rewardIds) {
|
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">
|
<div className="border-t border-gray-100">
|
||||||
<button
|
<button
|
||||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
setShowUserMenu(false);
|
try {
|
||||||
logout();
|
setShowUserMenu(false);
|
||||||
|
logout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout button error:', error);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Keluar
|
Keluar
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ export interface LoadingState {
|
||||||
// Enhanced Question Types for Interactive Quiz
|
// Enhanced Question Types for Interactive Quiz
|
||||||
export interface InteractiveQuestion {
|
export interface InteractiveQuestion {
|
||||||
id: string;
|
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;
|
content: QuestionContent;
|
||||||
interactiveElements?: InteractiveElement[];
|
interactiveElements?: InteractiveElement[];
|
||||||
behaviorMetrics?: BehaviorMetric[];
|
behaviorMetrics?: BehaviorMetric[];
|
||||||
|
|
@ -425,12 +425,21 @@ export interface InteractiveQuestion {
|
||||||
// Scenario-specific data
|
// Scenario-specific data
|
||||||
scenarioData?: ScenarioData;
|
scenarioData?: ScenarioData;
|
||||||
|
|
||||||
|
// Video scenario specific data
|
||||||
|
scenarios?: VideoScenarioStep[];
|
||||||
|
|
||||||
|
// Image hotspot specific data
|
||||||
|
hotspots?: ImageHotspot[];
|
||||||
|
|
||||||
// New quiz type properties
|
// New quiz type properties
|
||||||
visualElements?: VisualMatchingConfig;
|
visualElements?: VisualMatchingConfig;
|
||||||
reactionTimeConfig?: ReactionTimeConfig;
|
reactionTimeConfig?: ReactionTimeConfig;
|
||||||
rpgElements?: RPGConfig;
|
rpgElements?: RPGConfig;
|
||||||
reflectionConfig?: ReflectionConfig;
|
reflectionConfig?: ReflectionConfig;
|
||||||
|
|
||||||
|
// Media gallery specific
|
||||||
|
mediaItems?: MediaItem[];
|
||||||
|
|
||||||
// Legacy properties for backward compatibility
|
// Legacy properties for backward compatibility
|
||||||
text?: string;
|
text?: string;
|
||||||
question?: string;
|
question?: string;
|
||||||
|
|
@ -489,8 +498,27 @@ export interface MultimediaContent {
|
||||||
thumbnail?: string;
|
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 {
|
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
|
config?: any; // Flexible configuration object
|
||||||
content?: string; // For hint and explanation content
|
content?: string; // For hint and explanation content
|
||||||
validation?: ValidationRule[];
|
validation?: ValidationRule[];
|
||||||
|
|
@ -502,15 +530,24 @@ export interface ValidationRule {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SliderConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
minValue: number;
|
||||||
|
maxValue: number;
|
||||||
|
correctValue: number;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Puzzle Data Types
|
// Puzzle Data Types
|
||||||
export interface PuzzleData {
|
export interface PuzzleData {
|
||||||
type: 'jigsaw' | 'sequence' | 'word_puzzle';
|
type: 'jigsaw' | 'sequence' | 'word_puzzle' | 'slider';
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
pieces?: PuzzlePiece[];
|
pieces?: PuzzlePiece[];
|
||||||
sequence?: string[];
|
sequence?: string[];
|
||||||
words?: string[];
|
words?: string[];
|
||||||
correctOrder?: number[];
|
correctOrder?: number[];
|
||||||
sliders?: any[];
|
sliders?: SliderConfig[];
|
||||||
targetAreas?: any[];
|
targetAreas?: any[];
|
||||||
gridSize?: {
|
gridSize?: {
|
||||||
rows: number;
|
rows: number;
|
||||||
|
|
@ -1049,6 +1086,42 @@ export interface EmojiScaleConfig {
|
||||||
allowHalfSteps: boolean;
|
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 {
|
export interface EmojiOption {
|
||||||
value: number;
|
value: number;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue