Mitra §A: pre-home (S3a/S3b/AccountInactive) + design system + Bestie Home

- Port halo_tokens + halo_theme + HaloButton to mitra_app (rose palette,
  Bricolage display, Poppins body, JetBrainsMono).
- Build S3a Input WhatsApp (figma-bestie BestieS3 first half) with
  +62 chip, leading-zero/62 normalization, allow '+' in input.
- Build S3b OTP verification (6-digit, 60s resend timer, attempts hint,
  Focus(canRequestFocus:false) for maestro inputText compat) with full
  error branching (CODE_MISMATCH, OTP_EXPIRED, OTP_USED, ATTEMPTS_EXCEEDED,
  WRONG_FLOW, ACCOUNT_INACTIVE).
- Add AccountInactive terminal screen for is_active=false mitras.
- Typed MitraAuthError with Indonesian-first localized messages +
  retryAfterSeconds passthrough.
- Rebuild home_screen.dart to match figma BestieHome (greeting + status
  card + Ganti Status CTA + Pengingat + 2-tile dark grid).
- Backend: POST /internal/_test/seed-mitra (idempotent) and
  PATCH /internal/mitras/:id (display_name update).
- Control center: inline Edit Nama on mitras row + expandable inline log
  table under clicked mitra (vs old below-table panel).
- 5 maestro flows ts-mitra-A-01/03/04/05/06 covering invalid input, happy
  path, account inactive, phone-format normalization, and the back-to-S3a
  regression. All green.

Plan + memory documented in:
- requirement/phase4-mitra-prehome-plan.md
- requirement/flow_mitra.md / flow_mitra.mermaid.md §A

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 22:01:28 +08:00
parent ad02ee252d
commit 9696eadeaf
37 changed files with 3406 additions and 326 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { Fragment, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
@@ -22,6 +22,11 @@ const updateMitraStatus = async ({ id, is_active }) => {
return res.data.data
}
const updateMitraName = async ({ id, display_name }) => {
const res = await apiClient.patch(`/internal/mitras/${id}`, { display_name })
return res.data.data
}
const fetchOnlineLogs = async (mitraId) => {
const res = await apiClient.get(`/internal/mitras/${mitraId}/online-logs`)
return res.data.data
@@ -39,6 +44,8 @@ export default function MitrasPage() {
const [form, setForm] = useState({ phone: '', display_name: '' })
const [showForm, setShowForm] = useState(false)
const [logsForMitra, setLogsForMitra] = useState(null)
const [editingId, setEditingId] = useState(null)
const [editName, setEditName] = useState('')
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['mitra-online-logs', logsForMitra],
@@ -60,6 +67,29 @@ export default function MitrasPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['mitras'] }),
})
const nameMutation = useMutation({
mutationFn: updateMitraName,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mitras'] })
setEditingId(null)
setEditName('')
},
})
const startEdit = (mitra) => {
setEditingId(mitra.id)
setEditName(mitra.display_name)
}
const cancelEdit = () => {
setEditingId(null)
setEditName('')
}
const saveEdit = (id) => {
const trimmed = editName.trim()
if (!trimmed) return
nameMutation.mutate({ id, display_name: trimmed })
}
if (isLoading) return <div>Loading...</div>
// Build a set of online mitra IDs for quick lookup
@@ -106,9 +136,27 @@ export default function MitrasPage() {
<tbody>
{data?.items?.map((mitra) => {
const onlineInfo = onlineMitraMap.get(mitra.id)
const isEditing = editingId === mitra.id
return (
<tr key={mitra.id}>
<td style={{ padding: 8 }}>{mitra.display_name}</td>
<Fragment key={mitra.id}>
<tr>
<td style={{ padding: 8 }}>
{isEditing ? (
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit(mitra.id)
if (e.key === 'Escape') cancelEdit()
}}
disabled={nameMutation.isPending}
style={{ width: '100%' }}
/>
) : (
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 }}>
@@ -118,48 +166,69 @@ export default function MitrasPage() {
</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>
{isEditing ? (
<>
<button
onClick={() => saveEdit(mitra.id)}
disabled={nameMutation.isPending || !editName.trim() || editName.trim() === mitra.display_name}
>
{nameMutation.isPending ? 'Menyimpan...' : 'Simpan'}
</button>
<button onClick={cancelEdit} disabled={nameMutation.isPending}>Batal</button>
</>
) : (
<>
<button onClick={() => startEdit(mitra)}>Edit Nama</button>
<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>
{logsForMitra === mitra.id && (
<tr>
<td colSpan={6} style={{ padding: 0, background: '#fafafa', borderBottom: '1px solid #eee' }}>
<div style={{ padding: 16 }}>
<h4 style={{ margin: '0 0 12px' }}>Log Online/Offline · {mitra.display_name}</h4>
{logsLoading ? (
<p style={{ margin: 0 }}>Loading...</p>
) : logsData?.items?.length ? (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff' }}>
<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>
) : (
<p style={{ margin: 0, color: '#888' }}>Belum ada log.</p>
)}
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</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>
)
}