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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
60
control_center/src/pages/dashboard/DashboardPage.jsx
Normal file
60
control_center/src/pages/dashboard/DashboardPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
131
control_center/src/pages/sessions/SessionsPage.jsx
Normal file
131
control_center/src/pages/sessions/SessionsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user