Phase 2 scaffold: mitra online status & pairing logic

Add mitra online/offline status with heartbeat-based auto-offline,
customer-mitra pairing via Valkey pub/sub blast, session management,
and control center dashboard with real-time stats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 23:17:49 +08:00
parent a7a2a32d27
commit d668112edd
44 changed files with 2800 additions and 80 deletions

View File

@@ -1,7 +1,9 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './core/auth/AuthContext'
import LoginPage from './pages/login/LoginPage'
import DashboardPage from './pages/dashboard/DashboardPage'
import MitrasPage from './pages/mitras/MitrasPage'
import SessionsPage from './pages/sessions/SessionsPage'
import UsersPage from './pages/users/UsersPage'
import SettingsPage from './pages/settings/SettingsPage'
import Layout from './components/Layout'
@@ -17,8 +19,10 @@ export default function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Navigate to="/mitras" replace />} />
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="mitras" element={<MitrasPage />} />
<Route path="sessions" element={<SessionsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>

View File

@@ -9,7 +9,9 @@ export default function Layout() {
<nav style={{ width: 220, borderRight: '1px solid #eee', padding: 16 }}>
<h2>Control Center</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li><NavLink to="/dashboard">Dashboard</NavLink></li>
<li><NavLink to="/mitras">Mitra</NavLink></li>
<li><NavLink to="/sessions">Sesi</NavLink></li>
<li><NavLink to="/users">Users</NavLink></li>
<li><NavLink to="/settings">Settings</NavLink></li>
</ul>

View File

@@ -0,0 +1,60 @@
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
const fetchDashboardStats = async () => {
const res = await apiClient.get('/internal/sessions/dashboard/stats')
return res.data.data
}
export default function DashboardPage() {
const { data, isLoading } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
refetchInterval: 10000,
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<h1>Dashboard</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 32 }}>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#2563eb' }}>{data?.active_chats ?? 0}</div>
<div style={{ color: '#666' }}>Chat Aktif</div>
</div>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#16a34a' }}>{data?.online_mitras ?? 0}</div>
<div style={{ color: '#666' }}>Mitra Online</div>
</div>
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#f59e0b' }}>{data?.pending_requests ?? 0}</div>
<div style={{ color: '#666' }}>Request Pending</div>
</div>
</div>
<h2>Customer per Mitra</h2>
{data?.customers_per_mitra?.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Sesi Aktif</th>
</tr>
</thead>
<tbody>
{data.customers_per_mitra.map((m) => (
<tr key={m.id}>
<td style={{ padding: 8 }}>{m.display_name}</td>
<td style={{ padding: 8 }}>{m.active_session_count}</td>
</tr>
))}
</tbody>
</table>
) : (
<p style={{ color: '#666' }}>Tidak ada mitra online.</p>
)}
</div>
)
}

View File

