14 KiB
GFIBER-622 — Session History (User Level)
Context
Users currently have no way to review their past upsell evaluations. Every session
is persisted in upsell_tickets (joined via clients for the GAID), but that data
is only visible to admins in the Sales Dashboard. GFIBER-622 adds a personal history
view so each agent can see their own sessions, their outcomes, and navigate into the
full detail of any ticket.
Acceptance Criteria (from Jira):
- Each user can access their previous sessions and corresponding data/outcomes
- The history shows only sessions belonging to the current user (RLS already enforces this)
- UI draft presented to team for validation → Improvements → Approved
Architecture
Routes (new)
app/(protected)/upsell-evaluator/
├── history/
│ ├── page.tsx ← Server Component: listMyTickets() → <SessionHistoryView>
│ └── [id]/
│ └── page.tsx ← Server Component: getTicketById(id) → <TicketDetailView>
Components (new)
app/(protected)/upsell-evaluator/
├── _history/
│ ├── SessionHistoryView.tsx ← 'use client' (SearchField needs state)
│ └── TicketDetailView.tsx ← Server Component (pure render, Link for back)
Files to modify
| File | Change |
|---|---|
app/(protected)/upsell-evaluator/actions.ts |
Add listMyTickets() + getTicketById() |
components/NavBar.tsx |
Add "History" nav link for all authenticated users |
Design System — Components Used (zero DS extensions needed)
| Component | Where | How |
|---|---|---|
PageHeader |
Both pages | History: title + count badge; Detail: title + back <Link> in actions |
DataTable |
SessionHistoryView |
Generic columned table; render fn accepts ReactNode → <Badge> in outcome cell |
Badge |
Outcome column + detail | variant: success→accepted, danger→declined_*, info→no_pitch/no_offer, solid→in_progress |
AgentStatCard |
SessionHistoryView |
3-card grid: Total Sessions · Accepted · Conversion Rate |
SearchField |
SessionHistoryView |
Client-side filter on Case ID or GAID (no server round-trip) |
Card |
TicketDetailView |
3 sections: Identification · Outcome · Call Details |
DataRow |
TicketDetailView |
label/value pairs per field, tone prop for outcome color |
Button variant="ghost" |
Detail "back" | <Link> styled or a DS Button wrapping the link |
No DS component extensions required. DataTable.render already accepts ReactNode.
Outcome badge mapping
// utils/upsell/outcomeDisplay.ts (new small utility, importable from both views)
import type { Database } from '@/utils/supabase/database.types';
type UpsellState = Database['public']['Enums']['upsell_state'];
export const OUTCOME_BADGE: Record<UpsellState, { variant: 'success' | 'danger' | 'info' | 'solid'; label: string }> = {
accepted: { variant: 'success', label: 'Accepted' },
declined_hard: { variant: 'danger', label: 'Declined' },
declined_soft: { variant: 'danger', label: 'Declined (soft)' },
no_pitch: { variant: 'info', label: 'No Pitch' },
no_offer: { variant: 'info', label: 'No Offer' },
in_progress: { variant: 'solid', label: 'In Progress' },
};
Server Actions (add to actions.ts)
listMyTickets()
export type TicketWithGaid = TicketRow & { clients: { gaid: string | null } | null };
export async function listMyTickets(): Promise<GemResult<TicketWithGaid[]>> {
const current = await getCurrentUserWithProfile();
if (!current) return { ok: false, error: 'Not authenticated.' };
const supabase = await createClient();
const { data, error } = await supabase
.from('upsell_tickets')
.select('*, clients(gaid)')
.eq('created_by', current.user.id)
.order('created_at', { ascending: false });
if (error) return { ok: false, error: 'Could not load history.' };
return { ok: true, data: (data ?? []) as TicketWithGaid[] };
}
getTicketById(id)
export async function getTicketById(id: string): Promise<GemResult<TicketWithGaid>> {
const current = await getCurrentUserWithProfile();
if (!current) return { ok: false, error: 'Not authenticated.' };
const supabase = await createClient();
const { data, error } = await supabase
.from('upsell_tickets')
.select('*, clients(gaid)')
.eq('id', id)
.eq('created_by', current.user.id) // defense-in-depth (RLS also filters)
.maybeSingle();
if (error) return { ok: false, error: 'Could not load ticket.' };
if (!data) return { ok: false, error: 'Ticket not found.' };
return { ok: true, data: data as TicketWithGaid };
}
RLS already guarantees created_by = auth.uid() — the explicit .eq is defense-in-depth.
SessionHistoryView.tsx — key details
DataTable columns (7 total — matches the Penpot mockup):
const COLUMNS: TDataColumn[] = [
{ key: 'created_at', header: 'Date', render: (r) => formatDate(r.created_at as string) },
{ key: 'case_id', header: 'Case ID', render: (r) => (r.case_id as string) ?? '—' },
{ key: 'gaid', header: 'GAID', render: (r) => truncateGaid(r.clients?.gaid ?? null) },
{ key: 'state', header: 'Outcome', render: (r) => {
const { variant, label } = OUTCOME_BADGE[r.state as UpsellState];
return <Badge variant={variant}>{label}</Badge>;
}},
{ key: 'plan_sold', header: 'Plan Sold', render: (r) => formatPlan(r.plan_sold as string | null) },
{ key: 'initiation_type', header: 'Type', render: (r) => formatInitiationType(r.initiation_type as string | null) },
{ key: 'id', header: '', align: 'right', render: (r) => (
<Link href={`/upsell-evaluator/history/${r.id}`} className={styles.viewLink}>View →</Link>
)},
];
Display helpers (co-locate in _history/SessionHistoryView.tsx or extract to utils/upsell/outcomeDisplay.ts):
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
// Show first 18 chars + ellipsis so the column doesn't blow out
const truncateGaid = (gaid: string | null) => gaid ? gaid.slice(0, 18) + '…' : '—';
// '2_gig' → '2 Gig', null → '—'
const formatPlan = (plan: string | null) =>
plan ? plan.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) : '—';
// 'agent_initiated' → 'Agent', 'customer_initiated' → 'Customer', null → '—'
const formatInitiationType = (t: string | null) =>
t === 'agent_initiated' ? 'Agent' : t === 'customer_initiated' ? 'Customer' : '—';
// 'internet_wifi' → 'Internet / Wi-Fi' (best-effort title-case with slash)
const formatIssueType = (t: string | null) =>
t ? t.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') : '—';
SearchField — controlled useState, filters data client-side:
const filtered = tickets.filter(t =>
(t.case_id ?? '').includes(query) ||
(t.clients?.gaid ?? '').toLowerCase().includes(query.toLowerCase())
);
AgentStatCard grid (3 cards) — reuse countStates + formatRate from utils/dashboard/metrics.ts:
import { countStates, formatRate } from '@/utils/dashboard/metrics';
// countStates is not exported yet — either export it or inline the same logic:
// offers = accepted + declined_hard + declined_soft
// conversionRate = accepted / offers (0 when offers === 0)
// All definitions match utils/dashboard/metrics.ts exactly to avoid divergence.
const counts = countStates(tickets.map(t => t.state));
// counts.totalInteractions, counts.offers, counts.accepted, counts.conversionRate
Cards:
- Total Sessions →
counts.totalInteractions,tone="neutral", sublabel"All time" - Accepted →
counts.accepted,tone="success", sublabel"${counts.accepted} of ${counts.offers} offers" - Conversion Rate →
formatRate(counts.conversionRate),tone={counts.conversionRate > 0 ? "success" : "neutral"}, sublabel"Accepted / offers made"
Note:
countStatesis currently unexported fromutils/dashboard/metrics.ts. Either export it (export function countStates) as part of this ticket, or inline the identical logic in a co-located helper. Exporting is preferred — it avoids divergence.
Empty state: when tickets.length === 0, render a friendly message inside the DataTable area ("No sessions yet — start an evaluation to see your history here.").
TicketDetailView.tsx — key details
[id]/page.tsx — Server Component
import { notFound } from 'next/navigation';
import { getTicketById } from '../actions';
import { TicketDetailView } from '../_history/TicketDetailView';
export default async function TicketDetailPage({ params }: { params: { id: string } }) {
const result = await getTicketById(params.id);
if (!result.ok) notFound(); // triggers Next.js built-in 404 page
return <TicketDetailView ticket={result.data} />;
}
TicketDetailView.tsx — layout (matches Penpot mockup)
The detail view uses a 2-column grid (not stacked), mirroring the mockup:
PageHeader
title="Session Detail"
badge=<Badge variant={outcome.variant}>{outcome.label}</Badge>
actions=<Link href="/upsell-evaluator/history">← Back to History</Link>
<div className="grid grid-cols-2 gap-5 mt-6">
{/* LEFT column */}
<div className="flex flex-col gap-5">
Card title="Identification"
DataRow label="Date" value={formatDate(ticket.created_at)}
DataRow label="Case ID" value={ticket.case_id ?? '—'}
DataRow label="Order ID" value={ticket.order_id ?? '—'}
DataRow label="GAID" value={ticket.clients?.gaid ?? '—'}
Card title="Outcome"
DataRow label="Status" value=<Badge variant={...}>{label}</Badge>
DataRow label="Plan Sold" value={formatPlan(ticket.plan_sold)}
DataRow label="Recommended" value={formatPlan(ticket.recommended_plan)}
DataRow label="Initiation" value={formatInitiationType(ticket.initiation_type)}
</div>
{/* RIGHT column */}
<div>
Card title="Call Details"
DataRow label="Issue Type" value={formatIssueType(ticket.issue_type)}
DataRow label="Open Tech Ticket" value={ticket.open_tech_ticket ? 'Yes' : 'No'}
DataRow label="Hard Stops" value={ticket.hard_stop_reasons ?? 'None'}
</div>
</div>
Reuse the same display helpers (formatDate, formatPlan, formatInitiationType, formatIssueType) defined in outcomeDisplay.ts — import from there, don't duplicate.
Navigation entry point
Add one link to components/NavBar.tsx (visible to all authenticated users):
<Link href="/upsell-evaluator/history" className={linkClass}>
History
</Link>
Place after the "Upsell Evaluator" link, before "My Metrics".
Tests (TDD — required before PR)
Server Actions
__tests__/upsell/listMyTickets.test.ts- Returns tickets ordered by
created_atDESC for the current user - Returns empty array when user has no tickets
- Returns
{ ok: false }when unauthenticated
- Returns tickets ordered by
__tests__/upsell/getTicketById.test.ts- Returns ticket when found and owned by current user
- Returns
{ ok: false, error: 'Ticket not found.' }when ticket belongs to another user (RLS) - Returns
{ ok: false }when unauthenticated
Components
__tests__/components/SessionHistoryView.test.tsx- Renders correct row count from
ticketsprop - SearchField filters rows by Case ID
- SearchField filters rows by GAID
- Shows empty state when
ticketsis empty - AgentStatCards show correct computed values
- Renders correct row count from
__tests__/components/TicketDetailView.test.tsx- Renders all DataRow fields present in the ticket
- Back link points to
/upsell-evaluator/history Badgevariant matches the ticketstate
Execution — Background Agent
Esta implementación la ejecutará un agente autónomo de background via la skill global background-orchestrator.
Instrucciones de lanzamiento:
lanza un agente para esto → GFIBER-622 Session History
Nombre del agente sugerido: agente-gfiber-622-session-history
Rama: feat/gfiber-622-session-history (desde dev, PR apunta a dev)
Alcance delegado al agente:
- Exportar
countStatesenutils/dashboard/metrics.ts - Crear
utils/upsell/outcomeDisplay.tscon los helpers de display - Añadir
listMyTickets()+getTicketById()enactions.ts - Crear
app/(protected)/upsell-evaluator/history/page.tsx - Crear
app/(protected)/upsell-evaluator/history/[id]/page.tsx(connotFound()) - Crear
app/(protected)/upsell-evaluator/_history/SessionHistoryView.tsx - Crear
app/(protected)/upsell-evaluator/_history/TicketDetailView.tsx - Modificar
components/NavBar.tsx— agregar link "History" - Escribir todos los tests antes de cada implementación (TDD)
- Abrir PR via
aleleba-pral terminar con CI verde
El agente nunca mergea. La aprobación y merge quedan a decisión del usuario.
Verification
- Server Actions:
npm run test— all new tests pass, no regressions (638+ green). - History page (
/upsell-evaluator/history):- Loads with sessions listed for the logged-in user only (test with two accounts)
- Search by Case ID and GAID works
- AgentStatCards show correct counts
- "View →" link navigates to the detail page
- Detail page (
/upsell-evaluator/history/[id]):- All fields render correctly
- "Back to History" link returns to the list
- Attempting to access another user's ticket ID returns a 404/redirect (RLS)
- NavBar: "History" link appears for regular users (not admin-gated).
npm run build— no TypeScript errors.