# 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() → │ └── [id]/ │ └── page.tsx ← Server Component: getTicketById(id) → ``` ### 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 `` in `actions` | | `DataTable` | `SessionHistoryView` | Generic columned table; `render` fn accepts `ReactNode` → `` 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" | `` styled or a DS Button wrapping the link | **No DS component extensions required.** `DataTable.render` already accepts `ReactNode`. --- ## Outcome badge mapping ```ts // 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 = { 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()` ```ts export type TicketWithGaid = TicketRow & { clients: { gaid: string | null } | null }; export async function listMyTickets(): Promise> { 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)` ```ts export async function getTicketById(id: string): Promise> { 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): ```ts 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 {label}; }}, { 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) => ( View → )}, ]; ``` **Display helpers** (co-locate in `_history/SessionHistoryView.tsx` or extract to `utils/upsell/outcomeDisplay.ts`): ```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: ```ts 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`:** ```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:** `countStates` is currently unexported from `utils/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 ```tsx 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.tsx` — layout (matches Penpot mockup) The detail view uses a **2-column grid** (not stacked), mirroring the mockup: ``` PageHeader title="Session Detail" badge={outcome.label} actions=← Back to History
{/* LEFT column */}
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={label} 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)}
{/* RIGHT column */}
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'}
``` 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): ```tsx History ``` 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_at` DESC for the current user - Returns empty array when user has no tickets - Returns `{ ok: false }` when unauthenticated - `__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 `tickets` prop - SearchField filters rows by Case ID - SearchField filters rows by GAID - Shows empty state when `tickets` is empty - AgentStatCards show correct computed values - `__tests__/components/TicketDetailView.test.tsx` - Renders all DataRow fields present in the ticket - Back link points to `/upsell-evaluator/history` - `Badge` variant matches the ticket `state` --- ## 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:** 1. Exportar `countStates` en `utils/dashboard/metrics.ts` 2. Crear `utils/upsell/outcomeDisplay.ts` con los helpers de display 3. Añadir `listMyTickets()` + `getTicketById()` en `actions.ts` 4. Crear `app/(protected)/upsell-evaluator/history/page.tsx` 5. Crear `app/(protected)/upsell-evaluator/history/[id]/page.tsx` (con `notFound()`) 6. Crear `app/(protected)/upsell-evaluator/_history/SessionHistoryView.tsx` 7. Crear `app/(protected)/upsell-evaluator/_history/TicketDetailView.tsx` 8. Modificar `components/NavBar.tsx` — agregar link "History" 9. Escribir todos los tests **antes** de cada implementación (TDD) 10. Abrir PR via `aleleba-pr` al terminar con CI verde **El agente nunca mergea.** La aprobación y merge quedan a decisión del usuario. --- ## Verification 1. **Server Actions**: `npm run test` — all new tests pass, no regressions (638+ green). 2. **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 3. **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) 4. **NavBar**: "History" link appears for regular users (not admin-gated). 5. `npm run build` — no TypeScript errors.