Rende usabili i filtri destinatari e permette di eliminare le bozze email.
CI / scan_ruby (push) Failing after 12m54s
CI / scan_js (push) Successful in 12m34s
CI / lint (push) Failing after 13m10s

Le listbox non applicavano sport e altri criteri; ora i filtri sono checkbox, c'è «mai contattati» e dal cruscotto si cancellano le bozze. La lista organizzazioni allinea le colonne al foglio campagna.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-07 07:57:50 +02:00
co-authored by Cursor
parent e492c43bf8
commit 3e73e708e4
16 changed files with 1114 additions and 90 deletions
+15 -7
View File
@@ -127,10 +127,12 @@ class MailingsController < ApplicationController
@mailing.rebuild_recipients!
redirect_to @mailing, notice: "Lista generata: #{@mailing.pending_count} da inviare, #{@mailing.skipped_count} esclusi."
else
@page_title = "Destinatari · #{@mailing.name}"
load_audience_options
assign_audience_preview
render :audience
open_sections = Array(params[:open_sections]).map(&:to_s).compact_blank
if open_sections.any?
redirect_to audience_mailing_path(@mailing, open_sections: open_sections)
else
redirect_to audience_mailing_path(@mailing)
end
end
end
@@ -285,9 +287,15 @@ class MailingsController < ApplicationController
end
def audience_filter_params
params.fetch(:filters, ActionController::Parameters.new).permit(
:preset, :list_min, :list_max, :estimated_value, :history_kind, :history_mailing_id, :history_days,
:exclude_customers, :exclude_mailed_within_days,
raw = params.fetch(:filters, ActionController::Parameters.new)
copied = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
Mailings::AudienceFilters::ARRAY_KEYS.each do |key|
copied[key] = Array(copied[key]) if copied.key?(key)
end
ActionController::Parameters.new(copied).permit(
:preset, :list_min, :list_max, :estimated_value, :history_kind, :history_days,
:history_mailing_id, :never_contacted, :exclude_customers, :exclude_mailed_within_days,
sports: [], team_genders: [], regions: [], provinces: [], streaming_statuses: [], statuses: [],
send_statuses: [], ab_variants: [], pipeline_stages: [],
exclude_received_mailing_ids: [], exclude_opened_mailing_ids: []
+8
View File
@@ -232,6 +232,14 @@ module ApplicationHelper
value ? "" : "No"
end
def table_clip(text, length: 36)
return "" if text.blank?
value = text.to_s.strip
label = value.length > length ? "#{value[0, length]}" : value
tag.span label, title: value, class: "inline-block max-w-[14rem] truncate align-bottom"
end
def external_link(url, **options)
return "" if url.blank?
@@ -3,17 +3,27 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["intent"]
disconnect() {
clearTimeout(this.refreshTimer)
}
refresh() {
this.persistOpenSections()
this.setIntent("preview")
this.element.requestSubmit()
clearTimeout(this.refreshTimer)
this.refreshTimer = setTimeout(() => this.submitPreview(), 250)
}
apply() {
clearTimeout(this.refreshTimer)
this.persistOpenSections()
this.setIntent("apply")
}
submitPreview() {
this.persistOpenSections()
this.setIntent("preview")
this.element.requestSubmit()
}
persistOpenSections() {
this.element.querySelectorAll("input[data-open-section]").forEach((input) => input.remove())
this.element.querySelectorAll("details[data-section]").forEach((details) => {
+3 -2
View File
@@ -91,7 +91,7 @@ module Mailings
end
def history_active?
history_kind.present?
history_kind.present? && history_kind != "never"
end
def organization_extra_active?
@@ -115,7 +115,7 @@ module Mailings
def history_summary
case history_kind
when "never" then "mai ricevuta una campagna"
when "never" then "mai contattati via email"
when "received" then "già ricevuta campagna ##{history_mailing_id}"
when "not_received" then "non ricevuta campagna ##{history_mailing_id}"
when "opened" then "aperta campagna ##{history_mailing_id}"
@@ -137,6 +137,7 @@ module Mailings
data["list_max"] = integer_or_nil(source["list_max"])
data["estimated_value"] = source["estimated_value"].presence_in(VALUE_MODES) || "any"
data["history_kind"] = source["history_kind"].presence_in(HISTORY_KINDS)
data["history_kind"] = "never" if ActiveModel::Type::Boolean.new.cast(source["never_contacted"])
data["history_mailing_id"] = integer_or_nil(source["history_mailing_id"])
data["history_days"] = integer_or_nil(source["history_days"])
data["exclude_customers"] = ActiveModel::Type::Boolean.new.cast(source["exclude_customers"]) || false
+5 -1
View File
@@ -84,7 +84,7 @@ module Mailings
def apply_history_filters(scope)
case @filters.history_kind
when "never"
scope.where.not(id: sent_org_ids)
scope.where.not(id: sent_org_ids).where.not(id: emailed_activity_org_ids)
when "received"
scope.where(id: sent_org_ids(mailing_id: @filters.history_mailing_id))
when "not_received"
@@ -127,6 +127,10 @@ module Mailings
Opportunity.where(project_id: @project.id)
end
def emailed_activity_org_ids
Activity.where(activity_type: "email_sent", organization_id: Organization.for_project(@project).select(:id)).select(:organization_id)
end
def sent_org_ids(mailing_id: nil, since: nil)
recips = history_recipients.where(status: "sent")
recips = recips.where(mailing_id: mailing_id) if mailing_id.present?
@@ -0,0 +1,15 @@
<%# locals: (name:, choices:, selected:, scroll: false) %>
<% selected_values = Array(selected).map(&:to_s) %>
<% box_class = scroll ? "max-h-40 space-y-0.5 overflow-y-auto rounded-lg border border-zinc-200 p-2 dark:border-zinc-700" : "space-y-0.5" %>
<div class="<%= box_class %>">
<%= hidden_field_tag "#{name}[]", "" %>
<% Array(choices).each do |label, value| %>
<label class="flex min-h-9 cursor-pointer items-center gap-2 rounded-md px-1 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800">
<%= check_box_tag "#{name}[]", value, selected_values.include?(value.to_s), class: "size-4 shrink-0" %>
<span><%= label %></span>
</label>
<% end %>
<% if Array(choices).empty? %>
<p class="px-1 text-xs text-zinc-500">Nessun valore in anagrafica.</p>
<% end %>
</div>
@@ -0,0 +1,4 @@
<%# locals: (mailing:, label: "Elimina bozza", extra_class: nil, form_class: nil) %>
<%= button_to label, mailing_path(mailing), method: :delete,
class: [btn_danger, extra_class].compact.join(" "),
form: { class: form_class, data: { turbo_confirm: "Eliminare la bozza «#{mailing.name}»? Non si può annullare." } } %>
+36 -41
View File
@@ -6,7 +6,6 @@
"test_b" => "Solo chi ha già Test B in scheda."
}
extra_parts = @filters.summary_parts.drop(1)
compact_select = "#{input_class} h-28 py-1"
org_open = @filters.organization_extra_active? || @open_sections.include?("organization")
campaign_open = @filters.campaign_extra_active? || @open_sections.include?("campaign")
history_open = @filters.history_active? || @open_sections.include?("history")
@@ -30,7 +29,7 @@
<section class="<%= card_class %> space-y-4 p-4 md:p-6">
<div>
<h2 class="text-base font-semibold">Chi deve ricevere questa email?</h2>
<p class="mt-1 text-sm text-zinc-500">Tre cose bastano: lista di partenza, sport, regione. Il resto è opzionale.</p>
<p class="mt-1 text-sm text-zinc-500">Lista di partenza, sport, regione e chi non ha mai ricevuto una email. Il resto è opzionale.</p>
</div>
<div>
@@ -39,19 +38,25 @@
<p class="mt-1 text-xs text-zinc-500"><%= preset_hints[@filters.preset] %></p>
</div>
<label class="flex min-h-11 cursor-pointer items-start gap-3 text-sm">
<%= hidden_field_tag "filters[never_contacted]", "0" %>
<%= check_box_tag "filters[never_contacted]", "1", @filters.history_kind == "never", class: "mt-1 size-5 shrink-0" %>
<span>
<span class="font-medium">Solo mai contattati</span>
<span class="mt-0.5 block text-xs text-zinc-500">Nessuna email di campagna inviata dal CRM, né in questa né in altre liste.</span>
</span>
</label>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium">Sport</label>
<%= hidden_field_tag "filters[sports][]", "" %>
<%= select_tag "filters[sports][]", options_for_select(@sport_options, @filters["sports"]),
multiple: true, size: 5, class: compact_select %>
<p class="mt-1 text-xs text-zinc-500">Vuoto = tutti gli sport. Tieni premuto Ctrl o Cmd per più valori.</p>
<%= render "mailings/audience_choices", name: "filters[sports]",
choices: @sport_options.map { |sport| [sport, sport] }, selected: @filters["sports"] %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Regione</label>
<%= hidden_field_tag "filters[regions][]", "" %>
<%= select_tag "filters[regions][]", options_for_select(@region_options, @filters["regions"]),
multiple: true, size: 5, class: compact_select %>
<%= render "mailings/audience_choices", name: "filters[regions]",
choices: @region_options.map { |region| [region, region] }, selected: @filters["regions"], scroll: true %>
</div>
</div>
@@ -74,27 +79,23 @@
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium">M/F</label>
<%= hidden_field_tag "filters[team_genders][]", "" %>
<%= select_tag "filters[team_genders][]", options_for_select(Catalog::TEAM_GENDERS.map { |k, v| [v, k] }, @filters["team_genders"]),
multiple: true, size: 3, class: compact_select %>
<%= render "mailings/audience_choices", name: "filters[team_genders]",
choices: Catalog::TEAM_GENDERS.map { |k, v| [v, k] }, selected: @filters["team_genders"] %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Provincia</label>
<%= hidden_field_tag "filters[provinces][]", "" %>
<%= select_tag "filters[provinces][]", options_for_select(@province_options, @filters["provinces"]),
multiple: true, size: 4, class: compact_select %>
<%= render "mailings/audience_choices", name: "filters[provinces]",
choices: @province_options.map { |province| [province, province] }, selected: @filters["provinces"], scroll: true %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Streaming</label>
<%= hidden_field_tag "filters[streaming_statuses][]", "" %>
<%= select_tag "filters[streaming_statuses][]", options_for_select(Catalog::STREAMING_STATUSES.map { |k, v| [v, k] }, @filters["streaming_statuses"]),
multiple: true, size: 4, class: compact_select %>
<%= render "mailings/audience_choices", name: "filters[streaming_statuses]",
choices: Catalog::STREAMING_STATUSES.map { |k, v| [v, k] }, selected: @filters["streaming_statuses"] %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Stato organizzazione</label>
<%= hidden_field_tag "filters[statuses][]", "" %>
<%= select_tag "filters[statuses][]", options_for_select(Catalog::ORGANIZATION_STATUSES.map { |k, v| [v, k] }, @filters["statuses"]),
multiple: true, size: 4, class: compact_select %>
<%= render "mailings/audience_choices", name: "filters[statuses]",
choices: Catalog::ORGANIZATION_STATUSES.map { |k, v| [v, k] }, selected: @filters["statuses"] %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">N. lista da</label>
@@ -117,22 +118,19 @@
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium">Stato invio campagna</label>
<%= hidden_field_tag "filters[send_statuses][]", "" %>
<%= select_tag "filters[send_statuses][]", options_for_select(Catalog::SEND_STATUSES.map { |k, v| [v, k] }, @filters["send_statuses"]),
multiple: true, size: 3, class: compact_select %>
<%= render "mailings/audience_choices", name: "filters[send_statuses]",
choices: Catalog::SEND_STATUSES.map { |k, v| [v, k] }, selected: @filters["send_statuses"] %>
<p class="mt-1 text-xs text-zinc-500">Serve soprattutto se la lista di partenza è «tutte».</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Test A/B in scheda</label>
<%= hidden_field_tag "filters[ab_variants][]", "" %>
<%= select_tag "filters[ab_variants][]", options_for_select(Catalog::AB_VARIANTS.map { |k, v| [v, k] }, @filters["ab_variants"]),
multiple: true, size: 2, class: compact_select %>
<%= render "mailings/audience_choices", name: "filters[ab_variants]",
choices: Catalog::AB_VARIANTS.map { |k, v| [v, k] }, selected: @filters["ab_variants"] %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Stage pipeline</label>
<%= hidden_field_tag "filters[pipeline_stages][]", "" %>
<%= select_tag "filters[pipeline_stages][]", options_for_select(Catalog::PIPELINE_STAGES.map { |k, v| [v, k] }, @filters["pipeline_stages"]),
multiple: true, size: 5, class: compact_select %>
<%= render "mailings/audience_choices", name: "filters[pipeline_stages]",
choices: Catalog::PIPELINE_STAGES.map { |k, v| [v, k] }, selected: @filters["pipeline_stages"] %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Valore stimato</label>
@@ -144,7 +142,7 @@
<%= render layout: "mailings/audience_disclosure", locals: {
name: "history",
title: "Storico email",
hint: "Chi ha già ricevuto o aperto una campagna",
hint: "Ricevute, aperte o invii recenti (i mai contattati stanno sopra)",
open: history_open,
count: @filters.history_active? ? 1 : 0
} do %>
@@ -153,13 +151,12 @@
<label class="mb-1 block text-sm font-medium">Criterio</label>
<%= select_tag "filters[history_kind]", options_for_select([
["Nessun filtro storico", ""],
["Mai ricevuta una campagna", "never"],
["Ha ricevuto questa campagna", "received"],
["Non ha ricevuto questa campagna", "not_received"],
["Ha aperto questa campagna", "opened"],
["Ha ricevuto ma non aperto questa campagna", "not_opened"],
["Inviata negli ultimi N giorni", "recent"]
], @filters.history_kind), class: input_class %>
], @filters.history_kind == "never" ? "" : @filters.history_kind), class: input_class %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Campagna</label>
@@ -188,17 +185,15 @@
</label>
<div>
<label class="mb-1 block text-sm font-medium">Chi ha già ricevuto</label>
<%= hidden_field_tag "filters[exclude_received_mailing_ids][]", "" %>
<%= select_tag "filters[exclude_received_mailing_ids][]",
options_from_collection_for_select(@history_mailings, :id, :name, @filters["exclude_received_mailing_ids"]),
multiple: true, size: 4, class: compact_select %>
<%= render "mailings/audience_choices", name: "filters[exclude_received_mailing_ids]",
choices: @history_mailings.map { |mailing| [mailing.name, mailing.id] },
selected: @filters["exclude_received_mailing_ids"], scroll: true %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Chi ha aperto</label>
<%= hidden_field_tag "filters[exclude_opened_mailing_ids][]", "" %>
<%= select_tag "filters[exclude_opened_mailing_ids][]",
options_from_collection_for_select(@history_mailings, :id, :name, @filters["exclude_opened_mailing_ids"]),
multiple: true, size: 4, class: compact_select %>
<%= render "mailings/audience_choices", name: "filters[exclude_opened_mailing_ids]",
choices: @history_mailings.map { |mailing| [mailing.name, mailing.id] },
selected: @filters["exclude_opened_mailing_ids"], scroll: true %>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Chi ha ricevuto qualcosa negli ultimi N giorni</label>
+28 -15
View File
@@ -63,22 +63,29 @@
<% else %>
<div class="divide-y divide-zinc-100 dark:divide-zinc-800 md:hidden">
<% @mailings.each do |mailing| %>
<%= link_to mailing, class: "block p-4 active:bg-zinc-50 dark:active:bg-zinc-800" do %>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="font-semibold"><%= mailing.name %></div>
<div class="mt-0.5 text-xs text-zinc-500 break-anywhere"><%= mailing.mail_identity.from_email %></div>
<div class="p-4">
<%= link_to mailing, class: "block active:bg-zinc-50 dark:active:bg-zinc-800" do %>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="font-semibold"><%= mailing.name %></div>
<div class="mt-0.5 text-xs text-zinc-500 break-anywhere"><%= mailing.mail_identity.from_email %></div>
</div>
<%= mailing_status_badge(mailing.status) %>
</div>
<%= mailing_status_badge(mailing.status) %>
</div>
<div class="mt-3"><%= progress_bar(mailing.progress_percentage) %></div>
<div class="mt-2 text-xs text-zinc-500">
<%= mailing.sent_count %> inviate · <%= mailing.opened_count %> aperte · <%= mailing.pending_count %> in coda
<% if mailing.sending? && mailing.next_send_at.present? && mailing.next_send_at > Time.current %>
· Riparte <%= l(mailing.next_send_at, format: :short) %>
<% end %>
</div>
<% end %>
<div class="mt-3"><%= progress_bar(mailing.progress_percentage) %></div>
<div class="mt-2 text-xs text-zinc-500">
<%= mailing.sent_count %> inviate · <%= mailing.opened_count %> aperte · <%= mailing.pending_count %> in coda
<% if mailing.sending? && mailing.next_send_at.present? && mailing.next_send_at > Time.current %>
· Riparte <%= l(mailing.next_send_at, format: :short) %>
<% end %>
</div>
<% end %>
<% if mailing.draft? %>
<div class="mt-3">
<%= render "mailings/delete_draft_button", mailing: mailing, extra_class: "w-full" %>
</div>
<% end %>
</div>
<% end %>
</div>
<div class="hidden md:block">
@@ -90,6 +97,7 @@
<th class="px-4 py-3">Avanzamento</th>
<th class="px-4 py-3">Stato</th>
<th class="px-4 py-3">Prossimo / creato</th>
<th class="px-4 py-3"><span class="sr-only">Azioni</span></th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@@ -128,6 +136,11 @@
<%= format_dt(mailing.created_at) %>
<% end %>
</td>
<td class="px-4 py-3 text-right">
<% if mailing.draft? %>
<%= render "mailings/delete_draft_button", mailing: mailing, label: "Elimina", extra_class: "text-sm", form_class: "inline" %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
+2 -3
View File
@@ -33,6 +33,7 @@
<%= link_to "Modifica contenuto", edit_mailing_path(@mailing), class: btn_secondary %>
<%= link_to "Raffina destinatari", audience_mailing_path(@mailing), class: btn_secondary %>
<%= button_to "Rigenera lista", refresh_recipients_mailing_path(@mailing), method: :post, class: btn_secondary %>
<%= render "mailings/delete_draft_button", mailing: @mailing, extra_class: "flex-1 sm:flex-none" %>
<% elsif @mailing.sending? %>
<%= button_to "Ferma invio", cancel_mailing_path(@mailing), method: :post,
class: btn_danger,
@@ -333,9 +334,7 @@
<% end %>
<% if @mailing.draft? %>
<%= button_to "Elimina bozza", mailing_path(@mailing), method: :delete,
class: btn_danger,
form: { data: { turbo_confirm: "Eliminare questa bozza?" } } %>
<%= render "mailings/delete_draft_button", mailing: @mailing %>
<% end %>
</section>
</div>
+40 -18
View File
@@ -46,6 +46,7 @@
</div>
<%= opp ? stage_badge(opp.pipeline_stage) : nil %>
</div>
<div class="mt-1 break-anywhere text-xs text-zinc-500"><%= org.email.presence || "—" %></div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<%= streaming_badge(org.streaming_status) %>
<% if opp&.ab_variant.present? %><%= ab_variant_badge(opp.ab_variant) %><% end %>
@@ -63,31 +64,52 @@
<table class="min-w-full divide-y divide-zinc-100 dark:divide-zinc-800 text-sm">
<thead class="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
<tr>
<th class="px-4 py-3">N.</th>
<th class="px-4 py-3">Organizzazione</th>
<th class="px-4 py-3">Regione</th>
<th class="px-4 py-3">M/F</th>
<th class="px-4 py-3">Streaming</th>
<th class="px-4 py-3">Test</th>
<th class="px-4 py-3">Stage</th>
<th class="px-4 py-3">Invio</th>
<th class="px-3 py-3">N.</th>
<th class="px-3 py-3">Regione</th>
<th class="px-3 py-3">Prov.</th>
<th class="px-3 py-3">Società</th>
<th class="px-3 py-3">M/F</th>
<th class="px-3 py-3">Email verificata</th>
<th class="px-3 py-3">Sito / profilo</th>
<th class="px-3 py-3">Streaming rilevato</th>
<th class="px-3 py-3">Evidenza / fit commerciale</th>
<th class="px-3 py-3">Test A/B</th>
<th class="px-3 py-3">Stato invio</th>
<th class="px-3 py-3">Data invio</th>
<th class="px-3 py-3">Esito</th>
<th class="px-3 py-3">Demo / Trial</th>
<th class="px-3 py-3">Conversione</th>
<th class="px-3 py-3">Piano</th>
<th class="px-3 py-3">Fonte contatto / ricerca</th>
<th class="px-3 py-3">Data verifica</th>
<th class="px-3 py-3">Note follow-up</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
<% @organizations.each do |org| %>
<% opp = org.campaign_opportunity(current_project) %>
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-800/80">
<td class="px-4 py-3 tabular-nums text-zinc-500"><%= org.list_position || "—" %></td>
<td class="px-4 py-3">
<td class="whitespace-nowrap px-3 py-3 tabular-nums text-zinc-500"><%= org.list_position || "—" %></td>
<td class="whitespace-nowrap px-3 py-3"><%= org.region.presence || "—" %></td>
<td class="whitespace-nowrap px-3 py-3"><%= org.province.presence || "—" %></td>
<td class="px-3 py-3">
<%= link_to org.name, org, class: "font-medium text-zinc-900 hover:underline dark:text-zinc-100" %>
<div class="text-xs text-slate-500"><%= org.email.presence || org.organization_type_label %></div>
</td>
<td class="px-4 py-3"><%= [org.region, org.province].compact_blank.join(" · ").presence || "—" %></td>
<td class="px-4 py-3"><%= org.team_gender_label.presence || "—" %></td>
<td class="px-4 py-3"><%= streaming_badge(org.streaming_status) || "—" %></td>
<% opp = org.campaign_opportunity(current_project) %>
<td class="px-4 py-3"><%= opp&.ab_variant.presence || "—" %></td>
<td class="px-4 py-3"><%= opp ? stage_badge(opp.pipeline_stage) : "—" %></td>
<td class="px-4 py-3"><%= opp&.send_status_label.presence || "—" %></td>
<td class="whitespace-nowrap px-3 py-3"><%= org.team_gender_label.presence || "—" %></td>
<td class="px-3 py-3"><%= mailto_link(org.email, class: "text-sky-700 hover:underline dark:text-sky-400") %></td>
<td class="px-3 py-3"><%= external_link(org.website, class: "text-sky-700 hover:underline dark:text-sky-400") %></td>
<td class="whitespace-nowrap px-3 py-3"><%= streaming_badge(org.streaming_status) || "—" %></td>
<td class="px-3 py-3 text-zinc-600 dark:text-zinc-300"><%= table_clip(org.commercial_fit) %></td>
<td class="px-3 py-3"><%= opp&.ab_variant.present? ? ab_variant_badge(opp.ab_variant) : "—" %></td>
<td class="whitespace-nowrap px-3 py-3"><%= opp&.send_status_label.presence || "—" %></td>
<td class="whitespace-nowrap px-3 py-3"><%= format_date(opp&.sent_on) %></td>
<td class="px-3 py-3"><%= table_clip(opp&.outcome, length: 24) %></td>
<td class="whitespace-nowrap px-3 py-3"><%= opp ? yes_no(opp.demo_trial) : "—" %></td>
<td class="whitespace-nowrap px-3 py-3"><%= opp ? yes_no(opp.converted) : "—" %></td>
<td class="whitespace-nowrap px-3 py-3"><%= opp&.product.presence || "—" %></td>
<td class="px-3 py-3"><%= external_link(org.source_url, class: "text-sky-700 hover:underline dark:text-sky-400") %></td>
<td class="whitespace-nowrap px-3 py-3"><%= format_date(org.verified_at) %></td>
<td class="px-3 py-3 text-zinc-600 dark:text-zinc-300"><%= table_clip(org.notes) %></td>
</tr>
<% end %>
</tbody>