@@ -7,6 +7,11 @@ const fetchMitras = async () => {
return res.data.data
}
const fetchOnlineMitras = async () => {
const res = await apiClient.get('/internal/mitras/online')
return res.data.data
}
const createMitra = async (data) => {
const res = await apiClient.post('/internal/mitras', data)
return res.data.data
@@ -17,12 +22,29 @@ const updateMitraStatus = async ({ id, is_active }) => {
return res.data.data
}
const fetchOnlineLogs = async (mitraId) => {
const res = await apiClient.get(`/internal/mitras/${mitraId}/online-logs`)
return res.data.data
}
export default function MitrasPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['mitras'], queryFn: fetchMitras })
const { data: onlineData } = useQuery({
queryKey: ['mitras-online'],
queryFn: fetchOnlineMitras,
refetchInterval: 10000,
})
const [form, setForm] = useState({ phone: '', display_name: '' })
const [showForm, setShowForm] = useState(false)
const [logsForMitra, setLogsForMitra] = useState(null)
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['mitra-online-logs', logsForMitra],
queryFn: () => fetchOnlineLogs(logsForMitra),
enabled: !!logsForMitra,
})
const createMutation = useMutation({
mutationFn: createMitra,
@@ -40,6 +62,12 @@ export default function MitrasPage() {
if (isLoading) return <div>Loading...</div>
// Build a set of online mitra IDs for quick lookup
const onlineMitraMap = new Map()
for (const m of onlineData ?? []) {
onlineMitraMap.set(m.id, m)
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -69,25 +97,69 @@ export default function MitrasPage() {
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nomor HP</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status Akun</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Online</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Sesi Aktif</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
</tr>
</thead>
<tbody>
{data?.items?.map((mitra) => (
<tr key={mitra.id}>
<td style={{ padding: 8 }}>{mitra.display_name}</td>
<td style={{ padding: 8 }}>{mitra.phone}</td>
<td style={{ padding: 8 }}>{mitra.is_active ? 'Aktif' : 'Nonaktif'}</td>
<td style={{ padding: 8 }}>
<button onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}>
{mitra.is_active ? 'Nonaktifkan' : 'Aktifkan'}
</button>
</td>
</tr>
))}
{data?.items?.map((mitra) => {
const onlineInfo = onlineMitraMap.get(mitra.id)
return (
<tr key={mitra.id}>
<td style={{ padding: 8 }}>{mitra.display_name}</td>
<td style={{ padding: 8 }}>{mitra.phone}</td>
<td style={{ padding: 8 }}>{mitra.is_active ? 'Aktif' : 'Nonaktif'}</td>
<td style={{ padding: 8 }}>
<span style={{ color: onlineInfo ? 'green' : 'grey' }}>
{onlineInfo ? '● Online' : '○ Offline'}
</span>
</td>
<td style={{ padding: 8 }}>{onlineInfo ? onlineInfo.active_session_count : '-'}</td>
<td style={{ padding: 8, display: 'flex', gap: 8 }}>
<button onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}>
{mitra.is_active ? 'Nonaktifkan' : 'Aktifkan'}
</button>
<button onClick={() => setLogsForMitra(logsForMitra === mitra.id ? null : mitra.id)}>
{logsForMitra === mitra.id ? 'Tutup Log' : 'Log Online'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
{logsForMitra && (
<div style={{ marginTop: 16, padding: 16, border: '1px solid #eee' }}>
<h3>Log Online/Offline</h3>
{logsLoading ? (
<p>Loading...</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Waktu</th>
</tr>
</thead>
<tbody>
{logsData?.items?.map((log) => (
<tr key={log.id}>
<td style={{ padding: 8 }}>
<span style={{ color: log.status === 'online' ? 'green' : 'grey' }}>
{log.status === 'online' ? '● Online' : '○ Offline'}
</span>
</td>
<td style={{ padding: 8 }}>{new Date(log.timestamp).toLocaleString('id-ID')}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,131 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
const fetchSessions = async ({ status, page }) => {
const params = new URLSearchParams()
if (status) params.set('status', status)
params.set('page', page)
params.set('limit', '20')
const res = await apiClient.get(`/internal/sessions?${params}`)
return res.data.data
}
const fetchOnlineMitras = async () => {
const res = await apiClient.get('/internal/mitras/online')
return res.data.data
}
const rerouteSession = async ({ sessionId, new_mitra_id }) => {
const res = await apiClient.post(`/internal/sessions/${sessionId}/reroute`, { new_mitra_id })
return res.data.data
}
const STATUS_OPTIONS = [
{ value: '', label: 'Semua' },
{ value: 'active', label: 'Aktif' },
{ value: 'pending_acceptance', label: 'Menunggu Mitra' },
{ value: 'pending_payment', label: 'Menunggu Bayar' },
{ value: 'completed', label: 'Selesai' },
{ value: 'cancelled', label: 'Dibatalkan' },
{ value: 'expired', label: 'Kedaluwarsa' },
]
export default function SessionsPage() {
const queryClient = useQueryClient()
const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1)
const [rerouteTarget, setRerouteTarget] = useState({})
const { data, isLoading } = useQuery({
queryKey: ['sessions', statusFilter, page],
queryFn: () => fetchSessions({ status: statusFilter, page }),
refetchInterval: 10000,
})
const { data: onlineMitras } = useQuery({
queryKey: ['mitras-online-for-reroute'],
queryFn: fetchOnlineMitras,
})
const rerouteMutation = useMutation({
mutationFn: rerouteSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] })
setRerouteTarget({})
},
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<h1>Sesi</h1>
<div style={{ marginBottom: 16, display: 'flex', gap: 8, alignItems: 'center' }}>
<label>Filter: </label>
<select value={statusFilter} onChange={e => { setStatusFilter(e.target.value); setPage(1) }}>
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Customer</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Waktu</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
</tr>
</thead>
<tbody>
{data?.items?.map((session) => (
<tr key={session.id}>
<td style={{ padding: 8 }}>{session.customer_display_name}</td>
<td style={{ padding: 8 }}>{session.mitra_display_name ?? '-'}</td>
<td style={{ padding: 8 }}>{session.status}</td>
<td style={{ padding: 8 }}>{new Date(session.created_at).toLocaleString('id-ID')}</td>
<td style={{ padding: 8 }}>
{['active', 'pending_payment'].includes(session.status) && (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<select
value={rerouteTarget[session.id] ?? ''}
onChange={e => setRerouteTarget(t => ({ ...t, [session.id]: e.target.value }))}
style={{ fontSize: 12 }}
>
<option value="">Reroute ke...</option>
{(onlineMitras ?? [])
.filter(m => m.id !== session.mitra_id)
.map(m => <option key={m.id} value={m.id}>{m.display_name}</option>)}
</select>
<button
disabled={!rerouteTarget[session.id] || rerouteMutation.isPending}
onClick={() => rerouteMutation.mutate({
sessionId: session.id,
new_mitra_id: rerouteTarget[session.id],
})}
style={{ fontSize: 12 }}
>
Reroute
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{data && (
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</button>
<span>Halaman {data.page} dari {Math.ceil(data.total / data.limit) || 1}</span>
<button disabled={page >= Math.ceil(data.total / data.limit)} onClick={() => setPage(p => p + 1)}>Next</button>
</div>
)}
{rerouteMutation.isError && <p style={{ color: 'red', marginTop: 8 }}>Gagal reroute sesi.</p>}
</div>
)
}

View File

@@ -11,16 +11,36 @@ const updateAnonymityConfig = async (anonymity_enabled) => {
return res.data.data
}
const fetchMaxCustomersConfig = async () => {
const res = await apiClient.get('/internal/config/max-customers-per-mitra')
return res.data.data
}
const updateMaxCustomersConfig = async (max_customers_per_mitra) => {
const res = await apiClient.patch('/internal/config/max-customers-per-mitra', { max_customers_per_mitra })
return res.data.data
}
export default function SettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
const mutation = useMutation({
const anonymityMutation = useMutation({
mutationFn: updateAnonymityConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-anonymity'] }),
})
if (isLoading) return <div>Loading...</div>
const { data: maxData, isLoading: maxLoading } = useQuery({
queryKey: ['config-max-customers'],
queryFn: fetchMaxCustomersConfig,
})
const maxMutation = useMutation({
mutationFn: updateMaxCustomersConfig,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }),
})
if (isLoading || maxLoading) return <div>Loading...</div>
return (
<div>
@@ -33,12 +53,32 @@ export default function SettingsPage() {
<input
type="checkbox"
checked={data?.anonymity_enabled ?? true}
onChange={e => mutation.mutate(e.target.checked)}
disabled={mutation.isPending}
onChange={e => anonymityMutation.mutate(e.target.checked)}
disabled={anonymityMutation.isPending}
/>
Izinkan pengguna anonim
</label>
{mutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
{anonymityMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Maks Customer per Mitra</h2>
<p>Jumlah maksimal customer yang bisa ditangani satu Mitra secara bersamaan. Perubahan hanya berlaku untuk chat baru.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="1"
value={maxData?.max_customers_per_mitra ?? 3}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 1) maxMutation.mutate(val)
}}
disabled={maxMutation.isPending}
style={{ width: 80 }}
/>
<span>customer</span>
</div>
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div>
)