Commit iniziale di eminuxCRM: CRM Rails con pipeline, campagne email e Docker.
Include autenticazione, progetti isolati, mail marketing HTML con SMTP, test A/B e editor WYSIWYG. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* Application styles. Tailwind utilities live in builds/tailwind.css
|
||||
*/
|
||||
|
||||
.pagy {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagy a, .pagy span {
|
||||
display: inline-block;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 0.875rem;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.pagy a:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.pagy .current {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
border-color: #0f172a;
|
||||
}
|
||||
|
||||
html.dark .pagy a,
|
||||
html.dark .pagy span {
|
||||
background: #27272a;
|
||||
color: #e4e4e7;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
html.dark .pagy a:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
|
||||
html.dark .pagy .current {
|
||||
background: #fafafa;
|
||||
color: #18181b;
|
||||
border-color: #fafafa;
|
||||
}
|
||||
|
||||
/* Kanban: scrollbar orizzontale sempre visibile e usabile */
|
||||
.kanban-board {
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #94a3b8 #e2e8f0;
|
||||
}
|
||||
|
||||
.kanban-board::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.kanban-board::-webkit-scrollbar-track {
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.kanban-board::-webkit-scrollbar-thumb {
|
||||
background: #94a3b8;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.kanban-board::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
html.dark .kanban-board {
|
||||
scrollbar-color: #52525b #27272a;
|
||||
}
|
||||
|
||||
html.dark .kanban-board::-webkit-scrollbar-track {
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
html.dark .kanban-board::-webkit-scrollbar-thumb {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
html.dark .kanban-board::-webkit-scrollbar-thumb:hover {
|
||||
background: #71717a;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Superfici “slate-50” usate come righe/chip senza dark:bg — in dark non restano bianche. */
|
||||
html.dark .bg-slate-50 {
|
||||
background-color: #27272a;
|
||||
}
|
||||
|
||||
html.dark tr.hover\:bg-slate-50:hover,
|
||||
html.dark .hover\:bg-slate-50:hover {
|
||||
background-color: #27272a;
|
||||
}
|
||||
|
||||
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||
html.dark select,
|
||||
html.dark textarea {
|
||||
background-color: #09090b;
|
||||
color: #fafafa;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
html.dark input::placeholder,
|
||||
html.dark textarea::placeholder {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
html.dark select option {
|
||||
background-color: #18181b;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
/* Pulsanti: appearance none così input[type=submit] non resta grigio nativo con testo bianco. */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-danger {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #18181b;
|
||||
border-color: #18181b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #27272a;
|
||||
border-color: #27272a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
html.dark .btn-primary {
|
||||
background-color: #f4f4f5;
|
||||
border-color: #f4f4f5;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
html.dark .btn-primary:hover {
|
||||
background-color: #fff;
|
||||
border-color: #fff;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #fff;
|
||||
border-color: #e4e4e7;
|
||||
color: #3f3f46;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #fafafa;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
html.dark .btn-secondary {
|
||||
background-color: #18181b;
|
||||
border-color: #3f3f46;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
html.dark .btn-secondary:hover {
|
||||
background-color: #27272a;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #fff;
|
||||
border-color: #fecdd3;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #fff1f2;
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
html.dark .btn-danger {
|
||||
background-color: #18181b;
|
||||
border-color: #881337;
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
html.dark .btn-danger:hover {
|
||||
background-color: #4c0519;
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
/* Editor Trix (template e campagne email) */
|
||||
trix-toolbar {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-group {
|
||||
border-color: #e4e4e7;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button {
|
||||
background: #fff;
|
||||
border-color: #e4e4e7;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
html.dark trix-toolbar .trix-button-group {
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
html.dark trix-toolbar .trix-button {
|
||||
/* Icone SVG nere: inverti su fondo chiaro così diventano bianche su scuro. */
|
||||
background-color: #f4f4f5;
|
||||
border-color: #d4d4d8;
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
html.dark trix-toolbar .trix-button.trix-active {
|
||||
background-color: #d4d4d8;
|
||||
}
|
||||
|
||||
html.dark trix-toolbar .trix-dialog {
|
||||
background: #18181b;
|
||||
border-color: #3f3f46;
|
||||
color: #fafafa;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
trix-editor {
|
||||
min-height: 14rem;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: #fff;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
trix-editor:focus,
|
||||
trix-editor:focus-visible {
|
||||
outline: none;
|
||||
border-color: #a1a1aa;
|
||||
box-shadow: 0 0 0 1px #a1a1aa;
|
||||
}
|
||||
|
||||
trix-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
trix-editor figure.attachment {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
trix-editor figure.attachment.is-resizing {
|
||||
outline: 2px solid #18181b;
|
||||
outline-offset: 2px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
html.dark trix-editor figure.attachment.is-resizing {
|
||||
outline-color: #e4e4e7;
|
||||
}
|
||||
|
||||
.trix-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #18181b;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
html.dark .trix-resize-handle {
|
||||
background: #fafafa;
|
||||
border-color: #18181b;
|
||||
}
|
||||
|
||||
.trix-resize-handle--nw { top: -5px; left: -5px; cursor: nwse-resize; }
|
||||
.trix-resize-handle--ne { top: -5px; right: -5px; cursor: nesw-resize; }
|
||||
.trix-resize-handle--sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
|
||||
.trix-resize-handle--se { bottom: -5px; right: -5px; cursor: nwse-resize; }
|
||||
|
||||
html.dark trix-editor {
|
||||
background: #09090b;
|
||||
border-color: #3f3f46;
|
||||
color: #fafafa;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* :is() ha specificità reale: dark:bg-* / dark:text-* vincono sui pari light.
|
||||
:where() (default v4) ha specificità 0 e, a seconda dell’ordine nel CSS,
|
||||
le carte restano bianche mentre il testo diventa chiaro (o il contrario). */
|
||||
@custom-variant dark (&:is(.dark, .dark *));
|
||||
@@ -0,0 +1,55 @@
|
||||
class ActivitiesController < ApplicationController
|
||||
before_action :set_organization, only: %i[create]
|
||||
|
||||
def create
|
||||
@activity = @organization.activities.build(activity_params)
|
||||
@activity.user = current_user
|
||||
@activity.happened_at ||= Time.current
|
||||
|
||||
if @activity.save
|
||||
maybe_update_pipeline_from_activity!
|
||||
redirect_to @organization, notice: "Attività registrata."
|
||||
else
|
||||
redirect_to @organization, alert: @activity.errors.full_messages.to_sentence
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_organization
|
||||
@organization = Organization.find(params[:organization_id])
|
||||
end
|
||||
|
||||
def activity_params
|
||||
params.require(:activity).permit(
|
||||
:activity_type, :subject, :description, :happened_at, :contact_id, :opportunity_id
|
||||
)
|
||||
end
|
||||
|
||||
def maybe_update_pipeline_from_activity!
|
||||
opportunity = @activity.opportunity || @organization.opportunities.open_stage.order(updated_at: :desc).first
|
||||
return unless opportunity
|
||||
|
||||
stage_map = {
|
||||
"email_sent" => "contacted",
|
||||
"email_received" => "replied",
|
||||
"call" => "contacted",
|
||||
"demo" => "demo_trial",
|
||||
"trial_started" => "demo_trial",
|
||||
"first_use" => "first_use",
|
||||
"proposal_sent" => "proposal",
|
||||
"won" => "won",
|
||||
"lost" => "lost"
|
||||
}
|
||||
target = stage_map[@activity.activity_type]
|
||||
return unless target
|
||||
return if opportunity.won? || opportunity.lost?
|
||||
return if Catalog::PIPELINE_ORDER.index(opportunity.pipeline_stage).to_i >= Catalog::PIPELINE_ORDER.index(target).to_i
|
||||
|
||||
attrs = { pipeline_stage: target }
|
||||
if target == "lost"
|
||||
attrs[:lost_reason] = params[:lost_reason].presence || "other"
|
||||
end
|
||||
opportunity.move_to_stage!(target, lost_reason: attrs[:lost_reason], user: current_user)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class AdminController < ApplicationController
|
||||
before_action :require_admin
|
||||
|
||||
def show
|
||||
@page_title = "Impostazioni"
|
||||
@users_count = User.count
|
||||
@active_users_count = User.active.count
|
||||
@projects_count = Project.count
|
||||
@mail_identities_count = MailIdentity.count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Authentication
|
||||
include ProjectScoping
|
||||
include Pagy::Backend
|
||||
|
||||
allow_browser versions: :modern
|
||||
before_action :set_current_user
|
||||
|
||||
helper_method :page_title
|
||||
|
||||
private
|
||||
|
||||
def page_title
|
||||
@page_title || Rails.application.config.x.app_name
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :require_login
|
||||
helper_method :current_user, :logged_in?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_user
|
||||
@current_user ||= session[:user_id] ? User.find_by(id: session[:user_id]) : nil
|
||||
end
|
||||
|
||||
def logged_in?
|
||||
current_user.present? && current_user.active?
|
||||
end
|
||||
|
||||
def require_login
|
||||
if current_user.present? && !current_user.active?
|
||||
logout
|
||||
redirect_to login_path, alert: "Il tuo account è stato disabilitato."
|
||||
return
|
||||
end
|
||||
return if logged_in?
|
||||
|
||||
redirect_to login_path, alert: "Effettua l'accesso per continuare."
|
||||
end
|
||||
|
||||
def require_admin
|
||||
return if current_user&.admin? && current_user.active?
|
||||
|
||||
redirect_to root_path, alert: "Accesso riservato agli amministratori."
|
||||
end
|
||||
|
||||
def login_as(user)
|
||||
reset_session
|
||||
session[:user_id] = user.id
|
||||
@current_user = user
|
||||
Current.user = user
|
||||
end
|
||||
|
||||
def logout
|
||||
reset_session
|
||||
@current_user = nil
|
||||
Current.user = nil
|
||||
end
|
||||
|
||||
def set_current_user
|
||||
Current.user = current_user
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
module ProjectScoping
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
helper_method :current_project, :available_projects, :in_project_space?
|
||||
before_action :set_current_project, if: :logged_in?
|
||||
end
|
||||
|
||||
def default_url_options
|
||||
return {} unless respond_to?(:request) && request&.path&.start_with?("/p/") && current_project
|
||||
|
||||
{ project_code: current_project.code }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def available_projects
|
||||
@available_projects ||= if current_user&.admin?
|
||||
Project.active.ordered
|
||||
elsif current_user
|
||||
current_user.accessible_projects
|
||||
else
|
||||
Project.none
|
||||
end
|
||||
end
|
||||
|
||||
def current_project
|
||||
Current.project
|
||||
end
|
||||
|
||||
def in_project_space?
|
||||
current_project.present? && request.path.start_with?("/p/")
|
||||
end
|
||||
|
||||
def set_current_project
|
||||
code = params[:project_code].to_s.presence
|
||||
unless code
|
||||
Current.project = nil
|
||||
return
|
||||
end
|
||||
|
||||
project = available_projects.find_by(code: code)
|
||||
if project.nil?
|
||||
redirect_to root_path, alert: "Non hai accesso a questo progetto."
|
||||
return
|
||||
end
|
||||
|
||||
Current.project = project
|
||||
session[:last_project_code] = project.code
|
||||
end
|
||||
|
||||
def require_current_project!
|
||||
return if current_project
|
||||
|
||||
redirect_to root_path, alert: "Seleziona un progetto per continuare."
|
||||
end
|
||||
|
||||
def organizations_for_current_project
|
||||
Organization.for_project(current_project)
|
||||
end
|
||||
|
||||
def opportunities_for_current_project
|
||||
Opportunity.for_project(current_project)
|
||||
end
|
||||
|
||||
def tasks_for_current_project
|
||||
Task.for_project(current_project)
|
||||
end
|
||||
|
||||
def project_home_path_for(project)
|
||||
project_root_path(project_code: project.code)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
class ContactsController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
before_action :set_contact, only: %i[show edit update destroy]
|
||||
before_action :load_organizations, only: %i[new create edit update]
|
||||
|
||||
def index
|
||||
@page_title = "Contatti"
|
||||
scope = Contact.joins(:organization)
|
||||
.merge(organizations_for_current_project)
|
||||
.includes(:organization)
|
||||
.primary_first
|
||||
scope = scope.search(params[:q]) if params[:q].present?
|
||||
scope = scope.where(organization_id: params[:organization_id]) if params[:organization_id].present?
|
||||
@pagy, @contacts = pagy(scope, items: 30)
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.csv { send_data CsvExport.contacts(scope), filename: "contacts-#{Date.current}.csv" }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@page_title = @contact.full_name
|
||||
end
|
||||
|
||||
def new
|
||||
@page_title = "Nuovo contatto"
|
||||
@contact = Contact.new(organization_id: params[:organization_id], primary_contact: params[:primary].present?)
|
||||
end
|
||||
|
||||
def create
|
||||
@contact = Contact.new(contact_params)
|
||||
if @contact.save
|
||||
redirect_to @contact.organization, notice: "Contatto creato."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@page_title = "Modifica #{@contact.full_name}"
|
||||
end
|
||||
|
||||
def update
|
||||
if @contact.update(contact_params)
|
||||
redirect_to @contact.organization, notice: "Contatto aggiornato."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
org = @contact.organization
|
||||
@contact.destroy!
|
||||
redirect_to org, notice: "Contatto eliminato."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_contact
|
||||
@contact = Contact.find(params[:id])
|
||||
end
|
||||
|
||||
def load_organizations
|
||||
@organizations = organizations_for_current_project.order(:name)
|
||||
end
|
||||
|
||||
def contact_params
|
||||
params.require(:contact).permit(
|
||||
:organization_id, :first_name, :last_name, :role, :email, :phone, :mobile,
|
||||
:preferred_contact_method, :notes, :primary_contact
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class DashboardController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
|
||||
def show
|
||||
@page_title = "Dashboard"
|
||||
@goal = SalesGoal.current(current_project).order(created_at: :desc).first
|
||||
opp_scope = opportunities_for_current_project
|
||||
@metrics = Dashboard::Metrics.new(scope: opp_scope, project: current_project)
|
||||
task_scope = tasks_for_current_project
|
||||
@overdue_tasks = task_scope.overdue.includes(:organization, :contact, :assigned_user).ordered.limit(10)
|
||||
@today_tasks = task_scope.due_today.includes(:organization, :contact, :assigned_user).ordered.limit(10)
|
||||
@upcoming_tasks = task_scope.upcoming.includes(:organization, :contact, :assigned_user).ordered.limit(10)
|
||||
@attention = @metrics.attention_items
|
||||
@funnel = @metrics.funnel_steps
|
||||
@stage_counts = @metrics.stage_counts
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
class HomeController < ApplicationController
|
||||
def index
|
||||
@page_title = "I miei progetti"
|
||||
@projects = available_projects
|
||||
|
||||
if !current_user.admin? && @projects.one?
|
||||
redirect_to project_root_path(project_code: @projects.first.code) and return
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
class ImportsController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
|
||||
def new
|
||||
@page_title = "Import CSV"
|
||||
end
|
||||
|
||||
def create
|
||||
unless params[:file].present?
|
||||
redirect_to new_import_path, alert: "Seleziona un file CSV." and return
|
||||
end
|
||||
|
||||
importer = CsvImport::Organizations.new(file: params[:file], user: current_user, project: current_project)
|
||||
|
||||
if params[:preview].present?
|
||||
@preview = importer.preview
|
||||
@page_title = "Anteprima import"
|
||||
render :preview and return
|
||||
end
|
||||
|
||||
@result = importer.import!
|
||||
@page_title = "Risultato import"
|
||||
render :result
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
class MailIdentitiesController < ApplicationController
|
||||
before_action :require_admin
|
||||
before_action :set_identity, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@page_title = "Account email"
|
||||
@mail_identities = MailIdentity.ordered
|
||||
end
|
||||
|
||||
def new
|
||||
@page_title = "Nuovo account email"
|
||||
@mail_identity = MailIdentity.new(smtp_port: 587, encryption: "starttls", smtp_authentication: "plain", active: true, verify_ssl: true)
|
||||
end
|
||||
|
||||
def create
|
||||
@mail_identity = MailIdentity.new(identity_params)
|
||||
if @mail_identity.save
|
||||
redirect_to mail_identities_path, notice: "Account email creato. Da qui partono gli invii."
|
||||
else
|
||||
@page_title = "Nuovo account email"
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@page_title = "Modifica account email"
|
||||
end
|
||||
|
||||
def update
|
||||
attrs = identity_params
|
||||
attrs.delete(:smtp_password) if attrs[:smtp_password].blank?
|
||||
if @mail_identity.update(attrs)
|
||||
redirect_to mail_identities_path, notice: "Account email aggiornato."
|
||||
else
|
||||
@page_title = "Modifica account email"
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @mail_identity.mailings.exists?
|
||||
redirect_to mail_identities_path, alert: "Non puoi eliminare un account già usato in un invio. Disattivalo."
|
||||
return
|
||||
end
|
||||
|
||||
@mail_identity.destroy!
|
||||
redirect_to mail_identities_path, notice: "Account email eliminato."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_identity
|
||||
@mail_identity = MailIdentity.find(params[:id])
|
||||
end
|
||||
|
||||
def identity_params
|
||||
params.require(:mail_identity).permit(
|
||||
:name, :from_name, :from_email, :reply_to, :smtp_host, :smtp_port,
|
||||
:smtp_username, :smtp_password, :smtp_authentication, :encryption,
|
||||
:verify_ssl, :active
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
class MailImagesController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
|
||||
MAX_BYTES = 5.megabytes
|
||||
ALLOWED_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
|
||||
|
||||
def create
|
||||
file = params[:file]
|
||||
unless file.respond_to?(:content_type)
|
||||
return render json: { error: "Seleziona un'immagine." }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
content_type = Marcel::MimeType.for(file, name: file.original_filename, declared_type: file.content_type)
|
||||
unless ALLOWED_TYPES.include?(content_type)
|
||||
return render json: { error: "Formato non valido. Usa JPG, PNG, GIF o WebP." }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if file.size > MAX_BYTES
|
||||
return render json: { error: "L'immagine supera i 5 MB." }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: file,
|
||||
filename: file.original_filename.presence || "immagine",
|
||||
content_type: content_type
|
||||
)
|
||||
|
||||
render json: { url: url_for(blob) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
class MailTemplatesController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
before_action :set_template, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@page_title = "Template email"
|
||||
@mail_templates = MailTemplate.for_project(current_project)
|
||||
end
|
||||
|
||||
def new
|
||||
@page_title = "Nuovo template"
|
||||
@mail_template = MailTemplate.new(project: current_project, body_html: default_html, subject: "MatchLiveTV per {{societa}}")
|
||||
end
|
||||
|
||||
def create
|
||||
@mail_template = MailTemplate.new(template_params)
|
||||
@mail_template.project ||= current_project
|
||||
if @mail_template.save
|
||||
redirect_to mail_templates_path, notice: "Template salvato."
|
||||
else
|
||||
@page_title = "Nuovo template"
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@page_title = "Modifica template"
|
||||
end
|
||||
|
||||
def update
|
||||
if @mail_template.update(template_params)
|
||||
redirect_to mail_templates_path, notice: "Template aggiornato."
|
||||
else
|
||||
@page_title = "Modifica template"
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @mail_template.mailings.exists? || @mail_template.mailings_as_b.exists?
|
||||
redirect_to mail_templates_path, alert: "Template già usato in un invio: non si elimina, puoi solo modificarlo."
|
||||
return
|
||||
end
|
||||
|
||||
@mail_template.destroy!
|
||||
redirect_to mail_templates_path, notice: "Template eliminato."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_template
|
||||
@mail_template = MailTemplate.for_project(current_project).find(params[:id])
|
||||
end
|
||||
|
||||
def template_params
|
||||
params.require(:mail_template).permit(:name, :subject, :body_html)
|
||||
end
|
||||
|
||||
def default_html
|
||||
<<~HTML
|
||||
<p>Ciao {{contatto_nome}},</p>
|
||||
<p>scriviamo a <strong>{{societa}}</strong> ({{regione}}) per presentarti MatchLiveTV.</p>
|
||||
<p>Possiamo aiutarvi a trasmettere le giovanili in modo semplice.</p>
|
||||
<p>A presto,<br>Il team MatchLiveTV</p>
|
||||
HTML
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,206 @@
|
||||
class MailingsController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
before_action :set_mailing, only: %i[show edit update destroy queue test_send refresh_recipients update_recipients preview]
|
||||
before_action :load_form_collections, only: %i[new create edit update]
|
||||
|
||||
def index
|
||||
@page_title = "Email"
|
||||
@mailings = Mailing.for_project(current_project).includes(:mail_identity).recent
|
||||
@mail_identities_count = MailIdentity.active.count
|
||||
end
|
||||
|
||||
def new
|
||||
@page_title = "Nuovo invio"
|
||||
template = MailTemplate.for_project(current_project).order(:name).first
|
||||
identity = MailIdentity.active.ordered.first
|
||||
@mailing = Mailing.new(
|
||||
project: current_project,
|
||||
mail_template: template,
|
||||
mail_identity: identity,
|
||||
audience: "to_send",
|
||||
interval_seconds: 0,
|
||||
ab_assignment: "from_record",
|
||||
name: "Invio #{l(Time.zone.today)}",
|
||||
subject: template&.subject,
|
||||
body_html: template&.body_html
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@mailing = Mailing.new(mailing_params)
|
||||
@mailing.project = current_project
|
||||
apply_template_if_needed
|
||||
if @mailing.save
|
||||
@mailing.rebuild_recipients!
|
||||
redirect_to @mailing, notice: "Bozza creata. Controlla i destinatari e poi invia."
|
||||
else
|
||||
@page_title = "Nuovo invio"
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@page_title = @mailing.name
|
||||
@recipients = @mailing.mailing_recipients.includes(:organization, :contact).ordered
|
||||
@preview_recipient = preview_recipient
|
||||
@preview_variant = preview_variant
|
||||
context = @preview_recipient&.merge_context || { project: current_project, ab_variant: @preview_variant }
|
||||
@preview_html = MailMerge.render(@mailing.body_for(@preview_variant), **context)
|
||||
@preview_subject = MailMerge.render(@mailing.subject_for(@preview_variant), **context)
|
||||
end
|
||||
|
||||
def edit
|
||||
unless @mailing.editable?
|
||||
redirect_to @mailing, alert: "Questo invio non è più modificabile."
|
||||
return
|
||||
end
|
||||
@page_title = "Modifica invio"
|
||||
end
|
||||
|
||||
def update
|
||||
unless @mailing.editable?
|
||||
redirect_to @mailing, alert: "Questo invio non è più modificabile."
|
||||
return
|
||||
end
|
||||
|
||||
@mailing.assign_attributes(mailing_params)
|
||||
apply_template_if_needed
|
||||
if @mailing.save
|
||||
rebuild_recipients_if_needed
|
||||
redirect_to @mailing, notice: "Invio aggiornato."
|
||||
else
|
||||
@page_title = "Modifica invio"
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
unless @mailing.draft?
|
||||
redirect_to mailings_path, alert: "Puoi eliminare solo le bozze."
|
||||
return
|
||||
end
|
||||
|
||||
@mailing.destroy!
|
||||
redirect_to mailings_path, notice: "Invio eliminato."
|
||||
end
|
||||
|
||||
def refresh_recipients
|
||||
unless @mailing.editable?
|
||||
redirect_to @mailing, alert: "Destinatari bloccati: l'invio è già partito."
|
||||
return
|
||||
end
|
||||
|
||||
@mailing.rebuild_recipients!
|
||||
redirect_to @mailing, notice: "Lista destinatari aggiornata."
|
||||
end
|
||||
|
||||
def update_recipients
|
||||
unless @mailing.editable?
|
||||
redirect_to @mailing, alert: "Non puoi più cambiare i destinatari."
|
||||
return
|
||||
end
|
||||
|
||||
selected = Array(params[:pending_ids]).map(&:to_i)
|
||||
@mailing.mailing_recipients.where.not(status: %w[sent queued]).find_each do |recipient|
|
||||
if selected.include?(recipient.id) && recipient.email_ok?
|
||||
recipient.update!(status: "pending", skip_reason: nil)
|
||||
else
|
||||
reason = recipient.email_ok? ? "escluso dal check" : (recipient.skip_reason.presence || "email non valida")
|
||||
recipient.update!(status: "skipped", skip_reason: reason)
|
||||
end
|
||||
end
|
||||
redirect_to @mailing, notice: "Selezione destinatari salvata."
|
||||
end
|
||||
|
||||
def queue
|
||||
unless @mailing.draft? || @mailing.sending?
|
||||
redirect_to @mailing, alert: "Questo invio è già stato concluso."
|
||||
return
|
||||
end
|
||||
|
||||
@mailing.queue_send!
|
||||
redirect_to @mailing, notice: "Invio avviato. Le email partono in background#{@mailing.interval_seconds.positive? ? " con pausa di #{@mailing.interval_seconds}s" : ""}."
|
||||
rescue StandardError => e
|
||||
redirect_to @mailing, alert: e.message
|
||||
end
|
||||
|
||||
def test_send
|
||||
recipient = preview_recipient
|
||||
unless recipient&.email_ok?
|
||||
redirect_to @mailing, alert: "Nessun destinatario valido da usare come dati di prova."
|
||||
return
|
||||
end
|
||||
|
||||
variant = preview_variant
|
||||
html = MailMerge.render(@mailing.body_for(variant), **recipient.merge_context.merge(ab_variant: variant))
|
||||
subject = "[TEST#{@mailing.ab_test? ? " #{variant}" : ""}] #{MailMerge.render(@mailing.subject_for(variant), **recipient.merge_context.merge(ab_variant: variant))}"
|
||||
test_recipient = recipient.dup
|
||||
test_recipient.email = current_user.email
|
||||
CampaignMailer.outreach(test_recipient, html: html, subject: subject).deliver_now
|
||||
redirect_to mailing_path(@mailing, recipient_id: recipient.id, variant: variant), notice: "Email di prova (#{@mailing.ab_test? ? "variante #{variant}" : "unica"}) inviata a #{current_user.email}."
|
||||
rescue StandardError => e
|
||||
redirect_to @mailing, alert: "Invio di prova non riuscito: #{e.message}"
|
||||
end
|
||||
|
||||
def preview
|
||||
redirect_to mailing_path(@mailing, recipient_id: params[:recipient_id])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_mailing
|
||||
@mailing = Mailing.for_project(current_project).find(params[:id])
|
||||
end
|
||||
|
||||
def load_form_collections
|
||||
@mail_identities = MailIdentity.active.ordered
|
||||
@mail_templates = MailTemplate.for_project(current_project)
|
||||
end
|
||||
|
||||
def mailing_params
|
||||
params.require(:mailing).permit(
|
||||
:name, :mail_identity_id, :mail_template_id, :mail_template_b_id, :audience,
|
||||
:subject, :body_html, :subject_b, :body_html_b, :ab_test, :ab_assignment,
|
||||
:interval_seconds, files: []
|
||||
)
|
||||
end
|
||||
|
||||
def apply_template_if_needed
|
||||
copy_template(params.dig(:mailing, :mail_template_id), :subject, :body_html, params[:use_template_content])
|
||||
copy_template(params.dig(:mailing, :mail_template_b_id), :subject_b, :body_html_b, params[:use_template_b_content])
|
||||
end
|
||||
|
||||
def copy_template(template_id, subject_attr, body_attr, flag)
|
||||
return if template_id.blank?
|
||||
return unless ActiveModel::Type::Boolean.new.cast(flag)
|
||||
|
||||
template = MailTemplate.for_project(current_project).find_by(id: template_id)
|
||||
return unless template
|
||||
|
||||
@mailing.public_send("#{subject_attr}=", template.subject)
|
||||
@mailing.public_send("#{body_attr}=", template.body_html)
|
||||
end
|
||||
|
||||
def rebuild_recipients_if_needed
|
||||
return unless @mailing.editable?
|
||||
return unless @mailing.saved_change_to_audience? || @mailing.saved_change_to_ab_test? || @mailing.saved_change_to_ab_assignment?
|
||||
|
||||
@mailing.rebuild_recipients!
|
||||
end
|
||||
|
||||
def preview_recipient
|
||||
if params[:recipient_id].present?
|
||||
@mailing.mailing_recipients.find_by(id: params[:recipient_id])
|
||||
else
|
||||
@mailing.mailing_recipients.pending.ordered.first || @mailing.mailing_recipients.ordered.first
|
||||
end
|
||||
end
|
||||
|
||||
def preview_variant
|
||||
requested = params[:variant].to_s.upcase
|
||||
return requested if requested.in?(%w[A B])
|
||||
return "A" unless @mailing.ab_test?
|
||||
|
||||
preview_recipient&.ab_variant.presence_in(%w[A B]) || "A"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,108 @@
|
||||
class OpportunitiesController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
before_action :set_opportunity, only: %i[show edit update destroy update_stage]
|
||||
before_action :load_form_data, only: %i[new create edit update]
|
||||
|
||||
def index
|
||||
@page_title = "Opportunità"
|
||||
scope = opportunities_for_current_project.includes(:organization, :assigned_user, :project).order(updated_at: :desc)
|
||||
scope = scope.where(pipeline_stage: params[:pipeline_stage]) if params[:pipeline_stage].present?
|
||||
scope = scope.where(assigned_user_id: params[:owner]) if params[:owner].present?
|
||||
@pagy, @opportunities = pagy(scope, items: 30)
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.csv { send_data CsvExport.opportunities(scope), filename: "opportunities-#{Date.current}.csv" }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
redirect_to @opportunity.organization
|
||||
end
|
||||
|
||||
def new
|
||||
@page_title = "Nuova opportunità"
|
||||
@opportunity = Opportunity.new(
|
||||
organization_id: params[:organization_id],
|
||||
project: current_project,
|
||||
assigned_user: current_user,
|
||||
pipeline_stage: "to_contact",
|
||||
probability: 5
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@opportunity = Opportunity.new(opportunity_params)
|
||||
@opportunity.project ||= current_project
|
||||
if @opportunity.save
|
||||
redirect_to @opportunity.organization, notice: "Opportunità creata."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@page_title = "Modifica opportunità"
|
||||
end
|
||||
|
||||
def update
|
||||
if @opportunity.update(opportunity_params)
|
||||
redirect_to @opportunity.organization, notice: "Opportunità aggiornata."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
org = @opportunity.organization
|
||||
@opportunity.destroy!
|
||||
redirect_to org, notice: "Opportunità eliminata."
|
||||
end
|
||||
|
||||
def update_stage
|
||||
stage_params = params.permit(:pipeline_stage, :lost_reason, :notes)
|
||||
new_stage = stage_params[:pipeline_stage].to_s
|
||||
unless Catalog::PIPELINE_STAGES.key?(new_stage)
|
||||
redirect_back fallback_location: pipeline_path, alert: "Stage non valido." and return
|
||||
end
|
||||
|
||||
begin
|
||||
@opportunity.move_to_stage!(
|
||||
new_stage,
|
||||
lost_reason: stage_params[:lost_reason],
|
||||
notes: stage_params[:notes],
|
||||
user: current_user
|
||||
)
|
||||
respond_to do |format|
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { notice: "Stage aggiornato." }) }
|
||||
format.html { redirect_back fallback_location: pipeline_path, notice: "Stage aggiornato." }
|
||||
format.json { render json: { ok: true, stage: @opportunity.pipeline_stage } }
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
respond_to do |format|
|
||||
format.html { redirect_back fallback_location: pipeline_path, alert: e.record.errors.full_messages.to_sentence }
|
||||
format.json { render json: { ok: false, errors: e.record.errors.full_messages }, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_opportunity
|
||||
@opportunity = Opportunity.find(params[:id])
|
||||
end
|
||||
|
||||
def load_form_data
|
||||
@organizations = organizations_for_current_project.order(:name)
|
||||
@users = User.active.order(:first_name)
|
||||
@products = Product.active.ordered
|
||||
@projects = available_projects
|
||||
end
|
||||
|
||||
def opportunity_params
|
||||
params.require(:opportunity).permit(
|
||||
:organization_id, :project_id, :name, :pipeline_stage, :estimated_value, :probability,
|
||||
:expected_close_date, :product, :assigned_user_id, :lost_reason, :notes,
|
||||
:ab_variant, :send_status, :sent_on, :outcome, :demo_trial, :converted
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,144 @@
|
||||
class OrganizationsController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
before_action :set_organization, only: %i[show edit update destroy]
|
||||
before_action :load_form_collections, only: %i[new create edit update]
|
||||
|
||||
def index
|
||||
@page_title = "Organizzazioni"
|
||||
scope = organizations_for_current_project.includes(:assigned_user, :contacts, :opportunities, :tasks, :projects)
|
||||
scope = scope.search(params[:q]) if params[:q].present?
|
||||
scope = apply_filters(scope)
|
||||
scope = apply_sort(scope)
|
||||
@pagy, @organizations = pagy(scope, items: 50)
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.csv { send_data CsvExport.organizations(scope), filename: "organizations-#{Date.current}.csv" }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
authorize_organization!(@organization)
|
||||
@page_title = @organization.name
|
||||
@contacts = @organization.contacts.primary_first
|
||||
@opportunities = @organization.opportunities.for_project(current_project).includes(:assigned_user, :project).order(updated_at: :desc)
|
||||
@pending_tasks = @organization.tasks.pending.ordered
|
||||
@completed_tasks = @organization.tasks.completed.order(completed_at: :desc).limit(10)
|
||||
@activities = @organization.activities.includes(:user, :contact, :opportunity).recent_first
|
||||
@next_task = @organization.next_pending_task
|
||||
@activity = @organization.activities.build(happened_at: Time.current, user: current_user)
|
||||
@users = User.active.order(:first_name)
|
||||
end
|
||||
|
||||
def new
|
||||
@page_title = "Nuova organizzazione"
|
||||
@organization = Organization.new(assigned_user: current_user, country: "Italia", status: "prospect")
|
||||
@organization.project_ids = [current_project.id] if current_project
|
||||
end
|
||||
|
||||
def create
|
||||
@organization = Organization.new(organization_params)
|
||||
if @organization.save
|
||||
redirect_to @organization, notice: "Organizzazione creata."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize_organization!(@organization)
|
||||
@page_title = "Modifica #{@organization.name}"
|
||||
end
|
||||
|
||||
def update
|
||||
authorize_organization!(@organization)
|
||||
if @organization.update(organization_params)
|
||||
redirect_to @organization, notice: "Organizzazione aggiornata."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize_organization!(@organization)
|
||||
@organization.destroy!
|
||||
redirect_to organizations_path, notice: "Organizzazione eliminata."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_organization
|
||||
@organization = Organization.find(params[:id])
|
||||
end
|
||||
|
||||
def authorize_organization!(org)
|
||||
return if current_user.admin?
|
||||
return if org.projects.merge(available_projects).exists?
|
||||
|
||||
redirect_to organizations_path, alert: "Organizzazione non disponibile per i tuoi progetti." and return
|
||||
end
|
||||
|
||||
def load_form_collections
|
||||
@users = User.active.order(:first_name)
|
||||
@projects = available_projects
|
||||
end
|
||||
|
||||
def organization_params
|
||||
params.require(:organization).permit(
|
||||
:name, :legal_name, :organization_type, :sport, :country, :region, :province, :city,
|
||||
:address, :website, :source_url, :phone, :email, :vat_number, :notes, :status, :lead_source,
|
||||
:assigned_user_id, :list_position, :team_gender, :streaming_status, :commercial_fit, :verified_at,
|
||||
project_ids: []
|
||||
)
|
||||
end
|
||||
|
||||
def apply_filters(scope)
|
||||
scope = scope.where(status: params[:status]) if params[:status].present?
|
||||
scope = scope.where(lead_source: params[:lead_source]) if params[:lead_source].present?
|
||||
scope = scope.where(organization_type: params[:organization_type]) if params[:organization_type].present?
|
||||
scope = scope.where(sport: params[:sport]) if params[:sport].present?
|
||||
scope = scope.where(country: params[:country]) if params[:country].present?
|
||||
scope = scope.where(region: params[:region]) if params[:region].present?
|
||||
scope = scope.where(assigned_user_id: params[:owner]) if params[:owner].present?
|
||||
scope = scope.where(team_gender: params[:team_gender]) if params[:team_gender].present?
|
||||
scope = scope.where(streaming_status: params[:streaming_status]) if params[:streaming_status].present?
|
||||
|
||||
if params[:customer] == "yes"
|
||||
scope = scope.customers
|
||||
elsif params[:customer] == "no"
|
||||
scope = scope.where.not(status: %w[active_customer inactive_customer])
|
||||
end
|
||||
|
||||
if params[:pipeline_stage].present?
|
||||
scope = scope.joins(:opportunities).where(opportunities: { pipeline_stage: params[:pipeline_stage], project_id: current_project.id }).distinct
|
||||
end
|
||||
|
||||
if params[:ab_variant].present?
|
||||
scope = scope.joins(:opportunities).where(opportunities: { ab_variant: params[:ab_variant], project_id: current_project.id }).distinct
|
||||
end
|
||||
|
||||
if params[:overdue_tasks] == "1"
|
||||
scope = scope.joins(:tasks).merge(Task.overdue).distinct
|
||||
end
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def apply_sort(scope)
|
||||
case params[:sort]
|
||||
when "name"
|
||||
scope.order(:name)
|
||||
when "updated"
|
||||
scope.order(updated_at: :desc)
|
||||
when "created"
|
||||
scope.order(created_at: :desc)
|
||||
when "lista"
|
||||
scope.order(Arel.sql("organizations.list_position ASC NULLS LAST, organizations.name ASC"))
|
||||
else
|
||||
if current_project&.code == "matchlivetv"
|
||||
scope.order(Arel.sql("organizations.list_position ASC NULLS LAST, organizations.name ASC"))
|
||||
else
|
||||
scope.order(updated_at: :desc)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class PasswordResetsController < ApplicationController
|
||||
skip_before_action :require_login
|
||||
|
||||
def new; end
|
||||
|
||||
def create
|
||||
user = User.active.find_by(email: params[:email].to_s.downcase.strip)
|
||||
if user
|
||||
user.generate_password_reset_token!
|
||||
PasswordMailer.reset(user).deliver_later
|
||||
end
|
||||
redirect_to login_path, notice: "Se l'email esiste, riceverai le istruzioni per il reset."
|
||||
end
|
||||
|
||||
def edit
|
||||
@user = User.find_by(password_reset_token: params[:token])
|
||||
return if @user&.password_reset_token_valid?
|
||||
|
||||
redirect_to new_password_reset_path, alert: "Link di reset non valido o scaduto."
|
||||
end
|
||||
|
||||
def update
|
||||
@user = User.find_by(password_reset_token: params[:token])
|
||||
unless @user&.password_reset_token_valid?
|
||||
redirect_to new_password_reset_path, alert: "Link di reset non valido o scaduto." and return
|
||||
end
|
||||
|
||||
if @user.update(password_params)
|
||||
@user.clear_password_reset_token!
|
||||
redirect_to login_path, notice: "Password aggiornata. Ora puoi accedere."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class PasswordsController < ApplicationController
|
||||
def edit
|
||||
@user = current_user
|
||||
end
|
||||
|
||||
def update
|
||||
@user = current_user
|
||||
if @user.authenticate(params[:current_password]) && @user.update(password_params)
|
||||
redirect_to root_path, notice: "Password aggiornata."
|
||||
else
|
||||
flash.now[:alert] = "Impossibile aggiornare la password. Verifica i dati inseriti."
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class PipelineController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
|
||||
def show
|
||||
@page_title = "Pipeline"
|
||||
@opportunities_by_stage = Catalog::PIPELINE_ORDER.index_with do |stage|
|
||||
opportunities_for_current_project.where(pipeline_stage: stage)
|
||||
.includes(:organization, :assigned_user, :tasks, organization: :contacts)
|
||||
.order(updated_at: :desc)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
class ProjectsController < ApplicationController
|
||||
before_action :require_admin
|
||||
before_action :set_project, only: %i[edit update destroy]
|
||||
skip_before_action :set_current_project, only: %i[create], raise: false
|
||||
|
||||
def create
|
||||
@project = Project.new(project_params)
|
||||
if @project.save
|
||||
redirect_to settings_path(project_code: @project.code), notice: "Progetto creato. Sei nell'istanza #{@project.name}."
|
||||
else
|
||||
redirect_to root_path(new_project: 1), alert: @project.errors.full_messages.to_sentence
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@page_title = "Modifica progetto"
|
||||
end
|
||||
|
||||
def update
|
||||
if @project.update(project_params)
|
||||
redirect_to(current_project ? settings_path : root_path, notice: "Progetto aggiornato.")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @project.opportunities.exists?
|
||||
redirect_to(current_project ? settings_path : root_path, alert: "Impossibile eliminare: ci sono opportunità collegate.")
|
||||
else
|
||||
@project.destroy!
|
||||
redirect_to root_path, notice: "Progetto eliminato."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_project
|
||||
@project = Project.find(params[:id])
|
||||
end
|
||||
|
||||
def project_params
|
||||
params.require(:project).permit(:name, :code, :description, :active, :position, :color)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
class ReportsController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
|
||||
def index
|
||||
@page_title = "Report"
|
||||
@reports = Reports::Builder.new(project: current_project)
|
||||
@funnel = @reports.funnel
|
||||
@lead_sources = @reports.lead_sources
|
||||
@won_lost = @reports.won_lost_by_month
|
||||
@lost_reasons = @reports.lost_reasons
|
||||
@sales_owners = @reports.sales_owners
|
||||
@revenue = @reports.revenue_by_month
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
class SearchController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
|
||||
def show
|
||||
@page_title = "Ricerca"
|
||||
@query = params[:q].to_s.strip
|
||||
if @query.present?
|
||||
@organizations = organizations_for_current_project.search(@query).includes(:assigned_user).limit(20)
|
||||
@contacts = Contact.joins(:organization).merge(organizations_for_current_project).search(@query).includes(:organization).limit(20)
|
||||
@opportunities = opportunities_for_current_project.joins(:organization)
|
||||
.where("opportunities.name ILIKE :q OR organizations.name ILIKE :q", q: "%#{ActiveRecord::Base.sanitize_sql_like(@query)}%")
|
||||
.includes(:organization)
|
||||
.limit(20)
|
||||
else
|
||||
@organizations = []
|
||||
@contacts = []
|
||||
@opportunities = []
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
class SessionsController < ApplicationController
|
||||
skip_before_action :require_login, only: %i[new create]
|
||||
skip_before_action :set_current_project, only: %i[new create], raise: false
|
||||
|
||||
def new
|
||||
redirect_to after_login_path if logged_in?
|
||||
end
|
||||
|
||||
def create
|
||||
user = User.active.find_by(email: params[:email].to_s.downcase.strip)
|
||||
|
||||
if user&.authenticate(params[:password])
|
||||
login_as(user)
|
||||
redirect_to after_login_path, notice: "Bentornato, #{user.first_name}!"
|
||||
else
|
||||
flash.now[:alert] = "Email o password non validi."
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
logout
|
||||
redirect_to login_path, notice: "Disconnesso correttamente."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def after_login_path
|
||||
projects = if current_user.admin?
|
||||
Project.active.ordered
|
||||
else
|
||||
current_user.accessible_projects
|
||||
end
|
||||
|
||||
if projects.one?
|
||||
project_root_path(project_code: projects.first.code)
|
||||
elsif (code = session[:last_project_code]) && projects.exists?(code: code)
|
||||
project_root_path(project_code: code)
|
||||
else
|
||||
root_path
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
class SettingsController < ApplicationController
|
||||
def show
|
||||
@page_title = "Impostazioni"
|
||||
@goal = SalesGoal.current(current_project).order(created_at: :desc).first || SalesGoal.new(project: current_project)
|
||||
@goals = SalesGoal.for_project(current_project).order(start_date: :desc)
|
||||
@products = Product.ordered
|
||||
@projects = Project.ordered
|
||||
end
|
||||
|
||||
def update_goal
|
||||
@goal = params[:id].present? ? SalesGoal.find(params[:id]) : SalesGoal.new
|
||||
attrs = goal_params.merge(active: true)
|
||||
attrs[:project_id] ||= current_project&.id
|
||||
if @goal.update(attrs)
|
||||
if params[:make_current] == "1" && @goal.project_id.present?
|
||||
SalesGoal.where(project_id: @goal.project_id).where.not(id: @goal.id).update_all(active: false)
|
||||
end
|
||||
redirect_to settings_path, notice: "Obiettivo aggiornato."
|
||||
else
|
||||
redirect_to settings_path, alert: @goal.errors.full_messages.to_sentence
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def goal_params
|
||||
params.require(:sales_goal).permit(:name, :metric, :target_value, :start_date, :end_date, :active, :project_id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
class TasksController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
before_action :set_task, only: %i[show edit update destroy complete]
|
||||
before_action :load_form_data, only: %i[new create edit update]
|
||||
|
||||
def index
|
||||
@page_title = "Task"
|
||||
tasks = tasks_for_current_project
|
||||
@overdue_tasks = tasks.overdue.includes(:organization, :contact, :assigned_user).ordered
|
||||
@today_tasks = tasks.due_today.includes(:organization, :contact, :assigned_user).ordered
|
||||
@upcoming_tasks = tasks.upcoming(30).includes(:organization, :contact, :assigned_user).ordered
|
||||
@completed_tasks = tasks.completed.includes(:organization).order(completed_at: :desc).limit(20)
|
||||
end
|
||||
|
||||
def show
|
||||
redirect_to @task.organization
|
||||
end
|
||||
|
||||
def new
|
||||
@page_title = "Nuovo task"
|
||||
@task = Task.new(
|
||||
organization_id: params[:organization_id],
|
||||
opportunity_id: params[:opportunity_id],
|
||||
contact_id: params[:contact_id],
|
||||
assigned_user: current_user,
|
||||
due_at: 1.day.from_now.change(hour: 10),
|
||||
priority: "normal",
|
||||
task_type: params[:task_type].presence || "follow_up"
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@task = Task.new(task_params)
|
||||
if @task.save
|
||||
redirect_to(@task.organization || tasks_path, notice: "Task creato.")
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@page_title = "Modifica task"
|
||||
end
|
||||
|
||||
def update
|
||||
if @task.update(task_params)
|
||||
redirect_to @task.organization, notice: "Task aggiornato."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
org = @task.organization
|
||||
@task.destroy!
|
||||
redirect_to org, notice: "Task eliminato."
|
||||
end
|
||||
|
||||
def complete
|
||||
if @task.complete!(user: current_user)
|
||||
redirect_back fallback_location: today_path, notice: "Task completato."
|
||||
else
|
||||
redirect_back fallback_location: today_path, alert: "Il task non può essere completato."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_task
|
||||
@task = Task.find(params[:id])
|
||||
end
|
||||
|
||||
def load_form_data
|
||||
@organizations = organizations_for_current_project.order(:name)
|
||||
@users = User.active.order(:first_name)
|
||||
end
|
||||
|
||||
def task_params
|
||||
params.require(:task).permit(
|
||||
:title, :description, :organization_id, :contact_id, :opportunity_id,
|
||||
:assigned_user_id, :due_at, :priority, :task_type, :status
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
class TodayController < ApplicationController
|
||||
before_action :require_current_project!
|
||||
|
||||
def show
|
||||
@page_title = "Oggi"
|
||||
tasks = tasks_for_current_project
|
||||
@overdue_tasks = tasks.overdue.includes(:organization, :contact, :opportunity, :assigned_user).ordered
|
||||
@today_tasks = tasks.due_today.includes(:organization, :contact, :opportunity, :assigned_user).ordered
|
||||
@upcoming_tasks = tasks.upcoming.includes(:organization, :contact, :opportunity, :assigned_user).ordered
|
||||
@stalled = opportunities_for_current_project.open_stage
|
||||
.where("stage_changed_at < ? OR (stage_changed_at IS NULL AND opportunities.created_at < ?)", 7.days.ago, 7.days.ago)
|
||||
.includes(:organization, :assigned_user, :tasks)
|
||||
@new_prospects = organizations_for_current_project.prospects
|
||||
.left_joins(:opportunities)
|
||||
.where("opportunities.id IS NULL OR (opportunities.project_id = ? AND opportunities.pipeline_stage = ?)", current_project.id, "to_contact")
|
||||
.includes(:contacts, :assigned_user, :opportunities)
|
||||
.distinct
|
||||
.limit(20)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
class UsersController < ApplicationController
|
||||
before_action :require_admin
|
||||
before_action :set_user, only: %i[edit update destroy]
|
||||
before_action :load_projects, only: %i[new create edit update]
|
||||
|
||||
def index
|
||||
@page_title = "Utenti"
|
||||
@users = User.includes(user_projects: :project).order(:first_name, :last_name)
|
||||
end
|
||||
|
||||
def new
|
||||
@page_title = "Nuovo utente"
|
||||
@user = User.new(role: "user", active: true)
|
||||
end
|
||||
|
||||
def create
|
||||
@page_title = "Nuovo utente"
|
||||
@user = User.new(user_params)
|
||||
if @user.save
|
||||
sync_user_projects!(@user, params[:project_ids]) unless @user.admin?
|
||||
redirect_to users_path, notice: "Utente creato."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@page_title = "Modifica utente"
|
||||
end
|
||||
|
||||
def update
|
||||
@page_title = "Modifica utente"
|
||||
attrs = user_params
|
||||
attrs.delete(:password) if attrs[:password].blank?
|
||||
attrs.delete(:password_confirmation) if attrs[:password].blank?
|
||||
attrs[:active] = ActiveModel::Type::Boolean.new.cast(attrs[:active]) if attrs.key?(:active)
|
||||
|
||||
if @user.update(attrs)
|
||||
sync_user_projects!(@user, params[:project_ids]) if !@user.admin? && params.key?(:project_ids)
|
||||
notice = if @user.saved_change_to_active?
|
||||
@user.active? ? "#{@user.full_name} riabilitato." : "#{@user.full_name} disabilitato."
|
||||
else
|
||||
"Utente aggiornato."
|
||||
end
|
||||
redirect_to users_path, notice: notice
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @user == current_user
|
||||
redirect_to users_path, alert: "Non puoi eliminare il tuo account." and return
|
||||
end
|
||||
|
||||
unless @user.can_be_destroyed?
|
||||
redirect_to users_path, alert: "Non puoi eliminare l'unico amministratore attivo." and return
|
||||
end
|
||||
|
||||
name = @user.full_name
|
||||
@user.destroy!
|
||||
redirect_to users_path, notice: "#{name} eliminato."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = User.find(params[:id])
|
||||
end
|
||||
|
||||
def load_projects
|
||||
@projects = Project.ordered
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:email, :first_name, :last_name, :role, :password, :password_confirmation, :active)
|
||||
end
|
||||
|
||||
def sync_user_projects!(user, selected_ids)
|
||||
selected_ids = Array(selected_ids).map(&:presence).compact.map(&:to_i)
|
||||
Project.find_each do |project|
|
||||
up = user.user_projects.find_or_initialize_by(project: project)
|
||||
up.enabled = selected_ids.include?(project.id)
|
||||
up.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,180 @@
|
||||
module ApplicationHelper
|
||||
include Pagy::Frontend
|
||||
|
||||
def app_name
|
||||
Rails.application.config.x.app_name
|
||||
end
|
||||
|
||||
def btn_primary
|
||||
"btn-primary inline-flex items-center justify-center rounded-lg px-3.5 py-2 text-sm font-medium"
|
||||
end
|
||||
|
||||
def btn_secondary
|
||||
"btn-secondary inline-flex items-center justify-center rounded-lg px-3.5 py-2 text-sm font-medium"
|
||||
end
|
||||
|
||||
def btn_danger
|
||||
"btn-danger inline-flex items-center justify-center rounded-lg px-3.5 py-2 text-sm font-medium"
|
||||
end
|
||||
|
||||
def input_class
|
||||
"w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-400 focus:outline-none focus:ring-1 focus:ring-zinc-400 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-zinc-500 dark:focus:ring-zinc-500"
|
||||
end
|
||||
|
||||
def card_class
|
||||
"rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"
|
||||
end
|
||||
|
||||
def table_class
|
||||
"min-w-full text-sm text-zinc-900 dark:text-zinc-100"
|
||||
end
|
||||
|
||||
def status_badge(status)
|
||||
colors = {
|
||||
"prospect" => "bg-sky-100 text-sky-800",
|
||||
"active_customer" => "bg-emerald-100 text-emerald-800",
|
||||
"inactive_customer" => "bg-slate-100 text-slate-700",
|
||||
"partner" => "bg-violet-100 text-violet-800",
|
||||
"lost" => "bg-rose-100 text-rose-800"
|
||||
}
|
||||
content_tag :span, Catalog.label_for(Catalog::ORGANIZATION_STATUSES, status),
|
||||
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[status] || 'bg-slate-100 text-slate-700'}"
|
||||
end
|
||||
|
||||
def stage_badge(stage)
|
||||
colors = {
|
||||
"to_contact" => "bg-slate-100 text-slate-700",
|
||||
"contacted" => "bg-sky-100 text-sky-800",
|
||||
"replied" => "bg-cyan-100 text-cyan-800",
|
||||
"interested" => "bg-amber-100 text-amber-800",
|
||||
"demo_trial" => "bg-orange-100 text-orange-800",
|
||||
"first_use" => "bg-lime-100 text-lime-800",
|
||||
"proposal" => "bg-indigo-100 text-indigo-800",
|
||||
"won" => "bg-emerald-100 text-emerald-800",
|
||||
"lost" => "bg-rose-100 text-rose-800"
|
||||
}
|
||||
content_tag :span, Catalog.label_for(Catalog::PIPELINE_STAGES, stage),
|
||||
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[stage] || 'bg-slate-100 text-slate-700'}"
|
||||
end
|
||||
|
||||
def priority_badge(priority)
|
||||
colors = {
|
||||
"low" => "bg-slate-100 text-slate-600",
|
||||
"normal" => "bg-sky-100 text-sky-700",
|
||||
"high" => "bg-amber-100 text-amber-800",
|
||||
"urgent" => "bg-rose-100 text-rose-800"
|
||||
}
|
||||
content_tag :span, Catalog.label_for(Catalog::TASK_PRIORITIES, priority),
|
||||
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[priority] || 'bg-slate-100'}"
|
||||
end
|
||||
|
||||
def format_money(value)
|
||||
number_to_currency(value.to_f, unit: "€", separator: ",", delimiter: ".", format: "%n %u")
|
||||
end
|
||||
|
||||
def format_dt(value)
|
||||
return "—" if value.blank?
|
||||
|
||||
I18n.l(value, format: :short)
|
||||
end
|
||||
|
||||
def format_date(value)
|
||||
return "—" if value.blank?
|
||||
|
||||
I18n.l(value.to_date, format: :default)
|
||||
end
|
||||
|
||||
def progress_bar(percentage, color: "bg-emerald-500")
|
||||
content_tag :div, class: "h-3 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-zinc-700" do
|
||||
content_tag :div, "", class: "h-full #{color} transition-all", style: "width: #{percentage.to_i}%"
|
||||
end
|
||||
end
|
||||
|
||||
def nav_link(label, path, icon: nil)
|
||||
home = begin
|
||||
project_root_path
|
||||
rescue StandardError
|
||||
root_path
|
||||
end
|
||||
active = current_page?(path) || (path != home && request.fullpath.start_with?(path.split("?").first))
|
||||
classes = if active
|
||||
"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
|
||||
else
|
||||
"text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
|
||||
end
|
||||
link_to path, class: "flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium #{classes}" do
|
||||
label
|
||||
end
|
||||
end
|
||||
|
||||
def options_for_catalog(hash, selected = nil)
|
||||
options_for_select(hash.map { |k, v| [v, k] }, selected)
|
||||
end
|
||||
|
||||
def mailing_status_badge(status)
|
||||
colors = {
|
||||
"draft" => "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200",
|
||||
"sending" => "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
|
||||
"sent" => "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
|
||||
"cancelled" => "bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-200"
|
||||
}
|
||||
content_tag :span, Catalog.label_for(Catalog::MAILING_STATUSES, status),
|
||||
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[status] || 'bg-zinc-100 text-zinc-700'}"
|
||||
end
|
||||
|
||||
def mailing_recipient_status_badge(status)
|
||||
colors = {
|
||||
"pending" => "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-200",
|
||||
"skipped" => "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300",
|
||||
"queued" => "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
|
||||
"sent" => "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
|
||||
"failed" => "bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-200"
|
||||
}
|
||||
content_tag :span, Catalog.label_for(Catalog::MAILING_RECIPIENT_STATUSES, status),
|
||||
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[status] || 'bg-zinc-100 text-zinc-700'}"
|
||||
end
|
||||
|
||||
def ab_variant_badge(variant)
|
||||
return if variant.blank?
|
||||
|
||||
colors = {
|
||||
"A" => "bg-indigo-100 text-indigo-800 dark:bg-indigo-950 dark:text-indigo-200",
|
||||
"B" => "bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-950 dark:text-fuchsia-200"
|
||||
}
|
||||
content_tag :span, "Test #{variant}",
|
||||
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[variant] || 'bg-zinc-100 text-zinc-700'}"
|
||||
end
|
||||
|
||||
def sanitize_email_html(html)
|
||||
sanitize(
|
||||
html.to_s,
|
||||
tags: %w[p br strong b em i u a ul ol li h1 h2 h3 h4 h5 blockquote pre hr img figure figcaption span div],
|
||||
attributes: %w[href src alt style class width height]
|
||||
)
|
||||
end
|
||||
|
||||
def streaming_badge(status)
|
||||
return if status.blank?
|
||||
|
||||
colors = {
|
||||
"not_detected" => "bg-zinc-100 text-zinc-700",
|
||||
"limited" => "bg-amber-100 text-amber-800",
|
||||
"yes_partial" => "bg-sky-100 text-sky-800",
|
||||
"yes" => "bg-emerald-100 text-emerald-800",
|
||||
"yes_sportcam" => "bg-emerald-100 text-emerald-800"
|
||||
}
|
||||
content_tag :span, Catalog.label_for(Catalog::STREAMING_STATUSES, status),
|
||||
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[status] || 'bg-zinc-100 text-zinc-700'}"
|
||||
end
|
||||
|
||||
def yes_no(value)
|
||||
value ? "Sì" : "No"
|
||||
end
|
||||
|
||||
def external_link(url, **options)
|
||||
return "—" if url.blank?
|
||||
|
||||
href = url.match?(%r{\Ahttps?://}i) ? url : "https://#{url}"
|
||||
link_to url, href, target: "_blank", rel: "noopener", **options
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
import "trix"
|
||||
import "@hotwired/turbo-rails"
|
||||
import "controllers"
|
||||
|
||||
if (window.Trix?.config?.lang) {
|
||||
Object.assign(window.Trix.config.lang, {
|
||||
attachFiles: "Inserisci immagine",
|
||||
bold: "Grassetto",
|
||||
bullets: "Elenco puntato",
|
||||
captionPlaceholder: "Didascalia…",
|
||||
code: "Codice",
|
||||
heading1: "Titolo",
|
||||
indent: "Aumenta rientro",
|
||||
italic: "Corsivo",
|
||||
link: "Link",
|
||||
numbers: "Elenco numerato",
|
||||
outdent: "Riduci rientro",
|
||||
quote: "Citazione",
|
||||
redo: "Ripeti",
|
||||
remove: "Rimuovi",
|
||||
strike: "Barrato",
|
||||
undo: "Annulla",
|
||||
unlink: "Togli link",
|
||||
url: "URL",
|
||||
urlPlaceholder: "https://…"
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["checkbox", "panel", "grid"]
|
||||
|
||||
connect() {
|
||||
this.sync()
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.sync()
|
||||
}
|
||||
|
||||
sync() {
|
||||
if (!this.hasCheckboxTarget) return
|
||||
|
||||
const on = this.checkboxTarget.checked
|
||||
this.panelTargets.forEach((el) => el.classList.toggle("hidden", !on))
|
||||
if (this.hasGridTarget) this.gridTarget.classList.toggle("lg:grid-cols-2", on)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Application } from "@hotwired/stimulus"
|
||||
|
||||
const application = Application.start()
|
||||
|
||||
// Configure Stimulus development experience
|
||||
application.debug = false
|
||||
window.Stimulus = application
|
||||
|
||||
export { application }
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["source", "checkbox"]
|
||||
|
||||
toggle() {
|
||||
const checked = this.sourceTarget.checked
|
||||
this.checkboxTargets.forEach((el) => {
|
||||
if (!el.disabled) el.checked = checked
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["menu"]
|
||||
|
||||
toggle(event) {
|
||||
event.stopPropagation()
|
||||
this.menuTarget.classList.toggle("hidden")
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.boundClose = this.close.bind(this)
|
||||
document.addEventListener("click", this.boundClose)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("click", this.boundClose)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.menuTarget.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = "Hello World!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Import and register all your controllers from the importmap via controllers/**/*_controller
|
||||
import { application } from "controllers/application"
|
||||
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||
eagerLoadControllersFrom("controllers", application)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { projectCode: String }
|
||||
|
||||
dragstart(event) {
|
||||
event.dataTransfer.setData("text/plain", event.target.dataset.opportunityId)
|
||||
event.dataTransfer.effectAllowed = "move"
|
||||
}
|
||||
|
||||
dragover(event) {
|
||||
event.preventDefault()
|
||||
event.currentTarget.classList.add("ring-2", "ring-emerald-400")
|
||||
}
|
||||
|
||||
dragleave(event) {
|
||||
event.currentTarget.classList.remove("ring-2", "ring-emerald-400")
|
||||
}
|
||||
|
||||
async drop(event) {
|
||||
event.preventDefault()
|
||||
event.currentTarget.classList.remove("ring-2", "ring-emerald-400")
|
||||
|
||||
const opportunityId = event.dataTransfer.getData("text/plain")
|
||||
const stage = event.currentTarget.dataset.stage
|
||||
if (!opportunityId || !stage) return
|
||||
|
||||
if (stage === "lost") {
|
||||
const reason = prompt("Motivo perdita (no_response, not_interested, price, competitor, no_streaming, timing, technical, deferred, other):", "other")
|
||||
if (!reason) return
|
||||
await this.updateStage(opportunityId, stage, reason)
|
||||
} else {
|
||||
await this.updateStage(opportunityId, stage)
|
||||
}
|
||||
}
|
||||
|
||||
async updateStage(opportunityId, stage, lostReason = null) {
|
||||
const token = document.querySelector("meta[name='csrf-token']").content
|
||||
const body = { pipeline_stage: stage }
|
||||
if (lostReason) body.lost_reason = lostReason
|
||||
|
||||
const response = await fetch(`/p/${this.projectCodeValue}/opportunities/${opportunityId}/update_stage`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": token,
|
||||
"Accept": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
alert(data.errors?.join(", ") || "Impossibile aggiornare lo stage")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { insertMergeToken } from "controllers/wysiwyg_controller"
|
||||
|
||||
export default class extends Controller {
|
||||
insert(event) {
|
||||
event.preventDefault()
|
||||
insertMergeToken(`{{${event.params.token}}}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["panel"]
|
||||
static values = { open: Boolean }
|
||||
|
||||
connect() {
|
||||
if (this.openValue) this.show()
|
||||
this.boundKey = this.onKey.bind(this)
|
||||
document.addEventListener("keydown", this.boundKey)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("keydown", this.boundKey)
|
||||
}
|
||||
|
||||
show() {
|
||||
this.panelTarget.classList.remove("hidden")
|
||||
this.panelTarget.querySelector("input, textarea, select")?.focus()
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.panelTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
onKey(event) {
|
||||
if (event.key === "Escape") this.hide()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
toggle() {
|
||||
const next = document.documentElement.classList.contains("dark") ? "light" : "dark"
|
||||
this.apply(next)
|
||||
}
|
||||
|
||||
apply(theme) {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark")
|
||||
try { localStorage.setItem("theme", theme) } catch (_) { /* ignore */ }
|
||||
document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
let activeEditor = null
|
||||
|
||||
function insertAtCursor(field, text) {
|
||||
const start = field.selectionStart ?? field.value.length
|
||||
const end = field.selectionEnd ?? field.value.length
|
||||
field.value = `${field.value.slice(0, start)}${text}${field.value.slice(end)}`
|
||||
const pos = start + text.length
|
||||
field.setSelectionRange(pos, pos)
|
||||
field.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
field.focus()
|
||||
}
|
||||
|
||||
export function insertMergeToken(token) {
|
||||
const field = document.activeElement
|
||||
if (field && field.matches("input:not([type=hidden]):not([type=checkbox]):not([type=radio]), textarea")) {
|
||||
insertAtCursor(field, token)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeEditor) {
|
||||
activeEditor.insertString(token)
|
||||
return
|
||||
}
|
||||
|
||||
document.querySelector("trix-editor")?.editor?.insertString(token)
|
||||
}
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "editor"]
|
||||
static values = { uploadUrl: String }
|
||||
|
||||
connect() {
|
||||
this.onFocus = () => {
|
||||
activeEditor = this.editorTarget.editor
|
||||
}
|
||||
this.onEditorClick = this.onEditorClick.bind(this)
|
||||
this.onHandlePointerDown = this.onHandlePointerDown.bind(this)
|
||||
this.onPointerMove = this.onPointerMove.bind(this)
|
||||
this.onPointerUp = this.onPointerUp.bind(this)
|
||||
|
||||
this.editorTarget.addEventListener("trix-focus", this.onFocus)
|
||||
this.editorTarget.addEventListener("click", this.onEditorClick)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.editorTarget.removeEventListener("trix-focus", this.onFocus)
|
||||
this.editorTarget.removeEventListener("click", this.onEditorClick)
|
||||
this.teardownResize()
|
||||
if (activeEditor === this.editorTarget.editor) activeEditor = null
|
||||
}
|
||||
|
||||
acceptFile(event) {
|
||||
const file = event.file
|
||||
if (file && file.type.startsWith("image/")) return
|
||||
|
||||
event.preventDefault()
|
||||
window.alert("Puoi inserire solo immagini (JPG, PNG, GIF o WebP).")
|
||||
}
|
||||
|
||||
async upload(event) {
|
||||
const attachment = event.attachment
|
||||
if (!attachment.file) return
|
||||
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
const form = new FormData()
|
||||
form.append("file", attachment.file)
|
||||
|
||||
try {
|
||||
const response = await fetch(this.uploadUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-CSRF-Token": csrf
|
||||
},
|
||||
body: form
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
attachment.remove()
|
||||
window.alert(data.error || "Upload immagine non riuscito.")
|
||||
return
|
||||
}
|
||||
|
||||
attachment.setAttributes({
|
||||
url: data.url,
|
||||
href: data.url
|
||||
})
|
||||
} catch (_error) {
|
||||
attachment.remove()
|
||||
window.alert("Upload immagine non riuscito.")
|
||||
}
|
||||
}
|
||||
|
||||
onEditorClick(event) {
|
||||
if (event.target.closest(".trix-resize-handle")) return
|
||||
|
||||
const figure = event.target.closest("figure.attachment")
|
||||
if (figure && this.editorTarget.contains(figure) && figure.querySelector("img")) {
|
||||
this.showHandles(figure)
|
||||
return
|
||||
}
|
||||
|
||||
this.clearHandles()
|
||||
}
|
||||
|
||||
showHandles(figure) {
|
||||
this.clearHandles()
|
||||
figure.classList.add("is-resizing")
|
||||
;["nw", "ne", "sw", "se"].forEach((corner) => {
|
||||
const handle = document.createElement("span")
|
||||
handle.className = `trix-resize-handle trix-resize-handle--${corner}`
|
||||
handle.dataset.corner = corner
|
||||
handle.addEventListener("pointerdown", this.onHandlePointerDown)
|
||||
figure.appendChild(handle)
|
||||
})
|
||||
this.resizeFigure = figure
|
||||
}
|
||||
|
||||
clearHandles() {
|
||||
this.editorTarget.querySelectorAll(".trix-resize-handle").forEach((handle) => handle.remove())
|
||||
this.editorTarget.querySelectorAll("figure.attachment.is-resizing").forEach((figure) => {
|
||||
figure.classList.remove("is-resizing")
|
||||
})
|
||||
this.resizeFigure = null
|
||||
}
|
||||
|
||||
onHandlePointerDown(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const figure = event.currentTarget.closest("figure.attachment")
|
||||
const img = figure?.querySelector("img")
|
||||
if (!img) return
|
||||
|
||||
const rect = img.getBoundingClientRect()
|
||||
this.drag = {
|
||||
figure,
|
||||
img,
|
||||
corner: event.currentTarget.dataset.corner,
|
||||
startX: event.clientX,
|
||||
startW: rect.width,
|
||||
ratio: rect.height > 0 ? rect.width / rect.height : 1
|
||||
}
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
window.addEventListener("pointermove", this.onPointerMove)
|
||||
window.addEventListener("pointerup", this.onPointerUp)
|
||||
}
|
||||
|
||||
onPointerMove(event) {
|
||||
const drag = this.drag
|
||||
if (!drag) return
|
||||
|
||||
const dx = event.clientX - drag.startX
|
||||
const grow = drag.corner.includes("e") ? dx : -dx
|
||||
const max = Math.max(80, this.editorTarget.clientWidth - 32)
|
||||
const width = Math.round(Math.min(max, Math.max(64, drag.startW + grow)))
|
||||
const height = Math.round(width / drag.ratio)
|
||||
|
||||
drag.img.style.width = `${width}px`
|
||||
drag.img.style.height = "auto"
|
||||
drag.img.setAttribute("width", String(width))
|
||||
drag.img.setAttribute("height", String(height))
|
||||
drag.width = width
|
||||
drag.height = height
|
||||
}
|
||||
|
||||
onPointerUp() {
|
||||
window.removeEventListener("pointermove", this.onPointerMove)
|
||||
window.removeEventListener("pointerup", this.onPointerUp)
|
||||
this.commitResize()
|
||||
}
|
||||
|
||||
commitResize() {
|
||||
const drag = this.drag
|
||||
this.drag = null
|
||||
if (!drag?.figure || !drag.width) return
|
||||
|
||||
const editor = this.editorTarget.editor
|
||||
editor?.recordUndoEntry?.("Ridimensiona immagine")
|
||||
|
||||
const attachment = this.findAttachment(drag.figure)
|
||||
if (attachment) {
|
||||
attachment.setAttributes({ width: drag.width, height: drag.height })
|
||||
requestAnimationFrame(() => {
|
||||
const next = this.findFigureByUrl(attachment.getURL?.() || attachment.getAttributes?.().url)
|
||||
if (next) this.showHandles(next)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
drag.img.style.width = `${drag.width}px`
|
||||
drag.img.style.height = "auto"
|
||||
this.editorTarget.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
}
|
||||
|
||||
findAttachment(figure) {
|
||||
const raw = figure.getAttribute("data-trix-attachment")
|
||||
if (!raw) return null
|
||||
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
|
||||
const attachments = this.editorTarget.editor?.getDocument?.().getAttachments?.() || []
|
||||
return attachments.find((attachment) => {
|
||||
const url = attachment.getURL?.() || attachment.getAttributes?.().url
|
||||
return url && data.url && url === data.url
|
||||
})
|
||||
}
|
||||
|
||||
findFigureByUrl(url) {
|
||||
if (!url) return null
|
||||
return Array.from(this.editorTarget.querySelectorAll("figure.attachment")).find((figure) => {
|
||||
const raw = figure.getAttribute("data-trix-attachment") || ""
|
||||
return raw.includes(url)
|
||||
})
|
||||
}
|
||||
|
||||
teardownResize() {
|
||||
this.clearHandles()
|
||||
window.removeEventListener("pointermove", this.onPointerMove)
|
||||
window.removeEventListener("pointerup", this.onPointerUp)
|
||||
this.drag = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
class SendMailingRecipientJob < ApplicationJob
|
||||
queue_as :mailers
|
||||
|
||||
def perform(recipient_id)
|
||||
recipient = MailingRecipient.find_by(id: recipient_id)
|
||||
return if recipient.nil?
|
||||
|
||||
mailing = recipient.mailing
|
||||
return unless mailing.sending?
|
||||
return unless recipient.pending? || recipient.status == "queued"
|
||||
|
||||
begin
|
||||
CampaignMailer.raise_delivery_errors = true
|
||||
recipient.deliver!
|
||||
ensure
|
||||
CampaignMailer.raise_delivery_errors = false if Rails.env.development?
|
||||
end
|
||||
|
||||
nxt = mailing.mailing_recipients.pending.order(:id).first
|
||||
return if nxt.nil?
|
||||
|
||||
wait = mailing.interval_seconds.to_i.seconds
|
||||
if wait.positive?
|
||||
self.class.set(wait: wait).perform_later(nxt.id)
|
||||
else
|
||||
self.class.perform_later(nxt.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: ENV.fetch("MAILER_FROM", "noreply@simplecrm.local")
|
||||
layout "mailer"
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class CampaignMailer < ApplicationMailer
|
||||
layout false
|
||||
|
||||
def outreach(recipient, html:, subject:)
|
||||
mailing = recipient.mailing
|
||||
identity = mailing.mail_identity
|
||||
html = Mailings::InlineImages.call(self, html)
|
||||
|
||||
mailing.files.each do |file|
|
||||
attachments[file.filename.to_s] = {
|
||||
mime_type: file.content_type,
|
||||
content: file.download
|
||||
}
|
||||
end
|
||||
|
||||
mail(
|
||||
from: identity.from_header,
|
||||
to: recipient.email,
|
||||
reply_to: identity.reply_to.presence,
|
||||
subject: subject,
|
||||
delivery_method_options: identity.smtp_settings
|
||||
) do |format|
|
||||
format.html { render html: wrap_html(html).html_safe }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def wrap_html(html)
|
||||
return html if html.to_s.match?(/<html[\s>]/i)
|
||||
|
||||
<<~HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: system-ui, sans-serif; line-height: 1.5; color: #18181b;">
|
||||
#{html}
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class PasswordMailer < ApplicationMailer
|
||||
def reset(user)
|
||||
@user = user
|
||||
@url = edit_password_resets_url(token: user.password_reset_token)
|
||||
mail(to: user.email, subject: "Reset password #{Rails.application.config.x.app_name}")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
class Activity < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :organization
|
||||
belongs_to :contact, optional: true
|
||||
belongs_to :opportunity, optional: true
|
||||
|
||||
validates :activity_type, inclusion: { in: Catalog::ACTIVITY_TYPES.keys }
|
||||
validates :subject, :happened_at, presence: true
|
||||
|
||||
scope :recent_first, -> { order(happened_at: :desc, id: :desc) }
|
||||
scope :chronological, -> { order(happened_at: :asc, id: :asc) }
|
||||
|
||||
def activity_type_label
|
||||
Catalog.label_for(Catalog::ACTIVITY_TYPES, activity_type)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
@@ -0,0 +1,193 @@
|
||||
module Catalog
|
||||
ORGANIZATION_STATUSES = {
|
||||
"prospect" => "Prospect",
|
||||
"active_customer" => "Cliente attivo",
|
||||
"inactive_customer" => "Cliente inattivo",
|
||||
"partner" => "Partner",
|
||||
"lost" => "Perso"
|
||||
}.freeze
|
||||
|
||||
ORGANIZATION_TYPES = {
|
||||
"societa_sportiva" => "Società sportiva",
|
||||
"organizzatore_evento" => "Organizzatore evento",
|
||||
"federazione" => "Federazione",
|
||||
"comitato" => "Comitato",
|
||||
"azienda" => "Azienda",
|
||||
"altro" => "Altro"
|
||||
}.freeze
|
||||
|
||||
TEAM_GENDERS = {
|
||||
"female" => "Femminile",
|
||||
"male" => "Maschile",
|
||||
"mixed" => "Maschile e femminile"
|
||||
}.freeze
|
||||
|
||||
STREAMING_STATUSES = {
|
||||
"not_detected" => "Non rilevato",
|
||||
"limited" => "Limitato",
|
||||
"yes_partial" => "Sì / parziale",
|
||||
"yes" => "Sì",
|
||||
"yes_sportcam" => "Sì – SportCam"
|
||||
}.freeze
|
||||
|
||||
SEND_STATUSES = {
|
||||
"to_send" => "Da inviare",
|
||||
"sent" => "Inviato",
|
||||
"no_send" => "Non inviare"
|
||||
}.freeze
|
||||
|
||||
AB_VARIANTS = {
|
||||
"A" => "Test A",
|
||||
"B" => "Test B"
|
||||
}.freeze
|
||||
|
||||
MAIL_ENCRYPTIONS = {
|
||||
"starttls" => "STARTTLS (porta 587)",
|
||||
"tls" => "SSL/TLS (porta 465)",
|
||||
"none" => "Nessuna"
|
||||
}.freeze
|
||||
|
||||
MAIL_AUTH_METHODS = {
|
||||
"plain" => "PLAIN",
|
||||
"login" => "LOGIN",
|
||||
"cram_md5" => "CRAM-MD5"
|
||||
}.freeze
|
||||
|
||||
MAILING_AUDIENCES = {
|
||||
"to_send" => "Da inviare (campagna)",
|
||||
"test_a" => "Solo chi è già Test A in scheda",
|
||||
"test_b" => "Solo chi è già Test B in scheda",
|
||||
"all" => "Tutte le organizzazioni del progetto"
|
||||
}.freeze
|
||||
|
||||
MAILING_AB_ASSIGNMENTS = {
|
||||
"from_record" => "Usa il Test A/B già in scheda",
|
||||
"split" => "Suddividi a metà (A / B)"
|
||||
}.freeze
|
||||
|
||||
MAILING_STATUSES = {
|
||||
"draft" => "Bozza",
|
||||
"sending" => "Invio in corso",
|
||||
"sent" => "Inviata",
|
||||
"cancelled" => "Annullata"
|
||||
}.freeze
|
||||
|
||||
MAILING_RECIPIENT_STATUSES = {
|
||||
"pending" => "Da inviare",
|
||||
"skipped" => "Escluso",
|
||||
"queued" => "In coda",
|
||||
"sent" => "Inviata",
|
||||
"failed" => "Errore"
|
||||
}.freeze
|
||||
|
||||
PIPELINE_STAGES = {
|
||||
"to_contact" => "Da contattare",
|
||||
"contacted" => "Contattato",
|
||||
"replied" => "Ha risposto",
|
||||
"interested" => "Interessato",
|
||||
"demo_trial" => "Demo / Trial",
|
||||
"first_use" => "Primo utilizzo",
|
||||
"proposal" => "Proposta",
|
||||
"won" => "Cliente",
|
||||
"lost" => "Perso"
|
||||
}.freeze
|
||||
|
||||
PIPELINE_ORDER = PIPELINE_STAGES.keys.freeze
|
||||
|
||||
OPEN_PIPELINE_STAGES = %w[to_contact contacted replied interested demo_trial first_use proposal].freeze
|
||||
|
||||
LEAD_SOURCES = {
|
||||
"outbound" => "Contatto diretto",
|
||||
"campaign" => "Campagna lancio",
|
||||
"referral" => "Referral",
|
||||
"federation" => "Federazione/comitato",
|
||||
"event" => "Evento",
|
||||
"website" => "Sito",
|
||||
"organic" => "Organico",
|
||||
"partner" => "Partner",
|
||||
"social" => "Social",
|
||||
"other" => "Altro"
|
||||
}.freeze
|
||||
|
||||
LOST_REASONS = {
|
||||
"no_response" => "Nessuna risposta",
|
||||
"not_interested" => "Non interessato",
|
||||
"price" => "Prezzo",
|
||||
"competitor" => "Usa già altra soluzione",
|
||||
"no_streaming" => "Non fa streaming",
|
||||
"timing" => "Timing",
|
||||
"technical" => "Problema tecnico",
|
||||
"deferred" => "Decisione rimandata",
|
||||
"other" => "Altro"
|
||||
}.freeze
|
||||
|
||||
ACTIVITY_TYPES = {
|
||||
"note" => "Nota",
|
||||
"email_sent" => "Email inviata",
|
||||
"email_received" => "Email ricevuta",
|
||||
"call" => "Telefonata",
|
||||
"meeting" => "Meeting",
|
||||
"demo" => "Demo",
|
||||
"trial_started" => "Trial attivato",
|
||||
"follow_up" => "Follow-up",
|
||||
"first_use" => "Primo utilizzo",
|
||||
"proposal_sent" => "Proposta inviata",
|
||||
"won" => "Vinto",
|
||||
"lost" => "Perso",
|
||||
"other" => "Altro"
|
||||
}.freeze
|
||||
|
||||
TASK_TYPES = {
|
||||
"follow_up" => "Follow-up",
|
||||
"call" => "Chiamata",
|
||||
"email" => "Email",
|
||||
"meeting" => "Meeting",
|
||||
"demo" => "Demo",
|
||||
"proposal" => "Proposta",
|
||||
"generic" => "Generico"
|
||||
}.freeze
|
||||
|
||||
TASK_PRIORITIES = {
|
||||
"low" => "Bassa",
|
||||
"normal" => "Normale",
|
||||
"high" => "Alta",
|
||||
"urgent" => "Urgente"
|
||||
}.freeze
|
||||
|
||||
TASK_STATUSES = {
|
||||
"pending" => "In corso",
|
||||
"completed" => "Completato",
|
||||
"cancelled" => "Annullato"
|
||||
}.freeze
|
||||
|
||||
CONTACT_METHODS = {
|
||||
"email" => "Email",
|
||||
"phone" => "Telefono",
|
||||
"mobile" => "Cellulare",
|
||||
"whatsapp" => "WhatsApp",
|
||||
"meeting" => "Incontro"
|
||||
}.freeze
|
||||
|
||||
GOAL_METRICS = {
|
||||
"customers_acquired" => "Clienti acquisiti",
|
||||
"won_value" => "Valore opportunità vinte",
|
||||
"trials" => "Numero trial",
|
||||
"first_uses" => "Numero primi utilizzi"
|
||||
}.freeze
|
||||
|
||||
STAGE_PROBABILITIES = {
|
||||
"to_contact" => 5,
|
||||
"contacted" => 10,
|
||||
"replied" => 20,
|
||||
"interested" => 40,
|
||||
"demo_trial" => 55,
|
||||
"first_use" => 70,
|
||||
"proposal" => 80,
|
||||
"won" => 100,
|
||||
"lost" => 0
|
||||
}.freeze
|
||||
|
||||
def self.label_for(hash, key)
|
||||
hash[key.to_s] || key.to_s.humanize
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
module Auditable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
belongs_to :created_by, class_name: "User", optional: true
|
||||
belongs_to :updated_by, class_name: "User", optional: true
|
||||
|
||||
before_create :set_created_by
|
||||
before_save :set_updated_by
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_created_by
|
||||
self.created_by_id ||= Current.user&.id
|
||||
end
|
||||
|
||||
def set_updated_by
|
||||
self.updated_by_id = Current.user&.id if Current.user
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
module HtmlBlankable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def clears_blank_html(*attributes)
|
||||
before_validation do
|
||||
attributes.each do |attribute|
|
||||
value = public_send(attribute)
|
||||
public_send("#{attribute}=", "") if HtmlBlankable.blank_html?(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.blank_html?(html)
|
||||
html.to_s.gsub(/<[^>]*>/, " ").gsub(" ", " ").gsub(/\s+/, " ").strip.blank?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class Contact < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :organization
|
||||
has_many :activities, dependent: :nullify
|
||||
has_many :tasks, dependent: :nullify
|
||||
has_many :mailing_recipients, dependent: :nullify
|
||||
|
||||
validates :first_name, :last_name, presence: true
|
||||
validates :preferred_contact_method, inclusion: { in: Catalog::CONTACT_METHODS.keys }, allow_blank: true
|
||||
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
|
||||
|
||||
before_save :ensure_single_primary, if: -> { primary_contact? && organization_id.present? }
|
||||
|
||||
scope :primary_first, -> { order(primary_contact: :desc, last_name: :asc, first_name: :asc) }
|
||||
scope :search, ->(query) {
|
||||
return all if query.blank?
|
||||
|
||||
q = "%#{sanitize_sql_like(query.strip)}%"
|
||||
where(
|
||||
"first_name ILIKE :q OR last_name ILIKE :q OR email ILIKE :q OR phone ILIKE :q OR mobile ILIKE :q",
|
||||
q: q
|
||||
)
|
||||
}
|
||||
|
||||
def full_name
|
||||
"#{first_name} #{last_name}"
|
||||
end
|
||||
|
||||
def preferred_contact_method_label
|
||||
Catalog.label_for(Catalog::CONTACT_METHODS, preferred_contact_method)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_single_primary
|
||||
scope = organization.contacts
|
||||
scope = scope.where.not(id: id) if persisted?
|
||||
scope.update_all(primary_contact: false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
attribute :project
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class MailIdentity < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
encrypts :smtp_password
|
||||
|
||||
require "openssl"
|
||||
|
||||
has_many :mailings, dependent: :restrict_with_exception
|
||||
|
||||
ENCRYPTIONS = Catalog::MAIL_ENCRYPTIONS.keys.freeze
|
||||
|
||||
validates :name, :from_name, :from_email, :smtp_host, :smtp_port, presence: true
|
||||
validates :from_email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :reply_to, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
|
||||
validates :smtp_port, numericality: { in: 1..65535 }
|
||||
validates :encryption, inclusion: { in: ENCRYPTIONS }
|
||||
validates :smtp_authentication, inclusion: { in: Catalog::MAIL_AUTH_METHODS.keys }
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(:name) }
|
||||
|
||||
def from_header
|
||||
%(#{from_name} <#{from_email}>)
|
||||
end
|
||||
|
||||
def smtp_settings
|
||||
settings = {
|
||||
address: smtp_host,
|
||||
port: smtp_port,
|
||||
enable_starttls_auto: encryption == "starttls",
|
||||
ssl: encryption == "tls",
|
||||
openssl_verify_mode: verify_ssl? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
||||
}
|
||||
if smtp_username.present?
|
||||
settings[:user_name] = smtp_username
|
||||
settings[:password] = smtp_password
|
||||
settings[:authentication] = smtp_authentication.to_sym
|
||||
end
|
||||
settings
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class MailTemplate < ApplicationRecord
|
||||
include Auditable
|
||||
include HtmlBlankable
|
||||
|
||||
belongs_to :project, optional: true
|
||||
has_many :mailings, dependent: :nullify
|
||||
has_many :mailings_as_b, class_name: "Mailing", foreign_key: :mail_template_b_id, dependent: :nullify
|
||||
|
||||
clears_blank_html :body_html
|
||||
validates :name, :subject, :body_html, presence: true
|
||||
|
||||
scope :for_project, ->(project) {
|
||||
where(project_id: [nil, project&.id].compact).order(:name)
|
||||
}
|
||||
|
||||
def preview_html(organization: nil, contact: nil, project: nil, opportunity: nil)
|
||||
MailMerge.render(body_html, organization:, contact:, project:, opportunity:)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
class Mailing < ApplicationRecord
|
||||
include Auditable
|
||||
include HtmlBlankable
|
||||
|
||||
belongs_to :project
|
||||
belongs_to :mail_identity
|
||||
belongs_to :mail_template, optional: true
|
||||
belongs_to :mail_template_b, class_name: "MailTemplate", optional: true
|
||||
has_many :mailing_recipients, dependent: :destroy
|
||||
has_many :organizations, through: :mailing_recipients
|
||||
has_many_attached :files
|
||||
|
||||
validates :name, :subject, :body_html, presence: true
|
||||
validates :status, inclusion: { in: Catalog::MAILING_STATUSES.keys }
|
||||
validates :audience, inclusion: { in: Catalog::MAILING_AUDIENCES.keys }
|
||||
validates :ab_assignment, inclusion: { in: Catalog::MAILING_AB_ASSIGNMENTS.keys }
|
||||
validates :interval_seconds, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 86_400 }
|
||||
validates :subject_b, :body_html_b, presence: true, if: :ab_test?
|
||||
clears_blank_html :body_html, :body_html_b
|
||||
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
scope :for_project, ->(project) { where(project_id: project.id) }
|
||||
|
||||
def status_label
|
||||
Catalog.label_for(Catalog::MAILING_STATUSES, status)
|
||||
end
|
||||
|
||||
def audience_label
|
||||
Catalog.label_for(Catalog::MAILING_AUDIENCES, audience)
|
||||
end
|
||||
|
||||
def ab_assignment_label
|
||||
Catalog.label_for(Catalog::MAILING_AB_ASSIGNMENTS, ab_assignment)
|
||||
end
|
||||
|
||||
def ab_from_record?
|
||||
ab_assignment == "from_record"
|
||||
end
|
||||
|
||||
def subject_for(variant)
|
||||
ab_test? && variant.to_s.upcase == "B" ? subject_b : subject
|
||||
end
|
||||
|
||||
def body_for(variant)
|
||||
ab_test? && variant.to_s.upcase == "B" ? body_html_b : body_html
|
||||
end
|
||||
|
||||
def variant_pending_count(variant)
|
||||
mailing_recipients.pending.where(ab_variant: variant).count
|
||||
end
|
||||
|
||||
def variant_sent_count(variant)
|
||||
mailing_recipients.sent.where(ab_variant: variant).count
|
||||
end
|
||||
|
||||
def draft?
|
||||
status == "draft"
|
||||
end
|
||||
|
||||
def sending?
|
||||
status == "sending"
|
||||
end
|
||||
|
||||
def sent?
|
||||
status == "sent"
|
||||
end
|
||||
|
||||
def editable?
|
||||
draft?
|
||||
end
|
||||
|
||||
def pending_count
|
||||
mailing_recipients.pending.count
|
||||
end
|
||||
|
||||
def skipped_count
|
||||
mailing_recipients.skipped.count
|
||||
end
|
||||
|
||||
def sent_count
|
||||
mailing_recipients.sent.count
|
||||
end
|
||||
|
||||
def failed_count
|
||||
mailing_recipients.failed.count
|
||||
end
|
||||
|
||||
def rebuild_recipients!
|
||||
mailing_recipients.delete_all
|
||||
Mailings::RecipientBuilder.new(self).call
|
||||
end
|
||||
|
||||
def queue_send!
|
||||
raise "Nessun destinatario da inviare" if pending_count.zero?
|
||||
|
||||
update!(status: "sending", queued_at: Time.current)
|
||||
first = mailing_recipients.pending.order(:id).first
|
||||
SendMailingRecipientJob.perform_later(first.id)
|
||||
end
|
||||
|
||||
def mark_finished_if_done!
|
||||
return unless sending?
|
||||
return if mailing_recipients.pending.exists? || mailing_recipients.queued.exists?
|
||||
|
||||
update!(status: "sent", completed_at: Time.current)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
class MailingRecipient < ApplicationRecord
|
||||
belongs_to :mailing
|
||||
belongs_to :organization
|
||||
belongs_to :contact, optional: true
|
||||
|
||||
validates :status, inclusion: { in: Catalog::MAILING_RECIPIENT_STATUSES.keys }
|
||||
validates :ab_variant, inclusion: { in: Catalog::AB_VARIANTS.keys }, allow_blank: true
|
||||
|
||||
scope :pending, -> { where(status: "pending") }
|
||||
scope :skipped, -> { where(status: "skipped") }
|
||||
scope :sent, -> { where(status: "sent") }
|
||||
scope :failed, -> { where(status: "failed") }
|
||||
scope :queued, -> { where(status: "queued") }
|
||||
scope :ordered, -> { joins(:organization).order("organizations.list_position ASC NULLS LAST", "organizations.name ASC") }
|
||||
|
||||
def status_label
|
||||
Catalog.label_for(Catalog::MAILING_RECIPIENT_STATUSES, status)
|
||||
end
|
||||
|
||||
def pending?
|
||||
status == "pending"
|
||||
end
|
||||
|
||||
def email_ok?
|
||||
email.present? && email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
end
|
||||
|
||||
def merge_context
|
||||
opportunity = organization.campaign_opportunity(mailing.project)
|
||||
{
|
||||
organization: organization,
|
||||
contact: contact,
|
||||
project: mailing.project,
|
||||
opportunity: opportunity,
|
||||
ab_variant: ab_variant.presence || opportunity&.ab_variant
|
||||
}
|
||||
end
|
||||
|
||||
def rendered_html
|
||||
MailMerge.render(mailing.body_for(ab_variant), **merge_context)
|
||||
end
|
||||
|
||||
def rendered_subject_line
|
||||
MailMerge.render(mailing.subject_for(ab_variant), **merge_context)
|
||||
end
|
||||
|
||||
def deliver!
|
||||
with_lock do
|
||||
return if status.in?(%w[sent skipped])
|
||||
|
||||
update!(status: "queued")
|
||||
end
|
||||
|
||||
html = rendered_html
|
||||
subject_line = rendered_subject_line
|
||||
CampaignMailer.outreach(self, html: html, subject: subject_line).deliver_now
|
||||
record_success!(subject_line)
|
||||
rescue StandardError => e
|
||||
update!(status: "failed", error_message: e.message.to_s.truncate(500))
|
||||
ensure
|
||||
mailing.mark_finished_if_done!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def record_success!(subject_line)
|
||||
transaction do
|
||||
update!(status: "sent", sent_at: Time.current, rendered_subject: subject_line, error_message: nil)
|
||||
sync_opportunity!
|
||||
log_activity!(subject_line)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_opportunity!
|
||||
opportunity = organization.campaign_opportunity(mailing.project)
|
||||
return unless opportunity
|
||||
|
||||
attrs = {}
|
||||
attrs[:send_status] = "sent" if opportunity.send_status == "to_send"
|
||||
attrs[:sent_on] ||= Date.current if opportunity.sent_on.blank?
|
||||
attrs[:pipeline_stage] = "contacted" if opportunity.pipeline_stage == "to_contact"
|
||||
attrs[:ab_variant] = ab_variant if mailing.ab_test? && ab_variant.present? && opportunity.ab_variant.blank?
|
||||
opportunity.update!(attrs) if attrs.any?
|
||||
end
|
||||
|
||||
def log_activity!(subject_line)
|
||||
variant_note = "variante #{ab_variant}" if mailing.ab_test? && ab_variant.present?
|
||||
organization.activities.create!(
|
||||
activity_type: "email_sent",
|
||||
subject: subject_line.presence || mailing.name,
|
||||
description: ["Campagna email: #{mailing.name}", variant_note].compact.join(" · "),
|
||||
happened_at: Time.current,
|
||||
user: mailing.created_by,
|
||||
contact: contact,
|
||||
opportunity: organization.campaign_opportunity(mailing.project)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
class Opportunity < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :organization
|
||||
belongs_to :project
|
||||
belongs_to :assigned_user, class_name: "User", optional: true
|
||||
|
||||
has_many :activities, dependent: :nullify
|
||||
has_many :tasks, dependent: :nullify
|
||||
|
||||
validates :name, presence: true
|
||||
validates :pipeline_stage, inclusion: { in: Catalog::PIPELINE_STAGES.keys }
|
||||
validates :lost_reason, inclusion: { in: Catalog::LOST_REASONS.keys }, allow_blank: true
|
||||
validates :ab_variant, inclusion: { in: Catalog::AB_VARIANTS.keys }, allow_blank: true
|
||||
validates :send_status, inclusion: { in: Catalog::SEND_STATUSES.keys }, allow_blank: true
|
||||
validates :probability, numericality: { in: 0..100 }, allow_nil: true
|
||||
validates :estimated_value, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
||||
validate :lost_reason_required_when_lost
|
||||
|
||||
before_validation :set_default_probability, on: :create
|
||||
before_validation :default_project_from_current, on: :create
|
||||
before_save :track_stage_change
|
||||
after_save :handle_stage_side_effects
|
||||
after_save :ensure_organization_in_project
|
||||
|
||||
scope :open_stage, -> { where(pipeline_stage: Catalog::OPEN_PIPELINE_STAGES) }
|
||||
scope :won, -> { where(pipeline_stage: "won") }
|
||||
scope :lost, -> { where(pipeline_stage: "lost") }
|
||||
scope :in_stage, ->(stage) { where(pipeline_stage: stage) }
|
||||
scope :for_project, ->(project) {
|
||||
return none if project.nil?
|
||||
|
||||
where(project_id: project.id)
|
||||
}
|
||||
|
||||
def pipeline_stage_label
|
||||
Catalog.label_for(Catalog::PIPELINE_STAGES, pipeline_stage)
|
||||
end
|
||||
|
||||
def lost_reason_label
|
||||
Catalog.label_for(Catalog::LOST_REASONS, lost_reason)
|
||||
end
|
||||
|
||||
def ab_variant_label
|
||||
Catalog.label_for(Catalog::AB_VARIANTS, ab_variant)
|
||||
end
|
||||
|
||||
def send_status_label
|
||||
Catalog.label_for(Catalog::SEND_STATUSES, send_status)
|
||||
end
|
||||
|
||||
def open?
|
||||
Catalog::OPEN_PIPELINE_STAGES.include?(pipeline_stage)
|
||||
end
|
||||
|
||||
def won?
|
||||
pipeline_stage == "won"
|
||||
end
|
||||
|
||||
def lost?
|
||||
pipeline_stage == "lost"
|
||||
end
|
||||
|
||||
def next_pending_task
|
||||
tasks.pending.order(:due_at).first
|
||||
end
|
||||
|
||||
def days_in_current_stage
|
||||
anchor = stage_changed_at || created_at
|
||||
return 0 unless anchor
|
||||
|
||||
((Time.current - anchor) / 1.day).floor
|
||||
end
|
||||
|
||||
def move_to_stage!(new_stage, lost_reason: nil, notes: nil, user: Current.user)
|
||||
attrs = { pipeline_stage: new_stage }
|
||||
attrs[:lost_reason] = lost_reason if lost_reason.present?
|
||||
attrs[:notes] = [self.notes, notes].compact_blank.join("\n") if notes.present?
|
||||
attrs[:probability] = Catalog::STAGE_PROBABILITIES.fetch(new_stage.to_s, probability)
|
||||
update!(attrs)
|
||||
|
||||
create_stage_activity!(user) if saved_change_to_pipeline_stage?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_default_probability
|
||||
self.probability ||= Catalog::STAGE_PROBABILITIES.fetch(pipeline_stage, 0)
|
||||
end
|
||||
|
||||
def track_stage_change
|
||||
return unless pipeline_stage_changed?
|
||||
|
||||
self.stage_changed_at = Time.current
|
||||
self.first_contacted_at ||= Time.current if pipeline_stage != "to_contact"
|
||||
|
||||
case pipeline_stage
|
||||
when "won"
|
||||
self.won_at ||= Time.current
|
||||
self.lost_at = nil
|
||||
self.probability = 100
|
||||
when "lost"
|
||||
self.lost_at ||= Time.current
|
||||
self.won_at = nil
|
||||
self.probability = 0
|
||||
else
|
||||
self.won_at = nil
|
||||
self.lost_at = nil
|
||||
end
|
||||
end
|
||||
|
||||
def handle_stage_side_effects
|
||||
return unless saved_change_to_pipeline_stage?
|
||||
|
||||
organization.mark_as_active_customer! if won?
|
||||
end
|
||||
|
||||
def lost_reason_required_when_lost
|
||||
return unless pipeline_stage == "lost"
|
||||
return if lost_reason.present?
|
||||
|
||||
errors.add(:lost_reason, "è obbligatorio quando l'opportunità è persa")
|
||||
end
|
||||
|
||||
def create_stage_activity!(user)
|
||||
return unless user
|
||||
|
||||
type = won? ? "won" : lost? ? "lost" : "other"
|
||||
subject = if won?
|
||||
"Cliente #{product.presence || name}"
|
||||
elsif lost?
|
||||
"Perso: #{lost_reason_label}"
|
||||
else
|
||||
"Stage: #{pipeline_stage_label}"
|
||||
end
|
||||
|
||||
activities.create!(
|
||||
activity_type: type,
|
||||
subject: subject,
|
||||
description: notes,
|
||||
happened_at: Time.current,
|
||||
user: user,
|
||||
organization: organization,
|
||||
created_by: user,
|
||||
updated_by: user
|
||||
)
|
||||
end
|
||||
|
||||
def default_project_from_current
|
||||
self.project ||= Current.project
|
||||
end
|
||||
|
||||
def ensure_organization_in_project
|
||||
return unless project && organization
|
||||
|
||||
organization.ensure_in_project!(project)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,116 @@
|
||||
class Organization < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :assigned_user, class_name: "User", optional: true
|
||||
|
||||
has_many :organization_projects, dependent: :destroy
|
||||
has_many :projects, through: :organization_projects
|
||||
has_many :contacts, dependent: :destroy
|
||||
has_many :opportunities, dependent: :destroy
|
||||
has_many :activities, dependent: :destroy
|
||||
has_many :tasks, dependent: :destroy
|
||||
has_many :mailing_recipients, dependent: :destroy
|
||||
|
||||
has_one :primary_contact, -> { where(primary_contact: true) }, class_name: "Contact", inverse_of: :organization
|
||||
|
||||
validates :name, presence: true
|
||||
validates :status, inclusion: { in: Catalog::ORGANIZATION_STATUSES.keys }
|
||||
validates :organization_type, inclusion: { in: Catalog::ORGANIZATION_TYPES.keys }
|
||||
validates :lead_source, inclusion: { in: Catalog::LEAD_SOURCES.keys }, allow_blank: true
|
||||
validates :team_gender, inclusion: { in: Catalog::TEAM_GENDERS.keys }, allow_blank: true
|
||||
validates :streaming_status, inclusion: { in: Catalog::STREAMING_STATUSES.keys }, allow_blank: true
|
||||
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
|
||||
validate :must_have_at_least_one_project
|
||||
before_validation :assign_current_project_if_needed, on: :create
|
||||
|
||||
scope :prospects, -> { where(status: "prospect") }
|
||||
scope :customers, -> { where(status: %w[active_customer inactive_customer]) }
|
||||
scope :active_customers, -> { where(status: "active_customer") }
|
||||
scope :for_project, ->(project) {
|
||||
return none if project.nil?
|
||||
|
||||
joins(:organization_projects).where(organization_projects: { project_id: project.id }).distinct
|
||||
}
|
||||
scope :search, ->(query) {
|
||||
return all if query.blank?
|
||||
|
||||
q = "%#{sanitize_sql_like(query.strip)}%"
|
||||
left_joins(:contacts).where(
|
||||
"organizations.name ILIKE :q OR organizations.city ILIKE :q OR organizations.email ILIKE :q OR organizations.phone ILIKE :q OR organizations.website ILIKE :q OR contacts.first_name ILIKE :q OR contacts.last_name ILIKE :q OR contacts.email ILIKE :q OR contacts.phone ILIKE :q OR contacts.mobile ILIKE :q",
|
||||
q: q
|
||||
).distinct
|
||||
}
|
||||
|
||||
def status_label
|
||||
Catalog.label_for(Catalog::ORGANIZATION_STATUSES, status)
|
||||
end
|
||||
|
||||
def organization_type_label
|
||||
Catalog.label_for(Catalog::ORGANIZATION_TYPES, organization_type)
|
||||
end
|
||||
|
||||
def lead_source_label
|
||||
Catalog.label_for(Catalog::LEAD_SOURCES, lead_source)
|
||||
end
|
||||
|
||||
def team_gender_label
|
||||
Catalog.label_for(Catalog::TEAM_GENDERS, team_gender)
|
||||
end
|
||||
|
||||
def streaming_status_label
|
||||
Catalog.label_for(Catalog::STREAMING_STATUSES, streaming_status)
|
||||
end
|
||||
|
||||
def campaign_opportunity(project = nil)
|
||||
list = opportunities.to_a
|
||||
list = list.select { |opp| opp.project_id == project.id } if project
|
||||
list.max_by(&:updated_at)
|
||||
end
|
||||
|
||||
def primary_pipeline_stage(project = nil)
|
||||
scope = opportunities
|
||||
scope = scope.where(project_id: project.id) if project
|
||||
scope.open_stage.order(updated_at: :desc).first&.pipeline_stage ||
|
||||
scope.order(updated_at: :desc).first&.pipeline_stage
|
||||
end
|
||||
|
||||
def next_pending_task
|
||||
tasks.pending.order(:due_at).first
|
||||
end
|
||||
|
||||
def last_activity_at
|
||||
activities.maximum(:happened_at)
|
||||
end
|
||||
|
||||
def days_since_last_activity
|
||||
return nil unless last_activity_at
|
||||
|
||||
((Time.current - last_activity_at) / 1.day).floor
|
||||
end
|
||||
|
||||
def mark_as_active_customer!
|
||||
update!(status: "active_customer") if status != "active_customer"
|
||||
end
|
||||
|
||||
def ensure_in_project!(project)
|
||||
return if project.nil?
|
||||
return if projects.exists?(project.id)
|
||||
|
||||
projects << project
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_current_project_if_needed
|
||||
return if organization_projects.any? || project_ids.reject(&:blank?).any?
|
||||
return unless Current.project
|
||||
|
||||
organization_projects.build(project: Current.project)
|
||||
end
|
||||
|
||||
def must_have_at_least_one_project
|
||||
return if organization_projects.any? || project_ids.reject(&:blank?).any?
|
||||
|
||||
errors.add(:projects, "seleziona almeno un progetto")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class OrganizationProject < ApplicationRecord
|
||||
belongs_to :organization
|
||||
belongs_to :project
|
||||
|
||||
validates :organization_id, uniqueness: { scope: :project_id }
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class Product < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
validates :name, :code, presence: true
|
||||
validates :code, uniqueness: true
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(:position, :name) }
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
class Project < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
has_many :user_projects, dependent: :destroy
|
||||
has_many :users, through: :user_projects
|
||||
has_many :organization_projects, dependent: :destroy
|
||||
has_many :organizations, through: :organization_projects
|
||||
has_many :opportunities, dependent: :restrict_with_exception
|
||||
has_many :sales_goals, dependent: :nullify
|
||||
has_many :mail_templates, dependent: :destroy
|
||||
has_many :mailings, dependent: :destroy
|
||||
|
||||
validates :name, :code, presence: true
|
||||
validates :code, uniqueness: { case_sensitive: false },
|
||||
format: { with: /\A[a-z0-9_-]+\z/, message: "usa solo lettere minuscole, numeri, - e _" }
|
||||
|
||||
before_validation :normalize_code
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(:position, :name) }
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_code
|
||||
self.code = code.to_s.strip.downcase.parameterize(separator: "_")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
class SalesGoal < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :project, optional: true
|
||||
|
||||
validates :name, :metric, :target_value, :start_date, :end_date, presence: true
|
||||
validates :metric, inclusion: { in: Catalog::GOAL_METRICS.keys }
|
||||
validates :target_value, numericality: { greater_than: 0 }
|
||||
validate :end_date_after_start_date
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :for_project, ->(project) {
|
||||
return none if project.nil?
|
||||
|
||||
where(project_id: project.id)
|
||||
}
|
||||
scope :current, ->(project = nil) {
|
||||
today = Time.zone.today
|
||||
scope = active.where("start_date <= ? AND end_date >= ?", today, today)
|
||||
scope = scope.where(project_id: project.id) if project
|
||||
scope
|
||||
}
|
||||
|
||||
def metric_label
|
||||
Catalog.label_for(Catalog::GOAL_METRICS, metric)
|
||||
end
|
||||
|
||||
def current_value
|
||||
opps = project ? Opportunity.for_project(project) : Opportunity.all
|
||||
activities = if project
|
||||
Activity.joins(:organization)
|
||||
.joins("INNER JOIN organization_projects ON organization_projects.organization_id = activities.organization_id")
|
||||
.where(organization_projects: { project_id: project.id })
|
||||
else
|
||||
Activity.all
|
||||
end
|
||||
|
||||
case metric
|
||||
when "customers_acquired"
|
||||
opps.won.where(won_at: start_date.beginning_of_day..end_date.end_of_day).count
|
||||
when "won_value"
|
||||
opps.won.where(won_at: start_date.beginning_of_day..end_date.end_of_day).sum(:estimated_value).to_f
|
||||
when "trials"
|
||||
activities.where(activity_type: "trial_started", happened_at: start_date.beginning_of_day..end_date.end_of_day).count
|
||||
when "first_uses"
|
||||
activities.where(activity_type: "first_use", happened_at: start_date.beginning_of_day..end_date.end_of_day).count
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
def progress_percentage
|
||||
return 0 if target_value.to_f.zero?
|
||||
|
||||
[[(current_value.to_f / target_value.to_f * 100).round, 0].max, 100].min
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def end_date_after_start_date
|
||||
return if start_date.blank? || end_date.blank?
|
||||
return if end_date >= start_date
|
||||
|
||||
errors.add(:end_date, "deve essere successiva alla data di inizio")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
class Task < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
belongs_to :organization
|
||||
belongs_to :contact, optional: true
|
||||
belongs_to :opportunity, optional: true
|
||||
belongs_to :assigned_user, class_name: "User", optional: true
|
||||
|
||||
validates :title, :due_at, presence: true
|
||||
validates :priority, inclusion: { in: Catalog::TASK_PRIORITIES.keys }
|
||||
validates :task_type, inclusion: { in: Catalog::TASK_TYPES.keys }
|
||||
validates :status, inclusion: { in: Catalog::TASK_STATUSES.keys }
|
||||
|
||||
scope :pending, -> { where(status: "pending") }
|
||||
scope :completed, -> { where(status: "completed") }
|
||||
scope :overdue, -> { pending.where("due_at < ?", Time.zone.now.beginning_of_day) }
|
||||
scope :due_today, -> { pending.where(due_at: Time.zone.now.all_day) }
|
||||
scope :upcoming, ->(days = 7) {
|
||||
pending.where(due_at: Time.zone.now.tomorrow.beginning_of_day..(Time.zone.now + days.days).end_of_day)
|
||||
}
|
||||
scope :ordered, -> { order(Arel.sql("CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END"), :due_at) }
|
||||
scope :for_project, ->(project) {
|
||||
return none if project.nil?
|
||||
|
||||
where(
|
||||
id: left_joins(:opportunity)
|
||||
.joins("INNER JOIN organization_projects ON organization_projects.organization_id = tasks.organization_id")
|
||||
.where(
|
||||
"organization_projects.project_id = :pid OR opportunities.project_id = :pid",
|
||||
pid: project.id
|
||||
)
|
||||
.select("tasks.id")
|
||||
.distinct
|
||||
)
|
||||
}
|
||||
|
||||
def priority_label
|
||||
Catalog.label_for(Catalog::TASK_PRIORITIES, priority)
|
||||
end
|
||||
|
||||
def task_type_label
|
||||
Catalog.label_for(Catalog::TASK_TYPES, task_type)
|
||||
end
|
||||
|
||||
def status_label
|
||||
Catalog.label_for(Catalog::TASK_STATUSES, status)
|
||||
end
|
||||
|
||||
def overdue?
|
||||
pending? && due_at < Time.zone.now.beginning_of_day
|
||||
end
|
||||
|
||||
def due_today?
|
||||
pending? && due_at.to_date == Time.zone.today
|
||||
end
|
||||
|
||||
def pending?
|
||||
status == "pending"
|
||||
end
|
||||
|
||||
def completed?
|
||||
status == "completed"
|
||||
end
|
||||
|
||||
def complete!(user: Current.user, create_activity: true)
|
||||
return false unless pending?
|
||||
|
||||
transaction do
|
||||
update!(status: "completed", completed_at: Time.current, updated_by: user)
|
||||
create_completion_activity!(user) if create_activity && user
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_completion_activity!(user)
|
||||
activities_attrs = {
|
||||
activity_type: activity_type_for_task,
|
||||
subject: title,
|
||||
description: description,
|
||||
happened_at: Time.current,
|
||||
user: user,
|
||||
organization: organization,
|
||||
contact: contact,
|
||||
opportunity: opportunity,
|
||||
created_by: user,
|
||||
updated_by: user
|
||||
}
|
||||
Activity.create!(activities_attrs)
|
||||
end
|
||||
|
||||
def activity_type_for_task
|
||||
{
|
||||
"follow_up" => "follow_up",
|
||||
"call" => "call",
|
||||
"email" => "email_sent",
|
||||
"meeting" => "meeting",
|
||||
"demo" => "demo",
|
||||
"proposal" => "proposal_sent",
|
||||
"generic" => "other"
|
||||
}.fetch(task_type, "other")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
class User < ApplicationRecord
|
||||
include Auditable
|
||||
|
||||
has_secure_password
|
||||
|
||||
has_many :assigned_organizations, class_name: "Organization", foreign_key: :assigned_user_id, dependent: :nullify, inverse_of: :assigned_user
|
||||
has_many :assigned_opportunities, class_name: "Opportunity", foreign_key: :assigned_user_id, dependent: :nullify, inverse_of: :assigned_user
|
||||
has_many :assigned_tasks, class_name: "Task", foreign_key: :assigned_user_id, dependent: :nullify, inverse_of: :assigned_user
|
||||
has_many :activities, dependent: :nullify
|
||||
has_many :user_projects, dependent: :destroy
|
||||
has_many :projects, through: :user_projects
|
||||
|
||||
ROLES = %w[admin user].freeze
|
||||
|
||||
validates :email, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :first_name, :last_name, presence: true
|
||||
validates :role, inclusion: { in: ROLES }
|
||||
validates :password, length: { minimum: 8 }, if: -> { password.present? }
|
||||
validate :must_keep_at_least_one_active_admin
|
||||
|
||||
before_validation :normalize_email
|
||||
before_destroy :prevent_destroying_last_admin
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :admins, -> { where(role: "admin") }
|
||||
scope :active_admins, -> { active.admins }
|
||||
|
||||
def admin?
|
||||
role == "admin"
|
||||
end
|
||||
|
||||
def full_name
|
||||
"#{first_name} #{last_name}"
|
||||
end
|
||||
|
||||
def last_active_admin?
|
||||
admin? && active? && self.class.active_admins.where.not(id: id).none?
|
||||
end
|
||||
|
||||
def can_be_deactivated?
|
||||
return true unless admin? && active?
|
||||
|
||||
!last_active_admin?
|
||||
end
|
||||
|
||||
def can_be_destroyed?
|
||||
!last_active_admin?
|
||||
end
|
||||
|
||||
def accessible_projects
|
||||
return Project.active.ordered if admin?
|
||||
|
||||
Project.active.ordered
|
||||
.joins(:user_projects)
|
||||
.where(user_projects: { user_id: id, enabled: true })
|
||||
end
|
||||
|
||||
def can_access_project?(project)
|
||||
return true if admin?
|
||||
return false if project.nil?
|
||||
|
||||
user_projects.exists?(project_id: project.id, enabled: true)
|
||||
end
|
||||
|
||||
def enable_project!(project)
|
||||
up = user_projects.find_or_initialize_by(project: project)
|
||||
up.enabled = true
|
||||
up.save!
|
||||
end
|
||||
|
||||
def disable_project!(project)
|
||||
up = user_projects.find_or_initialize_by(project: project)
|
||||
up.enabled = false
|
||||
up.save!
|
||||
end
|
||||
|
||||
def project_enabled?(project)
|
||||
return true if admin?
|
||||
|
||||
user_projects.exists?(project_id: project.id, enabled: true)
|
||||
end
|
||||
|
||||
def generate_password_reset_token!
|
||||
update!(
|
||||
password_reset_token: SecureRandom.urlsafe_base64(32),
|
||||
password_reset_sent_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def password_reset_token_valid?
|
||||
password_reset_token.present? &&
|
||||
password_reset_sent_at.present? &&
|
||||
password_reset_sent_at > 2.hours.ago
|
||||
end
|
||||
|
||||
def clear_password_reset_token!
|
||||
update!(password_reset_token: nil, password_reset_sent_at: nil)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_email
|
||||
self.email = email.to_s.strip.downcase
|
||||
end
|
||||
|
||||
def must_keep_at_least_one_active_admin
|
||||
return if new_record?
|
||||
|
||||
was_active_admin = role_in_database == "admin" && active_in_database != false
|
||||
return unless was_active_admin
|
||||
|
||||
deactivating = will_save_change_to_active? && !active?
|
||||
demoting = will_save_change_to_role? && role != "admin"
|
||||
return unless deactivating || demoting
|
||||
return if self.class.active_admins.where.not(id: id).exists?
|
||||
|
||||
errors.add(:base, "Deve restare almeno un amministratore attivo. Crea o promuovi un altro admin prima.")
|
||||
end
|
||||
|
||||
def prevent_destroying_last_admin
|
||||
return unless last_active_admin?
|
||||
|
||||
errors.add(:base, "Non puoi eliminare l'unico amministratore attivo.")
|
||||
throw :abort
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class UserProject < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :project
|
||||
|
||||
validates :user_id, uniqueness: { scope: :project_id }
|
||||
end
|
||||
@@ -0,0 +1,208 @@
|
||||
require "csv"
|
||||
|
||||
class CampaignImport::MatchlivetvLaunch
|
||||
CAMPAIGN_NAME = "Campagna lancio 01".freeze
|
||||
SPORT = "Pallavolo".freeze
|
||||
|
||||
Result = Struct.new(:wiped_organizations, :imported, :errors, keyword_init: true)
|
||||
|
||||
STREAMING_MAP = {
|
||||
"NON RILEVATO" => "not_detected",
|
||||
"LIMITATO" => "limited",
|
||||
"SI / PARZIALE" => "yes_partial",
|
||||
"SI" => "yes",
|
||||
"SI - SPORTCAM" => "yes_sportcam",
|
||||
"SI SPORTCAM" => "yes_sportcam"
|
||||
}.freeze
|
||||
|
||||
GENDER_MAP = {
|
||||
"F" => "female",
|
||||
"M" => "male",
|
||||
"M/F" => "mixed"
|
||||
}.freeze
|
||||
|
||||
SEND_MAP = {
|
||||
"DA INVIARE" => "to_send",
|
||||
"INVIATO" => "sent",
|
||||
"NON INVIARE" => "no_send"
|
||||
}.freeze
|
||||
|
||||
def initialize(path:, user: nil, project: nil, wipe: true)
|
||||
@path = Pathname.new(path)
|
||||
@user = user
|
||||
@project = project
|
||||
@wipe = wipe
|
||||
end
|
||||
|
||||
def call
|
||||
raise ArgumentError, "File non trovato: #{@path}" unless @path.exist?
|
||||
|
||||
@project ||= Project.find_by!(code: "matchlivetv")
|
||||
@user ||= User.find_by!(role: "admin")
|
||||
Current.user = @user
|
||||
Current.project = @project
|
||||
|
||||
wiped = @wipe ? wipe_project! : 0
|
||||
imported = 0
|
||||
errors = []
|
||||
|
||||
rows.each_with_index do |row, idx|
|
||||
import_row!(row)
|
||||
imported += 1
|
||||
rescue StandardError => e
|
||||
errors << { line: idx + 2, name: row["Società"], message: e.message }
|
||||
end
|
||||
|
||||
Result.new(wiped_organizations: wiped, imported: imported, errors: errors)
|
||||
ensure
|
||||
Current.user = nil
|
||||
Current.project = nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rows
|
||||
CSV.read(@path, headers: true, encoding: "bom|utf-8")
|
||||
end
|
||||
|
||||
def wipe_project!
|
||||
destroyed = 0
|
||||
Organization.transaction do
|
||||
Opportunity.where(project_id: @project.id).find_each(&:destroy!)
|
||||
org_ids = OrganizationProject.where(project_id: @project.id).pluck(:organization_id)
|
||||
OrganizationProject.where(project_id: @project.id).delete_all
|
||||
Organization.where(id: org_ids).find_each do |org|
|
||||
next if org.organization_projects.exists?
|
||||
|
||||
org.destroy!
|
||||
destroyed += 1
|
||||
end
|
||||
end
|
||||
destroyed
|
||||
end
|
||||
|
||||
def import_row!(row)
|
||||
name = cell(row, "Società")
|
||||
raise "Società vuota" if name.blank?
|
||||
|
||||
org = Organization.new(
|
||||
name: name,
|
||||
organization_type: "societa_sportiva",
|
||||
sport: SPORT,
|
||||
country: "Italia",
|
||||
region: cell(row, "Regione"),
|
||||
province: cell(row, "Prov."),
|
||||
email: cell(row, "Email verificata")&.downcase,
|
||||
website: cell(row, "Sito / profilo"),
|
||||
source_url: cell(row, "Fonte contatto / ricerca"),
|
||||
commercial_fit: cell(row, "Evidenza / fit commerciale"),
|
||||
team_gender: GENDER_MAP.fetch(normalize_key(cell(row, "M/F"))) { raise "M/F sconosciuto: #{row['M/F']}" },
|
||||
streaming_status: streaming_status_for(cell(row, "Streaming rilevato")),
|
||||
list_position: integer_cell(row, "N."),
|
||||
verified_at: parse_date(cell(row, "Data verifica")),
|
||||
status: yes?(cell(row, "Conversione")) ? "active_customer" : "prospect",
|
||||
lead_source: "campaign",
|
||||
assigned_user: @user,
|
||||
notes: cell(row, "Note follow-up")
|
||||
)
|
||||
org.projects = [@project]
|
||||
org.save!
|
||||
|
||||
first_name, last_name, role = contact_from(org.email, org.name)
|
||||
org.contacts.create!(
|
||||
first_name: first_name,
|
||||
last_name: last_name,
|
||||
role: role,
|
||||
email: org.email,
|
||||
preferred_contact_method: "email",
|
||||
primary_contact: true
|
||||
)
|
||||
|
||||
converted = yes?(cell(row, "Conversione"))
|
||||
demo = yes?(cell(row, "Demo / Trial"))
|
||||
stage = if converted
|
||||
"won"
|
||||
elsif demo
|
||||
"demo_trial"
|
||||
else
|
||||
"to_contact"
|
||||
end
|
||||
|
||||
org.opportunities.create!(
|
||||
name: CAMPAIGN_NAME,
|
||||
project: @project,
|
||||
pipeline_stage: stage,
|
||||
product: cell(row, "Piano"),
|
||||
assigned_user: @user,
|
||||
ab_variant: cell(row, "Test A/B").to_s.upcase.presence,
|
||||
send_status: SEND_MAP.fetch(normalize_key(cell(row, "Stato invio")), "to_send"),
|
||||
sent_on: parse_date(cell(row, "Data invio")),
|
||||
outcome: cell(row, "Esito"),
|
||||
demo_trial: demo,
|
||||
converted: converted,
|
||||
notes: [cell(row, "Evidenza / fit commerciale"), cell(row, "Note follow-up")].compact_blank.join("\n\n")
|
||||
)
|
||||
end
|
||||
|
||||
def cell(row, header)
|
||||
value = row[header].to_s.strip
|
||||
value.presence
|
||||
end
|
||||
|
||||
def integer_cell(row, header)
|
||||
raw = cell(row, header)
|
||||
return if raw.blank?
|
||||
|
||||
raw.to_i
|
||||
end
|
||||
|
||||
def normalize_key(value)
|
||||
I18n.transliterate(value.to_s)
|
||||
.gsub(/[\u2013\u2014\u2212]/, "-")
|
||||
.encode("ASCII", invalid: :replace, undef: :replace, replace: "")
|
||||
.strip
|
||||
.upcase
|
||||
.gsub(/\s+/, " ")
|
||||
end
|
||||
|
||||
def streaming_status_for(value)
|
||||
key = normalize_key(value)
|
||||
return STREAMING_MAP[key] if STREAMING_MAP.key?(key)
|
||||
return "yes_sportcam" if key.include?("SPORTCAM")
|
||||
return "yes_partial" if key.include?("PARZIALE")
|
||||
return "not_detected" if key.include?("NON RILEVATO")
|
||||
return "limited" if key.include?("LIMITATO")
|
||||
return "yes" if key == "SI" || key.start_with?("SI ")
|
||||
|
||||
raise "Streaming sconosciuto: #{value}"
|
||||
end
|
||||
|
||||
def yes?(value)
|
||||
%w[SI YES TRUE 1].include?(normalize_key(value))
|
||||
end
|
||||
|
||||
def parse_date(value)
|
||||
return if value.blank?
|
||||
return Date.iso8601(value) if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
||||
return Date.strptime(value, "%d/%m/%Y") if value.match?(/\A\d{1,2}\/\d{1,2}\/\d{4}\z/)
|
||||
|
||||
serial = Float(value)
|
||||
Date.new(1899, 12, 30) + serial.to_i
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def contact_from(email, org_name)
|
||||
local = email.to_s.split("@").first.to_s
|
||||
if local.match?(/\A[a-z]+[._][a-z]+\z/i)
|
||||
first, last = local.split(/[._]/)
|
||||
[first.capitalize, last.capitalize, "Contatto"]
|
||||
elsif local.match?(/\A[a-z]\.[a-z]+\z/i)
|
||||
initial, last = local.split(".")
|
||||
["#{initial.upcase}.", last.capitalize, "Contatto"]
|
||||
else
|
||||
token = org_name.to_s.split(/[\s\/–-]+/).last.presence || "Società"
|
||||
["Segreteria", token, "Segreteria"]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
require "csv"
|
||||
|
||||
class CsvExport
|
||||
def self.organizations(scope)
|
||||
CSV.generate(headers: true) do |csv|
|
||||
csv << %w[id list_position name team_gender streaming_status region province website email status lead_source commercial_fit source_url verified_at assigned_user notes created_at]
|
||||
scope.includes(:assigned_user).find_each do |org|
|
||||
csv << [
|
||||
org.id, org.list_position, org.name, org.team_gender, org.streaming_status, org.region,
|
||||
org.province, org.website, org.email, org.status, org.lead_source, org.commercial_fit,
|
||||
org.source_url, org.verified_at, org.assigned_user&.full_name, org.notes, org.created_at
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.contacts(scope)
|
||||
CSV.generate(headers: true) do |csv|
|
||||
csv << %w[id organization_name first_name last_name role email phone mobile preferred_contact_method primary_contact notes]
|
||||
scope.includes(:organization).find_each do |contact|
|
||||
csv << [
|
||||
contact.id, contact.organization.name, contact.first_name, contact.last_name, contact.role,
|
||||
contact.email, contact.phone, contact.mobile, contact.preferred_contact_method,
|
||||
contact.primary_contact, contact.notes
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.opportunities(scope)
|
||||
CSV.generate(headers: true) do |csv|
|
||||
csv << %w[id organization_name name pipeline_stage ab_variant send_status sent_on outcome demo_trial converted estimated_value product assigned_user notes]
|
||||
scope.includes(:organization, :assigned_user).find_each do |opp|
|
||||
csv << [
|
||||
opp.id, opp.organization.name, opp.name, opp.pipeline_stage, opp.ab_variant, opp.send_status,
|
||||
opp.sent_on, opp.outcome, opp.demo_trial, opp.converted, opp.estimated_value, opp.product,
|
||||
opp.assigned_user&.full_name, opp.notes
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
require "csv"
|
||||
|
||||
class CsvImport::Organizations
|
||||
STANDARD_HEADERS = %w[
|
||||
organization_name organization_type sport country region province city website
|
||||
organization_email contact_first_name contact_last_name contact_role contact_email
|
||||
contact_phone lead_source notes
|
||||
].freeze
|
||||
|
||||
Result = Struct.new(:imported, :skipped, :errors, keyword_init: true)
|
||||
|
||||
def initialize(file:, user:, mapping: nil, project: nil)
|
||||
@file = file
|
||||
@user = user
|
||||
@mapping = mapping
|
||||
@project = project || Current.project
|
||||
end
|
||||
|
||||
def preview(limit: 10)
|
||||
rows = []
|
||||
CSV.foreach(@file.path, headers: true, encoding: "bom|utf-8") do |row|
|
||||
rows << row.to_h
|
||||
break if rows.size >= limit
|
||||
end
|
||||
{ headers: rows.first&.keys || [], rows: rows }
|
||||
end
|
||||
|
||||
def import!
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
CSV.foreach(@file.path, headers: true, encoding: "bom|utf-8").with_index(2) do |row, line|
|
||||
Current.user = @user
|
||||
attrs = mapped_attrs(row)
|
||||
|
||||
if duplicate?(attrs)
|
||||
skipped += 1
|
||||
next
|
||||
end
|
||||
|
||||
Organization.transaction do
|
||||
org = Organization.new(
|
||||
name: attrs[:organization_name],
|
||||
organization_type: normalize_type(attrs[:organization_type]),
|
||||
sport: attrs[:sport],
|
||||
country: attrs[:country].presence || "Italia",
|
||||
region: attrs[:region],
|
||||
province: attrs[:province],
|
||||
city: attrs[:city],
|
||||
website: normalize_website(attrs[:website]),
|
||||
email: attrs[:organization_email],
|
||||
lead_source: normalize_lead_source(attrs[:lead_source]),
|
||||
notes: attrs[:notes],
|
||||
status: "prospect",
|
||||
assigned_user: @user
|
||||
)
|
||||
org.projects = [@project].compact
|
||||
org.save!
|
||||
|
||||
if attrs[:contact_first_name].present? || attrs[:contact_last_name].present?
|
||||
org.contacts.create!(
|
||||
first_name: attrs[:contact_first_name].presence || "N/D",
|
||||
last_name: attrs[:contact_last_name].presence || "N/D",
|
||||
role: attrs[:contact_role],
|
||||
email: attrs[:contact_email],
|
||||
phone: attrs[:contact_phone],
|
||||
primary_contact: true
|
||||
)
|
||||
end
|
||||
end
|
||||
imported += 1
|
||||
rescue StandardError => e
|
||||
errors << { line: line, message: e.message, row: row.to_h }
|
||||
end
|
||||
|
||||
Result.new(imported: imported, skipped: skipped, errors: errors)
|
||||
ensure
|
||||
Current.user = nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mapped_attrs(row)
|
||||
source = row.to_h.transform_keys { |k| k.to_s.strip }
|
||||
STANDARD_HEADERS.index_with do |header|
|
||||
key = @mapping&.dig(header) || header
|
||||
source[key].to_s.strip.presence
|
||||
end.symbolize_keys
|
||||
end
|
||||
|
||||
def duplicate?(attrs)
|
||||
name = attrs[:organization_name].to_s
|
||||
email = attrs[:organization_email].to_s.downcase
|
||||
website = normalize_website(attrs[:website])
|
||||
|
||||
return true if name.present? && Organization.where("LOWER(name) = ?", name.downcase).exists?
|
||||
return true if email.present? && Organization.where("LOWER(email) = ?", email).exists?
|
||||
return true if website.present? && Organization.where("LOWER(website) = ?", website.downcase).exists?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def normalize_website(value)
|
||||
return if value.blank?
|
||||
|
||||
value.to_s.strip.downcase.sub(%r{\Ahttps?://}, "").sub(%r{/\z}, "")
|
||||
end
|
||||
|
||||
def normalize_type(value)
|
||||
return "altro" if value.blank?
|
||||
|
||||
key = value.to_s.downcase.gsub(/\s+/, "_")
|
||||
return key if Catalog::ORGANIZATION_TYPES.key?(key)
|
||||
|
||||
Catalog::ORGANIZATION_TYPES.find { |_k, label| label.downcase == value.to_s.downcase }&.first || "altro"
|
||||
end
|
||||
|
||||
def normalize_lead_source(value)
|
||||
return if value.blank?
|
||||
|
||||
key = value.to_s.downcase.gsub(/\s+/, "_")
|
||||
return key if Catalog::LEAD_SOURCES.key?(key)
|
||||
|
||||
Catalog::LEAD_SOURCES.find { |_k, label| label.downcase == value.to_s.downcase }&.first || "other"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,167 @@
|
||||
class Dashboard::Metrics
|
||||
def initialize(scope: Opportunity.all, project: nil)
|
||||
@scope = scope
|
||||
@project = project
|
||||
end
|
||||
|
||||
def stage_counts
|
||||
@stage_counts ||= Catalog::PIPELINE_ORDER.index_with { |stage| @scope.where(pipeline_stage: stage).count }
|
||||
end
|
||||
|
||||
def prospect_count
|
||||
organizations_scope.prospects.count
|
||||
end
|
||||
|
||||
def open_pipeline_value
|
||||
@scope.open_stage.sum(:estimated_value).to_f
|
||||
end
|
||||
|
||||
def won_value
|
||||
@scope.won.sum(:estimated_value).to_f
|
||||
end
|
||||
|
||||
def conversion_rates
|
||||
stages = Catalog::OPEN_PIPELINE_STAGES + %w[won]
|
||||
rates = {}
|
||||
stages.each_cons(2) do |from, to|
|
||||
from_count = cumulative_reached(from)
|
||||
to_count = cumulative_reached(to)
|
||||
rates["#{from}_to_#{to}"] = percentage(to_count, from_count)
|
||||
end
|
||||
rates
|
||||
end
|
||||
|
||||
def funnel_steps
|
||||
counts = {
|
||||
"contacted" => reached_or_beyond("contacted"),
|
||||
"replied" => reached_or_beyond("replied"),
|
||||
"interested" => reached_or_beyond("interested"),
|
||||
"first_use" => reached_or_beyond("first_use"),
|
||||
"won" => @scope.won.count
|
||||
}
|
||||
|
||||
steps = []
|
||||
previous = nil
|
||||
counts.each do |stage, count|
|
||||
rate = previous ? percentage(count, previous) : nil
|
||||
steps << { stage: stage, label: Catalog.label_for(Catalog::PIPELINE_STAGES, stage), count: count, rate: rate }
|
||||
previous = count
|
||||
end
|
||||
steps
|
||||
end
|
||||
|
||||
def avg_days_to_won
|
||||
records = @scope.won.where.not(first_contacted_at: nil, won_at: nil)
|
||||
return nil if records.empty?
|
||||
|
||||
total = records.sum { |o| ((o.won_at - o.first_contacted_at) / 1.day) }
|
||||
(total / records.size).round(1)
|
||||
end
|
||||
|
||||
def avg_days_since_last_activity
|
||||
orgs = organizations_scope.left_joins(:activities)
|
||||
.select("organizations.id, MAX(activities.happened_at) AS last_at")
|
||||
.group("organizations.id")
|
||||
.having("MAX(activities.happened_at) IS NOT NULL")
|
||||
return nil if orgs.empty?
|
||||
|
||||
days = orgs.map { |o| ((Time.current - o.last_at) / 1.day) }
|
||||
(days.sum / days.size).round(1)
|
||||
end
|
||||
|
||||
def prospects_without_activity_over_7_days
|
||||
organizations_scope.prospects
|
||||
.left_joins(:activities)
|
||||
.group("organizations.id")
|
||||
.having("MAX(activities.happened_at) IS NULL OR MAX(activities.happened_at) < ?", 7.days.ago)
|
||||
.count
|
||||
.size
|
||||
end
|
||||
|
||||
def overdue_tasks_count
|
||||
tasks_scope.overdue.count
|
||||
end
|
||||
|
||||
def opportunities_without_next_action
|
||||
opportunities_missing_next_action.count
|
||||
end
|
||||
|
||||
def demo_to_won_conversion
|
||||
demo = reached_or_beyond("demo_trial")
|
||||
percentage(@scope.won.count, demo)
|
||||
end
|
||||
|
||||
def first_use_to_won_conversion
|
||||
first_use = reached_or_beyond("first_use")
|
||||
percentage(@scope.won.count, first_use)
|
||||
end
|
||||
|
||||
def arpa
|
||||
won = @scope.won.where.not(estimated_value: nil)
|
||||
return 0 if won.empty?
|
||||
|
||||
(won.sum(:estimated_value).to_f / won.count).round(2)
|
||||
end
|
||||
|
||||
def attention_items
|
||||
{
|
||||
to_send_count: @scope.where(send_status: "to_send").count,
|
||||
interested_without_followup: interested_without_followup,
|
||||
stalled_opportunities: stalled_opportunities,
|
||||
overdue_tasks: tasks_scope.overdue.includes(:organization, :contact, :assigned_user).ordered.limit(20),
|
||||
organizations_without_contacts: organizations_scope.left_joins(:contacts).where(contacts: { id: nil }).limit(20),
|
||||
opportunities_without_value: advanced_open_stage.where(estimated_value: [nil, 0]).includes(:organization).limit(20),
|
||||
opportunities_without_next_action: opportunities_missing_next_action.merge(advanced_open_stage).limit(20)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def organizations_scope
|
||||
@project ? Organization.for_project(@project) : Organization.all
|
||||
end
|
||||
|
||||
def tasks_scope
|
||||
@project ? Task.for_project(@project) : Task.all
|
||||
end
|
||||
|
||||
def cumulative_reached(stage)
|
||||
reached_or_beyond(stage)
|
||||
end
|
||||
|
||||
def reached_or_beyond(stage)
|
||||
idx = Catalog::PIPELINE_ORDER.index(stage)
|
||||
return 0 unless idx
|
||||
|
||||
stages = Catalog::PIPELINE_ORDER[idx..] - ["lost"]
|
||||
@scope.where(pipeline_stage: stages).count
|
||||
end
|
||||
|
||||
def percentage(part, whole)
|
||||
return 0 if whole.to_i.zero?
|
||||
|
||||
((part.to_f / whole) * 100).round
|
||||
end
|
||||
|
||||
def interested_without_followup
|
||||
base = @scope.where(pipeline_stage: "interested")
|
||||
with_pending = Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
|
||||
base.where.not(id: with_pending).includes(:organization).limit(20)
|
||||
end
|
||||
|
||||
def stalled_opportunities
|
||||
@scope.open_stage
|
||||
.where("stage_changed_at < ? OR (stage_changed_at IS NULL AND opportunities.created_at < ?)", 7.days.ago, 7.days.ago)
|
||||
.includes(:organization, :assigned_user)
|
||||
.limit(20)
|
||||
end
|
||||
|
||||
def opportunities_missing_next_action
|
||||
with_pending = Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
|
||||
@scope.open_stage.where.not(id: with_pending).includes(:organization)
|
||||
end
|
||||
|
||||
def advanced_open_stage
|
||||
@scope.open_stage.where.not(pipeline_stage: "to_contact")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
module MailMerge
|
||||
TOKEN = /\{\{\s*([a-z0-9_.]+)\s*\}\}/i
|
||||
|
||||
class << self
|
||||
def catalog
|
||||
{
|
||||
"societa" => "Nome società",
|
||||
"regione" => "Regione",
|
||||
"provincia" => "Provincia",
|
||||
"citta" => "Città",
|
||||
"email" => "Email",
|
||||
"sito" => "Sito / profilo",
|
||||
"sport" => "Sport",
|
||||
"mf" => "M/F",
|
||||
"streaming" => "Streaming rilevato",
|
||||
"fit" => "Fit commerciale",
|
||||
"n_lista" => "N. in lista",
|
||||
"contatto_nome" => "Nome contatto",
|
||||
"contatto_ruolo" => "Ruolo contatto",
|
||||
"contatto_email" => "Email contatto",
|
||||
"test_ab" => "Test A/B",
|
||||
"progetto" => "Nome progetto",
|
||||
"piano" => "Piano / prodotto"
|
||||
}
|
||||
end
|
||||
|
||||
def variables_for(organization: nil, contact: nil, project: nil, opportunity: nil, ab_variant: nil)
|
||||
org = organization
|
||||
contact ||= org&.primary_contact || org&.contacts&.first
|
||||
opportunity ||= org&.campaign_opportunity(project) if org && project
|
||||
|
||||
{
|
||||
"societa" => org&.name,
|
||||
"organizzazione.nome" => org&.name,
|
||||
"regione" => org&.region,
|
||||
"provincia" => org&.province,
|
||||
"citta" => org&.city,
|
||||
"email" => (contact&.email.presence || org&.email),
|
||||
"sito" => org&.website,
|
||||
"sport" => org&.sport,
|
||||
"mf" => org&.team_gender_label,
|
||||
"streaming" => org&.streaming_status_label,
|
||||
"fit" => org&.commercial_fit,
|
||||
"n_lista" => org&.list_position,
|
||||
"contatto_nome" => contact&.full_name,
|
||||
"contatto_ruolo" => contact&.role,
|
||||
"contatto_email" => contact&.email,
|
||||
"test_ab" => ab_variant.presence || opportunity&.ab_variant,
|
||||
"progetto" => project&.name,
|
||||
"piano" => opportunity&.product
|
||||
}.transform_values { |value| value.to_s }
|
||||
end
|
||||
|
||||
def render(template, **context)
|
||||
vars = variables_for(**context)
|
||||
template.to_s.gsub(TOKEN) do
|
||||
key = Regexp.last_match(1).to_s.downcase
|
||||
vars[key].to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
class Mailings::InlineImages
|
||||
BLOB_PATH = %r{/rails/active_storage/blobs/(?:redirect/|proxy/)?([^/?#]+)}
|
||||
|
||||
def self.call(mailer, html)
|
||||
new(mailer).call(html)
|
||||
end
|
||||
|
||||
def initialize(mailer)
|
||||
@mailer = mailer
|
||||
end
|
||||
|
||||
def call(html)
|
||||
fragment = Nokogiri::HTML::DocumentFragment.parse(html.to_s)
|
||||
fragment.css("img").each_with_index do |img, index|
|
||||
blob = blob_from(img["src"])
|
||||
next unless blob
|
||||
|
||||
name = "inline-#{index}-#{blob.filename}"
|
||||
@mailer.attachments.inline[name] = {
|
||||
mime_type: blob.content_type,
|
||||
content: blob.download
|
||||
}
|
||||
img["src"] = @mailer.attachments[name].url
|
||||
width = img["width"].presence
|
||||
styles = ["max-width: 100%", "height: auto"]
|
||||
styles << "width: #{width}px" if width.present?
|
||||
img["style"] = [img["style"], *styles].compact.join("; ")
|
||||
end
|
||||
|
||||
fragment.css("figure").each do |figure|
|
||||
image = figure.at_css("img")
|
||||
image ? figure.replace(image) : figure.remove
|
||||
end
|
||||
|
||||
fragment.to_html
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def blob_from(src)
|
||||
signed_id = src.to_s[BLOB_PATH, 1]
|
||||
return if signed_id.blank?
|
||||
|
||||
ActiveStorage::Blob.find_signed(signed_id)
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
class Mailings::RecipientBuilder
|
||||
def initialize(mailing)
|
||||
@mailing = mailing
|
||||
end
|
||||
|
||||
def call
|
||||
split_index = 0
|
||||
ordered_scope.each do |org|
|
||||
contact = org.primary_contact || org.contacts.min_by(&:id)
|
||||
email = contact&.email.presence || org.email.presence
|
||||
attrs = {
|
||||
organization: org,
|
||||
contact: contact,
|
||||
email: email&.downcase
|
||||
}
|
||||
if email.blank?
|
||||
attrs[:status] = "skipped"
|
||||
attrs[:skip_reason] = "manca email"
|
||||
elsif !email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
attrs[:status] = "skipped"
|
||||
attrs[:skip_reason] = "email non valida"
|
||||
else
|
||||
attrs[:status] = "pending"
|
||||
end
|
||||
assign_variant!(attrs, org, split_index)
|
||||
split_index += 1 if attrs[:status] == "pending"
|
||||
@mailing.mailing_recipients.create!(attrs)
|
||||
end
|
||||
@mailing.mailing_recipients.ordered
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ordered_scope
|
||||
scope.reorder(Arel.sql("organizations.list_position ASC NULLS LAST"), "organizations.name ASC")
|
||||
end
|
||||
|
||||
def scope
|
||||
orgs = Organization.for_project(@mailing.project).includes(:contacts, :opportunities)
|
||||
org_ids =
|
||||
case @mailing.audience
|
||||
when "to_send"
|
||||
Opportunity.where(project_id: @mailing.project_id, send_status: "to_send").select(:organization_id)
|
||||
when "test_a"
|
||||
Opportunity.where(project_id: @mailing.project_id, ab_variant: "A").select(:organization_id)
|
||||
when "test_b"
|
||||
Opportunity.where(project_id: @mailing.project_id, ab_variant: "B").select(:organization_id)
|
||||
else
|
||||
orgs.select(:id)
|
||||
end
|
||||
orgs.where(id: org_ids)
|
||||
end
|
||||
|
||||
def assign_variant!(attrs, org, split_index)
|
||||
return unless @mailing.ab_test?
|
||||
|
||||
opportunity = org.campaign_opportunity(@mailing.project)
|
||||
recorded = opportunity&.ab_variant.to_s.upcase
|
||||
variant =
|
||||
if @mailing.ab_from_record? && recorded.in?(%w[A B])
|
||||
recorded
|
||||
else
|
||||
split_index.even? ? "A" : "B"
|
||||
end
|
||||
attrs[:ab_variant] = variant
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
class Reports::Builder
|
||||
def initialize(project: nil)
|
||||
@project = project
|
||||
@opp_scope = project ? Opportunity.for_project(project) : Opportunity.all
|
||||
@org_scope = project ? Organization.for_project(project) : Organization.all
|
||||
end
|
||||
|
||||
def funnel
|
||||
metrics = Dashboard::Metrics.new(scope: @opp_scope, project: @project)
|
||||
{
|
||||
stages: Catalog::PIPELINE_ORDER.map { |s| { stage: s, label: Catalog.label_for(Catalog::PIPELINE_STAGES, s), count: metrics.stage_counts[s] } },
|
||||
conversions: metrics.conversion_rates,
|
||||
funnel: metrics.funnel_steps
|
||||
}
|
||||
end
|
||||
|
||||
def lead_sources
|
||||
Catalog::LEAD_SOURCES.map do |key, label|
|
||||
orgs = @org_scope.where(lead_source: key)
|
||||
opps = @opp_scope.joins(:organization).where(organizations: { lead_source: key })
|
||||
won = opps.won.count
|
||||
leads = orgs.count
|
||||
{
|
||||
key: key,
|
||||
label: label,
|
||||
leads: leads,
|
||||
opportunities: opps.count,
|
||||
customers: won,
|
||||
conversion_rate: leads.zero? ? 0 : ((won.to_f / leads) * 100).round
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def won_lost_by_month(months: 6)
|
||||
start_date = months.months.ago.beginning_of_month
|
||||
won = @opp_scope.won.where("won_at >= ?", start_date).group("DATE_TRUNC('month', won_at)").count
|
||||
lost = @opp_scope.lost.where("lost_at >= ?", start_date).group("DATE_TRUNC('month', lost_at)").count
|
||||
keys = (won.keys + lost.keys).uniq.sort
|
||||
keys.map do |month|
|
||||
{
|
||||
month: month.to_date,
|
||||
won: won[month] || 0,
|
||||
lost: lost[month] || 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def lost_reasons
|
||||
@opp_scope.lost.group(:lost_reason).count.map do |reason, count|
|
||||
{
|
||||
key: reason,
|
||||
label: Catalog.label_for(Catalog::LOST_REASONS, reason),
|
||||
count: count
|
||||
}
|
||||
end.sort_by { |r| -r[:count] }
|
||||
end
|
||||
|
||||
def sales_owners
|
||||
User.active.map do |user|
|
||||
open_opps = user.assigned_opportunities.merge(@opp_scope).open_stage
|
||||
won_opps = user.assigned_opportunities.merge(@opp_scope).won
|
||||
{
|
||||
user: user,
|
||||
open_count: open_opps.count,
|
||||
open_value: open_opps.sum(:estimated_value).to_f,
|
||||
won_count: won_opps.count,
|
||||
won_value: won_opps.sum(:estimated_value).to_f
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def revenue_by_month(months: 6)
|
||||
start_date = months.months.ago.beginning_of_month
|
||||
@opp_scope.won
|
||||
.where("won_at >= ?", start_date)
|
||||
.group("DATE_TRUNC('month', won_at)")
|
||||
.sum(:estimated_value)
|
||||
.sort_by { |month, _| month }
|
||||
.map { |month, value| { month: month.to_date, value: value.to_f } }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Impostazioni piattaforma</h1>
|
||||
<p class="mt-1 text-zinc-500">Gestione globale di eminuxCRM, indipendente dai singoli progetti.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<%= link_to users_path, class: "rounded-2xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6 transition hover:border-zinc-400 dark:hover:border-zinc-500" do %>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Accessi</div>
|
||||
<h2 class="mt-2 text-xl font-semibold tracking-tight">Utenti</h2>
|
||||
<p class="mt-2 text-sm text-zinc-500">Crea, modifica, disabilita o elimina gli account. Gli admin vedono tutti i progetti.</p>
|
||||
<p class="mt-4 text-sm text-zinc-700 dark:text-zinc-300"><%= @active_users_count %> attivi · <%= @users_count %> totali</p>
|
||||
<div class="mt-4 text-sm font-medium">Apri utenti →</div>
|
||||
<% end %>
|
||||
|
||||
<%= link_to root_path, class: "rounded-2xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6 transition hover:border-zinc-400 dark:hover:border-zinc-500" do %>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Istanze</div>
|
||||
<h2 class="mt-2 text-xl font-semibold tracking-tight">Progetti</h2>
|
||||
<p class="mt-2 text-sm text-zinc-500">Ogni progetto è un CRM dedicato. Crei nuove istanze dalla home con +.</p>
|
||||
<p class="mt-4 text-sm text-zinc-700 dark:text-zinc-300"><%= @projects_count %> progetti</p>
|
||||
<div class="mt-4 text-sm font-medium">Torna ai progetti →</div>
|
||||
<% end %>
|
||||
|
||||
<%= link_to mail_identities_path, class: "rounded-2xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6 transition hover:border-zinc-400 dark:hover:border-zinc-500" do %>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Invii</div>
|
||||
<h2 class="mt-2 text-xl font-semibold tracking-tight">Account email / SMTP</h2>
|
||||
<p class="mt-2 text-sm text-zinc-500">Da quale indirizzo partono le campagne: server, porta, utente e password SMTP.</p>
|
||||
<p class="mt-4 text-sm text-zinc-700 dark:text-zinc-300"><%= @mail_identities_count %> account</p>
|
||||
<div class="mt-4 text-sm font-medium">Configura SMTP →</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="mx-auto max-w-2xl space-y-4">
|
||||
<h1 class="text-2xl font-semibold"><%= @contact.new_record? ? "Nuovo contatto" : "Modifica contatto" %></h1>
|
||||
<%= form_with model: @contact, class: "#{card_class} p-6 space-y-4" do |f| %>
|
||||
<%= render "shared/errors", object: @contact %>
|
||||
<%= f.label :organization_id, class: "mb-1 block text-sm font-medium" %>
|
||||
<%= f.collection_select :organization_id, @organizations, :id, :name, {}, class: input_class %>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<%= f.text_field :first_name, placeholder: "Nome", required: true, class: input_class %>
|
||||
<%= f.text_field :last_name, placeholder: "Cognome", required: true, class: input_class %>
|
||||
<%= f.text_field :role, placeholder: "Ruolo", class: input_class %>
|
||||
<%= f.email_field :email, placeholder: "Email", class: input_class %>
|
||||
<%= f.text_field :phone, placeholder: "Telefono", class: input_class %>
|
||||
<%= f.text_field :mobile, placeholder: "Cellulare", class: input_class %>
|
||||
<%= f.select :preferred_contact_method, Catalog::CONTACT_METHODS.map { |k,v| [v,k] }, {}, class: input_class %>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm"><%= f.check_box :primary_contact %> Contatto principale</label>
|
||||
<%= f.text_area :notes, rows: 3, placeholder: "Note", class: input_class %>
|
||||
<div class="flex gap-2">
|
||||
<%= f.submit class: btn_primary %>
|
||||
<%= link_to "Annulla", @contact.persisted? ? @contact.organization : contacts_path, class: btn_secondary %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<%= render "form" %>
|
||||
@@ -0,0 +1,31 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Contatti</h1>
|
||||
<div class="flex gap-2">
|
||||
<%= link_to "Export CSV", contacts_path(format: :csv), class: btn_secondary %>
|
||||
<%= link_to "Nuovo", new_contact_path, class: btn_primary %>
|
||||
</div>
|
||||
</div>
|
||||
<%= form_with url: contacts_path, method: :get, class: "flex gap-2" do %>
|
||||
<%= text_field_tag :q, params[:q], placeholder: "Cerca…", class: input_class %>
|
||||
<%= submit_tag "Cerca", class: btn_primary %>
|
||||
<% end %>
|
||||
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100">
|
||||
<table class="<%= table_class %>">
|
||||
<thead class="bg-zinc-50 text-left text-xs uppercase text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<tr><th class="px-4 py-3">Nome</th><th class="px-4 py-3">Organizzazione</th><th class="px-4 py-3">Email</th><th class="px-4 py-3">Telefono</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
<% @contacts.each do |c| %>
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium"><%= link_to c.full_name, c.organization, class: "hover:underline" %><% if c.primary_contact? %> ★<% end %></td>
|
||||
<td class="px-4 py-3"><%= c.organization.name %></td>
|
||||
<td class="px-4 py-3"><%= c.email %></td>
|
||||
<td class="px-4 py-3"><%= c.phone.presence || c.mobile %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<%== pagy_nav(@pagy) if @pagy.pages > 1 %>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<%= render "form" %>
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-semibold"><%= @contact.full_name %></h1>
|
||||
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-5 text-sm">
|
||||
<p><strong>Organizzazione:</strong> <%= link_to @contact.organization.name, @contact.organization %></p>
|
||||
<p><strong>Ruolo:</strong> <%= @contact.role %></p>
|
||||
<p><strong>Email:</strong> <%= @contact.email %></p>
|
||||
<p><strong>Telefono:</strong> <%= @contact.phone %></p>
|
||||
<p><strong>Cellulare:</strong> <%= @contact.mobile %></p>
|
||||
<%= link_to "Modifica", edit_contact_path(@contact), class: "mt-4 inline-block text-emerald-700 dark:text-emerald-400" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<% items = items.to_a %>
|
||||
<% empty_class = "mt-2 text-sm text-amber-900/75 dark:text-amber-100/75" %>
|
||||
<% if items.blank? %>
|
||||
<p class="<%= empty_class %>"><%= empty %></p>
|
||||
<% else %>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
<% items.first(8).each do |item| %>
|
||||
<li>
|
||||
<% if block_given? %>
|
||||
<%= yield item %>
|
||||
<% else %>
|
||||
<%= link_to "#{item.organization.name} · #{item.name}", item.organization, class: "hover:underline" %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if items.size > 8 %>
|
||||
<li class="text-xs text-amber-900/70 dark:text-amber-100/70">+ altre <%= items.size - 8 %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
@@ -0,0 +1,151 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Dashboard · <%= current_project.name %></h1>
|
||||
<p class="text-sm text-slate-500">Cosa devo fare oggi per trasformare i prospect in clienti?</p>
|
||||
</div>
|
||||
<%= link_to "Apri pagina Oggi →", today_path, class: btn_primary %>
|
||||
</div>
|
||||
|
||||
<% if @goal %>
|
||||
<section class="rounded-2xl border border-slate-200 bg-gradient-to-br from-slate-900 to-slate-800 p-6 text-white shadow-sm">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-slate-300"><%= @goal.name %></div>
|
||||
<div class="mt-2 flex flex-wrap items-end gap-4">
|
||||
<div class="text-4xl font-semibold tabular-nums"><%= @goal.current_value.to_i %> / <%= @goal.target_value.to_i %></div>
|
||||
<div class="pb-1 text-sm text-slate-300"><%= @goal.metric_label %> · <%= format_date(@goal.start_date) %> – <%= format_date(@goal.end_date) %></div>
|
||||
</div>
|
||||
<div class="mt-4"><%= progress_bar(@goal.progress_percentage, color: "bg-emerald-400") %></div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-3 text-lg font-semibold">Cosa devo fare oggi</h2>
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-rose-700">In ritardo (<%= @overdue_tasks.size %>)</h3>
|
||||
<%= render "shared/task_list", tasks: @overdue_tasks %>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-amber-700">Da fare oggi (<%= @today_tasks.size %>)</h3>
|
||||
<%= render "shared/task_list", tasks: @today_tasks %>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-600 dark:text-zinc-400">Prossimi (<%= @upcoming_tasks.size %>)</h3>
|
||||
<%= render "shared/task_list", tasks: @upcoming_tasks %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<% [
|
||||
["Prospect totali", @metrics.prospect_count],
|
||||
["Contattati", @stage_counts["contacted"]],
|
||||
["Risposte", @stage_counts["replied"]],
|
||||
["Interessati", @stage_counts["interested"]],
|
||||
["Demo/Trial", @stage_counts["demo_trial"]],
|
||||
["Primo utilizzo", @stage_counts["first_use"]],
|
||||
["Proposte", @stage_counts["proposal"]],
|
||||
["Clienti acquisiti", @stage_counts["won"]],
|
||||
["Persi", @stage_counts["lost"]],
|
||||
["Valore WON", format_money(@metrics.won_value)],
|
||||
["Pipeline aperta", format_money(@metrics.open_pipeline_value)],
|
||||
["Task scaduti", @metrics.overdue_tasks_count]
|
||||
].each do |label, value| %>
|
||||
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-4">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-slate-500"><%= label %></div>
|
||||
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= value %></div>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-5">
|
||||
<h2 class="text-lg font-semibold">Funnel commerciale</h2>
|
||||
<div class="mt-4 space-y-2">
|
||||
<% @funnel.each_with_index do |step, idx| %>
|
||||
<% if step[:rate] %>
|
||||
<div class="text-center text-xs font-medium text-slate-500">↓ <%= step[:rate] %>%</div>
|
||||
<% end %>
|
||||
<div class="flex items-center justify-between rounded-lg bg-zinc-50 px-4 py-3 dark:bg-zinc-800">
|
||||
<span class="font-medium text-zinc-800 dark:text-zinc-100"><%= step[:label].upcase %></span>
|
||||
<span class="text-xl font-semibold tabular-nums"><%= step[:count] %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-5">
|
||||
<h2 class="text-lg font-semibold">KPI processo</h2>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Giorni medi contatto → WON</dt><dd class="font-semibold"><%= @metrics.avg_days_to_won || "—" %></dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Giorni medi dall'ultima attività</dt><dd class="font-semibold"><%= @metrics.avg_days_since_last_activity || "—" %></dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Prospect senza attività > 7gg</dt><dd class="font-semibold"><%= @metrics.prospects_without_activity_over_7_days %></dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Opp. senza next action</dt><dd class="font-semibold"><%= @metrics.opportunities_without_next_action %></dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Demo/Trial → WON</dt><dd class="font-semibold"><%= @metrics.demo_to_won_conversion %>%</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">First Use → WON</dt><dd class="font-semibold"><%= @metrics.first_use_to_won_conversion %>%</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">ARPA (WON)</dt><dd class="font-semibold"><%= format_money(@metrics.arpa) %></dd></div>
|
||||
<div class="flex justify-between"><dt class="text-slate-500">Pipeline value</dt><dd class="font-semibold"><%= format_money(@metrics.open_pipeline_value) %></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-amber-200 bg-amber-50 p-5 text-zinc-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-50">
|
||||
<h2 class="text-lg font-semibold text-amber-900 dark:text-amber-100">Cosa sbloccare adesso</h2>
|
||||
<p class="mt-1 max-w-3xl text-sm text-amber-900/80 dark:text-amber-100/80">
|
||||
Non è un errore. Sono i buchi che bloccano la vendita: un contatto da chiamare, un’email da mandare, un follow-up mancante.
|
||||
Se una lista è vuota, su quel punto sei a posto.
|
||||
</p>
|
||||
|
||||
<% if @attention[:to_send_count].to_i.positive? %>
|
||||
<div class="mt-4 rounded-lg border border-amber-300 bg-white/70 p-4 dark:border-amber-800 dark:bg-amber-900/40">
|
||||
<h3 class="text-sm font-semibold text-amber-900 dark:text-amber-100">Campagna lancio · email da inviare (<%= @attention[:to_send_count] %>)</h3>
|
||||
<p class="mt-1 text-sm text-zinc-700 dark:text-amber-50/90">
|
||||
Le società sono censite e in stato <strong>Da contattare</strong>. Il passo successivo è mandare le email del test A/B
|
||||
(10–15 al giorno, A e B insieme). Non serve ancora mettere un prezzo o creare un follow-up: quelli arrivano dopo la risposta.
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
<%= link_to "Apri la lista da inviare →", organizations_path, class: "font-medium text-zinc-900 underline dark:text-amber-50" %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Interessati senza follow-up</h3>
|
||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Hanno detto sì in linea: manca la prossima chiamata/email in agenda.</p>
|
||||
<%= render "dashboard/attention_list", items: @attention[:interested_without_followup], empty: "Nessuno: nessun interessato scoperto." %>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Opportunità ferme da oltre 7 giorni</h3>
|
||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Lo stage non si è mosso: va rilanciato o chiuso come perso.</p>
|
||||
<%= render "dashboard/attention_list", items: @attention[:stalled_opportunities], empty: "Nessuna: nessuna trattativa ferma." do |opp| %>
|
||||
<%= link_to "#{opp.organization.name} (#{opp.days_in_current_stage}gg)", opp.organization, class: "hover:underline" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Task scaduti</h3>
|
||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Promemoria già passati: completarli o ripianificarli.</p>
|
||||
<%= render "dashboard/attention_list", items: @attention[:overdue_tasks], empty: "Nessuno: nessun task in ritardo." do |task| %>
|
||||
<%= link_to "#{task.organization.name}: #{task.title}", task.organization, class: "hover:underline" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza contatto</h3>
|
||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Manca una persona a cui scrivere o telefonare.</p>
|
||||
<%= render "dashboard/attention_list", items: @attention[:organizations_without_contacts], empty: "Nessuna: tutte hanno un contatto." do |org| %>
|
||||
<%= link_to org.name, org, class: "hover:underline" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza valore (già in trattativa)</h3>
|
||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Dopo il primo contatto serve un importo stimato (Light/Full…) per la pipeline.</p>
|
||||
<%= render "dashboard/attention_list", items: @attention[:opportunities_without_value], empty: "Nessuna in trattativa senza valore." %>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza prossima azione (già in trattativa)</h3>
|
||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Dopo l’invio o la risposta manca un task in agenda: la trattativa si ferma.</p>
|
||||
<%= render "dashboard/attention_list", items: @attention[:opportunities_without_next_action], empty: "Nessuna in trattativa senza next action." %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold tracking-tight">I miei progetti</h1>
|
||||
<p class="mt-1 text-zinc-500">Ogni progetto è un CRM dedicato, con dashboard e dati separati.</p>
|
||||
</div>
|
||||
|
||||
<% if @projects.any? %>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<% @projects.each do |project| %>
|
||||
<%= link_to project_root_path(project_code: project.code),
|
||||
class: "group rounded-2xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6 transition hover:border-zinc-400 dark:hover:border-zinc-500" do %>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-zinc-400"><%= project.code %></div>
|
||||
<div class="mt-2 text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100"><%= project.name %></div>
|
||||
<% if project.description.present? %>
|
||||
<p class="mt-2 text-sm text-zinc-500 line-clamp-2"><%= project.description %></p>
|
||||
<% end %>
|
||||
<div class="mt-4 text-sm font-medium text-zinc-900 dark:text-zinc-100">Apri CRM →</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if current_user.admin? %>
|
||||
<button type="button"
|
||||
data-action="modal#show"
|
||||
class="flex min-h-[10rem] flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-300 bg-transparent p-6 text-sm font-medium text-zinc-500 transition hover:border-zinc-500 hover:text-zinc-900 dark:border-zinc-700 dark:hover:border-zinc-400 dark:hover:text-zinc-100">
|
||||
<span class="text-2xl leading-none">+</span>
|
||||
<span class="mt-2">Nuovo progetto</span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-6 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
Nessun progetto disponibile.
|
||||
<% if current_user.admin? %>
|
||||
<%= button_tag "Crea il primo progetto", type: "button", data: { action: "modal#show" }, class: "mt-3 #{btn_primary}" %>
|
||||
<% else %>
|
||||
Chiedi a un amministratore di abilitarti.
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="mx-auto max-w-2xl space-y-4">
|
||||
<h1 class="text-2xl font-semibold">Import CSV</h1>
|
||||
<div class="<%= card_class %> p-5 text-sm text-zinc-600 dark:text-zinc-300">
|
||||
<p>Formato standard supportato:</p>
|
||||
<pre class="mt-2 overflow-x-auto rounded bg-zinc-50 p-3 text-xs text-zinc-800 dark:bg-zinc-950 dark:text-zinc-200">organization_name,organization_type,sport,country,region,province,city,website,organization_email,contact_first_name,contact_last_name,contact_role,contact_email,contact_phone,lead_source,notes</pre>
|
||||
<p class="mt-2">File di esempio: <code>examples/organizations_import_sample.csv</code></p>
|
||||
</div>
|
||||
<%= form_with url: imports_path, multipart: true, class: "rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-5 space-y-4" do %>
|
||||
<%= file_field_tag :file, accept: ".csv,text/csv", required: true, class: "block w-full text-sm" %>
|
||||
<div class="flex gap-2">
|
||||
<%= submit_tag "Anteprima", name: "preview", value: "1", class: btn_secondary %>
|
||||
<%= submit_tag "Importa", class: btn_primary %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-semibold">Anteprima import</h1>
|
||||
<div class="overflow-x-auto rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="bg-slate-50">
|
||||
<tr><% @preview[:headers].each do |h| %><th class="px-2 py-2 text-left"><%= h %></th><% end %></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @preview[:rows].each do |row| %>
|
||||
<tr class="border-t"><% @preview[:headers].each do |h| %><td class="px-2 py-1"><%= row[h] %></td><% end %></tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">Torna indietro e conferma l'import senza anteprima per caricare i dati.</p>
|
||||
<%= link_to "Torna all'import", new_import_path, class: "text-emerald-700 hover:underline dark:text-emerald-400" %>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-semibold">Risultato import</h1>
|
||||
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-5">
|
||||
<p>Importati: <strong><%= @result.imported %></strong></p>
|
||||
<p>Saltati (duplicati): <strong><%= @result.skipped %></strong></p>
|
||||
<p>Errori: <strong><%= @result.errors.size %></strong></p>
|
||||
<% if @result.errors.any? %>
|
||||
<ul class="mt-4 space-y-2 text-sm text-rose-700">
|
||||
<% @result.errors.each do |err| %>
|
||||
<li>Riga <%= err[:line] %>: <%= err[:message] %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= link_to "Vai alle organizzazioni", organizations_path, class: "text-emerald-700 hover:underline dark:text-emerald-400" %>
|
||||
</div>
|
||||
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it" class="<%= cookies[:theme] == "dark" ? "dark" : nil %>">
|
||||
<head>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("theme");
|
||||
var dark = stored ? stored === "dark" : document.cookie.indexOf("theme=dark") !== -1;
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
<title><%= page_title %><% if current_project %> · <%= current_project.name %><% end %> · <%= app_name %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="application-name" content="<%= app_name %>">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag "trix", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
</head>
|
||||
<body class="min-h-screen bg-zinc-50 text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-100">
|
||||
<% if logged_in? && in_project_space? %>
|
||||
<div class="flex min-h-screen">
|
||||
<aside class="hidden w-60 shrink-0 flex-col border-r border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950 md:flex">
|
||||
<div class="border-b border-zinc-100 px-5 py-5 dark:border-zinc-800">
|
||||
<%= link_to root_path, class: "flex items-start gap-2.5 text-zinc-900 dark:text-zinc-100" do %>
|
||||
<%= render "shared/logo", variant: :mark, size: :sm %>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-base font-semibold tracking-tight"><%= current_project.name %></span>
|
||||
<span class="mt-0.5 block text-[11px] text-zinc-400">istanza <%= app_name %></span>
|
||||
</span>
|
||||
<% end %>
|
||||
<%= link_to "← Tutti i progetti", root_path, class: "mt-3 inline-block text-xs text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100" %>
|
||||
</div>
|
||||
<nav class="flex flex-1 flex-col gap-0.5 p-3">
|
||||
<%= nav_link "Dashboard", project_root_path %>
|
||||
<%= nav_link "Oggi", today_path %>
|
||||
<%= nav_link "Organizzazioni", organizations_path %>
|
||||
<%= nav_link "Contatti", contacts_path %>
|
||||
<%= nav_link "Pipeline", pipeline_path %>
|
||||
<%= nav_link "Task", tasks_path %>
|
||||
<%= nav_link "Email", mailings_path %>
|
||||
<%= nav_link "Report", reports_path %>
|
||||
<%= nav_link "Impostazioni", settings_path %>
|
||||
<% if current_user.admin? %>
|
||||
<%= nav_link "Utenti", users_path %>
|
||||
<% end %>
|
||||
</nav>
|
||||
<div class="border-t border-zinc-100 p-3 space-y-2 dark:border-zinc-800">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-zinc-400">Cambia progetto</div>
|
||||
<% available_projects.each do |project| %>
|
||||
<%= link_to project.name,
|
||||
project_root_path(project_code: project.code),
|
||||
class: "block truncate rounded-md px-2 py-1 text-xs #{project.id == current_project.id ? 'bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900' : 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100'}" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<header class="sticky top-0 z-20 border-b border-zinc-200 bg-white/90 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/90">
|
||||
<div class="flex flex-wrap items-center gap-3 px-4 py-3 md:px-6">
|
||||
<div class="md:hidden">
|
||||
<%= link_to root_path, class: "flex items-center gap-2 font-semibold tracking-tight" do %>
|
||||
<%= render "shared/logo", variant: :mark, size: :xs %>
|
||||
<span><%= current_project.name %></span>
|
||||
<% end %>
|
||||
<%= link_to "Progetti", root_path, class: "text-xs text-zinc-500" %>
|
||||
</div>
|
||||
<%= form_with url: search_path, method: :get, class: "flex min-w-0 flex-1" do %>
|
||||
<%= text_field_tag :q, params[:q], placeholder: "Cerca in #{current_project.name}…", class: input_class %>
|
||||
<% end %>
|
||||
<div class="relative" data-controller="dropdown">
|
||||
<button type="button" data-action="dropdown#toggle" class="<%= btn_primary %>">+ Nuovo</button>
|
||||
<div data-dropdown-target="menu" class="absolute right-0 z-30 mt-2 hidden w-48 rounded-lg border border-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<%= link_to "Organizzazione", new_organization_path, class: "block px-4 py-2 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800" %>
|
||||
<%= link_to "Contatto", new_contact_path, class: "block px-4 py-2 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800" %>
|
||||
<%= link_to "Opportunità", new_opportunity_path, class: "block px-4 py-2 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800" %>
|
||||
<%= link_to "Task", new_task_path, class: "block px-4 py-2 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800" %>
|
||||
<%= link_to "Invio email", new_mailing_path, class: "block px-4 py-2 text-sm hover:bg-zinc-50 dark:hover:bg-zinc-800" %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render "shared/theme_toggle" %>
|
||||
<%= render "shared/user_menu" %>
|
||||
</div>
|
||||
<nav class="flex gap-1 overflow-x-auto border-t border-zinc-100 px-2 py-2 dark:border-zinc-800 md:hidden">
|
||||
<%= link_to "Home", project_root_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||
<%= link_to "Oggi", today_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||
<%= link_to "Org", organizations_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||
<%= link_to "Pipeline", pipeline_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||
<%= link_to "Task", tasks_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||
<%= link_to "Email", mailings_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||
<% if current_user.admin? %>
|
||||
<%= link_to "Utenti", users_path, class: "whitespace-nowrap rounded px-2 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300" %>
|
||||
<% end %>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="min-w-0 flex-1 overflow-x-hidden p-4 md:p-6">
|
||||
<div id="flash"><%= render "shared/flash" %></div>
|
||||
<%= yield %>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<% elsif logged_in? %>
|
||||
<div data-controller="modal" data-modal-open-value="<%= params[:new_project].present? %>">
|
||||
<header class="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<div class="mx-auto flex max-w-5xl items-center justify-between gap-3 px-4 py-4">
|
||||
<div>
|
||||
<%= link_to root_path, class: "text-zinc-900 dark:text-zinc-100" do %>
|
||||
<%= render "shared/logo", subtitle: (@page_title || "Seleziona un'istanza progetto") %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<% if current_user.admin? %>
|
||||
<button type="button" data-action="modal#show" class="<%= btn_primary %>" aria-label="Nuovo progetto" title="Nuovo progetto">+</button>
|
||||
<%= link_to "Impostazioni", admin_path, class: btn_secondary %>
|
||||
<% end %>
|
||||
<%= render "shared/theme_toggle" %>
|
||||
<%= render "shared/user_menu" %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mx-auto max-w-5xl p-4 md:p-8">
|
||||
<div id="flash"><%= render "shared/flash" %></div>
|
||||
<%= yield %>
|
||||
</main>
|
||||
<% if current_user.admin? %>
|
||||
<%= render "projects/new_modal" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="absolute right-4 top-4 z-10">
|
||||
<%= render "shared/theme_toggle" %>
|
||||
</div>
|
||||
<main class="min-h-screen">
|
||||
<div id="flash"><%= render "shared/flash" %></div>
|
||||
<%= yield %>
|
||||
</main>
|
||||
<% end %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<style>
|
||||
/* Email styles need to be inline */
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<%= yield %>
|
||||
@@ -0,0 +1,67 @@
|
||||
<%= form_with model: mail_identity, class: "space-y-6 rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6" do |f| %>
|
||||
<%= render "shared/errors", object: mail_identity %>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium">Nome account</label>
|
||||
<%= f.text_field :name, required: true, placeholder: "MatchLiveTV produzione", class: input_class %>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Nome mittente</label>
|
||||
<%= f.text_field :from_name, required: true, placeholder: "MatchLiveTV", class: input_class %>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Email mittente</label>
|
||||
<%= f.email_field :from_email, required: true, placeholder: "hello@example.com", class: input_class %>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium">Reply-To (opzionale)</label>
|
||||
<%= f.email_field :reply_to, class: input_class %>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Host SMTP</label>
|
||||
<%= f.text_field :smtp_host, required: true, placeholder: "smtp.example.com", class: input_class %>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Porta</label>
|
||||
<%= f.number_field :smtp_port, required: true, min: 1, max: 65535, class: input_class %>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Crittografia</label>
|
||||
<%= f.select :encryption, Catalog::MAIL_ENCRYPTIONS.map { |k, v| [v, k] }, {}, class: input_class %>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Autenticazione</label>
|
||||
<%= f.select :smtp_authentication, Catalog::MAIL_AUTH_METHODS.map { |k, v| [v, k] }, {}, class: input_class %>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Utente SMTP</label>
|
||||
<%= f.text_field :smtp_username, autocomplete: "off", class: input_class %>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Password SMTP<%= " (lascia vuoto per non cambiare)" unless mail_identity.new_record? %></label>
|
||||
<%= f.password_field :smtp_password, autocomplete: "new-password", class: input_class %>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<%= f.check_box :verify_ssl %>
|
||||
Verifica il certificato SSL del server
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<%= f.check_box :active %>
|
||||
Account attivo
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
||||
<%= f.submit mail_identity.new_record? ? "Crea account" : "Salva", class: btn_primary %>
|
||||
<%= link_to "Annulla", mail_identities_path, class: btn_secondary %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% unless mail_identity.new_record? %>
|
||||
<div class="mt-4">
|
||||
<%= button_to "Elimina account", mail_identity_path(mail_identity), method: :delete,
|
||||
class: btn_danger,
|
||||
form: { data: { turbo_confirm: "Eliminare #{mail_identity.name}?" } } %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Modifica account email</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500"><%= @mail_identity.from_email %></p>
|
||||
</div>
|
||||
|
||||
<%= render "form", mail_identity: @mail_identity %>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Account email / SMTP</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
<%= link_to "Impostazioni", admin_path, class: "hover:underline" %>
|
||||
· da questi indirizzi partono le campagne. Le password SMTP sono cifrate.
|
||||
</p>
|
||||
</div>
|
||||
<%= link_to "Nuovo account", new_mail_identity_path, class: btn_primary %>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden <%= card_class %>">
|
||||
<% if @mail_identities.empty? %>
|
||||
<p class="p-6 text-sm text-zinc-500">Nessun account. Crea il mittente con host, porta e credenziali SMTP.</p>
|
||||
<% else %>
|
||||
<table class="<%= table_class %>">
|
||||
<thead class="border-b border-zinc-100 bg-zinc-50 text-left text-xs font-medium uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Nome</th>
|
||||
<th class="px-4 py-3">Mittente</th>
|
||||
<th class="px-4 py-3">SMTP</th>
|
||||
<th class="px-4 py-3">Stato</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
<% @mail_identities.each do |identity| %>
|
||||
<tr class="<%= "opacity-60" unless identity.active? %>">
|
||||
<td class="px-4 py-3 font-medium"><%= identity.name %></td>
|
||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">
|
||||
<%= identity.from_name %><br>
|
||||
<span class="text-xs"><%= identity.from_email %></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">
|
||||
<%= identity.smtp_host %>:<%= identity.smtp_port %>
|
||||
<div class="text-xs text-zinc-500"><%= Catalog.label_for(Catalog::MAIL_ENCRYPTIONS, identity.encryption) %></div>
|
||||
</td>
|
||||
<td class="px-4 py-3"><%= identity.active? ? "Attivo" : "Disattivo" %></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<%= link_to "Modifica", edit_mail_identity_path(identity), class: "text-zinc-700 hover:underline dark:text-zinc-300" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Nuovo account email</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">Indirizzo mittente e impostazioni SMTP da cui partiranno gli invii.</p>
|
||||
</div>
|
||||
|
||||
<%= render "form", mail_identity: @mail_identity %>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<%= form_with model: mail_template, class: "grid gap-6 lg:grid-cols-[1fr_16rem]" do |f| %>
|
||||
<div class="space-y-6 rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6">
|
||||
<%= render "shared/errors", object: mail_template %>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Nome</label>
|
||||
<%= f.text_field :name, required: true, class: input_class %>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Oggetto</label>
|
||||
<%= f.text_field :subject, required: true, class: input_class %>
|
||||
</div>
|
||||
<div>
|
||||
<%= render "shared/wysiwyg_field", form: f, method: :body_html, label: "Corpo HTML" %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
||||
<%= f.submit mail_template.new_record? ? "Crea template" : "Salva", class: btn_primary %>
|
||||
<%= link_to "Annulla", mail_templates_path, class: btn_secondary %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<%= render "shared/merge_tokens" %>
|
||||
<% unless mail_template.new_record? %>
|
||||
<%= button_to "Elimina template", mail_template_path(mail_template), method: :delete,
|
||||
class: btn_danger,
|
||||
form: { data: { turbo_confirm: "Eliminare #{mail_template.name}?" } } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Modifica template</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500"><%= @mail_template.name %></p>
|
||||
</div>
|
||||
|
||||
<%= render "form", mail_template: @mail_template %>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Template email</h1>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
<%= link_to "Invii", mailings_path, class: "hover:underline" %>
|
||||
· HTML riutilizzabile con variabili della scheda cliente.
|
||||
</p>
|
||||
</div>
|
||||
<%= link_to "Nuovo template", new_mail_template_path, class: btn_primary %>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden <%= card_class %>">
|
||||
<% if @mail_templates.empty? %>
|
||||
<p class="p-6 text-sm text-zinc-500">Nessun template. Creane uno e usalo come base per gli invii.</p>
|
||||
<% else %>
|
||||
<table class="<%= table_class %>">
|
||||
<thead class="border-b border-zinc-100 bg-zinc-50 text-left text-xs font-medium uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Nome</th>
|
||||
<th class="px-4 py-3">Oggetto</th>
|
||||
<th class="px-4 py-3">Ambito</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
<% @mail_templates.each do |template| %>
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium"><%= template.name %></td>
|
||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300"><%= template.subject %></td>
|
||||
<td class="px-4 py-3 text-zinc-500"><%= template.project_id? ? current_project.name : "Tutti i progetti" %></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<%= link_to "Modifica", edit_mail_template_path(template), class: "text-zinc-700 hover:underline dark:text-zinc-300" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user