bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
527 lines
19 KiB
HTML
527 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="sv">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>QUIXZOOM Zoomer Admin</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
}
|
|
.header {
|
|
background: #1a1a2e;
|
|
color: white;
|
|
padding: 1rem 2rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.header h1 { font-size: 1.5rem; }
|
|
.stats-bar {
|
|
display: flex;
|
|
gap: 2rem;
|
|
padding: 1rem 2rem;
|
|
background: white;
|
|
border-bottom: 1px solid #ddd;
|
|
}
|
|
.stat {
|
|
text-align: center;
|
|
}
|
|
.stat-value {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
color: #1a1a2e;
|
|
}
|
|
.stat-label {
|
|
font-size: 0.875rem;
|
|
color: #666;
|
|
}
|
|
.container {
|
|
padding: 2rem;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
.filters {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.filter-btn {
|
|
padding: 0.5rem 1rem;
|
|
border: 1px solid #ddd;
|
|
background: white;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.filter-btn.active {
|
|
background: #1a1a2e;
|
|
color: white;
|
|
}
|
|
.zoomer-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
.zoomer-card {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
}
|
|
.zoomer-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: start;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.zoomer-name {
|
|
font-size: 1.25rem;
|
|
font-weight: bold;
|
|
}
|
|
.zoomer-level {
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 999px;
|
|
font-size: 0.75rem;
|
|
font-weight: bold;
|
|
text-transform: uppercase;
|
|
}
|
|
.level-1 { background: #e3f2fd; color: #1976d2; }
|
|
.level-2 { background: #fff3e0; color: #f57c00; }
|
|
.level-3 { background: #e8f5e9; color: #388e3c; }
|
|
.level-4 { background: #f3e5f5; color: #7b1fa2; }
|
|
.zoomer-meta {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
font-size: 0.875rem;
|
|
color: #666;
|
|
}
|
|
.score-bar {
|
|
margin-bottom: 1rem;
|
|
}
|
|
.score-label {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 0.875rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.score-track {
|
|
height: 8px;
|
|
background: #e0e0e0;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.score-fill {
|
|
height: 100%;
|
|
border-radius: 4px;
|
|
transition: width 0.3s;
|
|
}
|
|
.score-good { background: #4caf50; }
|
|
.score-medium { background: #ff9800; }
|
|
.score-bad { background: #f44336; }
|
|
.actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-top: 1rem;
|
|
}
|
|
.btn {
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
}
|
|
.btn-primary {
|
|
background: #1a1a2e;
|
|
color: white;
|
|
}
|
|
.btn-secondary {
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
}
|
|
.btn-success {
|
|
background: #4caf50;
|
|
color: white;
|
|
}
|
|
.detail-modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.5);
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 100;
|
|
}
|
|
.detail-modal.active {
|
|
display: flex;
|
|
}
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 2rem;
|
|
max-width: 800px;
|
|
width: 90%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.close-btn {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
}
|
|
.detail-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1.5rem;
|
|
}
|
|
.detail-section h3 {
|
|
margin-bottom: 0.5rem;
|
|
font-size: 1rem;
|
|
}
|
|
.detail-section table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.detail-section td {
|
|
padding: 0.5rem;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
.detail-section td:first-child {
|
|
color: #666;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>QUIXZOOM Zoomer Admin</h1>
|
|
<div>v1.1</div>
|
|
</div>
|
|
|
|
<div class="stats-bar">
|
|
<div class="stat">
|
|
<div class="stat-value" id="total-zoomers">0</div>
|
|
<div class="stat-label">Total Zoomers</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-value" id="active-zoomers">0</div>
|
|
<div class="stat-label">Active</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-value" id="trusted-zoomers">0</div>
|
|
<div class="stat-label">Trusted</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-value" id="coordinators">0</div>
|
|
<div class="stat-label">Coordinators</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-value" id="avg-score">0</div>
|
|
<div class="stat-label">Avg Quality</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<div class="filters">
|
|
<button class="filter-btn active" data-filter="all">Alla</button>
|
|
<button class="filter-btn" data-filter="onboarding">Onboarding</button>
|
|
<button class="filter-btn" data-filter="active">Active</button>
|
|
<button class="filter-btn" data-filter="trusted">Trusted</button>
|
|
<button class="filter-btn" data-filter="coordinator">Coordinators</button>
|
|
</div>
|
|
|
|
<div class="zoomer-grid" id="zoomer-grid"></div>
|
|
</div>
|
|
|
|
<div class="detail-modal" id="detail-modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h2 id="modal-name">Zoomer Details</h2>
|
|
<button class="close-btn" onclick="closeModal()">×</button>
|
|
</div>
|
|
<div class="detail-grid" id="detail-grid"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Mock data - replace with API call
|
|
const zoomers = [
|
|
{
|
|
id: 'zoomer_001',
|
|
name: 'Somchai',
|
|
email: 'somchai@example.com',
|
|
phone: '+66-81-234-5678',
|
|
location: 'Bangkok, Sukhumvit',
|
|
level: 4,
|
|
status: 'trusted',
|
|
role: 'regional_coordinator',
|
|
area: 'Sukhumvit',
|
|
joined: '2026-06-15',
|
|
lastActive: '2026-06-28',
|
|
observations: 234,
|
|
missions: 45,
|
|
score: {
|
|
overall: 0.84,
|
|
gps: 0.62,
|
|
image: 0.88,
|
|
corrections: 0.96,
|
|
approval: 0.90,
|
|
confirmations: 0.80,
|
|
response: 0.90,
|
|
completion: 0.90
|
|
}
|
|
},
|
|
{
|
|
id: 'zoomer_002',
|
|
name: 'Siriwan',
|
|
email: 'siriwan@example.com',
|
|
phone: '+66-82-345-6789',
|
|
location: 'Bangkok, Silom',
|
|
level: 3,
|
|
status: 'active',
|
|
role: 'zoomer',
|
|
joined: '2026-06-20',
|
|
lastActive: '2026-06-27',
|
|
observations: 89,
|
|
missions: 12,
|
|
score: {
|
|
overall: 0.72,
|
|
gps: 0.55,
|
|
image: 0.75,
|
|
corrections: 0.85,
|
|
approval: 0.80,
|
|
confirmations: 0.60,
|
|
response: 0.85,
|
|
completion: 0.75
|
|
}
|
|
},
|
|
{
|
|
id: 'zoomer_003',
|
|
name: 'Prasert',
|
|
email: 'prasert@example.com',
|
|
phone: '+66-83-456-7890',
|
|
location: 'Bangkok, Siam',
|
|
level: 2,
|
|
status: 'onboarding',
|
|
role: 'zoomer',
|
|
joined: '2026-06-25',
|
|
lastActive: '2026-06-26',
|
|
observations: 15,
|
|
missions: 1,
|
|
score: {
|
|
overall: 0.45,
|
|
gps: 0.30,
|
|
image: 0.50,
|
|
corrections: 0.70,
|
|
approval: 0.60,
|
|
confirmations: 0.20,
|
|
response: 0.50,
|
|
completion: 0.40
|
|
}
|
|
}
|
|
];
|
|
|
|
function getScoreColor(score) {
|
|
if (score >= 0.8) return 'score-good';
|
|
if (score >= 0.6) return 'score-medium';
|
|
return 'score-bad';
|
|
}
|
|
|
|
function getLevelClass(level) {
|
|
return `level-${level}`;
|
|
}
|
|
|
|
function getLevelName(level) {
|
|
const names = ['New', 'Capture Basics', 'Quality Test', 'Guided', 'Trusted'];
|
|
return names[level] || 'Unknown';
|
|
}
|
|
|
|
function renderZoomerCard(zoomer) {
|
|
return `
|
|
<div class="zoomer-card" data-status="${zoomer.status}" data-level="${zoomer.level}">
|
|
<div class="zoomer-header">
|
|
<div>
|
|
<div class="zoomer-name">${zoomer.name}</div>
|
|
<div class="zoomer-meta">
|
|
<span>${zoomer.location}</span>
|
|
<span>${zoomer.observations} obs</span>
|
|
</div>
|
|
</div>
|
|
<span class="zoomer-level ${getLevelClass(zoomer.level)}">
|
|
${getLevelName(zoomer.level)}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="score-bar">
|
|
<div class="score-label">
|
|
<span>Quality Score</span>
|
|
<span>${(zoomer.score.overall * 100).toFixed(0)}%</span>
|
|
</div>
|
|
<div class="score-track">
|
|
<div class="score-fill ${getScoreColor(zoomer.score.overall)}"
|
|
style="width: ${zoomer.score.overall * 100}%"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="score-bar">
|
|
<div class="score-label">
|
|
<span>GPS Accuracy</span>
|
|
<span>${(zoomer.score.gps * 100).toFixed(0)}%</span>
|
|
</div>
|
|
<div class="score-track">
|
|
<div class="score-fill ${getScoreColor(zoomer.score.gps)}"
|
|
style="width: ${zoomer.score.gps * 100}%"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="score-bar">
|
|
<div class="score-label">
|
|
<span>Image Quality</span>
|
|
<span>${(zoomer.score.image * 100).toFixed(0)}%</span>
|
|
</div>
|
|
<div class="score-track">
|
|
<div class="score-fill ${getScoreColor(zoomer.score.image)}"
|
|
style="width: ${zoomer.score.image * 100}%"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<button class="btn btn-primary" onclick="showDetail('${zoomer.id}')">
|
|
Details
|
|
</button>
|
|
${zoomer.level >= 3 ? `
|
|
<button class="btn btn-success" onclick="promote('${zoomer.id}')">
|
|
${zoomer.level === 4 ? 'Coordinator' : 'Promote'}
|
|
</button>
|
|
` : ''}
|
|
<button class="btn btn-secondary" onclick="message('${zoomer.id}')">
|
|
Message
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderGrid(filter = 'all') {
|
|
const grid = document.getElementById('zoomer-grid');
|
|
const filtered = filter === 'all'
|
|
? zoomers
|
|
: zoomers.filter(z => {
|
|
if (filter === 'coordinator') return z.role === 'regional_coordinator';
|
|
return z.status === filter;
|
|
});
|
|
|
|
grid.innerHTML = filtered.map(renderZoomerCard).join('');
|
|
}
|
|
|
|
function updateStats() {
|
|
document.getElementById('total-zoomers').textContent = zoomers.length;
|
|
document.getElementById('active-zoomers').textContent = zoomers.filter(z => z.status === 'active').length;
|
|
document.getElementById('trusted-zoomers').textContent = zoomers.filter(z => z.status === 'trusted').length;
|
|
document.getElementById('coordinators').textContent = zoomers.filter(z => z.role === 'regional_coordinator').length;
|
|
|
|
const avgScore = zoomers.reduce((sum, z) => sum + z.score.overall, 0) / zoomers.length;
|
|
document.getElementById('avg-score').textContent = (avgScore * 100).toFixed(0) + '%';
|
|
}
|
|
|
|
function showDetail(zoomerId) {
|
|
const zoomer = zoomers.find(z => z.id === zoomerId);
|
|
if (!zoomer) return;
|
|
|
|
document.getElementById('modal-name').textContent = zoomer.name;
|
|
|
|
const detailGrid = document.getElementById('detail-grid');
|
|
detailGrid.innerHTML = `
|
|
<div class="detail-section">
|
|
<h3>Profile</h3>
|
|
<table>
|
|
<tr><td>ID</td><td>${zoomer.id}</td></tr>
|
|
<tr><td>Email</td><td>${zoomer.email}</td></tr>
|
|
<tr><td>Phone</td><td>${zoomer.phone}</td></tr>
|
|
<tr><td>Location</td><td>${zoomer.location}</td></tr>
|
|
<tr><td>Joined</td><td>${zoomer.joined}</td></tr>
|
|
<tr><td>Last Active</td><td>${zoomer.lastActive}</td></tr>
|
|
</table>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h3>Performance</h3>
|
|
<table>
|
|
<tr><td>Observations</td><td>${zoomer.observations}</td></tr>
|
|
<tr><td>Missions</td><td>${zoomer.missions}</td></tr>
|
|
<tr><td>Quality Score</td><td>${(zoomer.score.overall * 100).toFixed(1)}%</td></tr>
|
|
<tr><td>GPS Score</td><td>${(zoomer.score.gps * 100).toFixed(1)}%</td></tr>
|
|
<tr><td>Image Score</td><td>${(zoomer.score.image * 100).toFixed(1)}%</td></tr>
|
|
<tr><td>Approval Rate</td><td>${(zoomer.score.approval * 100).toFixed(1)}%</td></tr>
|
|
</table>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h3>Detailed Scores</h3>
|
|
<table>
|
|
<tr><td>GPS</td><td>${(zoomer.score.gps * 100).toFixed(1)}%</td></tr>
|
|
<tr><td>Image</td><td>${(zoomer.score.image * 100).toFixed(1)}%</td></tr>
|
|
<tr><td>Corrections</td><td>${(zoomer.score.corrections * 100).toFixed(1)}%</td></tr>
|
|
<tr><td>Approval</td><td>${(zoomer.score.approval * 100).toFixed(1)}%</td></tr>
|
|
<tr><td>Confirmations</td><td>${(zoomer.score.confirmations * 100).toFixed(1)}%</td></tr>
|
|
<tr><td>Response</td><td>${(zoomer.score.response * 100).toFixed(1)}%</td></tr>
|
|
<tr><td>Completion</td><td>${(zoomer.score.completion * 100).toFixed(1)}%</td></tr>
|
|
</table>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h3>Actions</h3>
|
|
<div class="actions">
|
|
<button class="btn btn-primary" onclick="editZoomer('${zoomer.id}')">Edit</button>
|
|
<button class="btn btn-secondary" onclick="resetProgress('${zoomer.id}')">Reset Progress</button>
|
|
<button class="btn btn-secondary" style="background:#f44336;color:white" onclick="deactivate('${zoomer.id}')">Deactivate</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('detail-modal').classList.add('active');
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('detail-modal').classList.remove('active');
|
|
}
|
|
|
|
function promote(zoomerId) {
|
|
const zoomer = zoomers.find(z => z.id === zoomerId);
|
|
if (zoomer && zoomer.level === 3) {
|
|
zoomer.level = 4;
|
|
zoomer.status = 'trusted';
|
|
renderGrid();
|
|
updateStats();
|
|
}
|
|
}
|
|
|
|
function message(zoomerId) {
|
|
alert(`Messaging ${zoomerId} - feature coming soon`);
|
|
}
|
|
|
|
// Filter buttons
|
|
document.querySelectorAll('.filter-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
renderGrid(btn.dataset.filter);
|
|
});
|
|
});
|
|
|
|
// Initialize
|
|
renderGrid();
|
|
updateStats();
|
|
</script>
|
|
</body>
|
|
</html>
|