From 4c6130aa0438d0b49fe9b1ddac2960e1497fe1e3 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Thu, 9 Apr 2026 22:20:52 +0800 Subject: [PATCH] Phase 3.2 WS2: Mitra request activity log + control center page - DB migration: add active_session_count column + mitra_notified index - Constants: add MISSED to NotificationResponse - Pairing service: record active_session_count on notification creation, use MISSED (not IGNORED) when another mitra accepts first - New mitra-activity.service.js: getMitraActivityLog (paginated), getMitraActivitySummary (per-mitra aggregates with acceptance rate) - New mitra-activity.routes.js: GET /internal/mitra-activity/log, GET /internal/mitra-activity/summary - Control center: new MitraActivityPage with summary table + detail log, filters (mitra, date range), color-coded response types, pagination - Register route in App.jsx, add "Aktivitas Mitra" nav link in Layout Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/app.internal.js | 2 + backend/src/constants.js | 1 + backend/src/db/migrate.js | 12 ++ .../routes/internal/mitra-activity.routes.js | 33 ++++ .../src/services/mitra-activity.service.js | 75 ++++++++ backend/src/services/pairing.service.js | 13 +- control_center/src/App.jsx | 2 + control_center/src/components/Layout.jsx | 1 + .../mitra-activity/MitraActivityPage.jsx | 177 ++++++++++++++++++ 9 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 backend/src/routes/internal/mitra-activity.routes.js create mode 100644 backend/src/services/mitra-activity.service.js create mode 100644 control_center/src/pages/mitra-activity/MitraActivityPage.jsx diff --git a/backend/src/app.internal.js b/backend/src/app.internal.js index 7861b51..9700494 100644 --- a/backend/src/app.internal.js +++ b/backend/src/app.internal.js @@ -6,6 +6,7 @@ import { rolesRoutes } from './routes/internal/roles.routes.js' import { internalAuthRoutes } from './routes/internal/auth.routes.js' import { internalConfigRoutes } from './routes/internal/config.routes.js' import { sessionManagementRoutes } from './routes/internal/session.routes.js' +import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js' import { errorHandler } from './plugins/error-handler.js' export const buildInternalApp = async () => { @@ -20,6 +21,7 @@ export const buildInternalApp = async () => { app.register(rolesRoutes, { prefix: '/internal/roles' }) app.register(internalConfigRoutes, { prefix: '/internal/config' }) app.register(sessionManagementRoutes, { prefix: '/internal/sessions' }) + app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' }) return app } diff --git a/backend/src/constants.js b/backend/src/constants.js index ae06a8c..122c87c 100644 --- a/backend/src/constants.js +++ b/backend/src/constants.js @@ -33,6 +33,7 @@ export const MessageType = Object.freeze({ export const NotificationResponse = Object.freeze({ ACCEPTED: 'accepted', DECLINED: 'declined', + MISSED: 'missed', IGNORED: 'ignored', }) diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 9ecb72c..2847568 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -288,6 +288,18 @@ const migrate = async () => { ON CONFLICT (key) DO NOTHING ` + // --- Phase 3.2: Mitra Request Activity Log --- + + await sql` + ALTER TABLE chat_request_notifications + ADD COLUMN IF NOT EXISTS active_session_count INT NOT NULL DEFAULT 0 + ` + + await sql` + CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_mitra_notified + ON chat_request_notifications (mitra_id, notified_at) + ` + console.log('Migration complete.') await sql.end() } diff --git a/backend/src/routes/internal/mitra-activity.routes.js b/backend/src/routes/internal/mitra-activity.routes.js new file mode 100644 index 0000000..622ae8f --- /dev/null +++ b/backend/src/routes/internal/mitra-activity.routes.js @@ -0,0 +1,33 @@ +import { authenticate, requirePermission } from '../../plugins/auth.js' +import { getCcUserByFirebaseUid } from '../../services/cc-user.service.js' +import { getMitraActivityLog, getMitraActivitySummary } from '../../services/mitra-activity.service.js' + +const attachCcUser = async (request, reply) => { + const user = await getCcUserByFirebaseUid(request.firebaseUser.uid) + if (!user) return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Not a control center user' }, + }) + request.ccUser = user +} + +export const mitraActivityRoutes = async (app) => { + app.get('/log', { + preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')], + }, async (request, reply) => { + const { mitra_id, date_from, date_to, page = 1, limit = 20 } = request.query + const result = await getMitraActivityLog({ + mitra_id, date_from, date_to, + page: Number(page), limit: Number(limit), + }) + return reply.send({ success: true, data: result }) + }) + + app.get('/summary', { + preHandler: [authenticate, attachCcUser, requirePermission('mitra', 'read')], + }, async (request, reply) => { + const { mitra_id, date_from, date_to } = request.query + const result = await getMitraActivitySummary({ mitra_id, date_from, date_to }) + return reply.send({ success: true, data: result }) + }) +} diff --git a/backend/src/services/mitra-activity.service.js b/backend/src/services/mitra-activity.service.js new file mode 100644 index 0000000..0c287c1 --- /dev/null +++ b/backend/src/services/mitra-activity.service.js @@ -0,0 +1,75 @@ +import { getDb } from '../db/client.js' + +const sql = getDb() + +export const getMitraActivityLog = async ({ mitra_id, date_from, date_to, page = 1, limit = 20 } = {}) => { + const offset = (page - 1) * limit + const conditions = [] + + if (mitra_id) conditions.push(sql`crn.mitra_id = ${mitra_id}`) + if (date_from) conditions.push(sql`crn.notified_at >= ${date_from}`) + if (date_to) conditions.push(sql`crn.notified_at <= ${date_to}`) + + const where = conditions.length > 0 + ? sql`WHERE ${conditions.reduce((a, b) => sql`${a} AND ${b}`)}` + : sql`` + + const items = await sql` + SELECT crn.id, crn.session_id, crn.mitra_id, crn.response, + crn.notified_at, crn.responded_at, crn.active_session_count, + m.display_name AS mitra_display_name, + CASE WHEN crn.responded_at IS NOT NULL + THEN EXTRACT(EPOCH FROM (crn.responded_at - crn.notified_at))::int + ELSE NULL + END AS response_time_seconds + FROM chat_request_notifications crn + INNER JOIN mitras m ON m.id = crn.mitra_id + ${where} + ORDER BY crn.notified_at DESC + LIMIT ${limit} OFFSET ${offset} + ` + + const [{ count }] = await sql` + SELECT COUNT(*) FROM chat_request_notifications crn ${where} + ` + + return { items, total: Number(count), page, limit } +} + +export const getMitraActivitySummary = async ({ mitra_id, date_from, date_to } = {}) => { + const conditions = [] + + if (mitra_id) conditions.push(sql`crn.mitra_id = ${mitra_id}`) + if (date_from) conditions.push(sql`crn.notified_at >= ${date_from}`) + if (date_to) conditions.push(sql`crn.notified_at <= ${date_to}`) + + const where = conditions.length > 0 + ? sql`WHERE ${conditions.reduce((a, b) => sql`${a} AND ${b}`)}` + : sql`` + + const summaries = await sql` + SELECT crn.mitra_id, + m.display_name AS mitra_display_name, + COUNT(*)::int AS total_requests, + COUNT(*) FILTER (WHERE crn.response = 'accepted')::int AS accepted_count, + COUNT(*) FILTER (WHERE crn.response = 'declined')::int AS rejected_count, + COUNT(*) FILTER (WHERE crn.response = 'missed')::int AS missed_count, + COUNT(*) FILTER (WHERE crn.response = 'ignored')::int AS ignored_count, + ROUND( + 100.0 * COUNT(*) FILTER (WHERE crn.response = 'accepted') / NULLIF(COUNT(*), 0), 1 + ) AS acceptance_rate, + AVG( + CASE WHEN crn.responded_at IS NOT NULL + THEN EXTRACT(EPOCH FROM (crn.responded_at - crn.notified_at)) + ELSE NULL + END + )::numeric(10,1) AS avg_response_time_seconds + FROM chat_request_notifications crn + INNER JOIN mitras m ON m.id = crn.mitra_id + ${where} + GROUP BY crn.mitra_id, m.display_name + ORDER BY acceptance_rate DESC NULLS LAST + ` + + return summaries +} diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index 4b14a80..6e2efce 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -92,9 +92,14 @@ export const createPairingRequest = async (customerId, { duration_minutes, price // Create notifications for all available mitras for (const mitra of availableMitras) { + const [{ count: activeCount }] = await sql` + SELECT COUNT(*)::int AS count FROM chat_sessions + WHERE mitra_id = ${mitra.id} + AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.PENDING_PAYMENT}) + ` await sql` - INSERT INTO chat_request_notifications (session_id, mitra_id) - VALUES (${session.id}, ${mitra.id}) + INSERT INTO chat_request_notifications (session_id, mitra_id, active_session_count) + VALUES (${session.id}, ${mitra.id}, ${activeCount}) ` // Notify mitra via WebSocket (FCM fallback if offline) await notifyMitra(mitra.id, { @@ -139,10 +144,10 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} ` - // Mark other mitras' notifications as ignored + // Mark other mitras' notifications as missed (another mitra accepted) await sql` UPDATE chat_request_notifications - SET response = ${NotificationResponse.IGNORED}, responded_at = NOW() + SET response = ${NotificationResponse.MISSED}, responded_at = NOW() WHERE session_id = ${sessionId} AND mitra_id != ${mitraId} AND response IS NULL ` diff --git a/control_center/src/App.jsx b/control_center/src/App.jsx index 73c45f3..237cfa8 100644 --- a/control_center/src/App.jsx +++ b/control_center/src/App.jsx @@ -6,6 +6,7 @@ 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 MitraActivityPage from './pages/mitra-activity/MitraActivityPage' import Layout from './components/Layout' const ProtectedRoute = ({ children }) => { @@ -25,6 +26,7 @@ export default function App() { } /> } /> } /> + } /> ) diff --git a/control_center/src/components/Layout.jsx b/control_center/src/components/Layout.jsx index 48b2591..19b033d 100644 --- a/control_center/src/components/Layout.jsx +++ b/control_center/src/components/Layout.jsx @@ -13,6 +13,7 @@ export default function Layout() {
  • Mitra
  • Sesi
  • Users
  • +
  • Aktivitas Mitra
  • Settings
  • diff --git a/control_center/src/pages/mitra-activity/MitraActivityPage.jsx b/control_center/src/pages/mitra-activity/MitraActivityPage.jsx new file mode 100644 index 0000000..2c66aa9 --- /dev/null +++ b/control_center/src/pages/mitra-activity/MitraActivityPage.jsx @@ -0,0 +1,177 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { apiClient } from '../../core/api/api-client' + +const fetchSummary = async ({ mitra_id, date_from, date_to }) => { + const params = new URLSearchParams() + if (mitra_id) params.set('mitra_id', mitra_id) + if (date_from) params.set('date_from', date_from) + if (date_to) params.set('date_to', date_to) + const res = await apiClient.get(`/internal/mitra-activity/summary?${params}`) + return res.data.data +} + +const fetchLog = async ({ mitra_id, date_from, date_to, page, limit }) => { + const params = new URLSearchParams() + if (mitra_id) params.set('mitra_id', mitra_id) + if (date_from) params.set('date_from', date_from) + if (date_to) params.set('date_to', date_to) + params.set('page', String(page)) + params.set('limit', String(limit)) + const res = await apiClient.get(`/internal/mitra-activity/log?${params}`) + return res.data.data +} + +const fetchMitras = async () => { + const res = await apiClient.get('/internal/mitras') + return res.data.data +} + +const responseColor = (response) => { + switch (response) { + case 'accepted': return '#22c55e' + case 'declined': return '#ef4444' + case 'missed': return '#f97316' + case 'ignored': return '#9ca3af' + default: return '#6b7280' + } +} + +const formatDate = (dateStr) => { + if (!dateStr) return '-' + const d = new Date(dateStr) + return `${d.toLocaleDateString('id-ID')} ${d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}` +} + +export default function MitraActivityPage() { + const [mitraFilter, setMitraFilter] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + const [logPage, setLogPage] = useState(1) + const logLimit = 20 + + const filters = { mitra_id: mitraFilter || undefined, date_from: dateFrom || undefined, date_to: dateTo || undefined } + + const { data: mitras } = useQuery({ queryKey: ['mitras-list'], queryFn: fetchMitras }) + + const { data: summary, isLoading: summaryLoading } = useQuery({ + queryKey: ['mitra-activity-summary', filters], + queryFn: () => fetchSummary(filters), + }) + + const { data: logData, isLoading: logLoading } = useQuery({ + queryKey: ['mitra-activity-log', filters, logPage], + queryFn: () => fetchLog({ ...filters, page: logPage, limit: logLimit }), + }) + + return ( +
    +

    Aktivitas Mitra

    + +
    +
    + + +
    +
    + + { setDateFrom(e.target.value); setLogPage(1) }} /> +
    +
    + + { setDateTo(e.target.value); setLogPage(1) }} /> +
    +
    + +
    +

    Ringkasan

    + {summaryLoading ?

    Loading...

    : ( + + + + + + + + + + + + + + + {(summary || []).map(s => ( + + + + + + + + + + + ))} + {(!summary || summary.length === 0) && ( + + )} + +
    MitraTotalAcceptedRejectedMissedIgnoredRate (%)Avg Response (s)
    {s.mitra_display_name}{s.total_requests}{s.accepted_count}{s.rejected_count}{s.missed_count}{s.ignored_count}{s.acceptance_rate ?? '-'}%{s.avg_response_time_seconds ?? '-'}
    Tidak ada data
    + )} +
    + +
    +

    Detail Log

    + {logLoading ?

    Loading...

    : ( + <> + + + + + + + + + + + + + + {(logData?.items || []).map(item => ( + + + + + + + + + + ))} + {(!logData?.items || logData.items.length === 0) && ( + + )} + +
    MitraSessionResponseResponse Time (s)Active SessionsNotified AtResponded At
    {item.mitra_display_name}{item.session_id?.substring(0, 8)}... + + {item.response || '-'} + + {item.response_time_seconds ?? '-'}{item.active_session_count}{formatDate(item.notified_at)}{formatDate(item.responded_at)}
    Tidak ada data
    + {logData && logData.total > logLimit && ( +
    + + Halaman {logData.page} dari {Math.ceil(logData.total / logLimit)} + +
    + )} + + )} +
    +
    + ) +}