17 KiB
GFIBER-689 — Incentive Verification
Context
Jira ticket 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:
- Rename "Upsell Verification" → "Offer Verification".
- Rename the Status column → "Incentive", with values Yes / No / Pending.
- Add a "Sales Coach Notes" free-text field.
- 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) 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) has no role filter today — only.eq("state", "accepted").allAgents(role="user", pre-team-filter) is already computed at line 164, before theacceptedTicketsline (194) — 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:
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: 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).
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:
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_LABELShelper:Renderconst INCENTIVE_LABELS: Record<VerificationStatus, string> = { accepted: 'Yes', rejected: 'No', pending: 'Pending', };<StatusBadge status={derived} label={INCENTIVE_LABELS[derived]} />.
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, 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"):
export type SalesCoachNotesResult = { ok: true } | { error: string };
export async function setSalesCoachNotes(
ticketId: string,
notes: string | null,
): Promise<SalesCoachNotesResult> {
// 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: 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
notesDraftstate seeded fromticket.salesCoachNotes ?? '', save-on-blur (onSetSalesCoachNotes(notesDraft.trim() === '' ? null : notesDraft)), disabled while saving. - Include
savingSalesCoachNotesin 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
<AcceptedTicketsVerification tickets={tickets} hideHeader />— add ahideHeader?: booleanprop toAcceptedTicketsVerificationso its ownCardtitle/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 <AcceptedTicketsVerification tickets={acceptedTickets} /> (the "all" branch and the "mine" branch) with <ValidationsCard tickets={acceptedTickets} />.
7. Per-agent aggregation — buildValidationSummaryByAgent
New pure function in utils/upsell/verification.ts (co-located with deriveVerificationStatus, since it directly consumes it):
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):
const allAgentIds = new Set(allAgents.map((u) => u.id));
Then filter acceptedTickets (line 194) against allAgentIds (not the team-scoped agentIds, since Information-by-Agent must ignore the Mine/All toggle per the confirmed scope decision):
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).
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 — noteam, since this view ignores the team toggle). - Fetch
upsell_tickets(state = "accepted", date-range scoped) +profiles(role="user"), reusingbuildValidationSummaryByAgent. - 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
<a href="/dashboard/api/export/validations?start=...&end=..." download>inside the Information-by-Agent tab panel — same<a>+downloadconvention 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, mirrorsdashboard-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 linkhref). __tests__/utils/dashboard/excel.test.ts— newaddSheetFromRowshelper.__tests__/dashboard-export-validations-route.test.ts— new export route.__tests__/upsell-verification-status.test.ts— unchanged, regression guard thatderiveVerificationStatuslogic/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)
npm run lint && npm test(Vitest) and the design-system's Jest + Cypress suites — all green.supabase db pushagainst DEV (vshcimexazocttjmyrqy), regenerate types, confirmtsc --noEmit/npm run buildclean.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 justrouter.refresh()) to confirm persistence survived a real fetch.- 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).
- Confirm role filter: find or seed an
upsell_ticketsrow withcreated_bypointing to anadmin/ownerprofile andstate = 'accepted'— confirm it appears in neither tab (and would have appeared before thepage.tsxfix, to prove the filter is the actual cause). - 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. - Sanity-check no collision with the still-unmerged "Upsell State Override" proposal (separate plan, operates on manual
statereclassification) — no shared logic, onlyactions.tsandAcceptedTicketsVerification.tsxare 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.