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) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 22:20:52 +08:00
parent b9c4841eb1
commit 4c6130aa04
9 changed files with 312 additions and 4 deletions

View File

@@ -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
}