Compare commits
No commits in common. "2d96a4d65e65377ad7047dde02bc1e6dd92ea2e9" and "d0e53325638357564f7a31801bfaeb402007701d" have entirely different histories.
2d96a4d65e
...
d0e5332563
|
|
@ -1,355 +0,0 @@
|
||||||
# 🧪 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
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
images: {
|
images: {
|
||||||
domains: ['localhost', 'api.lms-bgn.id', 'upload.wikimedia.org'],
|
domains: ['localhost', 'api.lms-bgn.id'],
|
||||||
formats: ['image/webp', 'image/avif'],
|
formats: ['image/webp', 'image/avif'],
|
||||||
},
|
},
|
||||||
// PWA Configuration
|
// PWA Configuration
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { PayrollManagement } from '@/features/payroll-reward-system/components';
|
|
||||||
import DashboardLayout from '@/layouts/DashboardLayout';
|
|
||||||
|
|
||||||
export default function AdminPayrollPage() {
|
|
||||||
return (
|
|
||||||
<DashboardLayout>
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Admin Payroll</h1>
|
|
||||||
<p className="text-gray-600">Kelola perhitungan dan pembayaran payroll peserta.</p>
|
|
||||||
</div>
|
|
||||||
<PayrollManagement userRole="admin" />
|
|
||||||
</DashboardLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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, useRouter } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { InteractiveQuestion, EnhancedExamData, Question, PuzzleData } from '@/types';
|
import { InteractiveQuestion, EnhancedExamData, Question, PuzzleData } from '@/types';
|
||||||
|
|
||||||
// Feature Flags
|
// Feature Flags
|
||||||
|
|
@ -23,9 +23,6 @@ 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';
|
||||||
|
|
@ -144,235 +141,6 @@ 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',
|
||||||
|
|
@ -510,8 +278,7 @@ 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: {
|
||||||
|
|
@ -521,33 +288,27 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
{
|
{
|
||||||
id: 'ccp',
|
id: 'ccp',
|
||||||
content: 'Titik Kendali Kritis (CCP)',
|
content: 'Titik Kendali Kritis (CCP)',
|
||||||
correctPosition: 'def1',
|
correctPosition: 0,
|
||||||
isLocked: false
|
isLocked: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'haccp',
|
id: 'haccp',
|
||||||
content: 'Analisis Bahaya dan Titik Kendali Kritis',
|
content: 'Analisis Bahaya dan Titik Kendali Kritis',
|
||||||
correctPosition: 'def2',
|
correctPosition: 1,
|
||||||
isLocked: false
|
isLocked: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sanitasi',
|
id: 'sanitasi',
|
||||||
content: 'Proses pembersihan dan desinfeksi',
|
content: 'Proses pembersihan dan desinfeksi',
|
||||||
correctPosition: 'def3',
|
correctPosition: 2,
|
||||||
isLocked: false
|
isLocked: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'kontaminasi',
|
id: 'kontaminasi',
|
||||||
content: 'Pencemaran makanan oleh zat berbahaya',
|
content: 'Pencemaran makanan oleh zat berbahaya',
|
||||||
correctPosition: 'def4',
|
correctPosition: 3,
|
||||||
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' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -622,51 +383,18 @@ 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: 'Seret setiap langkah ke urutan yang benar (1-5).',
|
instructions: 'Susun langkah mencuci tangan dalam urutan yang benar.'
|
||||||
puzzleType: 'matching' as const
|
|
||||||
},
|
},
|
||||||
difficulty: 'easy' as const,
|
difficulty: 'easy' as const,
|
||||||
puzzleData: {
|
puzzleData: {
|
||||||
type: 'jigsaw' as const,
|
type: 'sequence' as const,
|
||||||
instructions: 'Urutkan langkah-langkah mencuci tangan sesuai standar Dapur MBG.',
|
instructions: 'Urutkan langkah-langkah mencuci tangan sesuai standar Dapur MBG.',
|
||||||
pieces: [
|
sequence: [
|
||||||
{
|
'Basahi tangan dengan air bersih',
|
||||||
id: 'step1',
|
'Gunakan sabun dan gosok hingga berbusa',
|
||||||
content: 'Basahi tangan dengan air bersih',
|
'Gosok selama minimal 20 detik',
|
||||||
correctPosition: 'pos1',
|
'Bilas hingga bersih',
|
||||||
isLocked: false
|
'Keringkan dengan handuk bersih'
|
||||||
},
|
|
||||||
{
|
|
||||||
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' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -678,51 +406,18 @@ 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 setiap tahapan ke urutan yang benar (1-5).',
|
instructions: 'Seret dan lepas untuk mengurutkan tahapan persiapan makanan.'
|
||||||
puzzleType: 'matching' as const
|
|
||||||
},
|
},
|
||||||
difficulty: 'hard' as const,
|
difficulty: 'hard' as const,
|
||||||
puzzleData: {
|
puzzleData: {
|
||||||
type: 'jigsaw' as const,
|
type: 'sequence' as const,
|
||||||
instructions: 'Urutkan tahapan persiapan makanan bergizi gratis sesuai standar MBG.',
|
instructions: 'Urutkan tahapan persiapan makanan bergizi gratis sesuai standar MBG.',
|
||||||
pieces: [
|
sequence: [
|
||||||
{
|
'Perencanaan menu bergizi seimbang',
|
||||||
id: 'stage1',
|
'Pemilihan dan pemeriksaan bahan baku',
|
||||||
content: 'Perencanaan menu bergizi seimbang',
|
'Persiapan dan pengolahan makanan',
|
||||||
correctPosition: 'order1',
|
'Kontrol kualitas dan suhu',
|
||||||
isLocked: false
|
'Penyajian dan distribusi'
|
||||||
},
|
|
||||||
{
|
|
||||||
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' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -964,6 +659,33 @@ 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',
|
||||||
|
|
@ -984,7 +706,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
leftItem: {
|
leftItem: {
|
||||||
id: 'img1',
|
id: 'img1',
|
||||||
content: 'Termometer Digital',
|
content: 'Termometer Digital',
|
||||||
image: 'https://cdn-icons-png.flaticon.com/512/2913/2913465.png'
|
image: '/images/thermometer-digital.png'
|
||||||
},
|
},
|
||||||
rightItem: {
|
rightItem: {
|
||||||
id: 'area1',
|
id: 'area1',
|
||||||
|
|
@ -996,7 +718,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
leftItem: {
|
leftItem: {
|
||||||
id: 'img2',
|
id: 'img2',
|
||||||
content: 'Cutting Board Berwarna',
|
content: 'Cutting Board Berwarna',
|
||||||
image: 'https://cdn-icons-png.flaticon.com/512/2515/2515183.png'
|
image: '/images/cutting-board-colored.png'
|
||||||
},
|
},
|
||||||
rightItem: {
|
rightItem: {
|
||||||
id: 'area2',
|
id: 'area2',
|
||||||
|
|
@ -1008,7 +730,7 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
leftItem: {
|
leftItem: {
|
||||||
id: 'img3',
|
id: 'img3',
|
||||||
content: 'Timer Dapur',
|
content: 'Timer Dapur',
|
||||||
image: 'https://cdn-icons-png.flaticon.com/512/2921/2921222.png'
|
image: '/images/kitchen-timer.png'
|
||||||
},
|
},
|
||||||
rightItem: {
|
rightItem: {
|
||||||
id: 'area3',
|
id: 'area3',
|
||||||
|
|
@ -1020,36 +742,12 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
leftItem: {
|
leftItem: {
|
||||||
id: 'img4',
|
id: 'img4',
|
||||||
content: 'Sarung Tangan Sekali Pakai',
|
content: 'Sarung Tangan Sekali Pakai',
|
||||||
image: 'https://cdn-icons-png.flaticon.com/512/2913/2913423.png'
|
image: '/images/disposable-gloves.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,
|
||||||
|
|
@ -1077,18 +775,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 sesuai kecepatan Anda untuk testing.'
|
instructions: 'Jawab dengan cepat! Anda memiliki waktu terbatas untuk menjawab.'
|
||||||
},
|
},
|
||||||
difficulty: 'easy' as const,
|
difficulty: 'easy' as const,
|
||||||
timeLimit: 300, // 5 minutes - very long time limit
|
timeLimit: 10,
|
||||||
reactionTimeConfig: {
|
reactionTimeConfig: {
|
||||||
timeLimit: 300000, // 5 minutes in milliseconds
|
timeLimit: 10,
|
||||||
showCountdown: false, // Hide countdown timer
|
showCountdown: true,
|
||||||
trackReactionTime: true, // Still track reaction time for testing
|
trackReactionTime: true,
|
||||||
instantFeedback: true,
|
instantFeedback: true,
|
||||||
penaltyForWrongAnswer: 0, // No penalty since this is for testing
|
penaltyForWrongAnswer: 2,
|
||||||
bonusForQuickAnswer: 5,
|
bonusForQuickAnswer: 5,
|
||||||
quickAnswerThreshold: 3000 // 3 seconds threshold
|
quickAnswerThreshold: 5
|
||||||
},
|
},
|
||||||
interactiveElements: [
|
interactiveElements: [
|
||||||
{
|
{
|
||||||
|
|
@ -1120,33 +818,29 @@ const sampleInteractiveExamData: EnhancedExamData = {
|
||||||
problemSolving: 60,
|
problemSolving: 60,
|
||||||
communication: 75
|
communication: 75
|
||||||
},
|
},
|
||||||
avatar: 'https://cdn-icons-png.flaticon.com/512/3135/3135715.png'
|
avatar: '/images/kitchen-manager-avatar.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: 0,
|
step: 1,
|
||||||
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: {
|
||||||
|
|
@ -1181,125 +875,9 @@ 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,
|
||||||
|
|
@ -1463,21 +1041,14 @@ 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);
|
||||||
|
|
@ -1537,33 +1108,13 @@ function InteractiveQuizWrapper() {
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
{state.currentQuestion >= (state.examData.interactiveData?.questions.length || state.examData.questions.length) - 1 ? (
|
<button
|
||||||
<button
|
onClick={() => dispatch({ type: 'SET_CURRENT_QUESTION', payload: state.currentQuestion + 1 })}
|
||||||
onClick={handleSubmitExam}
|
disabled={state.currentQuestion >= (state.examData.interactiveData?.questions.length || state.examData.questions.length) - 1}
|
||||||
disabled={state.answers[state.currentQuestion] === undefined}
|
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"
|
||||||
className={cn(
|
>
|
||||||
"px-4 py-2 rounded-lg transition-colors",
|
Next
|
||||||
state.answers[state.currentQuestion] !== undefined
|
</button>
|
||||||
? "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>
|
||||||
|
|
@ -1573,7 +1124,6 @@ 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
|
||||||
|
|
@ -1602,11 +1152,6 @@ function ExamSessionContent() {
|
||||||
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);
|
||||||
const [lastAnsweredQuestion, setLastAnsweredQuestion] = useState<number | null>(null);
|
const [lastAnsweredQuestion, setLastAnsweredQuestion] = useState<number | null>(null);
|
||||||
|
|
@ -1656,6 +1201,10 @@ 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 (
|
||||||
|
|
@ -1916,10 +1465,10 @@ function ExamSessionContent() {
|
||||||
{isLastQuestion ? (
|
{isLastQuestion ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => dispatch({ type: 'SHOW_SUBMIT_MODAL', payload: true })}
|
onClick={() => dispatch({ type: 'SHOW_SUBMIT_MODAL', payload: true })}
|
||||||
disabled={state.answers[state.currentQuestion] === undefined}
|
disabled={!canSubmit}
|
||||||
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",
|
||||||
state.answers[state.currentQuestion] !== undefined
|
canSubmit
|
||||||
? "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"
|
||||||
)}
|
)}
|
||||||
|
|
@ -1930,13 +1479,7 @@ function ExamSessionContent() {
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleNextQuestion}
|
onClick={handleNextQuestion}
|
||||||
disabled={state.answers[state.currentQuestion] === undefined}
|
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"
|
||||||
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" />
|
||||||
|
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Eye, EyeOff, Mail, Lock, AlertCircle, CheckCircle } from 'lucide-react';
|
import { Eye, EyeOff, Mail, Lock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -96,15 +95,8 @@ export default function LoginPage() {
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
{/* Logo and Title */}
|
{/* Logo and Title */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="mx-auto mb-4">
|
<div className="w-16 h-16 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
<Image
|
<span className="text-2xl font-bold text-white">LMS</span>
|
||||||
src="https://upload.wikimedia.org/wikipedia/id/thumb/2/29/Logo_Badan_Gizi_Nasional.svg/480px-Logo_Badan_Gizi_Nasional.svg.png"
|
|
||||||
alt="Logo Badan Gizi Nasional"
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
className="rounded-lg"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Selamat Datang</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Selamat Datang</h1>
|
||||||
<p className="text-gray-600">Masuk ke akun Learning Management System Anda</p>
|
<p className="text-gray-600">Masuk ke akun Learning Management System Anda</p>
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,44 @@ export default function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Development Demo Section moved to Sidebar under Add-ons */}
|
{/* Development Demo Section */}
|
||||||
|
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg shadow-sm p-6 border border-indigo-200">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className="text-3xl mr-4">🚀</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
Development Demo - Modular Architecture
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Lihat implementasi EPIC 17: Participant Payroll Reward System menggunakan arsitektur modular baru.
|
||||||
|
Demo ini menampilkan sistem reward, wallet management, dan payroll calculation.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<span className="bg-indigo-100 text-indigo-800 px-2 py-1 rounded text-xs font-medium">
|
||||||
|
Dependency Injection
|
||||||
|
</span>
|
||||||
|
<span className="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs font-medium">
|
||||||
|
Event Bus System
|
||||||
|
</span>
|
||||||
|
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-medium">
|
||||||
|
Plugin Architecture
|
||||||
|
</span>
|
||||||
|
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs font-medium">
|
||||||
|
Service Layer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/payroll-demo"
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
🎯 Lihat Demo Payroll System
|
||||||
|
<svg className="ml-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recent Activities Section */}
|
{/* Recent Activities Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -10,8 +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 as globalContainer } from '../../core/di/DIContainer';
|
import { container } from '../../core/di/DIContainer';
|
||||||
import DashboardLayout from '@/layouts/DashboardLayout';
|
|
||||||
|
|
||||||
export default function PayrollDemoPage() {
|
export default function PayrollDemoPage() {
|
||||||
const [moduleLoaded, setModuleLoaded] = useState(false);
|
const [moduleLoaded, setModuleLoaded] = useState(false);
|
||||||
|
|
@ -34,43 +33,23 @@ export default function PayrollDemoPage() {
|
||||||
// Create module instance
|
// Create module instance
|
||||||
const moduleInstance = new PayrollRewardSystemModule();
|
const moduleInstance = new PayrollRewardSystemModule();
|
||||||
|
|
||||||
// Activate the module first (this initializes the services)
|
// Register services in DI container
|
||||||
await moduleInstance.activate();
|
|
||||||
|
|
||||||
// Get services after activation
|
|
||||||
const services = moduleInstance.getServices();
|
const services = moduleInstance.getServices();
|
||||||
|
|
||||||
// Register services with container using lazy initialization to avoid circular dependency
|
// Activate the module
|
||||||
|
await moduleInstance.activate();
|
||||||
|
|
||||||
|
// Register services with container
|
||||||
for (const serviceConfig of services) {
|
for (const serviceConfig of services) {
|
||||||
try {
|
const serviceInstance = serviceConfig.factory();
|
||||||
console.log(`Registering service: ${serviceConfig.token}`);
|
container.registerInstance(serviceConfig.token, serviceInstance);
|
||||||
|
|
||||||
// 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) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize module';
|
setError(err instanceof Error ? err.message : 'Failed to initialize module');
|
||||||
setError(errorMessage);
|
console.error('Failed to initialize module:', err);
|
||||||
console.error('❌ Failed to initialize module:', err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -88,51 +67,46 @@ export default function PayrollDemoPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
<div className="min-h-[calc(100vh-4rem)] bg-gray-50 flex items-center justify-center">
|
<div className="text-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>
|
||||||
<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">Loading Payroll Reward System...</p>
|
||||||
<p className="text-gray-600">Loading Payroll Reward System...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
<div className="min-h-[calc(100vh-4rem)] bg-gray-50 flex items-center justify-center">
|
<div className="text-center max-w-md">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-6xl mb-4">❌</div>
|
||||||
<div className="text-6xl mb-4">❌</div>
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Module Load Error</h1>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Module Load Error</h1>
|
<p className="text-gray-600 mb-4">{error}</p>
|
||||||
<p className="text-gray-600 mb-4">{error}</p>
|
<button
|
||||||
<button
|
onClick={initializeModule}
|
||||||
onClick={initializeModule}
|
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
>
|
||||||
>
|
Retry
|
||||||
Retry
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<PayrollErrorBoundary>
|
||||||
<PayrollErrorBoundary>
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="min-h-[calc(100vh-4rem)] bg-gray-50">
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white shadow-sm border-b">
|
<div className="bg-white shadow-sm border-b">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center py-4">
|
<div className="flex justify-between items-center py-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
🎯 Payroll Reward System
|
🎯 Payroll Reward System Demo
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Modular Payroll Reward System
|
Modular Architecture Implementation - EPIC 17
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -181,6 +155,38 @@ export default function PayrollDemoPage() {
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Module Info Card */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className="text-2xl mr-3">ℹ️</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-blue-900 mb-1">
|
||||||
|
Modular Architecture Demo
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-800 text-sm mb-2">
|
||||||
|
This demo showcases the implementation of EPIC 17 using our new modular architecture.
|
||||||
|
The system includes reward calculation, wallet management, and payroll processing.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||||
|
✅ Core DI Container
|
||||||
|
</span>
|
||||||
|
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||||
|
✅ Event Bus System
|
||||||
|
</span>
|
||||||
|
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||||
|
✅ Feature Module Plugin
|
||||||
|
</span>
|
||||||
|
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||||
|
✅ Service Layer
|
||||||
|
</span>
|
||||||
|
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||||
|
✅ React Components
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Component Content */}
|
{/* Component Content */}
|
||||||
{moduleLoaded && (
|
{moduleLoaded && (
|
||||||
|
|
@ -208,9 +214,67 @@ export default function PayrollDemoPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Architecture Info */}
|
||||||
|
<div className="mt-8 bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">🏗️ Architecture Overview</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-2">🔧 Core Layer</h4>
|
||||||
|
<ul className="text-sm text-gray-600 space-y-1">
|
||||||
|
<li>• Dependency Injection Container</li>
|
||||||
|
<li>• Event Bus System</li>
|
||||||
|
<li>• Base Interfaces (IService, IRepository)</li>
|
||||||
|
<li>• Plugin Architecture</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-2">🎯 Feature Module</h4>
|
||||||
|
<ul className="text-sm text-gray-600 space-y-1">
|
||||||
|
<li>• RewardService</li>
|
||||||
|
<li>• WalletService</li>
|
||||||
|
<li>• PayrollCalculator</li>
|
||||||
|
<li>• Type Definitions</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-2">🎨 UI Components</h4>
|
||||||
|
<ul className="text-sm text-gray-600 space-y-1">
|
||||||
|
<li>• RewardDashboard</li>
|
||||||
|
<li>• WalletBalance</li>
|
||||||
|
<li>• PayrollManagement</li>
|
||||||
|
<li>• Responsive Design</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-2">📋 Implementation Status</h4>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-green-500 mr-2">✅</span>
|
||||||
|
<span>Core Architecture</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-green-500 mr-2">✅</span>
|
||||||
|
<span>Service Layer</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-green-500 mr-2">✅</span>
|
||||||
|
<span>UI Components</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-yellow-500 mr-2">⏳</span>
|
||||||
|
<span>Data Persistence</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PayrollErrorBoundary>
|
</PayrollErrorBoundary>
|
||||||
</DashboardLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -235,13 +235,9 @@ 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={async () => {
|
onClick={() => {
|
||||||
try {
|
setShowUserMenu(false);
|
||||||
setShowUserMenu(false);
|
logout();
|
||||||
logout();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout button error:', error);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Keluar
|
Keluar
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export interface InteractiveQuestionData {
|
||||||
interactiveConfig?: {
|
interactiveConfig?: {
|
||||||
// Enhanced MCQ
|
// Enhanced MCQ
|
||||||
allowMultipleAnswers?: boolean;
|
allowMultipleAnswers?: boolean;
|
||||||
|
showConfidenceScale?: boolean;
|
||||||
enableHints?: boolean;
|
enableHints?: boolean;
|
||||||
hints?: string[];
|
hints?: string[];
|
||||||
|
|
||||||
|
|
@ -155,6 +156,7 @@ 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,
|
||||||
|
|
@ -573,6 +575,16 @@ 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"
|
||||||
|
|
@ -840,6 +852,19 @@ 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,6 +42,7 @@ 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;
|
||||||
|
|
@ -66,6 +67,7 @@ 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,
|
||||||
|
|
@ -160,6 +162,16 @@ 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,
|
||||||
|
|
@ -192,6 +204,7 @@ 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,
|
||||||
|
|
@ -346,6 +359,34 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,358 +0,0 @@
|
||||||
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,9 +26,6 @@ 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;
|
||||||
|
|
@ -59,7 +56,8 @@ 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) {
|
||||||
|
|
@ -100,9 +98,19 @@ 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 = () => {
|
||||||
|
|
@ -123,12 +131,6 @@ 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} />;
|
||||||
}
|
}
|
||||||
|
|
@ -165,41 +167,6 @@ 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">
|
||||||
|
|
@ -308,6 +275,48 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,409 +0,0 @@
|
||||||
'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,43 +386,17 @@ export default function MiniSurveyReflection({
|
||||||
Sebelumnya
|
Sebelumnya
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="text-center text-sm">
|
<div className="text-center text-sm text-gray-500">
|
||||||
<div className="text-gray-500 mb-1">
|
{Object.keys(answers).length} dari {config.questions.length} pertanyaan dijawab
|
||||||
{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>
|
||||||
|
|
||||||
{isLastQuestion ? (
|
<Button
|
||||||
<Button
|
onClick={handleNext}
|
||||||
onClick={handleNext}
|
disabled={currentQuestion.required && answers[currentQuestion.id] === undefined}
|
||||||
disabled={!isCompleted}
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
className={`${
|
>
|
||||||
isCompleted
|
{isLastQuestion ? 'Selesai' : 'Selanjutnya'}
|
||||||
? 'bg-green-600 hover:bg-green-700'
|
</Button>
|
||||||
: '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 | string;
|
currentPosition: number;
|
||||||
isLocked?: boolean;
|
isLocked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,20 +43,24 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
question,
|
question,
|
||||||
questionIndex,
|
questionIndex,
|
||||||
onAnswerChange,
|
onAnswerChange,
|
||||||
adminLanguage = 'id'
|
adminLanguage
|
||||||
}) => {
|
}) => {
|
||||||
const { state, setPuzzleState } = useExam();
|
const { state, setPuzzleState } = useExam();
|
||||||
const [localPuzzleState, setLocalPuzzleState] = useState<PuzzleState | null>(null);
|
const [puzzleState, setLocalPuzzleState] = useState<PuzzleState>({
|
||||||
|
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 [selectedPieces, setSelectedPieces] = useState<string[]>([]);
|
|
||||||
const [showHint, setShowHint] = useState(false);
|
const [showHint, setShowHint] = useState(false);
|
||||||
const [hintMessage, setHintMessage] = useState('');
|
const [selectedPieces, setSelectedPieces] = useState<string[]>([]);
|
||||||
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 && !localPuzzleState) {
|
if (question.puzzleData) {
|
||||||
const savedState = state.puzzleStates?.[questionIndex];
|
const savedState = state.puzzleStates?.[questionIndex];
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
setLocalPuzzleState(savedState);
|
setLocalPuzzleState(savedState);
|
||||||
|
|
@ -118,18 +122,6 @@ 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 = {
|
||||||
|
|
@ -156,9 +148,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Handle piece movement
|
// Handle piece movement
|
||||||
const movePiece = (fromIndex: number, toIndex: number) => {
|
const movePiece = (fromIndex: number, toIndex: number) => {
|
||||||
if (!localPuzzleState) return;
|
const newPieces = [...puzzleState.pieces];
|
||||||
|
|
||||||
const newPieces = [...localPuzzleState.pieces];
|
|
||||||
const piece = newPieces[fromIndex];
|
const piece = newPieces[fromIndex];
|
||||||
|
|
||||||
if (piece.isLocked) return;
|
if (piece.isLocked) return;
|
||||||
|
|
@ -173,10 +163,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
const isComplete = checkCompletion(newPieces);
|
const isComplete = checkCompletion(newPieces);
|
||||||
|
|
||||||
const newState: PuzzleState = {
|
const newState: PuzzleState = {
|
||||||
...localPuzzleState,
|
...puzzleState,
|
||||||
pieces: newPieces,
|
pieces: newPieces,
|
||||||
isComplete,
|
isComplete,
|
||||||
moves: localPuzzleState.moves + 1
|
moves: puzzleState.moves + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
setLocalPuzzleState(newState);
|
setLocalPuzzleState(newState);
|
||||||
|
|
@ -186,54 +176,32 @@ 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 = localPuzzleState.pieces.find(p => p.id === pieceId);
|
const piece = puzzleState.pieces.find(p => p.id === pieceId);
|
||||||
|
|
||||||
if (!piece || piece.isLocked) return;
|
if (!piece || piece.isLocked) return;
|
||||||
|
|
||||||
let newPieces = [...localPuzzleState.pieces];
|
let newPieces = [...puzzleState.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 = localPuzzleState.pieces.find(p => p.correctPosition === targetPosition);
|
const existingPiece = puzzleState.pieces.find(p => p.correctPosition === targetPosition);
|
||||||
|
|
||||||
if (existingPiece && existingPiece.currentPosition === targetPosition) {
|
if (existingPiece && existingPiece.currentPosition === targetPosition) {
|
||||||
// Swap positions
|
// Swap positions
|
||||||
|
|
@ -251,10 +219,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
const isComplete = checkCompletion(newPieces);
|
const isComplete = checkCompletion(newPieces);
|
||||||
|
|
||||||
const newState: PuzzleState = {
|
const newState: PuzzleState = {
|
||||||
...localPuzzleState,
|
...puzzleState,
|
||||||
pieces: newPieces,
|
pieces: newPieces,
|
||||||
isComplete,
|
isComplete,
|
||||||
moves: localPuzzleState.moves + 1
|
moves: puzzleState.moves + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
setLocalPuzzleState(newState);
|
setLocalPuzzleState(newState);
|
||||||
|
|
@ -266,46 +234,22 @@ 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 = localPuzzleState.pieces.find(p => p.id === pieceId);
|
const piece = puzzleState.pieces.find(p => p.id === pieceId);
|
||||||
|
|
||||||
if (!piece || piece.isLocked) return;
|
if (!piece || piece.isLocked) return;
|
||||||
|
|
||||||
// Get the target area ID from puzzleData
|
const newPieces = [...puzzleState.pieces];
|
||||||
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 = {
|
||||||
...localPuzzleState,
|
...puzzleState,
|
||||||
pieces: newPieces,
|
pieces: newPieces,
|
||||||
isComplete,
|
isComplete,
|
||||||
moves: localPuzzleState.moves + 1
|
moves: puzzleState.moves + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
setLocalPuzzleState(newState);
|
setLocalPuzzleState(newState);
|
||||||
|
|
@ -314,39 +258,11 @@ 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 = localPuzzleState.pieces.findIndex(p => p.id === pieceId);
|
const sourceIndex = puzzleState.pieces.findIndex(p => p.id === pieceId);
|
||||||
|
|
||||||
if (sourceIndex !== -1 && sourceIndex !== targetIndex) {
|
if (sourceIndex !== -1 && sourceIndex !== targetIndex) {
|
||||||
movePiece(sourceIndex, targetIndex);
|
movePiece(sourceIndex, targetIndex);
|
||||||
|
|
@ -357,13 +273,11 @@ 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 = localPuzzleState.pieces.findIndex(p => p.id === selectedPieces[0]);
|
const firstPieceIndex = puzzleState.pieces.findIndex(p => p.id === selectedPieces[0]);
|
||||||
const secondPieceIndex = localPuzzleState.pieces.findIndex(p => p.id === pieceId);
|
const secondPieceIndex = puzzleState.pieces.findIndex(p => p.id === pieceId);
|
||||||
|
|
||||||
if (firstPieceIndex !== secondPieceIndex) {
|
if (firstPieceIndex !== secondPieceIndex) {
|
||||||
movePiece(firstPieceIndex, secondPieceIndex);
|
movePiece(firstPieceIndex, secondPieceIndex);
|
||||||
|
|
@ -380,22 +294,20 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Shuffle puzzle
|
// Shuffle puzzle
|
||||||
const shufflePuzzle = () => {
|
const shufflePuzzle = () => {
|
||||||
if (!localPuzzleState) return;
|
const unlockedPieces = puzzleState.pieces.filter(p => !p.isLocked);
|
||||||
|
|
||||||
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 = [...localPuzzleState.pieces];
|
const newPieces = [...puzzleState.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 = {
|
||||||
...localPuzzleState,
|
...puzzleState,
|
||||||
pieces: newPieces,
|
pieces: newPieces,
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
moves: localPuzzleState.moves + 1
|
moves: puzzleState.moves + 1
|
||||||
};
|
};
|
||||||
|
|
||||||
setLocalPuzzleState(newState);
|
setLocalPuzzleState(newState);
|
||||||
|
|
@ -404,26 +316,23 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Show hint
|
// Show hint
|
||||||
const showPuzzleHint = () => {
|
const showPuzzleHint = () => {
|
||||||
if (!localPuzzleState || localPuzzleState.hints >= 3) return; // Maksimal 3 hint
|
if (puzzleState.hints >= 3) return; // Maksimal 3 hint
|
||||||
|
|
||||||
const incorrectPieces = localPuzzleState.pieces.filter(
|
const incorrectPieces = puzzleState.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 = localPuzzleState.pieces.findIndex(p => p.correctPosition === randomPiece.correctPosition);
|
const correctIndex = puzzleState.pieces.findIndex(p => p.correctPosition === randomPiece.correctPosition);
|
||||||
|
|
||||||
if (correctIndex !== -1) {
|
if (correctIndex !== -1) {
|
||||||
movePiece(localPuzzleState.pieces.findIndex(p => p.id === randomPiece.id), correctIndex);
|
movePiece(puzzleState.pieces.findIndex(p => p.id === randomPiece.id), correctIndex);
|
||||||
|
|
||||||
setLocalPuzzleState(prev => {
|
setLocalPuzzleState(prev => ({
|
||||||
if (!prev) return null;
|
...prev,
|
||||||
return {
|
hints: prev.hints + 1
|
||||||
...prev,
|
}));
|
||||||
hints: prev.hints + 1
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -450,10 +359,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 && localPuzzleState?.isComplete && 'bg-green-100 border-green-500 shadow-md',
|
isCorrect && puzzleState.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 && !localPuzzleState?.isComplete && 'bg-green-50 border-green-400 shadow-sm'
|
!piece.isLocked && isCorrect && !puzzleState.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)',
|
||||||
|
|
@ -495,7 +404,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Correct indicator */}
|
{/* Correct indicator */}
|
||||||
{isCorrect && localPuzzleState?.isComplete && (
|
{isCorrect && puzzleState.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>
|
||||||
|
|
@ -541,29 +450,17 @@ 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' || contentPuzzleType === 'matching') {
|
} else if (puzzleType === 'jigsaw') {
|
||||||
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(localPuzzleState.pieces.length));
|
const gridCols = question.puzzleData?.gridSize?.cols || Math.ceil(Math.sqrt(puzzleState.pieces.length));
|
||||||
const gridRows = question.puzzleData?.gridSize?.rows || Math.ceil(localPuzzleState.pieces.length / gridCols);
|
const gridRows = question.puzzleData?.gridSize?.rows || Math.ceil(puzzleState.pieces.length / gridCols);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -574,20 +471,14 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
gridTemplateRows: `repeat(${gridRows}, 1fr)`
|
gridTemplateRows: `repeat(${gridRows}, 1fr)`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{localPuzzleState.pieces.map((piece, index) => renderPuzzlePiece(piece, index))}
|
{puzzleState.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 = () => {
|
||||||
if (!localPuzzleState) return null;
|
const sortedPieces = [...puzzleState.pieces].sort((a, b) => a.currentPosition - b.currentPosition);
|
||||||
|
|
||||||
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">
|
||||||
|
|
@ -628,9 +519,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(${localPuzzleState.pieces.length}, 1fr)` }}>
|
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${puzzleState.pieces.length}, 1fr)` }}>
|
||||||
{Array.from({ length: localPuzzleState.pieces.length }, (_, index) => {
|
{Array.from({ length: puzzleState.pieces.length }, (_, index) => {
|
||||||
const pieceInPosition = localPuzzleState.pieces.find(p => p.correctPosition === index);
|
const pieceInPosition = puzzleState.pieces.find(p => p.correctPosition === index);
|
||||||
const isCorrectlyPlaced = pieceInPosition && pieceInPosition.currentPosition === index;
|
const isCorrectlyPlaced = pieceInPosition && pieceInPosition.currentPosition === index;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -674,28 +565,12 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
// Render matching puzzle with clear areas
|
// Render matching puzzle with clear areas
|
||||||
const renderMatchingPuzzle = () => {
|
const renderMatchingPuzzle = () => {
|
||||||
if (!localPuzzleState) return null;
|
const items = puzzleState.pieces;
|
||||||
|
const definitions = question.puzzleData?.pieces?.map((_, index) => ({
|
||||||
const items = localPuzzleState.pieces;
|
id: index,
|
||||||
const targetAreas = question.puzzleData?.targetAreas || [];
|
label: `Definisi ${index + 1}`,
|
||||||
|
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">
|
||||||
|
|
@ -733,103 +608,31 @@ 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-6">
|
<div className="space-y-3">
|
||||||
{targetAreas.map((targetArea, index) => {
|
{definitions.map((def, index) => {
|
||||||
console.log('🎯 RENDERING TARGET AREA:', {
|
const matchedPiece = items.find(p => p.correctPosition === index && p.currentPosition === index);
|
||||||
targetArea: targetArea.id,
|
const isCorrect = matchedPiece !== undefined;
|
||||||
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={targetAreaId}
|
key={def.id}
|
||||||
onDragOver={(e) => {
|
onDragOver={handleDragOver}
|
||||||
e.preventDefault();
|
onDrop={(e) => handleDropInMatching(e, index)}
|
||||||
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-20 p-4 border-3 border-dashed rounded-xl transition-all duration-300',
|
'min-h-16 p-3 border-2 border-dashed rounded-lg transition-all duration-200',
|
||||||
'flex items-center justify-between relative cursor-pointer',
|
'flex items-center justify-between relative',
|
||||||
'bg-white shadow-lg hover:shadow-xl',
|
draggedPiece && 'border-green-400 bg-green-100 scale-102',
|
||||||
// Hover state when dragging
|
isCorrect && 'bg-green-100 border-green-400 border-solid',
|
||||||
isHovered && draggedPiece && 'border-green-500 bg-green-100 scale-105 shadow-2xl ring-2 ring-green-300',
|
!isCorrect && !draggedPiece && 'border-gray-300 bg-gray-50',
|
||||||
// Correct match state
|
!isCorrect && draggedPiece && 'hover:border-green-500 hover:bg-green-100'
|
||||||
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">Target {index + 1}:</div>
|
<div className="text-xs text-gray-600 mb-1">Definisi {index + 1}:</div>
|
||||||
<div className="text-sm text-gray-800">
|
<div className="text-sm text-gray-800">
|
||||||
{targetArea.content || targetArea.label || `Target area ${index + 1}`}
|
{/* You can add actual definitions here based on your data structure */}
|
||||||
|
Definisi untuk item {index + 1}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -841,28 +644,10 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
<Check size={16} className="text-green-600" />
|
<Check size={16} className="text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={cn(
|
<div className="p-2 border-2 border-dashed border-gray-400 rounded text-center">
|
||||||
"p-4 border-2 border-dashed rounded-xl text-center transition-all duration-300 min-h-16",
|
|
||||||
"bg-gradient-to-br from-white to-gray-50 shadow-inner",
|
|
||||||
isHovered && draggedPiece ? "border-green-500 bg-gradient-to-br from-green-50 to-green-100 shadow-lg animate-bounce" :
|
|
||||||
selectedPieces.length === 1 ? "border-blue-500 bg-gradient-to-br from-blue-50 to-blue-100 shadow-lg animate-pulse" :
|
|
||||||
"border-gray-500 bg-gradient-to-br from-gray-50 to-gray-100 hover:border-blue-400 hover:from-blue-50 hover:to-blue-100"
|
|
||||||
)}>
|
|
||||||
<div className="text-sm font-bold text-gray-700 mb-1">
|
|
||||||
{isHovered && draggedPiece ? '🎯 LEPAS DI SINI!' :
|
|
||||||
selectedPieces.length === 1 ? '👆 KLIK UNTUK MENEMPATKAN' :
|
|
||||||
'📋 AREA DROP'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{isHovered && draggedPiece ? 'Lepaskan item yang sedang diseret' :
|
{draggedPiece ? 'Lepas di sini' : 'Kosong'}
|
||||||
selectedPieces.length === 1 ? 'Klik untuk menempatkan item terpilih' :
|
|
||||||
'Seret item ke sini atau klik setelah memilih'}
|
|
||||||
</div>
|
</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>
|
||||||
|
|
@ -875,125 +660,9 @@ 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 = () => {
|
||||||
if (!localPuzzleState) return null;
|
const timeElapsed = Math.floor((Date.now() - puzzleState.startTime) / 1000);
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -1007,19 +676,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>{localPuzzleState.moves} {adminLanguage === 'id' ? 'gerakan' : 'moves'}</span>
|
<span>{puzzleState.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>{localPuzzleState.hints}/3 {adminLanguage === 'id' ? 'petunjuk' : 'hints'}</span>
|
<span>{puzzleState.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={localPuzzleState.hints >= 3 || localPuzzleState.isComplete}
|
disabled={puzzleState.hints >= 3 || puzzleState.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'}
|
||||||
|
|
@ -1027,7 +696,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={shufflePuzzle}
|
onClick={shufflePuzzle}
|
||||||
disabled={localPuzzleState.isComplete}
|
disabled={puzzleState.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} />
|
||||||
|
|
@ -1053,19 +722,10 @@ 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 */}
|
||||||
{localPuzzleState.moves === 0 && (
|
{puzzleState.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">
|
||||||
|
|
@ -1109,19 +769,19 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Progress indicator */}
|
{/* Progress indicator */}
|
||||||
{localPuzzleState && localPuzzleState.pieces.length > 0 && (
|
{puzzleState.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">
|
||||||
{localPuzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length} / {localPuzzleState.pieces.length}
|
{puzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length} / {puzzleState.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: `${(localPuzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length / localPuzzleState.pieces.length) * 100}%`
|
width: `${(puzzleState.pieces.filter(p => p.currentPosition === p.correctPosition).length / puzzleState.pieces.length) * 100}%`
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1135,7 +795,7 @@ const PuzzleQuestion: React.FC<PuzzleQuestionProps> = ({
|
||||||
{renderPuzzleGrid()}
|
{renderPuzzleGrid()}
|
||||||
|
|
||||||
{/* Completion message */}
|
{/* Completion message */}
|
||||||
{localPuzzleState?.isComplete && (
|
{puzzleState.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">
|
||||||
|
|
@ -1148,7 +808,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>{localPuzzleState.moves} gerakan</strong> dengan <strong>{localPuzzleState.hints} petunjuk</strong>.
|
Diselesaikan dalam <strong>{puzzleState.moves} gerakan</strong> dengan <strong>{puzzleState.hints} petunjuk</strong>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,14 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
const isIndonesian = adminLanguage === 'id';
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
title: 'Selamat Datang di Simulasi Skenario!',
|
title: isIndonesian ? 'Selamat Datang di Simulasi Skenario!' : 'Welcome to Scenario Simulation!',
|
||||||
content: 'Anda akan menghadapi situasi nyata di dapur dan harus membuat keputusan yang tepat. Setiap pilihan akan mempengaruhi skor dan kondisi dapur Anda.',
|
content: isIndonesian
|
||||||
|
? 'Anda akan menghadapi situasi nyata di dapur dan harus membuat keputusan yang tepat. Setiap pilihan akan mempengaruhi skor dan kondisi dapur Anda.'
|
||||||
|
: 'You will face real kitchen situations and must make the right decisions. Each choice will affect your score and kitchen conditions.',
|
||||||
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">
|
||||||
|
|
@ -39,76 +43,82 @@ const ScenarioIntroGuide: React.FC<ScenarioIntroGuideProps> = ({
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Cara Bermain',
|
title: isIndonesian ? 'Cara Bermain' : 'How to Play',
|
||||||
content: 'Baca situasi dengan cermat, pilih tindakan terbaik dari opsi yang tersedia, lalu klik "Jalankan Tindakan" untuk melihat hasilnya.',
|
content: isIndonesian
|
||||||
|
? 'Baca situasi dengan cermat, pilih tindakan terbaik dari opsi yang tersedia, lalu klik "Jalankan Tindakan" untuk melihat hasilnya.'
|
||||||
|
: 'Read the situation carefully, choose the best action from available options, then click "Execute Action" to see the results.',
|
||||||
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">
|
||||||
📖 1. Baca situasi
|
{isIndonesian ? '📖 1. Baca situasi' : '📖 1. Read situation'}
|
||||||
</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">
|
||||||
🎯 2. Pilih tindakan
|
{isIndonesian ? '🎯 2. Pilih tindakan' : '🎯 2. Choose action'}
|
||||||
</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">
|
||||||
⚡ 3. Jalankan & lihat hasil
|
{isIndonesian ? '⚡ 3. Jalankan & lihat hasil' : '⚡ 3. Execute & see results'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Memahami Indikator',
|
title: isIndonesian ? 'Memahami Indikator' : 'Understanding Indicators',
|
||||||
content: 'Perhatikan skor, waktu, kepuasan pelanggan, dan efisiensi. Indikator ini menunjukkan performa Anda dalam mengelola dapur.',
|
content: isIndonesian
|
||||||
|
? 'Perhatikan skor, waktu, kepuasan pelanggan, dan efisiensi. Indikator ini menunjukkan performa Anda dalam mengelola dapur.'
|
||||||
|
: 'Pay attention to score, time, customer satisfaction, and efficiency. These indicators show your performance in managing the kitchen.',
|
||||||
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">Skor</div>
|
<div className="text-xs text-red-700">{isIndonesian ? 'Skor' : 'Score'}</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">Waktu</div>
|
<div className="text-xs text-blue-700">{isIndonesian ? 'Waktu' : 'Time'}</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">Kepuasan</div>
|
<div className="text-xs text-green-700">{isIndonesian ? 'Kepuasan' : 'Satisfaction'}</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">Efisiensi</div>
|
<div className="text-xs text-yellow-700">{isIndonesian ? 'Efisiensi' : 'Efficiency'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Tips Sukses',
|
title: isIndonesian ? 'Tips Sukses' : 'Success Tips',
|
||||||
content: 'Prioritaskan keamanan pangan, kelola waktu dengan baik, dan pertimbangkan dampak jangka panjang dari setiap keputusan Anda.',
|
content: isIndonesian
|
||||||
|
? 'Prioritaskan keamanan pangan, kelola waktu dengan baik, dan pertimbangkan dampak jangka panjang dari setiap keputusan Anda.'
|
||||||
|
: 'Prioritize food safety, manage time well, and consider the long-term impact of each decision you make.',
|
||||||
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">
|
||||||
Keamanan pangan adalah prioritas utama
|
{isIndonesian ? 'Keamanan pangan adalah prioritas utama' : 'Food safety is top priority'}
|
||||||
</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">
|
||||||
Kelola waktu dengan efisien
|
{isIndonesian ? 'Kelola waktu dengan efisien' : 'Manage time efficiently'}
|
||||||
</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">
|
||||||
Pikirkan dampak jangka panjang
|
{isIndonesian ? 'Pikirkan dampak jangka panjang' : 'Think long-term impact'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -144,7 +154,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">
|
||||||
Langkah {currentStep + 1} dari {steps.length}
|
{isIndonesian ? 'Langkah' : 'Step'} {currentStep + 1} {isIndonesian ? 'dari' : 'of'} {steps.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -187,10 +197,13 @@ 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">
|
||||||
Simulasi Dapur MBG
|
{isIndonesian ? 'Khusus Simulasi Dapur MBG' : 'MBG Kitchen Simulation'}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-orange-800 text-sm">
|
<p className="text-orange-800 text-sm">
|
||||||
Simulasi ini dibuat khusus untuk melatih kemampuan Anda mengelola dapur dengan standar kebersihan dan keamanan makanan yang baik sesuai aturan MBG.
|
{isIndonesian
|
||||||
|
? 'Simulasi ini dirancang khusus untuk menguji kemampuan Anda dalam mengelola dapur sesuai standar keamanan pangan MBG.'
|
||||||
|
: 'This simulation is specifically designed to test your ability to manage a kitchen according to MBG food safety standards.'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -210,8 +223,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'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Sebelumnya
|
{isIndonesian ? 'Sebelumnya' : 'Previous'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{steps.map((_, index) => (
|
{steps.map((_, index) => (
|
||||||
|
|
@ -226,14 +239,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
|
||||||
? 'Mulai Bermain'
|
? (isIndonesian ? 'Mulai Simulasi' : 'Start Simulation')
|
||||||
: 'Lanjut'
|
: (isIndonesian ? 'Selanjutnya' : 'Next')
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
'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,6 +47,10 @@ 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');
|
||||||
|
|
@ -68,10 +72,6 @@ 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, router]);
|
}, [user, isLoading, pathname, isPublicRoute, router]);
|
||||||
|
|
||||||
const login = async (email: string, password: string): Promise<boolean> => {
|
const login = async (email: string, password: string): Promise<boolean> => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
@ -111,28 +111,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
try {
|
setUser(null);
|
||||||
setUser(null);
|
localStorage.removeItem('lms_user');
|
||||||
|
// Also clear cookie
|
||||||
// Safely remove from localStorage
|
document.cookie = 'lms_user=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||||
if (typeof window !== 'undefined' && window.localStorage) {
|
router.push('/login');
|
||||||
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' | 'video_scenario' | 'image_hotspot' | 'media_gallery';
|
currentQuestionType: 'mcq' | 'enhanced_mcq' | 'puzzle' | 'scenario' | 'simulation' | 'randomizer' | 'drag_drop' | 'puzzle_gambar_icon' | 'true_false_cepat' | 'mini_simulation_rpg' | 'mini_survey_reflection';
|
||||||
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,8 +21,6 @@ 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
|
||||||
|
|
@ -62,21 +60,16 @@ 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> {
|
||||||
// Return singleton if already created (prefer fast path)
|
// Check for circular dependencies
|
||||||
if (this.singletons.has(token)) {
|
|
||||||
return this.singletons.get(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a resolution is already in progress for this token, share the promise
|
|
||||||
if (this.pendingResolutions.has(token)) {
|
|
||||||
return await this.pendingResolutions.get(token) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for circular dependencies (true recursion within a single resolution chain)
|
|
||||||
if (this.resolving.has(token)) {
|
if (this.resolving.has(token)) {
|
||||||
throw new Error(`Circular dependency detected: ${token}`);
|
throw new Error(`Circular dependency detected: ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return singleton if already created
|
||||||
|
if (this.singletons.has(token)) {
|
||||||
|
return this.singletons.get(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}`);
|
||||||
|
|
@ -84,40 +77,32 @@ export class DIContainer {
|
||||||
|
|
||||||
this.resolving.add(token);
|
this.resolving.add(token);
|
||||||
|
|
||||||
// Create a shared promise for this resolution so concurrent callers can await it
|
try {
|
||||||
const resolutionPromise = (async () => {
|
// Resolve dependencies first
|
||||||
try {
|
const dependencies: any[] = [];
|
||||||
// Resolve dependencies first
|
if (registration.dependencies) {
|
||||||
const dependencies: any[] = [];
|
for (const dep of registration.dependencies) {
|
||||||
if (registration.dependencies) {
|
dependencies.push(await this.resolve(dep));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
this.pendingResolutions.set(token, resolutionPromise);
|
// Create service instance
|
||||||
|
const instance = await registration.factory();
|
||||||
|
|
||||||
return await resolutionPromise;
|
// Initialize service if it implements IService
|
||||||
|
if (this.isService(instance)) {
|
||||||
|
await instance.initialize?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store singleton
|
||||||
|
if (registration.singleton) {
|
||||||
|
this.singletons.set(token, instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
} finally {
|
||||||
|
this.resolving.delete(token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,8 @@ export const PayrollManagement: React.FC<PayrollManagementProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (payrollCalculator) {
|
loadPayrollData();
|
||||||
loadPayrollData();
|
}, []);
|
||||||
}
|
|
||||||
}, [payrollCalculator]);
|
|
||||||
|
|
||||||
const loadPayrollData = async () => {
|
const loadPayrollData = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -27,21 +27,10 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<'week' | 'month' | 'quarter'>('month');
|
const [selectedPeriod, setSelectedPeriod] = useState<'week' | 'month' | 'quarter'>('month');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rewardService) {
|
loadRewardData();
|
||||||
loadRewardData();
|
}, [userId, selectedPeriod]);
|
||||||
} 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);
|
||||||
|
|
@ -63,6 +52,10 @@ 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
|
||||||
|
|
@ -142,12 +135,12 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||||
<div className="text-center text-error-600">
|
<div className="text-center text-red-600">
|
||||||
<p className="text-lg font-semibold mb-2">Error</p>
|
<p className="text-lg font-semibold mb-2">Error</p>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={loadRewardData}
|
onClick={loadRewardData}
|
||||||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded hover:bg-primary-700"
|
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Coba Lagi
|
Coba Lagi
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -168,7 +161,7 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
|
||||||
onClick={() => setSelectedPeriod(period)}
|
onClick={() => setSelectedPeriod(period)}
|
||||||
className={`px-3 py-1 rounded text-sm font-medium ${
|
className={`px-3 py-1 rounded text-sm font-medium ${
|
||||||
selectedPeriod === period
|
selectedPeriod === period
|
||||||
? 'bg-primary-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -181,30 +174,30 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
<div className="rounded-lg p-4" style={{ backgroundColor: '#071e49', color: '#ffffff' }}>
|
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg p-4 text-white">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-primary-100 text-sm">Total Earned</p>
|
<p className="text-blue-100 text-sm">Total Earned</p>
|
||||||
<p className="text-2xl font-bold">{formatCurrency(stats.totalEarned)}</p>
|
<p className="text-2xl font-bold">{formatCurrency(stats.totalEarned)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl opacity-80">💰</div>
|
<div className="text-3xl opacity-80">💰</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg p-4" style={{ backgroundColor: '#071e49', color: '#ffffff' }}>
|
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-lg p-4 text-white">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-success-100 text-sm">Periode Ini</p>
|
<p className="text-green-100 text-sm">Periode Ini</p>
|
||||||
<p className="text-2xl font-bold">{formatCurrency(stats.thisMonthEarnings)}</p>
|
<p className="text-2xl font-bold">{formatCurrency(stats.thisMonthEarnings)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl opacity-80">📈</div>
|
<div className="text-3xl opacity-80">📈</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg p-4" style={{ backgroundColor: '#071e49', color: '#ffffff' }}>
|
<div className="bg-gradient-to-r from-yellow-500 to-yellow-600 rounded-lg p-4 text-white">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-warning-100 text-sm">Pending</p>
|
<p className="text-yellow-100 text-sm">Pending</p>
|
||||||
<p className="text-2xl font-bold">{formatCurrency(stats.pendingRewards)}</p>
|
<p className="text-2xl font-bold">{formatCurrency(stats.pendingRewards)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl opacity-80">⏳</div>
|
<div className="text-3xl opacity-80">⏳</div>
|
||||||
|
|
@ -292,7 +285,7 @@ export const RewardDashboard: React.FC<RewardDashboardProps> = ({
|
||||||
{/* View All Link */}
|
{/* View All Link */}
|
||||||
{rewards.length > 10 && (
|
{rewards.length > 10 && (
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<button className="text-primary-600 hover:text-primary-800 font-medium">
|
<button className="text-blue-600 hover:text-blue-800 font-medium">
|
||||||
Lihat Semua Reward ({rewards.length})
|
Lihat Semua Reward ({rewards.length})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,8 @@ export const WalletBalance: React.FC<WalletBalanceProps> = ({
|
||||||
const [showAllTransactions, setShowAllTransactions] = useState(false);
|
const [showAllTransactions, setShowAllTransactions] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (walletService) {
|
loadWalletData();
|
||||||
loadWalletData();
|
}, [userId]);
|
||||||
}
|
|
||||||
}, [userId, walletService]);
|
|
||||||
|
|
||||||
const loadWalletData = async () => {
|
const loadWalletData = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ 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 = {
|
||||||
|
|
@ -30,7 +29,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 | undefined;
|
private payrollCalculator: PayrollCalculator;
|
||||||
private services: Map<string, any> = new Map();
|
private services: Map<string, any> = new Map();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -38,8 +37,7 @@ 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 = undefined;
|
this.payrollCalculator = new PayrollCalculator(this.rewardService);
|
||||||
this.isActive = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async activate(): Promise<void> {
|
async activate(): Promise<void> {
|
||||||
|
|
@ -48,17 +46,10 @@ export class PayrollRewardSystemModule implements IPlugin {
|
||||||
// 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;
|
||||||
|
|
@ -109,11 +100,6 @@ 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',
|
||||||
|
|
@ -127,12 +113,7 @@ export class PayrollRewardSystemModule implements IPlugin {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
token: 'payroll-calculator',
|
token: 'payroll-calculator',
|
||||||
factory: () => {
|
factory: () => this.payrollCalculator,
|
||||||
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 rewardServiceFactory: () => IRewardService) {
|
constructor(private rewardService: 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.rewardServiceFactory().getTotalRewardsByPeriod(
|
const rewards = await this.rewardService.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.rewardServiceFactory().getRewardsByUserId(userId, {
|
const userRewardData = await this.rewardService.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.rewardServiceFactory().markRewardAsPaid(rewardId, `payroll_${batchId}`);
|
await this.rewardService.markRewardAsPaid(rewardId, `payroll_${batchId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import Image from 'next/image';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import {
|
import {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
|
|
@ -16,9 +14,7 @@ import {
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
Bars3Icon,
|
Bars3Icon
|
||||||
BanknotesIcon,
|
|
||||||
ShieldCheckIcon
|
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
|
@ -41,14 +37,8 @@ const navigation = [
|
||||||
{ name: 'Pengaturan', href: '/settings', icon: CogIcon },
|
{ name: 'Pengaturan', href: '/settings', icon: CogIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
const addons = [
|
|
||||||
{ name: 'Payroll Reward System', href: '/payroll-demo', icon: BanknotesIcon },
|
|
||||||
{ name: 'Admin Payroll', href: '/admin/payroll', icon: ShieldCheckIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Sidebar({ isOpen, isCollapsed, onClose, onToggleCollapse }: SidebarProps) {
|
export function Sidebar({ isOpen, isCollapsed, onClose, onToggleCollapse }: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -63,16 +53,9 @@ export function Sidebar({ isOpen, isCollapsed, onClose, onToggleCollapse }: Side
|
||||||
<div className="flex h-16 items-center justify-between px-4">
|
<div className="flex h-16 items-center justify-between px-4">
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="h-8 w-8">
|
<div className="flex h-8 w-8 items-center justify-center rounded bg-gold-500">
|
||||||
<Image
|
<AcademicCapIcon className="h-5 w-5 text-white" />
|
||||||
src="https://upload.wikimedia.org/wikipedia/id/thumb/2/29/Logo_Badan_Gizi_Nasional.svg/480px-Logo_Badan_Gizi_Nasional.svg.png"
|
</div>
|
||||||
alt="Logo Badan Gizi Nasional"
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="rounded"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-white">
|
<div className="text-white">
|
||||||
<div className="text-sm font-bold">LMS BGN</div>
|
<div className="text-sm font-bold">LMS BGN</div>
|
||||||
<div className="text-xs text-secondary-300">Badan Gizi Nasional</div>
|
<div className="text-xs text-secondary-300">Badan Gizi Nasional</div>
|
||||||
|
|
@ -125,39 +108,6 @@ export function Sidebar({ isOpen, isCollapsed, onClose, onToggleCollapse }: Side
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Add-ons section */}
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="mt-4 mb-1 px-2 text-xs font-semibold text-primary-300 uppercase tracking-wider">
|
|
||||||
Add-ons
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{addons
|
|
||||||
.filter(item => !item.href.startsWith('/admin') || user?.role === 'admin')
|
|
||||||
.map((item) => {
|
|
||||||
const isActive = pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center rounded-lg px-2 py-2 text-sm font-medium transition-colors',
|
|
||||||
isActive
|
|
||||||
? 'bg-indigo-600 text-white shadow-md'
|
|
||||||
: 'text-sidebar-300 hover:bg-sidebar-800 hover:text-white',
|
|
||||||
isCollapsed ? 'justify-center' : 'justify-start'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className={cn(
|
|
||||||
'flex-shrink-0',
|
|
||||||
isCollapsed ? 'h-6 w-6' : 'mr-3 h-5 w-5'
|
|
||||||
)} />
|
|
||||||
{!isCollapsed && (
|
|
||||||
<span className="truncate">{item.name}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
|
|
@ -185,15 +135,8 @@ export function Sidebar({ isOpen, isCollapsed, onClose, onToggleCollapse }: Side
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<div className="flex h-16 items-center justify-between px-4">
|
<div className="flex h-16 items-center justify-between px-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="h-8 w-8">
|
<div className="flex h-8 w-8 items-center justify-center rounded bg-gradient-to-r from-secondary-400 to-gold-500">
|
||||||
<Image
|
<AcademicCapIcon className="h-5 w-5 text-white" />
|
||||||
src="https://upload.wikimedia.org/wikipedia/id/thumb/2/29/Logo_Badan_Gizi_Nasional.svg/480px-Logo_Badan_Gizi_Nasional.svg.png"
|
|
||||||
alt="Logo Badan Gizi Nasional"
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="rounded"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white">
|
<div className="text-white">
|
||||||
<div className="text-sm font-bold">LMS BGN</div>
|
<div className="text-sm font-bold">LMS BGN</div>
|
||||||
|
|
@ -241,34 +184,6 @@ export function Sidebar({ isOpen, isCollapsed, onClose, onToggleCollapse }: Side
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Add-ons section */}
|
|
||||||
<div className="mt-4 mb-1 px-2 text-xs font-semibold text-primary-300 uppercase tracking-wider">
|
|
||||||
Add-ons
|
|
||||||
</div>
|
|
||||||
{addons
|
|
||||||
.filter(item => !item.href.startsWith('/admin') || user?.role === 'admin')
|
|
||||||
.map((item) => {
|
|
||||||
const isActive = pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
href={item.href}
|
|
||||||
onClick={onClose}
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center justify-between rounded-lg px-2 py-2 text-sm font-medium transition-colors',
|
|
||||||
isActive
|
|
||||||
? 'bg-indigo-600 text-white shadow-md'
|
|
||||||
: 'text-sidebar-300 hover:bg-sidebar-800 hover:text-white'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<item.icon className="mr-3 h-5 w-5 flex-shrink-0" />
|
|
||||||
<span className="truncate">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile user info */}
|
{/* Mobile user info */}
|
||||||
|
|
|
||||||
|
|
@ -185,13 +185,9 @@ 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={async () => {
|
onClick={() => {
|
||||||
try {
|
setShowUserMenu(false);
|
||||||
setShowUserMenu(false);
|
logout();
|
||||||
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' | 'video_scenario' | 'image_hotspot' | 'media_gallery';
|
type: 'mcq' | 'enhanced_mcq' | 'puzzle' | 'scenario' | 'simulation' | 'randomizer' | 'drag_drop' | 'puzzle_gambar_icon' | 'true_false_cepat' | 'mini_simulation_rpg' | 'mini_survey_reflection';
|
||||||
content: QuestionContent;
|
content: QuestionContent;
|
||||||
interactiveElements?: InteractiveElement[];
|
interactiveElements?: InteractiveElement[];
|
||||||
behaviorMetrics?: BehaviorMetric[];
|
behaviorMetrics?: BehaviorMetric[];
|
||||||
|
|
@ -425,21 +425,12 @@ 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;
|
||||||
|
|
@ -498,27 +489,8 @@ 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' | 'learning_objective' | 'assessment_criteria';
|
type: 'drag_drop' | 'matching' | 'sequencing' | 'slider' | 'hotspot' | 'timeline' | 'hint' | 'explanation';
|
||||||
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[];
|
||||||
|
|
@ -530,24 +502,15 @@ 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' | 'slider';
|
type: 'jigsaw' | 'sequence' | 'word_puzzle';
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
pieces?: PuzzlePiece[];
|
pieces?: PuzzlePiece[];
|
||||||
sequence?: string[];
|
sequence?: string[];
|
||||||
words?: string[];
|
words?: string[];
|
||||||
correctOrder?: number[];
|
correctOrder?: number[];
|
||||||
sliders?: SliderConfig[];
|
sliders?: any[];
|
||||||
targetAreas?: any[];
|
targetAreas?: any[];
|
||||||
gridSize?: {
|
gridSize?: {
|
||||||
rows: number;
|
rows: number;
|
||||||
|
|
@ -1086,42 +1049,6 @@ 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