Files
qwen3-6-lora/data/raw/sanitized/plans/puedes-conectarte-a-jira-flickering-bumblebee.md
T

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 Sessionscounts.totalInteractions, tone="neutral", sublabel "All time"
  • Acceptedcounts.accepted, tone="success", sublabel "${counts.accepted} of ${counts.offers} offers"
  • Conversion RateformatRate(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

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_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.