Commit iniziale di eminuxCRM: CRM Rails con pipeline, campagne email e Docker.
CI / scan_ruby (push) Failing after 11m20s
CI / scan_js (push) Successful in 10m35s
CI / lint (push) Has been cancelled

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:
2026-08-17 23:01:48 +02:00
co-authored by Cursor
commit c4e5f289cf
258 changed files with 11293 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.
# Ignore git directory.
/.git/
/.gitignore
# Ignore bundler config.
/.bundle
# Ignore all environment files.
/.env*
# Ignore all default key files.
/config/master.key
/config/credentials/*.key
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/.keep
# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/.keep
# Ignore assets.
/node_modules/
/app/assets/builds/*
!/app/assets/builds/.keep
/public/assets
# Ignore CI service files.
/.github
# Ignore development files
/.devcontainer
+29
View File
@@ -0,0 +1,29 @@
# Database
POSTGRES_USER=simplecrm
POSTGRES_PASSWORD=change_me_in_production
POSTGRES_DB=simplecrm_development
DATABASE_HOST=postgres
DATABASE_PORT=5432
# Rails
RAILS_ENV=development
RAILS_MASTER_KEY=
SECRET_KEY_BASE=generate_with_bin_rails_secret
APP_HOST=localhost
APP_PORT=3001
# Produzione dietro reverse proxy (es. crm.eminux.it). Separare con virgola.
# RAILS_ALLOWED_HOSTS=crm.eminux.it,192.168.1.158,localhost
# FORCE_SSL=false # true solo se Rails termina HTTPS (dietro NPM lasciare false)
# ASSUME_SSL=true # true se TLS termina su NPM/Caddy (URL https corretti)
# SESSION_COOKIE_SECURE=false # true solo con HTTPS end-to-end verso Rails
# Mailer (password reset in development uses letter_opener-like logging)
MAILER_FROM=noreply@simplecrm.local
SMTP_ADDRESS=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_DOMAIN=
# Timezone
TZ=Europe/Rome
+9
View File
@@ -0,0 +1,9 @@
# See https://git-scm.com/docs/gitattributes for more about git attribute files.
# Mark the database schema as having been generated.
db/schema.rb linguist-generated
# Mark any vendored files as having been vendored.
vendor/* linguist-vendored
config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials
+12
View File
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: bundler
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
+67
View File
@@ -0,0 +1,67 @@
name: CI
on:
pull_request:
push:
branches: [ main ]
jobs:
scan_ruby:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Scan for common Rails security vulnerabilities using static analysis
run: bin/brakeman --no-pager
- name: Scan for known security vulnerabilities in gems used
run: bin/bundler-audit
scan_js:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Scan for security vulnerabilities in JavaScript dependencies
run: bin/importmap audit
lint:
runs-on: ubuntu-latest
env:
RUBOCOP_CACHE_ROOT: tmp/rubocop
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Prepare RuboCop cache
uses: actions/cache@v4
env:
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}
with:
path: ${{ env.RUBOCOP_CACHE_ROOT }}
key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
restore-keys: |
rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
- name: Lint code for consistent style
run: bin/rubocop -f github
+40
View File
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# Temporary files generated by your text editor or operating system
# belong in git's global ignore instead:
# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore`
# Ignore bundler config.
/.bundle
# Ignore environment files but keep the example.
/.env
/.env.*
!/.env.example
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/
!/tmp/pids/.keep
# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/
!/tmp/storage/.keep
/public/assets
# Ignore key files for decrypting credentials and more.
/config/*.key
/app/assets/builds/*
!/app/assets/builds/.keep
+8
View File
@@ -0,0 +1,8 @@
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# Overwrite or add rules to create your own house style
#
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false
+1
View File
@@ -0,0 +1 @@
ruby-3.3.6
+71
View File
@@ -0,0 +1,71 @@
# syntax=docker/dockerfile:1
ARG RUBY_VERSION=3.3.6
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
WORKDIR /rails
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client libyaml-0-2 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
ENV BUNDLE_PATH="/usr/local/bundle" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Development image (locale)
FROM base AS development
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libpq-dev libvips libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
ENV RAILS_ENV=development \
BUNDLE_WITHOUT=""
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .
EXPOSE 3000
CMD ["bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]
# Production build
FROM base AS build
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libpq-dev libvips libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
ENV RAILS_ENV=production \
BUNDLE_DEPLOYMENT=1 \
BUNDLE_WITHOUT="development:test"
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails tailwindcss:build && \
SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
FROM base AS production
ENV RAILS_ENV=production \
BUNDLE_DEPLOYMENT=1 \
BUNDLE_WITHOUT="development:test" \
RAILS_LOG_TO_STDOUT=true
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
RUN mkdir -p db log storage tmp public && \
chmod +x bin/docker-entrypoint && \
useradd rails --create-home --shell /bin/bash && \
chown -R rails:rails db log storage tmp public
USER rails:rails
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
EXPOSE 3000
CMD ["./bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]
+32
View File
@@ -0,0 +1,32 @@
source "https://rubygems.org"
gem "rails", "~> 8.1.3", ">= 8.1.3.1"
gem "propshaft"
gem "pg", "~> 1.1"
gem "puma", ">= 5.0"
gem "importmap-rails"
gem "turbo-rails"
gem "stimulus-rails"
gem "tailwindcss-rails"
gem "bcrypt", "~> 3.1.7"
gem "pagy", "~> 9.3"
gem "csv"
gem "tzinfo-data", platforms: %i[ windows jruby ]
gem "bootsnap", require: false
gem "image_processing", "~> 1.2"
group :development, :test do
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
gem "bundler-audit", require: false
gem "brakeman", require: false
gem "rubocop-rails-omakase", require: false
end
group :development do
gem "web-console"
end
group :test do
gem "capybara"
gem "selenium-webdriver"
end
+335
View File
@@ -0,0 +1,335 @@
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.19)
railties
actioncable (8.1.3.1)
actionpack (= 8.1.3.1)
activesupport (= 8.1.3.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.3.1)
actionpack (= 8.1.3.1)
activejob (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
mail (>= 2.8.0)
actionmailer (8.1.3.1)
actionpack (= 8.1.3.1)
actionview (= 8.1.3.1)
activejob (= 8.1.3.1)
activesupport (= 8.1.3.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.3.1)
actionview (= 8.1.3.1)
activesupport (= 8.1.3.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.3.1)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.3.1)
activesupport (= 8.1.3.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.3.1)
activesupport (= 8.1.3.1)
globalid (>= 0.3.6)
activemodel (8.1.3.1)
activesupport (= 8.1.3.1)
activerecord (8.1.3.1)
activemodel (= 8.1.3.1)
activesupport (= 8.1.3.1)
timeout (>= 0.4.0)
activestorage (8.1.3.1)
actionpack (= 8.1.3.1)
activejob (= 8.1.3.1)
activerecord (= 8.1.3.1)
activesupport (= 8.1.3.1)
marcel (~> 1.0)
activesupport (8.1.3.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.22)
bigdecimal (4.1.2)
bindex (0.8.1)
bootsnap (1.25.0)
msgpack (~> 1.5)
brakeman (8.0.6)
racc
builder (3.3.0)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crass (1.0.7)
csv (3.3.6)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
drb (2.2.3)
erb (6.0.7)
erubi (1.13.1)
ffi (1.17.4-x86_64-linux-gnu)
globalid (1.4.0)
activesupport (>= 6.1)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.9.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.21.2)
language_server-protocol (3.17.0.6)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.2.1)
matrix (0.4.3)
mini_magick (5.3.3)
logger
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.4)
net-imap (0.6.6)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
pagy (9.4.0)
parallel (2.1.0)
parser (3.3.12.0)
ast (~> 2.4.1)
racc
pg (1.6.3-x86_64-linux)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
propshaft (1.3.2)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
public_suffix (7.0.5)
puma (8.0.2)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.7)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.3.1)
actioncable (= 8.1.3.1)
actionmailbox (= 8.1.3.1)
actionmailer (= 8.1.3.1)
actionpack (= 8.1.3.1)
actiontext (= 8.1.3.1)
actionview (= 8.1.3.1)
activejob (= 8.1.3.1)
activemodel (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
bundler (>= 1.15.0)
railties (= 8.1.3.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.3.1)
actionpack (= 8.1.3.1)
activesupport (= 8.1.3.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.4.2)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
regexp_parser (2.12.0)
reline (0.7.0)
io-console (~> 0.5)
rexml (3.4.4)
rubocop (1.89.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (>= 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.50.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.27.0)
lint_roller (~> 1.1)
rubocop (>= 1.89.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.37.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.89.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
rubyzip (3.4.1)
securerandom (0.4.1)
selenium-webdriver (4.47.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
tailwindcss-rails (4.6.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.3.3-x86_64-linux-gnu)
thor (1.5.0)
timeout (0.6.1)
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 8.0.0)
websocket (1.2.11)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.8.3)
PLATFORMS
x86_64-linux
DEPENDENCIES
bcrypt (~> 3.1.7)
bootsnap
brakeman
bundler-audit
capybara
csv
debug
image_processing (~> 1.2)
importmap-rails
pagy (~> 9.3)
pg (~> 1.1)
propshaft
puma (>= 5.0)
rails (~> 8.1.3, >= 8.1.3.1)
rubocop-rails-omakase
selenium-webdriver
stimulus-rails
tailwindcss-rails
turbo-rails
tzinfo-data
web-console
BUNDLED WITH
2.5.22
+2
View File
@@ -0,0 +1,2 @@
web: bin/rails server
css: bin/rails tailwindcss:watch
+183
View File
@@ -0,0 +1,183 @@
# eminuxCRM
CRM commerciale standalone focalizzato su una domanda:
> Cosa devo fare oggi per trasformare i miei prospect in clienti?
Applicazione Rails tradizionale (Hotwire / Turbo / Stimulus + Tailwind), pensata per poche persone, veloce da usare e semplice da mantenere. Completamente indipendente da MatchLiveTV.
## Architettura
- **Ruby on Rails 8** + **PostgreSQL 16**
- **Hotwire** (Turbo + Stimulus) — nessuna SPA
- **Tailwind CSS**
- Autenticazione locale con `has_secure_password` (bcrypt)
- Audit base `created_by` / `updated_by` sulle entità principali
- Timezone: `Europe/Rome` · UI: italiano
### Servizi Docker
| Servizio | Ruolo |
|----------|--------|
| `web` | Applicazione Rails (porta 3000) |
| `postgres` | Database PostgreSQL 16 |
## Requisiti
- Docker + Docker Compose
- (Opzionale) Ruby 3.3+ per sviluppo fuori da Docker
## Avvio rapido (Docker)
```bash
cp .env.example .env
# modifica SECRET_KEY_BASE e password DB se necessario
docker compose up -d --build
```
Al primo avvio vengono eseguiti automaticamente:
1. `bundle install` (in build)
2. `tailwindcss:build`
3. `db:prepare` (create + migrate)
4. `db:seed`
Apri: [http://localhost:3001](http://localhost:3001)
> Su questa macchina la porta 3000 è già usata da un altro stack: di default eminuxCRM usa **3001**.
> Puoi cambiarla con `APP_PORT` nel file `.env`.
### Progetti = istanze CRM separate
Ogni progetto ha un URL dedicato:
- Launcher: `/`
- MatchLiveTV: `/p/matchlivetv`
- RiskMeter: `/p/riskmeter`
- Cardoo: `/p/cardoo`
Dentro ogni istanza trovi dashboard, organizzazioni, pipeline, task, report e impostazioni **del solo quel progetto**.
- Gli **admin** vedono tutti i progetti
- Gli **utenti** solo i progetti abilitati
- In sidebar: “Tutti i progetti” + switch rapido tra istanze
## Credenziali development (solo seed)
| Email | Password | Ruolo | Progetti |
|-------|----------|-------|----------|
| `admin@simplecrm.local` | `password123` | Admin | Tutti |
| `marco@simplecrm.local` | `password123` | Utente | MatchLiveTV, RiskMeter |
| `lucia@simplecrm.local` | `password123` | Utente | MatchLiveTV, Cardoo |
## Configurazione `.env`
Copia `.env.example``.env`. Variabili principali:
- `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`
- `DATABASE_HOST` (in Docker: `postgres`)
- `SECRET_KEY_BASE`
- `MAILER_FROM`
- `APP_HOST` / `APP_PORT`
- `TZ=Europe/Rome`
**Non committare** `.env` con secret reali.
## Migration e seed
```bash
docker compose exec web bin/rails db:migrate
docker compose exec web bin/rails db:seed
```
Reset completo (distruttivo):
```bash
docker compose exec web bin/rails db:reset
```
## Test
```bash
docker compose exec web bin/rails db:test:prepare
docker compose exec web bin/rails test
```
## Backup / restore
```bash
bin/backup
# crea tmp/backups/simplecrm_YYYYMMDD_HHMMSS.sql.gz
bin/restore tmp/backups/simplecrm_YYYYMMDD_HHMMSS.sql.gz
```
Con Docker Compose attivo gli script usano `pg_dump` / `psql` sul container `postgres`.
## Import CSV
Pagina: **Impostazioni → Import CSV** oppure `/imports/new`.
Formato standard (file esempio: `examples/organizations_import_sample.csv`):
```csv
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
```
Duplicati evitati confrontando nome organizzazione, email e sito.
## Export CSV
Disponibile da liste Organizzazioni, Contatti e Opportunità (rispetta i filtri attivi dove applicabile).
## Struttura database (principale)
- `users` — autenticazione, ruolo admin/user
- `organizations` — prospect/clienti
- `contacts` — contatti per organizzazione
- `opportunities` — pipeline commerciale
- `activities` — timeline
- `tasks` — follow-up / next actions
- `sales_goals` — obiettivi dashboard
- `products` — catalogo prodotti (Light, Full, …)
Pipeline stages: Da contattare → Contattato → Ha risposto → Interessato → Demo/Trial → Primo utilizzo → Proposta → Cliente / Perso.
## Funzionalità principali
- Dashboard con obiettivo, KPI, funnel, “Cosa fare oggi”, indicatori di attenzione
- Pagina `/today` operativa
- Scheda Organization come centro operativo + quick actions
- Kanban pipeline con drag & drop (Stimulus) e cambio stage da menu
- Task con evidenziazione scaduti / oggi / futuri
- Report: funnel, lead source, won/lost, lost reasons, sales owner, revenue
- Import/export CSV
## Deploy (suggerimento semplice)
1. Copia il progetto su una VM
2. Configura `.env` di produzione (`RAILS_ENV=production`, `SECRET_KEY_BASE`, password DB forti)
3. `docker compose up -d --build` (oppure builda il target `production` del Dockerfile)
4. Metti un reverse proxy (Caddy/Nginx) con HTTPS davanti alla porta 3000
5. Esegui backup periodici con `bin/backup`
## Sviluppo locale senza Docker (opzionale)
```bash
bundle install
bin/rails db:prepare db:seed
bin/dev # server + tailwind watch
```
## Volutamente fuori scope (fase successiva)
- Integrazioni Gmail/SMTP avanzate, sync email, calendario
- Stripe, webhook, API REST pubblica
- Multi-tenant / multi-progetto
- Dark mode, BI avanzata, microservizi
## Licenza
Uso interno / progetto privato.
+6
View File
@@ -0,0 +1,6 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative "config/application"
Rails.application.load_tasks
View File
View File
+310
View File
@@ -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;
}
+6
View File
@@ -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 dellordine nel CSS,
le carte restano bianche mentre il testo diventa chiaro (o il contrario). */
@custom-variant dark (&:is(.dark, .dark *));
+55
View File
@@ -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
+11
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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
+73
View File
@@ -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
+17
View File
@@ -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
+10
View File
@@ -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
+25
View File
@@ -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
+30
View File
@@ -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
+206
View File
@@ -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
+108
View File
@@ -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
+144
View File
@@ -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
+21
View File
@@ -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
+12
View File
@@ -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
+45
View File
@@ -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
+14
View File
@@ -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
+20
View File
@@ -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
+43
View File
@@ -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
+29
View File
@@ -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
+84
View File
@@ -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
+20
View File
@@ -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
+87
View File
@@ -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
+180
View File
@@ -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 ? "" : "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
+27
View File
@@ -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!"
}
}
+4
View File
@@ -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
}
}
+7
View File
@@ -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
+29
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch("MAILER_FROM", "noreply@simplecrm.local")
layout "mailer"
end
+41
View File
@@ -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
+7
View File
@@ -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
+18
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
+193
View File
@@ -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" => "",
"yes_sportcam" => " 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
View File
+21
View File
@@ -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
+18
View File
@@ -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("&nbsp;", " ").gsub(/\s+/, " ").strip.blank?
end
end
+41
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
class Current < ActiveSupport::CurrentAttributes
attribute :user
attribute :project
end
+41
View File
@@ -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
+19
View File
@@ -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
+107
View File
@@ -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
+98
View File
@@ -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
+158
View File
@@ -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
+116
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
class OrganizationProject < ApplicationRecord
belongs_to :organization
belongs_to :project
validates :organization_id, uniqueness: { scope: :project_id }
end
+13
View File
@@ -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
+31
View File
@@ -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
+66
View File
@@ -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
+104
View File
@@ -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
+127
View File
@@ -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
+6
View File
@@ -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
+42
View File
@@ -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
+127
View File
@@ -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
+167
View File
@@ -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
+62
View File
@@ -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
+48
View File
@@ -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
+81
View File
@@ -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
+32
View File
@@ -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>
+23
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
<%= render "form" %>
+31
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
<%= render "form" %>
+11
View File
@@ -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 %>
+151
View File
@@ -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, unemail 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
(1015 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 linvio 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>

Some files were not shown because too many files have changed in this diff Show More