# GFIBER-689 — Incentive Verification ## Context Jira ticket [GFIBER-689](https://willowtree.atlassian.net/browse/GFIBER-689) ("Incentive Verification", reported by Josias Jimenez, assigned to Alejandro Lembke, status *In Development*) asks for cosmetic and functional changes to the admin dashboard's verification table: 1. Rename "Upsell Verification" → "Offer Verification". 2. Rename the Status column → "Incentive", with values Yes / No / Pending. 3. Add a "Sales Coach Notes" free-text field. 4. Add a button that opens an "Offers Table" with date filters and the offer details + notes. This ticket lands one day after **PR #100** ("Upsell Verification — 3 validaciones con Status derivado", merged 2026-07-13) shipped the exact feature it's extending: `components/dashboard/AcceptedTicketsVerification.tsx` already derives a `pending`/`accepted`/`rejected` status from three checks (`full_flow_completed`, `initiation_type`, `objection_handled_correctly`) via `utils/upsell/verification.ts`'s `deriveVerificationStatus()`. Docmost's `Estado_Actual_del_Proyecto` and the code agree exactly — verified directly, no drift. Brainstormed with the user to resolve what the ticket text alone couldn't answer, reusing the existing "all agents" performance/attendance tab pattern (`AllTeamsCard.tsx`) as the template for the new "Offers Table" ask, which turned out to really be a per-agent aggregate (not a per-ticket detail view). Final resolved scope, in the user's own words: the existing verification table becomes a "Validations" tab; a new "Information by Agent" tab shows per-agent Pending/Valid/Rejected/Total counts with its own Excel export; both views exclude `admin`/`owner`, showing only `role="user"` agents. **Why this matters beyond the literal ticket text:** the existing feature has hard-won business rules (a `customer_initiated` ticket can never be `accepted`; `full_flow_completed = false` is a non-overridable rejection backstop) that must survive this change untouched — this plan treats `deriveVerificationStatus()` as frozen and only relabels its output, never recomputes it differently. ## Scope decisions (confirmed with the user) | Question | Decision | |---|---| | Incentive Yes/No/Pending vs. existing derived status | Pure UI relabel, 1:1 (Yes=accepted, No=rejected, Pending=pending). No logic change. | | Sales Coach Notes location | Inside `TicketVerificationModal` only — no new table column. | | Sales Coach Notes vs. existing Salesforce-notes/objections_handled | Fully new, independent field. | | "Offers Table" shape | New "Information by Agent" tab inside a new wrapper card, mirroring `AllTeamsCard`'s tab pattern — not a per-ticket detail view. | | Role scope | Both tabs (Validations + Information by Agent) show only `role="user"` agents/tickets — `admin`/`owner` excluded entirely. | | Team-toggle interaction | Information by Agent ignores the Mine/All-teams toggle — always the full role=user roster, same as today's Validations table (rendered identically in both branches of `page.tsx`). | | Export button scope | Dedicated single-sheet export just for the Information-by-Agent table (new sibling route), not folded into the existing 5-sheet "Export report". | Verified directly against code (not just docs) before finalizing this plan: - `StatusBadge` ([index.tsx](packages/design-system/src/components/StatusBadge/index.tsx#L13-L17)) renders `{status}` literally with no label-override capability — a DS change is required for the Yes/No/Pending relabel. - The accepted-tickets query in `page.tsx` ([lines 124-130](app/(protected)/dashboard/page.tsx#L124-L130)) has **no role filter today** — only `.eq("state", "accepted")`. `allAgents` (role="user", pre-team-filter) is already computed at [line 164](app/(protected)/dashboard/page.tsx#L164), before the `acceptedTickets` line ([194](app/(protected)/dashboard/page.tsx#L194)) — the fix reuses that existing set. ## Implementation ### 1. Migration — `sales_coach_notes` New file `supabase/migrations/20260714120000_add_sales_coach_notes_to_upsell_tickets.sql`, following the exact comment style of `20260710090000_add_verification_columns_to_upsell_tickets.sql`: ```sql alter table public.upsell_tickets add column sales_coach_notes text default null; comment on column public.upsell_tickets.sales_coach_notes is 'Free-text coaching notes an admin/owner can attach to a ticket during ' 'verification review. NULL = no notes recorded. Write access restricted to ' 'admin/owner at the application layer (setSalesCoachNotes Server Action).'; ``` No backfill needed — `NULL` is the correct value for every pre-existing row. Apply to DEV (`supabase db push`, project `vshcimexazocttjmyrqy`), then `supabase gen types typescript --linked > utils/supabase/database.types.ts`. Do not push to PROD (`hwxkegbdnbrzvekrqxbd`) as part of this branch. ### 2. `StatusBadge` — optional label override (DS change) [packages/design-system/src/components/StatusBadge/index.tsx](packages/design-system/src/components/StatusBadge/index.tsx): add an optional `label` prop that overrides the displayed text while `status` still drives the color class (fully backward-compatible — no existing caller passes it today). ```ts type TStatusBadgeProps = { status: TStatus, /** Optional display text override — `status` still drives the color class. */ label?: string, className?: string, }; ``` The SCSS applies `text-transform: lowercase` to `.badge`; since "Yes/No/Pending" must render capitalized, add a `styles.customLabel` modifier (no text-transform) applied only when `label` is passed. DS-first updates: `StatusBadge.stories.tsx` (new `CustomLabel` story), `StatusBadge.test.tsx` (label override renders instead of status text; color class still keyed by `status`), and `StatusBadge.test.cy.tsx` if it exists. Apply the same Yes/No/Pending relabel to the modal's header badge too (`TicketVerificationModal` renders its own `StatusBadge` from `ticket.status` — thread a new `statusLabel?: string` prop through so the modal shows "Yes" rather than "accepted", keeping the table and modal consistent for the same ticket). ### 3. Header/subtext + Incentive column rename In [components/dashboard/AcceptedTicketsVerification.tsx](components/dashboard/AcceptedTicketsVerification.tsx): - `aria-label="Upsell Verification"` / `Card title="Upsell Verification"` → `"Offer Verification"`. - Subtitle → `"Review 3 criteria checks for each offer"`. - Column `header: 'Status'` → `header: 'Incentive'`. - Add a local label map next to the existing `PLAN_LABELS` helper: ```ts const INCENTIVE_LABELS: Record = { accepted: 'Yes', rejected: 'No', pending: 'Pending', }; ``` Render ``. Update `__tests__/accepted-tickets-verification.test.tsx`: heading assertion → "Offer Verification"; status-column assertions → "Yes"/"No"/"Pending" instead of raw values. ### 4. `setSalesCoachNotes` Server Action New export in [app/(protected)/dashboard/actions.ts](app/(protected)/dashboard/actions.ts), placed after `setObjectionHandled`, mirroring its exact structure — `requireRole(admin, owner)` gate, `state === "accepted"` guard (kept, since the modal only ever opens from accepted tickets today — no other entry point exists), `revalidatePath("/dashboard")`: ```ts export type SalesCoachNotesResult = { ok: true } | { error: string }; export async function setSalesCoachNotes( ticketId: string, notes: string | null, ): Promise { // identical shape to setObjectionHandled, updating sales_coach_notes } ``` New test `__tests__/dashboard-sales-coach-notes.test.ts`, structured identically to `__tests__/dashboard-initiation-type.test.ts`: permission checks, state guard, null clears notes, `revalidatePath` on success only, DB-error propagation. ### 5. `TicketVerificationModal` — 4th field [packages/design-system/src/components/TicketVerificationModal/index.tsx](packages/design-system/src/components/TicketVerificationModal/index.tsx): add a 4th block using the existing DS `Textarea` component (already exported from `@gfiber/design-system` — no new primitive needed): - New props: `salesCoachNotes: string | null`, `onSetSalesCoachNotes: (value: string | null) => void`, `savingSalesCoachNotes: boolean`. - Local `notesDraft` state seeded from `ticket.salesCoachNotes ?? ''`, save-on-blur (`onSetSalesCoachNotes(notesDraft.trim() === '' ? null : notesDraft)`), disabled while saving. - Include `savingSalesCoachNotes` in the modal's existing "saving" guard (blocks Escape/backdrop-close mid-save, same as the other two checks). DS-first updates: `TicketVerificationModal.test.tsx` (renders textarea with value; blur calls the handler; empty blur → `null`; disabled while saving), `.stories.tsx` (new args + `WithNotes` story), `.test.cy.tsx` (type + blur mount test). Wire up in `AcceptedTicketsVerification.tsx`: own `useTransition` for notes saving, extend the `AcceptedTicket` type with `sales_coach_notes: string | null`, pass the new props through. ### 6. New wrapper — `ValidationsCard` New file `components/dashboard/ValidationsCard.tsx` (client component), mirroring `AllTeamsCard.tsx`'s tab mechanics exactly — local `useState<'validations' | 'information'>('validations')`, `role="tablist"`/`role="tab"`/`role="tabpanel"`, no URL state: - Props: `{ tickets: AcceptedTicket[] }` — same shape already used, no new data-fetching, pure wrapper. - **"Validations" tab**: renders `` — add a `hideHeader?: boolean` prop to `AcceptedTicketsVerification` so its own `Card` title/subtitle don't double up inside the wrapper's own header. - **"Information by Agent" tab** (exact label): a small table (columns: Agent | Pending | Valid | Rejected | Total), built from `buildValidationSummaryByAgent()` (below), plus a dedicated export link (see §8). `page.tsx`: replace both occurrences of `` (the "all" branch and the "mine" branch) with ``. ### 7. Per-agent aggregation — `buildValidationSummaryByAgent` New pure function in `utils/upsell/verification.ts` (co-located with `deriveVerificationStatus`, since it directly consumes it): ```ts export type ValidationSummaryRow = { agentId: string; displayName: string | null; email: string; pending: number; valid: number; rejected: number; total: number; }; export function buildValidationSummaryByAgent( tickets: (VerifiableTicket & { created_by: string | null })[], agents: { id: string; displayName: string | null; email: string }[], ): ValidationSummaryRow[] ``` Groups tickets by `created_by`, tallies `deriveVerificationStatus()` per ticket into pending/valid/rejected, and returns one row per agent in the roster (zero-ticket agents included with all-zero counts, same convention as `buildDashboardMetrics`), sorted by total desc then name. Tickets with `created_by === null` are excluded. New unit test `__tests__/utils/upsell/validation-summary.test.ts`: counts derived via the real `deriveVerificationStatus` (not reimplemented), zero-ticket agents appear, null `created_by` excluded, sort order. ### 8. Role filter fix + dedicated export route **`page.tsx`** (the actual bug this ticket surfaces): hoist a role-scoped id set right after `allAgents` is computed ([~line 164](app/(protected)/dashboard/page.tsx#L164)): ```ts const allAgentIds = new Set(allAgents.map((u) => u.id)); ``` Then filter `acceptedTickets` ([line 194](app/(protected)/dashboard/page.tsx#L194)) against `allAgentIds` (not the team-scoped `agentIds`, since Information-by-Agent must ignore the Mine/All toggle per the confirmed scope decision): ```ts const acceptedTickets = (acceptedTicketsResult.data ?? []) .filter((t) => t.profiles !== null && t.created_by !== null && allAgentIds.has(t.created_by)) as unknown as AcceptedTicket[]; ``` Also add `sales_coach_notes` to the `acceptedTicketsResult` query's `select(...)` list ([line 126](app/(protected)/dashboard/page.tsx#L126)). **New export route** `app/(protected)/dashboard/api/export/validations/route.ts`, following the same pattern as the existing `app/(protected)/dashboard/api/export/route.ts` (Route Handler, `runtime = "nodejs"`, `dynamic = "force-dynamic"`, `requireRole(admin, owner)` gate, `exceljs`) but scoped to a single sheet: - Query params: `start`/`end` (date range only — no `team`, since this view ignores the team toggle). - Fetch `upsell_tickets` (`state = "accepted"`, date-range scoped) + `profiles` (role="user"), reusing `buildValidationSummaryByAgent`. - New shared helper `utils/dashboard/excel.ts` — `addSheetFromRows(workbook, name, columns, rows)` (extracted since no such helper exists yet; used only by this new route, not retrofitted onto the existing 5-sheet export to avoid unrelated regression risk). - Single worksheet "Information by Agent" with columns Agent/Pending/Valid/Rejected/Total, streamed the same way as the existing route (`Content-Disposition: attachment`). - Trigger: a plain `` inside the Information-by-Agent tab panel — same `` + `download` convention as the existing export button, no new button component. New tests: `__tests__/utils/dashboard/excel.test.ts` (bold header row, correct rows for `addSheetFromRows`), `__tests__/dashboard-export-validations-route.test.ts` (new route: correct single-sheet output, admin/owner tickets excluded, auth/role gating mirrors the existing export route's tests). ## Files touched | File | Change | |---|---| | `supabase/migrations/20260714120000_add_sales_coach_notes_to_upsell_tickets.sql` | new column | | `utils/supabase/database.types.ts` | regenerated after DEV push | | `packages/design-system/src/components/StatusBadge/*` | `label` prop + custom-label style + story/tests | | `packages/design-system/src/components/TicketVerificationModal/*` | notes textarea + `statusLabel` prop + story/tests | | `components/dashboard/AcceptedTicketsVerification.tsx` | rename copy, Incentive labels, `hideHeader` prop, notes wiring | | `components/dashboard/ValidationsCard.tsx` | new — tab wrapper | | `utils/upsell/verification.ts` | new `buildValidationSummaryByAgent` | | `utils/dashboard/excel.ts` | new — `addSheetFromRows` helper | | `app/(protected)/dashboard/actions.ts` | new `setSalesCoachNotes` | | `app/(protected)/dashboard/page.tsx` | role-filter fix, `sales_coach_notes` in select, swap in `ValidationsCard` | | `app/(protected)/dashboard/api/export/validations/route.ts` | new — dedicated single-sheet export | ## Tests (TDD — write these first, red before green) - `packages/design-system/.../StatusBadge.test.tsx` (+ stories, + Cypress) — label override. - `packages/design-system/.../TicketVerificationModal.test.tsx` (+ stories, + Cypress) — notes field, `statusLabel`. - `__tests__/dashboard-sales-coach-notes.test.ts` — new Server Action, mirrors `dashboard-initiation-type.test.ts`. - `__tests__/utils/upsell/validation-summary.test.ts` — new aggregation function. - `__tests__/accepted-tickets-verification.test.tsx` — updated copy/label assertions, notes wiring, `hideHeader`. - New component test for `ValidationsCard` (tab switching, literal "Information by Agent" label, aggregate counts, export link `href`). - `__tests__/utils/dashboard/excel.test.ts` — new `addSheetFromRows` helper. - `__tests__/dashboard-export-validations-route.test.ts` — new export route. - `__tests__/upsell-verification-status.test.ts` — unchanged, regression guard that `deriveVerificationStatus` logic/return values are untouched. - Do **not** touch `dashboard-initiation-type.test.ts` / `dashboard-objection-handled.test.ts` — those actions are unmodified. ## Verification (end-to-end) 1. `npm run lint && npm test` (Vitest) and the design-system's Jest + Cypress suites — all green. 2. `supabase db push` against DEV (`vshcimexazocttjmyrqy`), regenerate types, confirm `tsc --noEmit`/`npm run build` clean. 3. `npm run dev`, log in as admin/owner, open `/dashboard`: confirm "Offer Verification" header/subtext, Incentive column shows Yes/No/Pending, kebab modal shows Yes/No badge + notes textarea; type notes, blur, reload the page (not just `router.refresh()`) to confirm persistence survived a real fetch. 4. Switch to "Information by Agent" tab: counts match a manually-tallied ticket set for a known agent; confirm the tab ignores the Mine/All toggle (counts identical regardless of which team tab is selected elsewhere on the page). 5. Confirm role filter: find or seed an `upsell_tickets` row with `created_by` pointing to an `admin`/`owner` profile and `state = 'accepted'` — confirm it appears in **neither** tab (and would have appeared before the `page.tsx` fix, to prove the filter is the actual cause). 6. Click the Information-by-Agent export button, open the downloaded `.xlsx`, confirm one sheet named "Information by Agent" with matching data and the same admin/owner exclusion. 7. Sanity-check no collision with the still-unmerged "Upsell State Override" proposal (separate plan, operates on manual `state` reclassification) — no shared logic, only `actions.ts` and `AcceptedTicketsVerification.tsx` are touched by both, and here only additively. ## Git workflow Branch `feat/gfiber-689-incentive-verification` from `dev` (dashboard features branch from/merge to `dev` per `docs/knowledge/conventions.md`), PR → `dev`.