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