From c4e5f289cf673aa4b97e64b5c68406dc4355c08c Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Mon, 17 Aug 2026 23:01:48 +0200 Subject: [PATCH] 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 --- .dockerignore | 43 ++ .env.example | 29 ++ .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 67 +++ .gitignore | 40 ++ .rubocop.yml | 8 + .ruby-version | 1 + Dockerfile | 71 ++++ Gemfile | 32 ++ Gemfile.lock | 335 +++++++++++++++ Procfile.dev | 2 + README.md | 183 ++++++++ Rakefile | 6 + app/assets/builds/.keep | 0 app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 310 ++++++++++++++ app/assets/tailwind/application.css | 6 + app/controllers/activities_controller.rb | 55 +++ app/controllers/admin_controller.rb | 11 + app/controllers/application_controller.rb | 16 + app/controllers/concerns/.keep | 0 app/controllers/concerns/authentication.rb | 52 +++ app/controllers/concerns/project_scoping.rb | 73 ++++ app/controllers/contacts_controller.rb | 73 ++++ app/controllers/dashboard_controller.rb | 17 + app/controllers/home_controller.rb | 10 + app/controllers/imports_controller.rb | 25 ++ app/controllers/mail_identities_controller.rb | 63 +++ app/controllers/mail_images_controller.rb | 30 ++ app/controllers/mail_templates_controller.rb | 67 +++ app/controllers/mailings_controller.rb | 206 +++++++++ app/controllers/opportunities_controller.rb | 108 +++++ app/controllers/organizations_controller.rb | 144 +++++++ app/controllers/password_resets_controller.rb | 41 ++ app/controllers/passwords_controller.rb | 21 + app/controllers/pipeline_controller.rb | 12 + app/controllers/projects_controller.rb | 45 ++ app/controllers/reports_controller.rb | 14 + app/controllers/search_controller.rb | 20 + app/controllers/sessions_controller.rb | 43 ++ app/controllers/settings_controller.rb | 29 ++ app/controllers/tasks_controller.rb | 84 ++++ app/controllers/today_controller.rb | 20 + app/controllers/users_controller.rb | 87 ++++ app/helpers/application_helper.rb | 180 ++++++++ app/javascript/application.js | 27 ++ .../controllers/ab_test_controller.js | 21 + app/javascript/controllers/application.js | 9 + .../controllers/check_all_controller.js | 12 + .../controllers/dropdown_controller.js | 23 + .../controllers/hello_controller.js | 7 + app/javascript/controllers/index.js | 4 + .../controllers/kanban_controller.js | 59 +++ .../controllers/merge_tokens_controller.js | 9 + .../controllers/modal_controller.js | 29 ++ .../controllers/theme_controller.js | 14 + .../controllers/wysiwyg_controller.js | 230 ++++++++++ app/jobs/application_job.rb | 7 + app/jobs/send_mailing_recipient_job.rb | 29 ++ app/mailers/application_mailer.rb | 4 + app/mailers/campaign_mailer.rb | 41 ++ app/mailers/password_mailer.rb | 7 + app/models/activity.rb | 18 + app/models/application_record.rb | 3 + app/models/catalog.rb | 193 +++++++++ app/models/concerns/.keep | 0 app/models/concerns/auditable.rb | 21 + app/models/concerns/html_blankable.rb | 18 + app/models/contact.rb | 41 ++ app/models/current.rb | 4 + app/models/mail_identity.rb | 41 ++ app/models/mail_template.rb | 19 + app/models/mailing.rb | 107 +++++ app/models/mailing_recipient.rb | 98 +++++ app/models/opportunity.rb | 158 +++++++ app/models/organization.rb | 116 ++++++ app/models/organization_project.rb | 6 + app/models/product.rb | 13 + app/models/project.rb | 31 ++ app/models/sales_goal.rb | 66 +++ app/models/task.rb | 104 +++++ app/models/user.rb | 127 ++++++ app/models/user_project.rb | 6 + .../campaign_import/matchlivetv_launch.rb | 208 +++++++++ app/services/csv_export.rb | 42 ++ app/services/csv_import/organizations.rb | 127 ++++++ app/services/dashboard/metrics.rb | 167 ++++++++ app/services/mail_merge.rb | 62 +++ app/services/mailings/inline_images.rb | 48 +++ app/services/mailings/recipient_builder.rb | 67 +++ app/services/reports/builder.rb | 81 ++++ app/views/admin/show.html.erb | 32 ++ app/views/contacts/_form.html.erb | 23 + app/views/contacts/edit.html.erb | 1 + app/views/contacts/index.html.erb | 31 ++ app/views/contacts/new.html.erb | 1 + app/views/contacts/show.html.erb | 11 + app/views/dashboard/_attention_list.html.erb | 20 + app/views/dashboard/show.html.erb | 151 +++++++ app/views/home/index.html.erb | 39 ++ app/views/imports/new.html.erb | 15 + app/views/imports/preview.html.erb | 17 + app/views/imports/result.html.erb | 16 + app/views/layouts/application.html.erb | 144 +++++++ app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/mail_identities/_form.html.erb | 67 +++ app/views/mail_identities/edit.html.erb | 8 + app/views/mail_identities/index.html.erb | 49 +++ app/views/mail_identities/new.html.erb | 8 + app/views/mail_templates/_form.html.erb | 31 ++ app/views/mail_templates/edit.html.erb | 8 + app/views/mail_templates/index.html.erb | 41 ++ app/views/mail_templates/new.html.erb | 8 + app/views/mailings/_form.html.erb | 116 ++++++ app/views/mailings/edit.html.erb | 8 + app/views/mailings/index.html.erb | 69 +++ app/views/mailings/new.html.erb | 8 + app/views/mailings/show.html.erb | 207 +++++++++ app/views/opportunities/_form.html.erb | 27 ++ app/views/opportunities/edit.html.erb | 1 + app/views/opportunities/index.html.erb | 28 ++ app/views/opportunities/new.html.erb | 1 + app/views/organizations/_form.html.erb | 47 +++ app/views/organizations/edit.html.erb | 1 + app/views/organizations/index.html.erb | 73 ++++ app/views/organizations/new.html.erb | 1 + app/views/organizations/show.html.erb | 190 +++++++++ app/views/password_mailer/reset.html.erb | 4 + app/views/password_resets/edit.html.erb | 11 + app/views/password_resets/new.html.erb | 11 + app/views/passwords/edit.html.erb | 9 + app/views/pipeline/show.html.erb | 51 +++ app/views/projects/_new_modal.html.erb | 24 ++ app/views/projects/edit.html.erb | 16 + app/views/pwa/manifest.json.erb | 17 + app/views/pwa/service-worker.js | 26 ++ app/views/reports/index.html.erb | 126 ++++++ app/views/search/show.html.erb | 30 ++ app/views/sessions/new.html.erb | 22 + app/views/settings/show.html.erb | 72 ++++ app/views/shared/_errors.html.erb | 9 + app/views/shared/_flash.html.erb | 6 + app/views/shared/_logo.html.erb | 27 ++ app/views/shared/_merge_tokens.html.erb | 17 + app/views/shared/_task_list.html.erb | 27 ++ app/views/shared/_theme_toggle.html.erb | 14 + app/views/shared/_user_menu.html.erb | 14 + app/views/shared/_wysiwyg_field.html.erb | 10 + app/views/tasks/_form.html.erb | 18 + app/views/tasks/edit.html.erb | 1 + app/views/tasks/index.html.erb | 18 + app/views/tasks/new.html.erb | 1 + app/views/today/show.html.erb | 53 +++ app/views/users/_form.html.erb | 71 ++++ app/views/users/edit.html.erb | 8 + app/views/users/index.html.erb | 57 +++ app/views/users/new.html.erb | 8 + bin/backup | 24 ++ bin/brakeman | 7 + bin/bundler-audit | 6 + bin/ci | 6 + bin/dev | 16 + bin/docker-entrypoint | 21 + bin/importmap | 4 + bin/rails | 4 + bin/rake | 4 + bin/restore | 31 ++ bin/rubocop | 8 + bin/setup | 35 ++ config.ru | 6 + config/application.rb | 27 ++ config/boot.rb | 4 + config/bundler-audit.yml | 5 + config/cable.yml | 10 + config/ci.rb | 20 + config/credentials.yml.enc | 1 + config/database.yml | 22 + config/environment.rb | 5 + config/environments/development.rb | 84 ++++ config/environments/production.rb | 106 +++++ config/environments/test.rb | 53 +++ config/importmap.rb | 8 + .../initializers/active_record_encryption.rb | 25 ++ config/initializers/assets.rb | 7 + .../initializers/content_security_policy.rb | 29 ++ .../initializers/filter_parameter_logging.rb | 8 + config/initializers/inflections.rb | 16 + config/initializers/pagy.rb | 5 + config/initializers/session_store.rb | 12 + config/locales/en.yml | 31 ++ config/locales/it.yml | 143 +++++++ config/puma.rb | 39 ++ config/routes.rb | 62 +++ config/storage.yml | 27 ++ ...tchLiveTV_Campagna_Lancio_100_Societa.xlsx | Bin 0 -> 24026 bytes db/data/matchlivetv_campagna_100.csv | 101 +++++ .../20260816100000_create_simplecrm_schema.rb | 164 ++++++++ db/migrate/20260816120000_add_projects.rb | 75 ++++ ...60817120000_allow_null_activity_user_id.rb | 5 + ...elds_to_organizations_and_opportunities.rb | 26 ++ ...te_active_storage_tables.active_storage.rb | 57 +++ .../20260817170000_create_mail_marketing.rb | 79 ++++ .../20260817173000_add_ab_test_to_mailings.rb | 16 + db/schema.rb | 393 ++++++++++++++++++ db/seeds.rb | 103 +++++ deploy/Caddyfile | 22 + docker-compose.deploy.yml | 87 ++++ docker-compose.yml | 53 +++ examples/organizations_import_sample.csv | 3 + lib/tasks/.keep | 0 lib/tasks/matchlivetv.rake | 19 + log/.keep | 0 public/400.html | 135 ++++++ public/404.html | 135 ++++++ public/406-unsupported-browser.html | 135 ++++++ public/422.html | 135 ++++++ public/500.html | 135 ++++++ public/favicon.svg | 7 + public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + script/.keep | 0 storage/.keep | 0 test/controllers/authentication_test.rb | 89 ++++ .../mail_identities_controller_test.rb | 37 ++ .../mail_images_controller_test.rb | 44 ++ test/controllers/mailings_controller_test.rb | 107 +++++ test/controllers/users_controller_test.rb | 117 ++++++ test/fixtures/activities.yml | 9 + test/fixtures/contacts.yml | 9 + test/fixtures/opportunities.yml | 11 + test/fixtures/organization_projects.yml | 3 + test/fixtures/organizations.yml | 12 + test/fixtures/products.yml | 5 + test/fixtures/projects.yml | 13 + test/fixtures/sales_goals.yml | 8 + test/fixtures/tasks.yml | 10 + test/fixtures/user_projects.yml | 9 + test/fixtures/users.yml | 17 + test/jobs/send_mailing_recipient_job_test.rb | 17 + test/mailers/campaign_mailer_test.rb | 47 +++ test/models/dashboard_metrics_test.rb | 16 + test/models/opportunity_test.rb | 35 ++ test/models/organization_test.rb | 16 + test/models/project_access_test.rb | 21 + test/models/task_test.rb | 19 + test/models/user_management_test.rb | 51 +++ ...campaign_import_matchlivetv_launch_test.rb | 51 +++ test/services/mail_merge_test.rb | 36 ++ .../mailings_recipient_builder_test.rb | 75 ++++ test/test_helper.rb | 85 ++++ tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 258 files changed, 11293 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Procfile.dev create mode 100644 README.md create mode 100644 Rakefile create mode 100644 app/assets/builds/.keep create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/tailwind/application.css create mode 100644 app/controllers/activities_controller.rb create mode 100644 app/controllers/admin_controller.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/concerns/project_scoping.rb create mode 100644 app/controllers/contacts_controller.rb create mode 100644 app/controllers/dashboard_controller.rb create mode 100644 app/controllers/home_controller.rb create mode 100644 app/controllers/imports_controller.rb create mode 100644 app/controllers/mail_identities_controller.rb create mode 100644 app/controllers/mail_images_controller.rb create mode 100644 app/controllers/mail_templates_controller.rb create mode 100644 app/controllers/mailings_controller.rb create mode 100644 app/controllers/opportunities_controller.rb create mode 100644 app/controllers/organizations_controller.rb create mode 100644 app/controllers/password_resets_controller.rb create mode 100644 app/controllers/passwords_controller.rb create mode 100644 app/controllers/pipeline_controller.rb create mode 100644 app/controllers/projects_controller.rb create mode 100644 app/controllers/reports_controller.rb create mode 100644 app/controllers/search_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/controllers/settings_controller.rb create mode 100644 app/controllers/tasks_controller.rb create mode 100644 app/controllers/today_controller.rb create mode 100644 app/controllers/users_controller.rb create mode 100644 app/helpers/application_helper.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/controllers/ab_test_controller.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/check_all_controller.js create mode 100644 app/javascript/controllers/dropdown_controller.js create mode 100644 app/javascript/controllers/hello_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/javascript/controllers/kanban_controller.js create mode 100644 app/javascript/controllers/merge_tokens_controller.js create mode 100644 app/javascript/controllers/modal_controller.js create mode 100644 app/javascript/controllers/theme_controller.js create mode 100644 app/javascript/controllers/wysiwyg_controller.js create mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/send_mailing_recipient_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/mailers/campaign_mailer.rb create mode 100644 app/mailers/password_mailer.rb create mode 100644 app/models/activity.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/catalog.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/concerns/auditable.rb create mode 100644 app/models/concerns/html_blankable.rb create mode 100644 app/models/contact.rb create mode 100644 app/models/current.rb create mode 100644 app/models/mail_identity.rb create mode 100644 app/models/mail_template.rb create mode 100644 app/models/mailing.rb create mode 100644 app/models/mailing_recipient.rb create mode 100644 app/models/opportunity.rb create mode 100644 app/models/organization.rb create mode 100644 app/models/organization_project.rb create mode 100644 app/models/product.rb create mode 100644 app/models/project.rb create mode 100644 app/models/sales_goal.rb create mode 100644 app/models/task.rb create mode 100644 app/models/user.rb create mode 100644 app/models/user_project.rb create mode 100644 app/services/campaign_import/matchlivetv_launch.rb create mode 100644 app/services/csv_export.rb create mode 100644 app/services/csv_import/organizations.rb create mode 100644 app/services/dashboard/metrics.rb create mode 100644 app/services/mail_merge.rb create mode 100644 app/services/mailings/inline_images.rb create mode 100644 app/services/mailings/recipient_builder.rb create mode 100644 app/services/reports/builder.rb create mode 100644 app/views/admin/show.html.erb create mode 100644 app/views/contacts/_form.html.erb create mode 100644 app/views/contacts/edit.html.erb create mode 100644 app/views/contacts/index.html.erb create mode 100644 app/views/contacts/new.html.erb create mode 100644 app/views/contacts/show.html.erb create mode 100644 app/views/dashboard/_attention_list.html.erb create mode 100644 app/views/dashboard/show.html.erb create mode 100644 app/views/home/index.html.erb create mode 100644 app/views/imports/new.html.erb create mode 100644 app/views/imports/preview.html.erb create mode 100644 app/views/imports/result.html.erb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/mail_identities/_form.html.erb create mode 100644 app/views/mail_identities/edit.html.erb create mode 100644 app/views/mail_identities/index.html.erb create mode 100644 app/views/mail_identities/new.html.erb create mode 100644 app/views/mail_templates/_form.html.erb create mode 100644 app/views/mail_templates/edit.html.erb create mode 100644 app/views/mail_templates/index.html.erb create mode 100644 app/views/mail_templates/new.html.erb create mode 100644 app/views/mailings/_form.html.erb create mode 100644 app/views/mailings/edit.html.erb create mode 100644 app/views/mailings/index.html.erb create mode 100644 app/views/mailings/new.html.erb create mode 100644 app/views/mailings/show.html.erb create mode 100644 app/views/opportunities/_form.html.erb create mode 100644 app/views/opportunities/edit.html.erb create mode 100644 app/views/opportunities/index.html.erb create mode 100644 app/views/opportunities/new.html.erb create mode 100644 app/views/organizations/_form.html.erb create mode 100644 app/views/organizations/edit.html.erb create mode 100644 app/views/organizations/index.html.erb create mode 100644 app/views/organizations/new.html.erb create mode 100644 app/views/organizations/show.html.erb create mode 100644 app/views/password_mailer/reset.html.erb create mode 100644 app/views/password_resets/edit.html.erb create mode 100644 app/views/password_resets/new.html.erb create mode 100644 app/views/passwords/edit.html.erb create mode 100644 app/views/pipeline/show.html.erb create mode 100644 app/views/projects/_new_modal.html.erb create mode 100644 app/views/projects/edit.html.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100644 app/views/reports/index.html.erb create mode 100644 app/views/search/show.html.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/settings/show.html.erb create mode 100644 app/views/shared/_errors.html.erb create mode 100644 app/views/shared/_flash.html.erb create mode 100644 app/views/shared/_logo.html.erb create mode 100644 app/views/shared/_merge_tokens.html.erb create mode 100644 app/views/shared/_task_list.html.erb create mode 100644 app/views/shared/_theme_toggle.html.erb create mode 100644 app/views/shared/_user_menu.html.erb create mode 100644 app/views/shared/_wysiwyg_field.html.erb create mode 100644 app/views/tasks/_form.html.erb create mode 100644 app/views/tasks/edit.html.erb create mode 100644 app/views/tasks/index.html.erb create mode 100644 app/views/tasks/new.html.erb create mode 100644 app/views/today/show.html.erb create mode 100644 app/views/users/_form.html.erb create mode 100644 app/views/users/edit.html.erb create mode 100644 app/views/users/index.html.erb create mode 100644 app/views/users/new.html.erb create mode 100755 bin/backup create mode 100755 bin/brakeman create mode 100755 bin/bundler-audit create mode 100755 bin/ci create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/importmap create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/restore create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/bundler-audit.yml create mode 100644 config/cable.yml create mode 100644 config/ci.rb create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/importmap.rb create mode 100644 config/initializers/active_record_encryption.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/pagy.rb create mode 100644 config/initializers/session_store.rb create mode 100644 config/locales/en.yml create mode 100644 config/locales/it.yml create mode 100644 config/puma.rb create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 db/data/MatchLiveTV_Campagna_Lancio_100_Societa.xlsx create mode 100644 db/data/matchlivetv_campagna_100.csv create mode 100644 db/migrate/20260816100000_create_simplecrm_schema.rb create mode 100644 db/migrate/20260816120000_add_projects.rb create mode 100644 db/migrate/20260817120000_allow_null_activity_user_id.rb create mode 100644 db/migrate/20260817160000_add_campaign_fields_to_organizations_and_opportunities.rb create mode 100644 db/migrate/20260817160001_create_active_storage_tables.active_storage.rb create mode 100644 db/migrate/20260817170000_create_mail_marketing.rb create mode 100644 db/migrate/20260817173000_add_ab_test_to_mailings.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 deploy/Caddyfile create mode 100644 docker-compose.deploy.yml create mode 100644 docker-compose.yml create mode 100644 examples/organizations_import_sample.csv create mode 100644 lib/tasks/.keep create mode 100644 lib/tasks/matchlivetv.rake create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/favicon.svg create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep create mode 100644 storage/.keep create mode 100644 test/controllers/authentication_test.rb create mode 100644 test/controllers/mail_identities_controller_test.rb create mode 100644 test/controllers/mail_images_controller_test.rb create mode 100644 test/controllers/mailings_controller_test.rb create mode 100644 test/controllers/users_controller_test.rb create mode 100644 test/fixtures/activities.yml create mode 100644 test/fixtures/contacts.yml create mode 100644 test/fixtures/opportunities.yml create mode 100644 test/fixtures/organization_projects.yml create mode 100644 test/fixtures/organizations.yml create mode 100644 test/fixtures/products.yml create mode 100644 test/fixtures/projects.yml create mode 100644 test/fixtures/sales_goals.yml create mode 100644 test/fixtures/tasks.yml create mode 100644 test/fixtures/user_projects.yml create mode 100644 test/fixtures/users.yml create mode 100644 test/jobs/send_mailing_recipient_job_test.rb create mode 100644 test/mailers/campaign_mailer_test.rb create mode 100644 test/models/dashboard_metrics_test.rb create mode 100644 test/models/opportunity_test.rb create mode 100644 test/models/organization_test.rb create mode 100644 test/models/project_access_test.rb create mode 100644 test/models/task_test.rb create mode 100644 test/models/user_management_test.rb create mode 100644 test/services/campaign_import_matchlivetv_launch_test.rb create mode 100644 test/services/mail_merge_test.rb create mode 100644 test/services/mailings_recipient_builder_test.rb create mode 100644 test/test_helper.rb create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep create mode 100644 vendor/javascript/.keep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c9367ef --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a712a73 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..83610cf --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d58c2aa --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c54e0f2 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..f9d86d4 --- /dev/null +++ b/.rubocop.yml @@ -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 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..e391e18 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-3.3.6 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddf0e7f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..4e7cd3f --- /dev/null +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..f08256a --- /dev/null +++ b/Gemfile.lock @@ -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 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..da151fe --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,2 @@ +web: bin/rails server +css: bin/rails tailwindcss:watch diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad818e1 --- /dev/null +++ b/README.md @@ -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. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/Rakefile @@ -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 diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..55dae8b --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -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; +} diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 0000000..09dd81c --- /dev/null +++ b/app/assets/tailwind/application.css @@ -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 *)); diff --git a/app/controllers/activities_controller.rb b/app/controllers/activities_controller.rb new file mode 100644 index 0000000..eb478bf --- /dev/null +++ b/app/controllers/activities_controller.rb @@ -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 diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb new file mode 100644 index 0000000..acf1851 --- /dev/null +++ b/app/controllers/admin_controller.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..18d547c --- /dev/null +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 0000000..c120a68 --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -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 diff --git a/app/controllers/concerns/project_scoping.rb b/app/controllers/concerns/project_scoping.rb new file mode 100644 index 0000000..f130778 --- /dev/null +++ b/app/controllers/concerns/project_scoping.rb @@ -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 diff --git a/app/controllers/contacts_controller.rb b/app/controllers/contacts_controller.rb new file mode 100644 index 0000000..78feef4 --- /dev/null +++ b/app/controllers/contacts_controller.rb @@ -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 diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000..6fe836f --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -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 diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 0000000..eea3e9a --- /dev/null +++ b/app/controllers/home_controller.rb @@ -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 diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb new file mode 100644 index 0000000..2351774 --- /dev/null +++ b/app/controllers/imports_controller.rb @@ -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 diff --git a/app/controllers/mail_identities_controller.rb b/app/controllers/mail_identities_controller.rb new file mode 100644 index 0000000..97d08a9 --- /dev/null +++ b/app/controllers/mail_identities_controller.rb @@ -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 diff --git a/app/controllers/mail_images_controller.rb b/app/controllers/mail_images_controller.rb new file mode 100644 index 0000000..24f4b04 --- /dev/null +++ b/app/controllers/mail_images_controller.rb @@ -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 diff --git a/app/controllers/mail_templates_controller.rb b/app/controllers/mail_templates_controller.rb new file mode 100644 index 0000000..352b24e --- /dev/null +++ b/app/controllers/mail_templates_controller.rb @@ -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 +

Ciao {{contatto_nome}},

+

scriviamo a {{societa}} ({{regione}}) per presentarti MatchLiveTV.

+

Possiamo aiutarvi a trasmettere le giovanili in modo semplice.

+

A presto,
Il team MatchLiveTV

+ HTML + end +end diff --git a/app/controllers/mailings_controller.rb b/app/controllers/mailings_controller.rb new file mode 100644 index 0000000..518d57a --- /dev/null +++ b/app/controllers/mailings_controller.rb @@ -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 diff --git a/app/controllers/opportunities_controller.rb b/app/controllers/opportunities_controller.rb new file mode 100644 index 0000000..69536db --- /dev/null +++ b/app/controllers/opportunities_controller.rb @@ -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 diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb new file mode 100644 index 0000000..dc5db5d --- /dev/null +++ b/app/controllers/organizations_controller.rb @@ -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 diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb new file mode 100644 index 0000000..40a1083 --- /dev/null +++ b/app/controllers/password_resets_controller.rb @@ -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 diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 0000000..400ce91 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -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 diff --git a/app/controllers/pipeline_controller.rb b/app/controllers/pipeline_controller.rb new file mode 100644 index 0000000..81a7284 --- /dev/null +++ b/app/controllers/pipeline_controller.rb @@ -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 diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb new file mode 100644 index 0000000..4fe4cf0 --- /dev/null +++ b/app/controllers/projects_controller.rb @@ -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 diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb new file mode 100644 index 0000000..8a9e2a0 --- /dev/null +++ b/app/controllers/reports_controller.rb @@ -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 diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb new file mode 100644 index 0000000..e045860 --- /dev/null +++ b/app/controllers/search_controller.rb @@ -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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..d18411f --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -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 diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb new file mode 100644 index 0000000..232159b --- /dev/null +++ b/app/controllers/settings_controller.rb @@ -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 diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb new file mode 100644 index 0000000..c3b1035 --- /dev/null +++ b/app/controllers/tasks_controller.rb @@ -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 diff --git a/app/controllers/today_controller.rb b/app/controllers/today_controller.rb new file mode 100644 index 0000000..01f60ad --- /dev/null +++ b/app/controllers/today_controller.rb @@ -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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..ef2b6ef --- /dev/null +++ b/app/controllers/users_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..376a906 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 0000000..bf7e1a4 --- /dev/null +++ b/app/javascript/application.js @@ -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://…" + }) +} diff --git a/app/javascript/controllers/ab_test_controller.js b/app/javascript/controllers/ab_test_controller.js new file mode 100644 index 0000000..31bd2e3 --- /dev/null +++ b/app/javascript/controllers/ab_test_controller.js @@ -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) + } +} diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 0000000..1213e85 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -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 } diff --git a/app/javascript/controllers/check_all_controller.js b/app/javascript/controllers/check_all_controller.js new file mode 100644 index 0000000..01452a6 --- /dev/null +++ b/app/javascript/controllers/check_all_controller.js @@ -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 + }) + } +} diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js new file mode 100644 index 0000000..97b07db --- /dev/null +++ b/app/javascript/controllers/dropdown_controller.js @@ -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") + } +} diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000..5975c07 --- /dev/null +++ b/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 0000000..1156bf8 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -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) diff --git a/app/javascript/controllers/kanban_controller.js b/app/javascript/controllers/kanban_controller.js new file mode 100644 index 0000000..46884a0 --- /dev/null +++ b/app/javascript/controllers/kanban_controller.js @@ -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") + } + } +} diff --git a/app/javascript/controllers/merge_tokens_controller.js b/app/javascript/controllers/merge_tokens_controller.js new file mode 100644 index 0000000..361bfe9 --- /dev/null +++ b/app/javascript/controllers/merge_tokens_controller.js @@ -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}}}`) + } +} diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js new file mode 100644 index 0000000..694f503 --- /dev/null +++ b/app/javascript/controllers/modal_controller.js @@ -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() + } +} diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js new file mode 100644 index 0000000..be1857d --- /dev/null +++ b/app/javascript/controllers/theme_controller.js @@ -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` + } +} diff --git a/app/javascript/controllers/wysiwyg_controller.js b/app/javascript/controllers/wysiwyg_controller.js new file mode 100644 index 0000000..23c7590 --- /dev/null +++ b/app/javascript/controllers/wysiwyg_controller.js @@ -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 + } +} diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/app/jobs/application_job.rb @@ -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 diff --git a/app/jobs/send_mailing_recipient_job.rb b/app/jobs/send_mailing_recipient_job.rb new file mode 100644 index 0000000..bb25164 --- /dev/null +++ b/app/jobs/send_mailing_recipient_job.rb @@ -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 diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000..2ce5cb0 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: ENV.fetch("MAILER_FROM", "noreply@simplecrm.local") + layout "mailer" +end diff --git a/app/mailers/campaign_mailer.rb b/app/mailers/campaign_mailer.rb new file mode 100644 index 0000000..a3405d4 --- /dev/null +++ b/app/mailers/campaign_mailer.rb @@ -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?(/]/i) + + <<~HTML + + + + #{html} + + + HTML + end +end diff --git a/app/mailers/password_mailer.rb b/app/mailers/password_mailer.rb new file mode 100644 index 0000000..da95ceb --- /dev/null +++ b/app/mailers/password_mailer.rb @@ -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 diff --git a/app/models/activity.rb b/app/models/activity.rb new file mode 100644 index 0000000..6921d20 --- /dev/null +++ b/app/models/activity.rb @@ -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 diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/catalog.rb b/app/models/catalog.rb new file mode 100644 index 0000000..e2d0c25 --- /dev/null +++ b/app/models/catalog.rb @@ -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 diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/models/concerns/auditable.rb b/app/models/concerns/auditable.rb new file mode 100644 index 0000000..9b26720 --- /dev/null +++ b/app/models/concerns/auditable.rb @@ -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 diff --git a/app/models/concerns/html_blankable.rb b/app/models/concerns/html_blankable.rb new file mode 100644 index 0000000..01756b2 --- /dev/null +++ b/app/models/concerns/html_blankable.rb @@ -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 diff --git a/app/models/contact.rb b/app/models/contact.rb new file mode 100644 index 0000000..4ef6ef3 --- /dev/null +++ b/app/models/contact.rb @@ -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 diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 0000000..c44ec30 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :user + attribute :project +end diff --git a/app/models/mail_identity.rb b/app/models/mail_identity.rb new file mode 100644 index 0000000..5c1cdb2 --- /dev/null +++ b/app/models/mail_identity.rb @@ -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 diff --git a/app/models/mail_template.rb b/app/models/mail_template.rb new file mode 100644 index 0000000..070a0ea --- /dev/null +++ b/app/models/mail_template.rb @@ -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 diff --git a/app/models/mailing.rb b/app/models/mailing.rb new file mode 100644 index 0000000..4d4d238 --- /dev/null +++ b/app/models/mailing.rb @@ -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 diff --git a/app/models/mailing_recipient.rb b/app/models/mailing_recipient.rb new file mode 100644 index 0000000..930bfb3 --- /dev/null +++ b/app/models/mailing_recipient.rb @@ -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 diff --git a/app/models/opportunity.rb b/app/models/opportunity.rb new file mode 100644 index 0000000..48bf711 --- /dev/null +++ b/app/models/opportunity.rb @@ -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 diff --git a/app/models/organization.rb b/app/models/organization.rb new file mode 100644 index 0000000..dd868df --- /dev/null +++ b/app/models/organization.rb @@ -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 diff --git a/app/models/organization_project.rb b/app/models/organization_project.rb new file mode 100644 index 0000000..3e8a84d --- /dev/null +++ b/app/models/organization_project.rb @@ -0,0 +1,6 @@ +class OrganizationProject < ApplicationRecord + belongs_to :organization + belongs_to :project + + validates :organization_id, uniqueness: { scope: :project_id } +end diff --git a/app/models/product.rb b/app/models/product.rb new file mode 100644 index 0000000..498c9c0 --- /dev/null +++ b/app/models/product.rb @@ -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 diff --git a/app/models/project.rb b/app/models/project.rb new file mode 100644 index 0000000..6b06a36 --- /dev/null +++ b/app/models/project.rb @@ -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 diff --git a/app/models/sales_goal.rb b/app/models/sales_goal.rb new file mode 100644 index 0000000..1a7b573 --- /dev/null +++ b/app/models/sales_goal.rb @@ -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 diff --git a/app/models/task.rb b/app/models/task.rb new file mode 100644 index 0000000..9d4d065 --- /dev/null +++ b/app/models/task.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..2bb5259 --- /dev/null +++ b/app/models/user.rb @@ -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 diff --git a/app/models/user_project.rb b/app/models/user_project.rb new file mode 100644 index 0000000..672a4ab --- /dev/null +++ b/app/models/user_project.rb @@ -0,0 +1,6 @@ +class UserProject < ApplicationRecord + belongs_to :user + belongs_to :project + + validates :user_id, uniqueness: { scope: :project_id } +end diff --git a/app/services/campaign_import/matchlivetv_launch.rb b/app/services/campaign_import/matchlivetv_launch.rb new file mode 100644 index 0000000..afdeecb --- /dev/null +++ b/app/services/campaign_import/matchlivetv_launch.rb @@ -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 diff --git a/app/services/csv_export.rb b/app/services/csv_export.rb new file mode 100644 index 0000000..f9cfe8a --- /dev/null +++ b/app/services/csv_export.rb @@ -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 diff --git a/app/services/csv_import/organizations.rb b/app/services/csv_import/organizations.rb new file mode 100644 index 0000000..8fb3347 --- /dev/null +++ b/app/services/csv_import/organizations.rb @@ -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 diff --git a/app/services/dashboard/metrics.rb b/app/services/dashboard/metrics.rb new file mode 100644 index 0000000..3152769 --- /dev/null +++ b/app/services/dashboard/metrics.rb @@ -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 diff --git a/app/services/mail_merge.rb b/app/services/mail_merge.rb new file mode 100644 index 0000000..38b6e3f --- /dev/null +++ b/app/services/mail_merge.rb @@ -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 diff --git a/app/services/mailings/inline_images.rb b/app/services/mailings/inline_images.rb new file mode 100644 index 0000000..f64e596 --- /dev/null +++ b/app/services/mailings/inline_images.rb @@ -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 diff --git a/app/services/mailings/recipient_builder.rb b/app/services/mailings/recipient_builder.rb new file mode 100644 index 0000000..b201e33 --- /dev/null +++ b/app/services/mailings/recipient_builder.rb @@ -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 diff --git a/app/services/reports/builder.rb b/app/services/reports/builder.rb new file mode 100644 index 0000000..9352e89 --- /dev/null +++ b/app/services/reports/builder.rb @@ -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 diff --git a/app/views/admin/show.html.erb b/app/views/admin/show.html.erb new file mode 100644 index 0000000..c97c9f9 --- /dev/null +++ b/app/views/admin/show.html.erb @@ -0,0 +1,32 @@ +
+
+

Impostazioni piattaforma

+

Gestione globale di eminuxCRM, indipendente dai singoli progetti.

+
+ +
+ <%= 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 %> +
Accessi
+

Utenti

+

Crea, modifica, disabilita o elimina gli account. Gli admin vedono tutti i progetti.

+

<%= @active_users_count %> attivi · <%= @users_count %> totali

+
Apri utenti →
+ <% 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 %> +
Istanze
+

Progetti

+

Ogni progetto è un CRM dedicato. Crei nuove istanze dalla home con +.

+

<%= @projects_count %> progetti

+
Torna ai progetti →
+ <% 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 %> +
Invii
+

Account email / SMTP

+

Da quale indirizzo partono le campagne: server, porta, utente e password SMTP.

+

<%= @mail_identities_count %> account

+
Configura SMTP →
+ <% end %> +
+
diff --git a/app/views/contacts/_form.html.erb b/app/views/contacts/_form.html.erb new file mode 100644 index 0000000..3881431 --- /dev/null +++ b/app/views/contacts/_form.html.erb @@ -0,0 +1,23 @@ +
+

<%= @contact.new_record? ? "Nuovo contatto" : "Modifica contatto" %>

+ <%= 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 %> +
+ <%= 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 %> +
+ + <%= f.text_area :notes, rows: 3, placeholder: "Note", class: input_class %> +
+ <%= f.submit class: btn_primary %> + <%= link_to "Annulla", @contact.persisted? ? @contact.organization : contacts_path, class: btn_secondary %> +
+ <% end %> +
diff --git a/app/views/contacts/edit.html.erb b/app/views/contacts/edit.html.erb new file mode 100644 index 0000000..e0f80e7 --- /dev/null +++ b/app/views/contacts/edit.html.erb @@ -0,0 +1 @@ +<%= render "form" %> diff --git a/app/views/contacts/index.html.erb b/app/views/contacts/index.html.erb new file mode 100644 index 0000000..17dbdf5 --- /dev/null +++ b/app/views/contacts/index.html.erb @@ -0,0 +1,31 @@ +
+
+

Contatti

+
+ <%= link_to "Export CSV", contacts_path(format: :csv), class: btn_secondary %> + <%= link_to "Nuovo", new_contact_path, class: btn_primary %> +
+
+ <%= 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 %> +
+ + + + + + <% @contacts.each do |c| %> + + + + + + + <% end %> + +
NomeOrganizzazioneEmailTelefono
<%= link_to c.full_name, c.organization, class: "hover:underline" %><% if c.primary_contact? %> ★<% end %><%= c.organization.name %><%= c.email %><%= c.phone.presence || c.mobile %>
+
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %> +
diff --git a/app/views/contacts/new.html.erb b/app/views/contacts/new.html.erb new file mode 100644 index 0000000..e0f80e7 --- /dev/null +++ b/app/views/contacts/new.html.erb @@ -0,0 +1 @@ +<%= render "form" %> diff --git a/app/views/contacts/show.html.erb b/app/views/contacts/show.html.erb new file mode 100644 index 0000000..e1c229d --- /dev/null +++ b/app/views/contacts/show.html.erb @@ -0,0 +1,11 @@ +
+

<%= @contact.full_name %>

+
+

Organizzazione: <%= link_to @contact.organization.name, @contact.organization %>

+

Ruolo: <%= @contact.role %>

+

Email: <%= @contact.email %>

+

Telefono: <%= @contact.phone %>

+

Cellulare: <%= @contact.mobile %>

+ <%= link_to "Modifica", edit_contact_path(@contact), class: "mt-4 inline-block text-emerald-700 dark:text-emerald-400" %> +
+
diff --git a/app/views/dashboard/_attention_list.html.erb b/app/views/dashboard/_attention_list.html.erb new file mode 100644 index 0000000..9a82c29 --- /dev/null +++ b/app/views/dashboard/_attention_list.html.erb @@ -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? %> +

<%= empty %>

+<% else %> +
    + <% items.first(8).each do |item| %> +
  • + <% if block_given? %> + <%= yield item %> + <% else %> + <%= link_to "#{item.organization.name} · #{item.name}", item.organization, class: "hover:underline" %> + <% end %> +
  • + <% end %> + <% if items.size > 8 %> +
  • + altre <%= items.size - 8 %>
  • + <% end %> +
+<% end %> diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb new file mode 100644 index 0000000..e129c43 --- /dev/null +++ b/app/views/dashboard/show.html.erb @@ -0,0 +1,151 @@ +
+
+
+

Dashboard · <%= current_project.name %>

+

Cosa devo fare oggi per trasformare i prospect in clienti?

+
+ <%= link_to "Apri pagina Oggi →", today_path, class: btn_primary %> +
+ + <% if @goal %> +
+
<%= @goal.name %>
+
+
<%= @goal.current_value.to_i %> / <%= @goal.target_value.to_i %>
+
<%= @goal.metric_label %> · <%= format_date(@goal.start_date) %> – <%= format_date(@goal.end_date) %>
+
+
<%= progress_bar(@goal.progress_percentage, color: "bg-emerald-400") %>
+
+ <% end %> + +
+

Cosa devo fare oggi

+
+
+

In ritardo (<%= @overdue_tasks.size %>)

+ <%= render "shared/task_list", tasks: @overdue_tasks %> +
+
+

Da fare oggi (<%= @today_tasks.size %>)

+ <%= render "shared/task_list", tasks: @today_tasks %> +
+
+

Prossimi (<%= @upcoming_tasks.size %>)

+ <%= render "shared/task_list", tasks: @upcoming_tasks %> +
+
+
+ +
+ <% [ + ["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| %> +
+
<%= label %>
+
<%= value %>
+
+ <% end %> +
+ +
+
+

Funnel commerciale

+
+ <% @funnel.each_with_index do |step, idx| %> + <% if step[:rate] %> +
↓ <%= step[:rate] %>%
+ <% end %> +
+ <%= step[:label].upcase %> + <%= step[:count] %> +
+ <% end %> +
+
+ +
+

KPI processo

+
+
Giorni medi contatto → WON
<%= @metrics.avg_days_to_won || "—" %>
+
Giorni medi dall'ultima attività
<%= @metrics.avg_days_since_last_activity || "—" %>
+
Prospect senza attività > 7gg
<%= @metrics.prospects_without_activity_over_7_days %>
+
Opp. senza next action
<%= @metrics.opportunities_without_next_action %>
+
Demo/Trial → WON
<%= @metrics.demo_to_won_conversion %>%
+
First Use → WON
<%= @metrics.first_use_to_won_conversion %>%
+
ARPA (WON)
<%= format_money(@metrics.arpa) %>
+
Pipeline value
<%= format_money(@metrics.open_pipeline_value) %>
+
+
+
+ +
+

Cosa sbloccare adesso

+

+ 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. +

+ + <% if @attention[:to_send_count].to_i.positive? %> +
+

Campagna lancio · email da inviare (<%= @attention[:to_send_count] %>)

+

+ Le società sono censite e in stato Da contattare. 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. +

+

+ <%= link_to "Apri la lista da inviare →", organizations_path, class: "font-medium text-zinc-900 underline dark:text-amber-50" %> +

+
+ <% end %> + +
+
+

Interessati senza follow-up

+

Hanno detto sì in linea: manca la prossima chiamata/email in agenda.

+ <%= render "dashboard/attention_list", items: @attention[:interested_without_followup], empty: "Nessuno: nessun interessato scoperto." %> +
+
+

Opportunità ferme da oltre 7 giorni

+

Lo stage non si è mosso: va rilanciato o chiuso come perso.

+ <%= 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 %> +
+
+

Task scaduti

+

Promemoria già passati: completarli o ripianificarli.

+ <%= 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 %> +
+
+

Senza contatto

+

Manca una persona a cui scrivere o telefonare.

+ <%= 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 %> +
+
+

Senza valore (già in trattativa)

+

Dopo il primo contatto serve un importo stimato (Light/Full…) per la pipeline.

+ <%= render "dashboard/attention_list", items: @attention[:opportunities_without_value], empty: "Nessuna in trattativa senza valore." %> +
+
+

Senza prossima azione (già in trattativa)

+

Dopo l’invio o la risposta manca un task in agenda: la trattativa si ferma.

+ <%= render "dashboard/attention_list", items: @attention[:opportunities_without_next_action], empty: "Nessuna in trattativa senza next action." %> +
+
+
+
diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb new file mode 100644 index 0000000..b746d45 --- /dev/null +++ b/app/views/home/index.html.erb @@ -0,0 +1,39 @@ +
+
+

I miei progetti

+

Ogni progetto è un CRM dedicato, con dashboard e dati separati.

+
+ + <% if @projects.any? %> +
+ <% @projects.each do |project| %> + <%= link_to project_root_path(project_code: project.code), + class: "group rounded-2xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6 transition hover:border-zinc-400 dark:hover:border-zinc-500" do %> +
<%= project.code %>
+
<%= project.name %>
+ <% if project.description.present? %> +

<%= project.description %>

+ <% end %> +
Apri CRM →
+ <% end %> + <% end %> + <% if current_user.admin? %> + + <% end %> +
+ <% else %> +
+ Nessun progetto disponibile. + <% if current_user.admin? %> + <%= button_tag "Crea il primo progetto", type: "button", data: { action: "modal#show" }, class: "mt-3 #{btn_primary}" %> + <% else %> + Chiedi a un amministratore di abilitarti. + <% end %> +
+ <% end %> +
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb new file mode 100644 index 0000000..cd0dd17 --- /dev/null +++ b/app/views/imports/new.html.erb @@ -0,0 +1,15 @@ +
+

Import CSV

+
+

Formato standard supportato:

+
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
+

File di esempio: examples/organizations_import_sample.csv

+
+ <%= form_with url: imports_path, multipart: true, class: "rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-5 space-y-4" do %> + <%= file_field_tag :file, accept: ".csv,text/csv", required: true, class: "block w-full text-sm" %> +
+ <%= submit_tag "Anteprima", name: "preview", value: "1", class: btn_secondary %> + <%= submit_tag "Importa", class: btn_primary %> +
+ <% end %> +
diff --git a/app/views/imports/preview.html.erb b/app/views/imports/preview.html.erb new file mode 100644 index 0000000..58d35c9 --- /dev/null +++ b/app/views/imports/preview.html.erb @@ -0,0 +1,17 @@ +
+

Anteprima import

+
+ + + <% @preview[:headers].each do |h| %><% end %> + + + <% @preview[:rows].each do |row| %> + <% @preview[:headers].each do |h| %><% end %> + <% end %> + +
<%= h %>
<%= row[h] %>
+
+

Torna indietro e conferma l'import senza anteprima per caricare i dati.

+ <%= link_to "Torna all'import", new_import_path, class: "text-emerald-700 hover:underline dark:text-emerald-400" %> +
diff --git a/app/views/imports/result.html.erb b/app/views/imports/result.html.erb new file mode 100644 index 0000000..21c2c07 --- /dev/null +++ b/app/views/imports/result.html.erb @@ -0,0 +1,16 @@ +
+

Risultato import

+
+

Importati: <%= @result.imported %>

+

Saltati (duplicati): <%= @result.skipped %>

+

Errori: <%= @result.errors.size %>

+ <% if @result.errors.any? %> +
    + <% @result.errors.each do |err| %> +
  • Riga <%= err[:line] %>: <%= err[:message] %>
  • + <% end %> +
+ <% end %> +
+ <%= link_to "Vai alle organizzazioni", organizations_path, class: "text-emerald-700 hover:underline dark:text-emerald-400" %> +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..c09ea97 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,144 @@ + +"> + + + <%= page_title %><% if current_project %> · <%= current_project.name %><% end %> · <%= app_name %> + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <%= stylesheet_link_tag "trix", "data-turbo-track": "reload" %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + <% if logged_in? && in_project_space? %> +
+ + +
+
+
+
+ <%= link_to root_path, class: "flex items-center gap-2 font-semibold tracking-tight" do %> + <%= render "shared/logo", variant: :mark, size: :xs %> + <%= current_project.name %> + <% end %> + <%= link_to "Progetti", root_path, class: "text-xs text-zinc-500" %> +
+ <%= form_with url: search_path, method: :get, class: "flex min-w-0 flex-1" do %> + <%= text_field_tag :q, params[:q], placeholder: "Cerca in #{current_project.name}…", class: input_class %> + <% end %> +
+ + +
+ <%= render "shared/theme_toggle" %> + <%= render "shared/user_menu" %> +
+ +
+ +
+
<%= render "shared/flash" %>
+ <%= yield %> +
+
+
+ <% elsif logged_in? %> +
+
+
+
+ <%= link_to root_path, class: "text-zinc-900 dark:text-zinc-100" do %> + <%= render "shared/logo", subtitle: (@page_title || "Seleziona un'istanza progetto") %> + <% end %> +
+
+ <% if current_user.admin? %> + + <%= link_to "Impostazioni", admin_path, class: btn_secondary %> + <% end %> + <%= render "shared/theme_toggle" %> + <%= render "shared/user_menu" %> +
+
+
+
+
<%= render "shared/flash" %>
+ <%= yield %> +
+ <% if current_user.admin? %> + <%= render "projects/new_modal" %> + <% end %> +
+ <% else %> +
+ <%= render "shared/theme_toggle" %> +
+
+
<%= render "shared/flash" %>
+ <%= yield %> +
+ <% end %> + + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..3aac900 --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..37f0bdd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/mail_identities/_form.html.erb b/app/views/mail_identities/_form.html.erb new file mode 100644 index 0000000..9083905 --- /dev/null +++ b/app/views/mail_identities/_form.html.erb @@ -0,0 +1,67 @@ +<%= form_with model: mail_identity, class: "space-y-6 rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6" do |f| %> + <%= render "shared/errors", object: mail_identity %> + +
+
+ + <%= f.text_field :name, required: true, placeholder: "MatchLiveTV produzione", class: input_class %> +
+
+ + <%= f.text_field :from_name, required: true, placeholder: "MatchLiveTV", class: input_class %> +
+
+ + <%= f.email_field :from_email, required: true, placeholder: "hello@example.com", class: input_class %> +
+
+ + <%= f.email_field :reply_to, class: input_class %> +
+
+ + <%= f.text_field :smtp_host, required: true, placeholder: "smtp.example.com", class: input_class %> +
+
+ + <%= f.number_field :smtp_port, required: true, min: 1, max: 65535, class: input_class %> +
+
+ + <%= f.select :encryption, Catalog::MAIL_ENCRYPTIONS.map { |k, v| [v, k] }, {}, class: input_class %> +
+
+ + <%= f.select :smtp_authentication, Catalog::MAIL_AUTH_METHODS.map { |k, v| [v, k] }, {}, class: input_class %> +
+
+ + <%= f.text_field :smtp_username, autocomplete: "off", class: input_class %> +
+
+ + <%= f.password_field :smtp_password, autocomplete: "new-password", class: input_class %> +
+ + +
+ +
+ <%= f.submit mail_identity.new_record? ? "Crea account" : "Salva", class: btn_primary %> + <%= link_to "Annulla", mail_identities_path, class: btn_secondary %> +
+<% end %> + +<% unless mail_identity.new_record? %> +
+ <%= button_to "Elimina account", mail_identity_path(mail_identity), method: :delete, + class: btn_danger, + form: { data: { turbo_confirm: "Eliminare #{mail_identity.name}?" } } %> +
+<% end %> diff --git a/app/views/mail_identities/edit.html.erb b/app/views/mail_identities/edit.html.erb new file mode 100644 index 0000000..febdda7 --- /dev/null +++ b/app/views/mail_identities/edit.html.erb @@ -0,0 +1,8 @@ +
+
+

Modifica account email

+

<%= @mail_identity.from_email %>

+
+ + <%= render "form", mail_identity: @mail_identity %> +
diff --git a/app/views/mail_identities/index.html.erb b/app/views/mail_identities/index.html.erb new file mode 100644 index 0000000..ebc94e4 --- /dev/null +++ b/app/views/mail_identities/index.html.erb @@ -0,0 +1,49 @@ +
+
+
+

Account email / SMTP

+

+ <%= link_to "Impostazioni", admin_path, class: "hover:underline" %> + · da questi indirizzi partono le campagne. Le password SMTP sono cifrate. +

+
+ <%= link_to "Nuovo account", new_mail_identity_path, class: btn_primary %> +
+ +
+ <% if @mail_identities.empty? %> +

Nessun account. Crea il mittente con host, porta e credenziali SMTP.

+ <% else %> + + + + + + + + + + + + <% @mail_identities.each do |identity| %> + "> + + + + + + + <% end %> + +
NomeMittenteSMTPStato
<%= identity.name %> + <%= identity.from_name %>
+ <%= identity.from_email %> +
+ <%= identity.smtp_host %>:<%= identity.smtp_port %> +
<%= Catalog.label_for(Catalog::MAIL_ENCRYPTIONS, identity.encryption) %>
+
<%= identity.active? ? "Attivo" : "Disattivo" %> + <%= link_to "Modifica", edit_mail_identity_path(identity), class: "text-zinc-700 hover:underline dark:text-zinc-300" %> +
+ <% end %> +
+
diff --git a/app/views/mail_identities/new.html.erb b/app/views/mail_identities/new.html.erb new file mode 100644 index 0000000..8449a76 --- /dev/null +++ b/app/views/mail_identities/new.html.erb @@ -0,0 +1,8 @@ +
+
+

Nuovo account email

+

Indirizzo mittente e impostazioni SMTP da cui partiranno gli invii.

+
+ + <%= render "form", mail_identity: @mail_identity %> +
diff --git a/app/views/mail_templates/_form.html.erb b/app/views/mail_templates/_form.html.erb new file mode 100644 index 0000000..d76e419 --- /dev/null +++ b/app/views/mail_templates/_form.html.erb @@ -0,0 +1,31 @@ +<%= form_with model: mail_template, class: "grid gap-6 lg:grid-cols-[1fr_16rem]" do |f| %> +
+ <%= render "shared/errors", object: mail_template %> + +
+ + <%= f.text_field :name, required: true, class: input_class %> +
+
+ + <%= f.text_field :subject, required: true, class: input_class %> +
+
+ <%= render "shared/wysiwyg_field", form: f, method: :body_html, label: "Corpo HTML" %> +
+ +
+ <%= f.submit mail_template.new_record? ? "Crea template" : "Salva", class: btn_primary %> + <%= link_to "Annulla", mail_templates_path, class: btn_secondary %> +
+
+ +
+ <%= render "shared/merge_tokens" %> + <% unless mail_template.new_record? %> + <%= button_to "Elimina template", mail_template_path(mail_template), method: :delete, + class: btn_danger, + form: { data: { turbo_confirm: "Eliminare #{mail_template.name}?" } } %> + <% end %> +
+<% end %> diff --git a/app/views/mail_templates/edit.html.erb b/app/views/mail_templates/edit.html.erb new file mode 100644 index 0000000..bd47b53 --- /dev/null +++ b/app/views/mail_templates/edit.html.erb @@ -0,0 +1,8 @@ +
+
+

Modifica template

+

<%= @mail_template.name %>

+
+ + <%= render "form", mail_template: @mail_template %> +
diff --git a/app/views/mail_templates/index.html.erb b/app/views/mail_templates/index.html.erb new file mode 100644 index 0000000..0171ff6 --- /dev/null +++ b/app/views/mail_templates/index.html.erb @@ -0,0 +1,41 @@ +
+
+
+

Template email

+

+ <%= link_to "Invii", mailings_path, class: "hover:underline" %> + · HTML riutilizzabile con variabili della scheda cliente. +

+
+ <%= link_to "Nuovo template", new_mail_template_path, class: btn_primary %> +
+ +
+ <% if @mail_templates.empty? %> +

Nessun template. Creane uno e usalo come base per gli invii.

+ <% else %> + + + + + + + + + + + <% @mail_templates.each do |template| %> + + + + + + + <% end %> + +
NomeOggettoAmbito
<%= template.name %><%= template.subject %><%= template.project_id? ? current_project.name : "Tutti i progetti" %> + <%= link_to "Modifica", edit_mail_template_path(template), class: "text-zinc-700 hover:underline dark:text-zinc-300" %> +
+ <% end %> +
+
diff --git a/app/views/mail_templates/new.html.erb b/app/views/mail_templates/new.html.erb new file mode 100644 index 0000000..d8510a9 --- /dev/null +++ b/app/views/mail_templates/new.html.erb @@ -0,0 +1,8 @@ +
+
+

Nuovo template

+

HTML con variabili {{campo}} dalla scheda del cliente.

+
+ + <%= render "form", mail_template: @mail_template %> +
diff --git a/app/views/mailings/_form.html.erb b/app/views/mailings/_form.html.erb new file mode 100644 index 0000000..73d8ec6 --- /dev/null +++ b/app/views/mailings/_form.html.erb @@ -0,0 +1,116 @@ +<%= form_with model: mailing, class: "grid gap-6 lg:grid-cols-[1fr_16rem]", html: { multipart: true } do |f| %> +
+ <%= render "shared/errors", object: mailing %> + + <% if @mail_identities.blank? %> +
+ Serve almeno un account SMTP attivo. + <% if current_user.admin? %> + <%= link_to "Censiscine uno", new_mail_identity_path, class: "font-medium underline" %>. + <% end %> +
+ <% end %> + +
+ + <%= f.text_field :name, required: true, class: input_class %> +
+ +
+
+ + <%= f.collection_select :mail_identity_id, @mail_identities, :id, :name, { prompt: "Scegli SMTP" }, { class: input_class, required: true } %> +
+
+ + <%= f.select :audience, Catalog::MAILING_AUDIENCES.map { |k, v| [v, k] }, {}, class: input_class %> +
+
+ + <%= f.number_field :interval_seconds, min: 0, max: 86_400, class: input_class %> +

0 = in coda una dopo l’altra, senza attesa. Es. 120 = una email ogni 2 minuti.

+
+
+ +
+ + +
" data-ab-test-target="panel"> + + <%= f.select :ab_assignment, Catalog::MAILING_AB_ASSIGNMENTS.map { |k, v| [v, k] }, {}, class: input_class %> +

«Già in scheda» usa il Test A/B della trattativa (es. campagna MatchLiveTV). «Suddividi a metà» alterna A e B sulla lista.

+
+
+ +
" data-ab-test-target="grid"> +
+

Variante A

+
+ + <%= f.collection_select :mail_template_id, @mail_templates, :id, :name, { include_blank: "Nessuno" }, { class: input_class } %> +
+ <% unless mailing.new_record? %> + + <% end %> +
+ + <%= f.text_field :subject, required: true, class: input_class %> +
+
+ <%= render "shared/wysiwyg_field", form: f, method: :body_html, label: "Corpo HTML A" %> +
+
+ +
" data-ab-test-target="panel"> +

Variante B

+
+ + <%= f.collection_select :mail_template_b_id, @mail_templates, :id, :name, { include_blank: "Nessuno" }, { class: input_class } %> +
+ <% unless mailing.new_record? %> + + <% end %> +
+ + <%= f.text_field :subject_b, class: input_class %> +
+
+ <%= render "shared/wysiwyg_field", form: f, method: :body_html_b, label: "Corpo HTML B" %> +
+
+
+ +
+ + <%= f.file_field :files, multiple: true, class: "block w-full text-sm text-zinc-600 file:mr-3 file:rounded-lg file:border-0 file:bg-zinc-900 file:px-3 file:py-2 file:text-sm file:font-medium file:text-white dark:text-zinc-300 dark:file:bg-zinc-100 dark:file:text-zinc-900" %> + <% if mailing.files.attached? %> +
    + <% mailing.files.each do |file| %> +
  • <%= file.filename %>
  • + <% end %> +
+ <% end %> +
+ +
+ <%= f.submit mailing.new_record? ? "Crea bozza" : "Salva", class: btn_primary %> + <%= link_to "Annulla", mailing.new_record? ? mailings_path : mailing_path(mailing), class: btn_secondary %> +
+
+ +
+ <%= render "shared/merge_tokens" %> +
+<% end %> diff --git a/app/views/mailings/edit.html.erb b/app/views/mailings/edit.html.erb new file mode 100644 index 0000000..4a1e7ad --- /dev/null +++ b/app/views/mailings/edit.html.erb @@ -0,0 +1,8 @@ +
+
+

Modifica invio

+

<%= @mailing.name %>

+
+ + <%= render "form", mailing: @mailing %> +
diff --git a/app/views/mailings/index.html.erb b/app/views/mailings/index.html.erb new file mode 100644 index 0000000..cb565ca --- /dev/null +++ b/app/views/mailings/index.html.erb @@ -0,0 +1,69 @@ +
+
+
+

Email

+

Campagne HTML con variabili, check destinatari e invio scaglionato.

+
+
+ <%= link_to "Template", mail_templates_path, class: btn_secondary %> + <% if current_user.admin? %> + <%= link_to "Account SMTP", mail_identities_path, class: btn_secondary %> + <% end %> + <%= link_to "Nuovo invio", new_mailing_path, class: btn_primary %> +
+
+ + <% if @mail_identities_count.zero? %> +
+ Manca un account mittente SMTP. + <% if current_user.admin? %> + <%= link_to "Configuralo qui", mail_identities_path, class: "font-medium underline" %>. + <% else %> + Chiedi a un amministratore di censirlo. + <% end %> +
+ <% end %> + +
+ <% if @mailings.empty? %> +

Nessun invio. Crea una bozza, spunta i destinatari e poi manda.

+ <% else %> + + + + + + + + + + + + <% @mailings.each do |mailing| %> + + + + + + + + <% end %> + +
InvioMittenteDestinatariStatoCreato
+ <%= link_to mailing.name, mailing, class: "font-medium hover:underline" %> + <% if mailing.ab_test? %> + <%= ab_variant_badge("A") %><%= ab_variant_badge("B") %> +
A: <%= mailing.subject %> · B: <%= mailing.subject_b %>
+ <% else %> +
<%= mailing.subject %>
+ <% end %> +
<%= mailing.mail_identity.from_email %> + <%= mailing.pending_count %> da inviare + · <%= mailing.sent_count %> inviate + <% if mailing.failed_count.positive? %> + · <%= mailing.failed_count %> errori + <% end %> + <%= mailing_status_badge(mailing.status) %><%= format_dt(mailing.created_at) %>
+ <% end %> +
+
diff --git a/app/views/mailings/new.html.erb b/app/views/mailings/new.html.erb new file mode 100644 index 0000000..8547433 --- /dev/null +++ b/app/views/mailings/new.html.erb @@ -0,0 +1,8 @@ +
+
+

Nuovo invio

+

Scegli mittente, lista e contenuto. Dopo il salvataggio fai il check dei destinatari.

+
+ + <%= render "form", mailing: @mailing %> +
diff --git a/app/views/mailings/show.html.erb b/app/views/mailings/show.html.erb new file mode 100644 index 0000000..7846951 --- /dev/null +++ b/app/views/mailings/show.html.erb @@ -0,0 +1,207 @@ +
+
+
+

<%= @mailing.name %>

+

+ <%= mailing_status_badge(@mailing.status) %> + · <%= @mailing.audience_label %> + · da <%= @mailing.mail_identity.from_email %> + <% if @mailing.ab_test? %> + · Test A/B · <%= @mailing.ab_assignment_label %> + <% end %> + <% if @mailing.interval_seconds.positive? %> + · pausa <%= @mailing.interval_seconds %>s + <% end %> +

+
+
+ <%= link_to "Tutti gli invii", mailings_path, class: btn_secondary %> + <% if @mailing.editable? %> + <%= link_to "Modifica contenuto", edit_mailing_path(@mailing), class: btn_secondary %> + <%= button_to "Aggiorna lista", refresh_recipients_mailing_path(@mailing), method: :post, class: btn_secondary %> + <% end %> +
+
+ +
+
+
Da inviare
+
<%= @mailing.pending_count %>
+
+
+
Esclusi
+
<%= @mailing.skipped_count %>
+
+
+
Inviate
+
<%= @mailing.sent_count %>
+
+
+
Errori
+
<%= @mailing.failed_count %>
+
+
+ + <% if @mailing.ab_test? %> +
+
+
+
Variante A
+ <%= ab_variant_badge("A") %> +
+
<%= @mailing.variant_sent_count("A") %> inviate
+

<%= @mailing.variant_pending_count("A") %> in coda · <%= @mailing.subject %>

+
+
+
+
Variante B
+ <%= ab_variant_badge("B") %> +
+
<%= @mailing.variant_sent_count("B") %> inviate
+

<%= @mailing.variant_pending_count("B") %> in coda · <%= @mailing.subject_b %>

+
+
+ <% end %> + + <% if @mailing.files.attached? %> +
+
Allegati
+
    + <% @mailing.files.each do |file| %> +
  • <%= file.filename %> (<%= number_to_human_size(file.byte_size) %>)
  • + <% end %> +
+
+ <% end %> + +
+
+
+

Destinatari

+ <% if @mailing.editable? %> +

Spunta chi deve ricevere la mail, poi salva la selezione.

+ <% end %> +
+ + <%= form_with url: update_recipients_mailing_path(@mailing), method: :patch do %> +
+ + + + <% if @mailing.editable? %> + + <% end %> + + + <% if @mailing.ab_test? %> + + <% end %> + + + + + + <% @recipients.each do |recipient| %> + "> + <% if @mailing.editable? %> + + <% end %> + + + <% if @mailing.ab_test? %> + + <% end %> + + + + <% end %> + +
+ + OrganizzazioneEmailTestStato
+ <% if recipient.email_ok? %> + <%= check_box_tag "pending_ids[]", recipient.id, recipient.pending?, + id: "recipient_#{recipient.id}", + data: { check_all_target: "checkbox" } %> + <% else %> + + <% end %> + + <%= link_to recipient.organization.name, recipient.organization, class: "font-medium hover:underline" %> +
<%= recipient.contact&.full_name.presence || "Nessun contatto" %>
+
+ <% if recipient.email_ok? %> + <%= recipient.email %> + <% else %> + <%= recipient.email.presence || "manca email" %> + <% end %> + <% if recipient.skip_reason.present? && !recipient.pending? %> +
<%= recipient.skip_reason %>
+ <% end %> + <% if recipient.error_message.present? %> +
<%= recipient.error_message %>
+ <% end %> +
<%= ab_variant_badge(recipient.ab_variant) || "—" %><%= mailing_recipient_status_badge(recipient.status) %> + <% if recipient.email_ok? %> + <%= link_to "Anteprima", mailing_path(@mailing, recipient_id: recipient.id, variant: recipient.ab_variant), class: "text-zinc-700 hover:underline dark:text-zinc-300" %> + <% end %> +
+
+ + <% if @mailing.editable? %> +
+ <%= submit_tag "Salva selezione", class: btn_secondary %> + I non spuntati restano esclusi da questo invio. +
+ <% end %> + <% end %> +
+ +
+
+
+

Anteprima<%= " #{@preview_variant}" if @mailing.ab_test? %>

+ <% if @preview_recipient %> + <%= @preview_recipient.organization.name %> + <% end %> +
+ <% if @mailing.ab_test? %> +
+ <%= link_to "Variante A", mailing_path(@mailing, recipient_id: @preview_recipient&.id, variant: "A"), + class: @preview_variant == "A" ? btn_primary : btn_secondary %> + <%= link_to "Variante B", mailing_path(@mailing, recipient_id: @preview_recipient&.id, variant: "B"), + class: @preview_variant == "B" ? btn_primary : btn_secondary %> +
+ <% end %> +

<%= @preview_subject %>

+
+ <%= sanitize_email_html(@preview_html) %> +
+
+ + <% if @mailing.draft? || @mailing.sending? %> +
+ <% if @mailing.ab_test? %> + <%= button_to "Invia prova A a me", test_send_mailing_path(@mailing, recipient_id: @preview_recipient&.id, variant: "A"), method: :post, class: btn_secondary %> + <%= button_to "Invia prova B a me", test_send_mailing_path(@mailing, recipient_id: @preview_recipient&.id, variant: "B"), method: :post, class: btn_secondary %> + <% else %> + <%= button_to "Invia prova a me (#{current_user.email})", test_send_mailing_path(@mailing), method: :post, class: btn_secondary %> + <% end %> + <% if @mailing.pending_count.positive? %> + <%= button_to "Invia a #{@mailing.pending_count} destinatari", queue_mailing_path(@mailing), method: :post, + class: btn_primary, + form: { data: { turbo_confirm: "Inviare #{@mailing.pending_count} email da #{@mailing.mail_identity.from_email}?" } } %> + <% else %> +

Nessun destinatario da inviare. Spunta almeno un contatto con email valida.

+ <% end %> +
+ <% end %> + + <% if @mailing.draft? %> + <%= button_to "Elimina bozza", mailing_path(@mailing), method: :delete, + class: btn_danger, + form: { data: { turbo_confirm: "Eliminare questa bozza?" } } %> + <% end %> +
+
+
diff --git a/app/views/opportunities/_form.html.erb b/app/views/opportunities/_form.html.erb new file mode 100644 index 0000000..cc1cd7c --- /dev/null +++ b/app/views/opportunities/_form.html.erb @@ -0,0 +1,27 @@ +
+

<%= @opportunity.new_record? ? "Nuova opportunità" : "Modifica opportunità" %>

+ <%= form_with model: @opportunity, class: "#{card_class} p-6 space-y-4" do |f| %> + <%= render "shared/errors", object: @opportunity %> + <%= f.collection_select :organization_id, @organizations, :id, :name, {}, class: input_class %> + <%= f.collection_select :project_id, @projects, :id, :name, {}, class: input_class %> + <%= f.text_field :name, placeholder: "Nome opportunità", required: true, class: input_class %> + <%= f.select :pipeline_stage, Catalog::PIPELINE_STAGES.map { |k,v| [v,k] }, {}, class: input_class %> + <%= f.number_field :estimated_value, placeholder: "Valore stimato", step: 0.01, class: input_class %> + <%= f.number_field :probability, placeholder: "Probabilità %", min: 0, max: 100, class: input_class %> + <%= f.date_field :expected_close_date, class: input_class %> + <%= f.select :product, (@products.map(&:name) + %w[Light Full Partnership Evento Altro]).uniq, { include_blank: true }, class: input_class %> + <%= f.select :ab_variant, Catalog::AB_VARIANTS.map { |k,v| [v,k] }, { include_blank: true }, class: input_class %> + <%= f.select :send_status, Catalog::SEND_STATUSES.map { |k,v| [v,k] }, { include_blank: true }, class: input_class %> + <%= f.date_field :sent_on, class: input_class %> + <%= f.text_field :outcome, placeholder: "Esito", class: input_class %> + + + <%= f.collection_select :assigned_user_id, @users, :id, :full_name, { include_blank: true }, class: input_class %> + <%= f.select :lost_reason, Catalog::LOST_REASONS.map { |k,v| [v,k] }, { include_blank: true }, class: input_class %> + <%= f.text_area :notes, rows: 3, class: input_class %> +
+ <%= f.submit class: btn_primary %> + <%= link_to "Annulla", @opportunity.persisted? ? @opportunity.organization : opportunities_path, class: btn_secondary %> +
+ <% end %> +
diff --git a/app/views/opportunities/edit.html.erb b/app/views/opportunities/edit.html.erb new file mode 100644 index 0000000..e0f80e7 --- /dev/null +++ b/app/views/opportunities/edit.html.erb @@ -0,0 +1 @@ +<%= render "form" %> diff --git a/app/views/opportunities/index.html.erb b/app/views/opportunities/index.html.erb new file mode 100644 index 0000000..215271d --- /dev/null +++ b/app/views/opportunities/index.html.erb @@ -0,0 +1,28 @@ +
+
+

Opportunità

+
+ <%= link_to "Export CSV", opportunities_path(format: :csv), class: btn_secondary %> + <%= link_to "Nuova", new_opportunity_path, class: btn_primary %> +
+
+
+ + + + + + <% @opportunities.each do |opp| %> + + + + + + + + <% end %> + +
OrganizzazioneNomeStageValoreOwner
<%= link_to opp.organization.name, opp.organization, class: "hover:underline" %><%= opp.name %><%= stage_badge(opp.pipeline_stage) %><%= format_money(opp.estimated_value) %><%= opp.assigned_user&.full_name %>
+
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %> +
diff --git a/app/views/opportunities/new.html.erb b/app/views/opportunities/new.html.erb new file mode 100644 index 0000000..e0f80e7 --- /dev/null +++ b/app/views/opportunities/new.html.erb @@ -0,0 +1 @@ +<%= render "form" %> diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb new file mode 100644 index 0000000..a80c454 --- /dev/null +++ b/app/views/organizations/_form.html.erb @@ -0,0 +1,47 @@ +
+

<%= @organization.new_record? ? "Nuova organizzazione" : "Modifica organizzazione" %>

+ <%= form_with model: @organization, class: "#{card_class} p-6 space-y-4" do |f| %> + <%= render "shared/errors", object: @organization %> +
+
<%= f.label :name, class: "mb-1 block text-sm font-medium" %><%= f.text_field :name, required: true, class: input_class %>
+
<%= f.label :legal_name, class: "mb-1 block text-sm font-medium" %><%= f.text_field :legal_name, class: input_class %>
+
<%= f.label :organization_type, class: "mb-1 block text-sm font-medium" %><%= f.select :organization_type, Catalog::ORGANIZATION_TYPES.map { |k,v| [v,k] }, {}, class: input_class %>
+
<%= f.label :sport, class: "mb-1 block text-sm font-medium" %><%= f.text_field :sport, class: input_class %>
+
<%= f.label :team_gender, class: "mb-1 block text-sm font-medium" %><%= f.select :team_gender, Catalog::TEAM_GENDERS.map { |k,v| [v,k] }, { include_blank: true }, class: input_class %>
+
<%= f.label :streaming_status, class: "mb-1 block text-sm font-medium" %><%= f.select :streaming_status, Catalog::STREAMING_STATUSES.map { |k,v| [v,k] }, { include_blank: true }, class: input_class %>
+
<%= f.label :status, class: "mb-1 block text-sm font-medium" %><%= f.select :status, Catalog::ORGANIZATION_STATUSES.map { |k,v| [v,k] }, {}, class: input_class %>
+
<%= f.label :lead_source, class: "mb-1 block text-sm font-medium" %><%= f.select :lead_source, Catalog::LEAD_SOURCES.map { |k,v| [v,k] }, { include_blank: true }, class: input_class %>
+
<%= f.label :list_position, class: "mb-1 block text-sm font-medium" %><%= f.number_field :list_position, min: 1, class: input_class %>
+
<%= f.label :assigned_user_id, "Owner", class: "mb-1 block text-sm font-medium" %><%= f.collection_select :assigned_user_id, @users, :id, :full_name, { include_blank: true }, class: input_class %>
+
+ Progetti +
+ <% (@projects || Project.active.ordered).each do |project| %> + + <% end %> +
+ <%= hidden_field_tag "organization[project_ids][]", "" %> +
+
<%= f.label :country, class: "mb-1 block text-sm font-medium" %><%= f.text_field :country, class: input_class %>
+
<%= f.label :region, class: "mb-1 block text-sm font-medium" %><%= f.text_field :region, class: input_class %>
+
<%= f.label :province, class: "mb-1 block text-sm font-medium" %><%= f.text_field :province, class: input_class %>
+
<%= f.label :city, class: "mb-1 block text-sm font-medium" %><%= f.text_field :city, class: input_class %>
+
<%= f.label :address, class: "mb-1 block text-sm font-medium" %><%= f.text_field :address, class: input_class %>
+
<%= f.label :website, class: "mb-1 block text-sm font-medium" %><%= f.text_field :website, class: input_class %>
+
<%= f.label :source_url, class: "mb-1 block text-sm font-medium" %><%= f.text_field :source_url, class: input_class %>
+
<%= f.label :phone, class: "mb-1 block text-sm font-medium" %><%= f.text_field :phone, class: input_class %>
+
<%= f.label :email, class: "mb-1 block text-sm font-medium" %><%= f.email_field :email, class: input_class %>
+
<%= f.label :vat_number, class: "mb-1 block text-sm font-medium" %><%= f.text_field :vat_number, class: input_class %>
+
<%= f.label :verified_at, class: "mb-1 block text-sm font-medium" %><%= f.date_field :verified_at, class: input_class %>
+
<%= f.label :commercial_fit, class: "mb-1 block text-sm font-medium" %><%= f.text_area :commercial_fit, rows: 3, class: input_class %>
+
<%= f.label :notes, class: "mb-1 block text-sm font-medium" %><%= f.text_area :notes, rows: 4, class: input_class %>
+
+
+ <%= f.submit class: btn_primary %> + <%= link_to "Annulla", @organization.persisted? ? @organization : organizations_path, class: btn_secondary %> +
+ <% end %> +
diff --git a/app/views/organizations/edit.html.erb b/app/views/organizations/edit.html.erb new file mode 100644 index 0000000..e0f80e7 --- /dev/null +++ b/app/views/organizations/edit.html.erb @@ -0,0 +1 @@ +<%= render "form" %> diff --git a/app/views/organizations/index.html.erb b/app/views/organizations/index.html.erb new file mode 100644 index 0000000..23898b7 --- /dev/null +++ b/app/views/organizations/index.html.erb @@ -0,0 +1,73 @@ +
+
+

Organizzazioni

+
+ <%= link_to "Export CSV", organizations_path(request.query_parameters.merge(format: :csv)), class: btn_secondary %> + <%= link_to "Import CSV", new_import_path, class: btn_secondary %> + <%= link_to "Nuova", new_organization_path, class: btn_primary %> +
+
+ + <%= form_with url: organizations_path, method: :get, class: "#{card_class} p-4" do %> +
+ <%= text_field_tag :q, params[:q], placeholder: "Cerca nome, città, contatto, email…", class: "#{input_class} md:col-span-2" %> + <%= select_tag :status, options_for_catalog(Catalog::ORGANIZATION_STATUSES, params[:status]), include_blank: "Stato", class: input_class %> + <%= select_tag :pipeline_stage, options_for_catalog(Catalog::PIPELINE_STAGES, params[:pipeline_stage]), include_blank: "Pipeline stage", class: input_class %> + <%= select_tag :owner, options_from_collection_for_select(User.active.order(:first_name), :id, :full_name, params[:owner]), include_blank: "Owner", class: input_class %> + <%= select_tag :lead_source, options_for_catalog(Catalog::LEAD_SOURCES, params[:lead_source]), include_blank: "Fonte", class: input_class %> + <%= select_tag :organization_type, options_for_catalog(Catalog::ORGANIZATION_TYPES, params[:organization_type]), include_blank: "Tipologia", class: input_class %> + <%= text_field_tag :sport, params[:sport], placeholder: "Sport", class: input_class %> + <%= text_field_tag :country, params[:country], placeholder: "Paese", class: input_class %> + <%= text_field_tag :region, params[:region], placeholder: "Regione", class: input_class %> + <%= select_tag :customer, options_for_select([["Cliente/non cliente", ""], ["Solo clienti", "yes"], ["Non clienti", "no"]], params[:customer]), class: input_class %> + + <%= select_tag :team_gender, options_for_catalog(Catalog::TEAM_GENDERS, params[:team_gender]), include_blank: "M/F", class: input_class %> + <%= select_tag :streaming_status, options_for_catalog(Catalog::STREAMING_STATUSES, params[:streaming_status]), include_blank: "Streaming", class: input_class %> + <%= select_tag :ab_variant, options_for_catalog(Catalog::AB_VARIANTS, params[:ab_variant]), include_blank: "Test A/B", class: input_class %> + <%= select_tag :sort, options_for_select([["Lista campagna", "lista"], ["Aggiornate", "updated"], ["Nome", "name"], ["Create", "created"]], params[:sort]), class: input_class %> +
+
+ <%= submit_tag "Filtra", class: btn_primary %> + <%= link_to "Reset", organizations_path, class: btn_secondary %> +
+ <% end %> + +
+ + + + + + + + + + + + + + + <% @organizations.each do |org| %> + + + + + + + <% opp = org.campaign_opportunity(current_project) %> + + + + + <% end %> + +
N.OrganizzazioneRegioneM/FStreamingTestStageInvio
<%= org.list_position || "—" %> + <%= link_to org.name, org, class: "font-medium text-zinc-900 hover:underline dark:text-zinc-100" %> +
<%= org.email.presence || org.organization_type_label %>
+
<%= [org.region, org.province].compact_blank.join(" · ").presence || "—" %><%= org.team_gender_label.presence || "—" %><%= streaming_badge(org.streaming_status) || "—" %><%= opp&.ab_variant.presence || "—" %><%= opp ? stage_badge(opp.pipeline_stage) : "—" %><%= opp&.send_status_label.presence || "—" %>
+
+ <%== pagy_nav(@pagy) if @pagy.pages > 1 %> +
diff --git a/app/views/organizations/new.html.erb b/app/views/organizations/new.html.erb new file mode 100644 index 0000000..e0f80e7 --- /dev/null +++ b/app/views/organizations/new.html.erb @@ -0,0 +1 @@ +<%= render "form" %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb new file mode 100644 index 0000000..9819c54 --- /dev/null +++ b/app/views/organizations/show.html.erb @@ -0,0 +1,190 @@ +
+
+
+
+

<%= @organization.name %>

+
+ <%= status_badge(@organization.status) %> + <% stage = @organization.primary_pipeline_stage(current_project) %> + <% if stage %><%= stage_badge(stage) %><% end %> + Owner: <%= @organization.assigned_user&.full_name || "—" %> + Fonte: <%= @organization.lead_source_label %> + Progetti: <%= @organization.projects.ordered.map(&:name).join(", ").presence || "—" %> +
+
+
+ <%= link_to "Modifica", edit_organization_path(@organization), class: btn_secondary %> + <%= button_to "Elimina", @organization, method: :delete, form: { data: { turbo_confirm: "Eliminare?" } }, class: btn_danger %> +
+
+ +
+ <% [ + ["Aggiungi nota", "note", "Nota"], + ["Registra email", "email_sent", "Email inviata"], + ["Registra telefonata", "call", "Telefonata"], + ["Registra demo", "demo", "Demo"], + ["Segna primo utilizzo", "first_use", "Primo utilizzo"], + ["Crea proposta", "proposal_sent", "Proposta inviata"] + ].each do |label, type, subject| %> + <%= form_with url: organization_activities_path(@organization), class: "inline" do %> + <%= hidden_field_tag "activity[activity_type]", type %> + <%= hidden_field_tag "activity[subject]", subject %> + <%= hidden_field_tag "activity[happened_at]", Time.current %> + <%= submit_tag label, class: "rounded-md border border-zinc-300 bg-zinc-50 px-3 py-1.5 text-xs font-medium text-zinc-800 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700" %> + <% end %> + <% end %> + <%= link_to "Crea follow-up", new_task_path(organization_id: @organization.id, task_type: "follow_up"), class: "rounded-md border border-zinc-300 bg-zinc-50 px-3 py-1.5 text-xs font-medium text-zinc-800 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700" %> + <% opp = @opportunities.open_stage.first || @opportunities.first %> + <% if opp %> + <%= button_to "Segna come vinto", update_stage_opportunity_path(opp), method: :patch, params: { pipeline_stage: "won" }, class: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white" %> + <%= form_with url: update_stage_opportunity_path(opp), method: :patch, class: "inline-flex items-center gap-1" do %> + <%= hidden_field_tag :pipeline_stage, "lost" %> + <%= select_tag :lost_reason, options_for_catalog(Catalog::LOST_REASONS), class: "rounded-md border border-zinc-300 bg-white px-2 py-1 text-xs dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100" %> + <%= submit_tag "Segna come perso", class: "rounded-md bg-rose-600 px-3 py-1.5 text-xs font-medium text-white" %> + <% end %> + <% end %> +
+
+ + <% if @next_task %> +
+
Prossima azione
+
+
+
<%= @next_task.title %>
+
<%= format_dt(@next_task.due_at) %> · <%= @next_task.task_type_label %> · <%= priority_badge(@next_task.priority) %>
+
+ <%= button_to "Completa", complete_task_path(@next_task), method: :patch, class: "rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white" %> +
+
+ <% end %> + +
+
+
+
+

Informazioni

+
+
+
Tipologia
<%= @organization.organization_type_label %>
+
Sport
<%= [@organization.sport.presence, @organization.team_gender_label.presence].compact.join(" · ").presence || "—" %>
+
Località
<%= [@organization.city, @organization.province, @organization.region, @organization.country].compact_blank.join(", ").presence || "—" %>
+
Lista campagna
<%= @organization.list_position || "—" %>
+
Email
<%= @organization.email.presence || "—" %>
+
Telefono
<%= @organization.phone.presence || "—" %>
+
Sito / profilo
<%= external_link(@organization.website, class: "text-sky-700 hover:underline dark:text-sky-400") %>
+
Fonte ricerca
<%= external_link(@organization.source_url, class: "text-sky-700 hover:underline dark:text-sky-400") %>
+
Streaming rilevato
<%= streaming_badge(@organization.streaming_status) || "—" %>
+
Data verifica
<%= format_date(@organization.verified_at) %>
+
Evidenza / fit commerciale
<%= @organization.commercial_fit.presence || "—" %>
+
Note follow-up
<%= @organization.notes.presence || "—" %>
+
+
+ +
+
+

Contatti

+ <%= link_to "+ Contatto", new_contact_path(organization_id: @organization.id), class: "text-sm font-medium text-emerald-700 dark:text-emerald-400" %> +
+
+ <% @contacts.each do |contact| %> +
+
+
+ <%= contact.full_name %> + <% if contact.primary_contact? %>Principale<% end %> +
+
<%= contact.role %> · <%= contact.email %> · <%= contact.phone.presence || contact.mobile %>
+
+ <%= link_to "Modifica", edit_contact_path(contact), class: "text-sm text-zinc-600 hover:underline dark:text-zinc-300" %> +
+ <% end %> + <% if @contacts.blank? %>

Nessun contatto.

<% end %> +
+
+ +
+
+

Opportunità

+ <%= link_to "+ Opportunità", new_opportunity_path(organization_id: @organization.id), class: "text-sm font-medium text-emerald-700 dark:text-emerald-400" %> +
+
+ <% @opportunities.each do |opp| %> +
+
+
<%= opp.name %>
+ <%= stage_badge(opp.pipeline_stage) %> +
+
+ <%= format_money(opp.estimated_value) %> · <%= opp.product.presence || "—" %> · <%= opp.probability || 0 %>% · <%= opp.assigned_user&.full_name || "—" %> +
+
+ Test <%= opp.ab_variant.presence || "—" %> + · <%= opp.send_status_label.presence || "Invio n/d" %> + · Demo/Trial <%= yes_no(opp.demo_trial) %> + · Conversione <%= yes_no(opp.converted) %> + <% if opp.sent_on %> · Inviato il <%= format_date(opp.sent_on) %><% end %> + <% if opp.outcome.present? %> · Esito: <%= opp.outcome %><% end %> +
+ <%= form_with url: update_stage_opportunity_path(opp), method: :patch, class: "mt-2 flex flex-wrap items-center gap-2" do %> + <%= select_tag :pipeline_stage, options_for_catalog(Catalog::PIPELINE_STAGES, opp.pipeline_stage), class: "#{input_class} text-xs py-1" %> + <%= select_tag :lost_reason, options_for_catalog(Catalog::LOST_REASONS, opp.lost_reason), include_blank: "Motivo perdita", class: "#{input_class} text-xs py-1" %> + <%= submit_tag "Aggiorna stage", class: "#{btn_primary} px-2 py-1 text-xs" %> + <%= link_to "Modifica", edit_opportunity_path(opp), class: "text-xs text-zinc-600 hover:underline dark:text-zinc-300" %> + <% end %> +
+ <% end %> + <% if @opportunities.blank? %>

Nessuna opportunità.

<% end %> +
+
+ +
+
+

Task

+ <%= link_to "+ Task", new_task_path(organization_id: @organization.id), class: "text-sm font-medium text-emerald-700 dark:text-emerald-400" %> +
+

Pendenti

+ <%= render "shared/task_list", tasks: @pending_tasks %> +

Completati

+
    + <% @completed_tasks.each do |task| %> +
  • ✓ <%= task.title %> · <%= format_dt(task.completed_at) %>
  • + <% end %> + <% if @completed_tasks.blank? %>
  • Nessuno
  • <% end %> +
+
+
+ +
+
+

Aggiungi nota

+ <%= form_with url: organization_activities_path(@organization), class: "mt-3 space-y-3" do %> + <%= hidden_field_tag "activity[activity_type]", "note" %> + <%= text_field_tag "activity[subject]", nil, placeholder: "Oggetto", required: true, class: input_class %> + <%= text_area_tag "activity[description]", nil, rows: 3, placeholder: "Descrizione", class: input_class %> + <%= datetime_local_field_tag "activity[happened_at]", Time.current.strftime("%Y-%m-%dT%H:%M"), class: input_class %> + <%= submit_tag "Salva nota", class: btn_primary %> + <% end %> +
+ +
+

Timeline

+
+ <% @activities.each do |activity| %> +
+
+
<%= format_dt(activity.happened_at) %>
+
<%= activity.activity_type_label %>: <%= activity.subject %>
+ <% if activity.description.present? %> +
<%= activity.description %>
+ <% end %> +
<%= activity.user&.full_name || "Utente rimosso" %><% if activity.contact %> · <%= activity.contact.full_name %><% end %>
+
+ <% end %> + <% if @activities.blank? %>

Nessuna attività.

<% end %> +
+
+
+
+
diff --git a/app/views/password_mailer/reset.html.erb b/app/views/password_mailer/reset.html.erb new file mode 100644 index 0000000..905dbf5 --- /dev/null +++ b/app/views/password_mailer/reset.html.erb @@ -0,0 +1,4 @@ +

Ciao <%= @user.first_name %>,

+

Hai richiesto il reset della password di <%= Rails.application.config.x.app_name %>.

+

<%= link_to "Imposta nuova password", @url %>

+

Il link scade tra 2 ore.

diff --git a/app/views/password_resets/edit.html.erb b/app/views/password_resets/edit.html.erb new file mode 100644 index 0000000..a4fa1c1 --- /dev/null +++ b/app/views/password_resets/edit.html.erb @@ -0,0 +1,11 @@ +
+
+

Nuova password

+ <%= form_with model: @user, url: update_password_resets_path(token: params[:token]), method: :patch, class: "mt-6 space-y-4" do |f| %> + <%= render "shared/errors", object: @user %> + <%= f.password_field :password, placeholder: "Nuova password", required: true, class: input_class %> + <%= f.password_field :password_confirmation, placeholder: "Conferma password", required: true, class: input_class %> + <%= f.submit "Aggiorna password", class: "#{btn_primary} w-full py-2.5" %> + <% end %> +
+
diff --git a/app/views/password_resets/new.html.erb b/app/views/password_resets/new.html.erb new file mode 100644 index 0000000..0be067b --- /dev/null +++ b/app/views/password_resets/new.html.erb @@ -0,0 +1,11 @@ +
+
+

Recupero password

+

Inserisci la tua email per ricevere il link di reset.

+ <%= form_with url: password_resets_path, class: "mt-6 space-y-4" do %> + <%= email_field_tag :email, nil, required: true, placeholder: "Email", class: input_class %> + <%= submit_tag "Invia link", class: "#{btn_primary} w-full py-2.5" %> + <% end %> +
<%= link_to "Torna al login", login_path, class: "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100" %>
+
+
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 0000000..964796d --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,9 @@ +
+

Cambio password

+ <%= form_with url: password_path, method: :patch, class: "mt-6 space-y-4 rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6" do %> + <%= password_field_tag :current_password, nil, placeholder: "Password attuale", required: true, class: input_class %> + <%= password_field_tag "user[password]", nil, placeholder: "Nuova password", required: true, class: input_class %> + <%= password_field_tag "user[password_confirmation]", nil, placeholder: "Conferma", required: true, class: input_class %> + <%= submit_tag "Salva", class: btn_primary %> + <% end %> +
diff --git a/app/views/pipeline/show.html.erb b/app/views/pipeline/show.html.erb new file mode 100644 index 0000000..83e649e --- /dev/null +++ b/app/views/pipeline/show.html.erb @@ -0,0 +1,51 @@ +
+
+

Pipeline

+

Scorri orizzontalmente · trascina le card o cambia stage dal menu

+
+ +
+
+ <% Catalog::PIPELINE_ORDER.each do |stage| %> +
+
+
<%= Catalog.label_for(Catalog::PIPELINE_STAGES, stage) %>
+
<%= @opportunities_by_stage[stage].size %> opportunità
+
+
+ <% @opportunities_by_stage[stage].each do |opp| %> +
+ <%= link_to opp.organization.name, opp.organization, class: "font-medium text-zinc-900 hover:underline dark:text-zinc-50", data: { turbo: false } %> +
<%= opp.name %>
+
<%= format_money(opp.estimated_value) %>
+
+ <%= opp.organization.primary_contact&.full_name || opp.organization.contacts.first&.full_name || "—" %> +
+ <% next_task = opp.next_pending_task || opp.organization.next_pending_task %> +
+ <%= next_task ? "Next: #{next_task.title}" : "Nessuna next action" %> +
+
+ Ultima attività: <%= opp.organization.days_since_last_activity ? "#{opp.organization.days_since_last_activity}gg fa" : "—" %> +
+ <%= form_with url: update_stage_opportunity_path(opp), method: :patch, class: "mt-2" do %> + <%= select_tag :pipeline_stage, options_for_catalog(Catalog::PIPELINE_STAGES, opp.pipeline_stage), class: "#{input_class} text-xs py-1", onchange: "this.form.requestSubmit()" %> + <% if stage != "lost" %> + <%= select_tag :lost_reason, options_for_catalog(Catalog::LOST_REASONS), include_blank: "Motivo se perso", class: "#{input_class} mt-1 text-xs py-1" %> + <% end %> + <% end %> +
+ <% end %> +
+
+ <% end %> +
+
+
diff --git a/app/views/projects/_new_modal.html.erb b/app/views/projects/_new_modal.html.erb new file mode 100644 index 0000000..051aba0 --- /dev/null +++ b/app/views/projects/_new_modal.html.erb @@ -0,0 +1,24 @@ + diff --git a/app/views/projects/edit.html.erb b/app/views/projects/edit.html.erb new file mode 100644 index 0000000..9c496d5 --- /dev/null +++ b/app/views/projects/edit.html.erb @@ -0,0 +1,16 @@ +
+

Modifica progetto

+ <%= form_with model: @project, class: "#{card_class} p-6 space-y-4" do |f| %> + <%= render "shared/errors", object: @project %> + <%= f.text_field :name, class: input_class %> + <%= f.text_field :code, class: input_class %> + <%= f.text_area :description, rows: 3, class: input_class %> + <%= f.number_field :position, class: input_class %> + +
+ <%= f.submit "Salva", class: btn_primary %> + <%= link_to "Annulla", settings_path, class: btn_secondary %> + <%= button_to "Elimina", @project, method: :delete, form: { data: { turbo_confirm: "Eliminare il progetto?" }, class: "inline" }, class: btn_danger %> +
+ <% end %> +
diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..48344d0 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,17 @@ +{ + "name": "eminuxCRM", + "short_name": "eminuxCRM", + "icons": [ + { + "src": "/favicon.svg", + "type": "image/svg+xml", + "sizes": "any" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "CRM commerciale eminuxCRM", + "theme_color": "#18181b", + "background_color": "#09090b" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 0000000..b3a13fb --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb new file mode 100644 index 0000000..bba207d --- /dev/null +++ b/app/views/reports/index.html.erb @@ -0,0 +1,126 @@ +
+

Report

+ +
+

Funnel

+
+ + + + + + + + + <% @funnel[:stages].each do |s| %> + + + + + <% end %> + +
StageCount
<%= s[:label] %><%= s[:count] %>
+
+
+ <% @funnel[:funnel].each do |step| %> +
<%= step[:label] %>: <%= step[:count] %><% if step[:rate] %> (<%= step[:rate] %>% dal passo precedente)<% end %>
+ <% end %> +
+
+ +
+

Lead source

+ + + + + + + + + + + + <% @lead_sources.each do |row| %> + + + + + + + + <% end %> + +
FonteLeadOpp.ClientiConv.
<%= row[:label] %><%= row[:leads] %><%= row[:opportunities] %><%= row[:customers] %><%= row[:conversion_rate] %>%
+
+ +
+
+

Won / Lost per mese

+ + + + + + + + + + <% @won_lost.each do |row| %> + + + + + + <% end %> + +
MeseWonLost
<%= l(row[:month], format: "%B %Y") %><%= row[:won] %><%= row[:lost] %>
+
+ +
+

Lost reasons

+
    + <% @lost_reasons.each do |row| %> +
  • <%= row[:label] %><%= row[:count] %>
  • + <% end %> + <% if @lost_reasons.blank? %>
  • Nessun dato
  • <% end %> +
+
+
+ +
+

Sales owner

+ + + + + + + + + + + + <% @sales_owners.each do |row| %> + + + + + + + + <% end %> + +
UtenteAperteValore aperteVinteValore vinte
<%= row[:user].full_name %><%= row[:open_count] %><%= format_money(row[:open_value]) %><%= row[:won_count] %><%= format_money(row[:won_value]) %>
+
+ +
+

Revenue (WON per mese)

+
    + <% @revenue.each do |row| %> +
  • <%= l(row[:month], format: "%B %Y") %><%= format_money(row[:value]) %>
  • + <% end %> + <% if @revenue.blank? %>
  • Nessun dato
  • <% end %> +
+
+
diff --git a/app/views/search/show.html.erb b/app/views/search/show.html.erb new file mode 100644 index 0000000..290ab5e --- /dev/null +++ b/app/views/search/show.html.erb @@ -0,0 +1,30 @@ +
+

Ricerca: “<%= @query %>”

+
+

Organizzazioni

+
    + <% @organizations.each do |org| %> +
  • <%= link_to org.name, org, class: "hover:underline" %> · <%= org.city %>
  • + <% end %> + <% if @organizations.blank? %>
  • Nessun risultato
  • <% end %> +
+
+
+

Contatti

+
    + <% @contacts.each do |c| %> +
  • <%= link_to c.full_name, c.organization, class: "hover:underline" %> · <%= c.organization.name %>
  • + <% end %> + <% if @contacts.blank? %>
  • Nessun risultato
  • <% end %> +
+
+
+

Opportunità

+
    + <% @opportunities.each do |opp| %> +
  • <%= link_to "#{opp.organization.name} · #{opp.name}", opp.organization, class: "hover:underline" %>
  • + <% end %> + <% if @opportunities.blank? %>
  • Nessun risultato
  • <% end %> +
+
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..80d5500 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,22 @@ +
+
+

<%= render "shared/logo", size: :lg %>

+

Accedi al tuo CRM commerciale

+ + <%= form_with url: login_path, class: "mt-6 space-y-4" do %> +
+ + <%= email_field_tag :email, params[:email], required: true, autofocus: true, class: input_class %> +
+
+ + <%= password_field_tag :password, nil, required: true, class: input_class %> +
+ <%= submit_tag "Accedi", class: "#{btn_primary} w-full py-2.5" %> + <% end %> + +
+ <%= link_to "Password dimenticata?", new_password_reset_path, class: "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100" %> +
+
+
diff --git a/app/views/settings/show.html.erb b/app/views/settings/show.html.erb new file mode 100644 index 0000000..5dd622b --- /dev/null +++ b/app/views/settings/show.html.erb @@ -0,0 +1,72 @@ +
+

Impostazioni

+ + <% if current_project %> +
+ Progetto attivo: <%= current_project.name %> +
+ <% end %> + + <% if current_user.admin? %> +
+

Progetti

+

Censisci i progetti commerciali (MatchLiveTV, RiskMeter, Cardoo…).

+
    + <% @projects.each do |project| %> +
  • +
    +
    <%= project.name %> (<%= project.code %>)
    +
    <%= project.active? ? "Attivo" : "Disattivo" %> · pos. <%= project.position %> · <%= project.organizations.count %> organizzazioni
    +
    + <%= link_to "Modifica", edit_project_path(project), class: "text-zinc-700 hover:underline dark:text-zinc-300" %> +
  • + <% end %> +
+ + <%= form_with model: Project.new, url: projects_path, class: "mt-4 grid gap-2 md:grid-cols-2" do |f| %> + <%= f.text_field :name, placeholder: "Nome progetto", required: true, class: input_class %> + <%= f.text_field :code, placeholder: "codice (es. matchlivetv)", required: true, class: input_class %> + <%= f.text_area :description, placeholder: "Descrizione", rows: 2, class: "#{input_class} md:col-span-2" %> + <%= f.number_field :position, placeholder: "Posizione", value: (@projects.maximum(:position) || 0) + 1, class: input_class %> + + <%= f.submit "Crea progetto", class: "#{btn_primary} md:col-span-2" %> + <% end %> +
+ <% end %> + +
+

Obiettivo commerciale<%= " · #{current_project.name}" if current_project %>

+ <%= form_with model: @goal, url: update_goal_settings_path(@goal), method: :patch, class: "mt-4 grid gap-3 md:grid-cols-2" do |f| %> + <%= f.hidden_field :project_id, value: @goal.project_id || current_project&.id %> + <%= f.text_field :name, placeholder: "Nome obiettivo", class: input_class %> + <%= f.select :metric, Catalog::GOAL_METRICS.map { |k,v| [v,k] }, {}, class: input_class %> + <%= f.number_field :target_value, placeholder: "Target", class: input_class %> + <%= f.date_field :start_date, class: input_class %> + <%= f.date_field :end_date, class: input_class %> + + <%= f.submit "Salva obiettivo", class: "#{btn_primary} md:col-span-2" %> + <% end %> +
+ +
+

Prodotti

+
    + <% @products.each do |p| %> +
  • <%= p.name %> (<%= p.code %>)
  • + <% end %> +
+
+ +
+

Strumenti

+
    + <% if current_user.admin? %> +
  • <%= link_to "Gestione utenti", users_path, class: "hover:underline" %>
  • +
  • <%= link_to "Account email / SMTP", mail_identities_path, class: "hover:underline" %>
  • + <% end %> +
  • <%= link_to "Email e campagne", mailings_path, class: "hover:underline" %>
  • +
  • <%= link_to "Import CSV organizzazioni", new_import_path, class: "hover:underline" %>
  • +
  • <%= link_to "Cambio password", edit_password_path, class: "hover:underline" %>
  • +
+
+
diff --git a/app/views/shared/_errors.html.erb b/app/views/shared/_errors.html.erb new file mode 100644 index 0000000..a72109d --- /dev/null +++ b/app/views/shared/_errors.html.erb @@ -0,0 +1,9 @@ +<% if object.errors.any? %> +
+
    + <% object.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+<% end %> diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb new file mode 100644 index 0000000..f4fdc5d --- /dev/null +++ b/app/views/shared/_flash.html.erb @@ -0,0 +1,6 @@ +<% if notice.present? %> +
<%= notice %>
+<% end %> +<% if alert.present? %> +
<%= alert %>
+<% end %> diff --git a/app/views/shared/_logo.html.erb b/app/views/shared/_logo.html.erb new file mode 100644 index 0000000..38edc9a --- /dev/null +++ b/app/views/shared/_logo.html.erb @@ -0,0 +1,27 @@ +<% size = local_assigns.fetch(:size, :md) %> +<% variant = local_assigns.fetch(:variant, :full) %> +<% mark_class = { xs: "size-6", sm: "size-7", md: "size-8", lg: "size-10", xl: "size-12" }.fetch(size) %> +<% show_wordmark = variant != :mark %> + +<% mark = capture do %> + +<% end %> + +<% if show_wordmark %> + + <%= mark %> + + eminuxCRM + <% if local_assigns[:subtitle].present? %> + <%= subtitle %> + <% end %> + + +<% else %> + <%= mark %> +<% end %> diff --git a/app/views/shared/_merge_tokens.html.erb b/app/views/shared/_merge_tokens.html.erb new file mode 100644 index 0000000..6c91362 --- /dev/null +++ b/app/views/shared/_merge_tokens.html.erb @@ -0,0 +1,17 @@ +
+

Variabili

+

Clicca per inserirle nell’oggetto o nel testo, nel punto del cursore. All’invio diventano i dati della scheda.

+
    + <% MailMerge.catalog.each do |token, label| %> +
  • + +
  • + <% end %> +
+
diff --git a/app/views/shared/_task_list.html.erb b/app/views/shared/_task_list.html.erb new file mode 100644 index 0000000..af5d469 --- /dev/null +++ b/app/views/shared/_task_list.html.erb @@ -0,0 +1,27 @@ +
+
+ <% tasks.each do |task| %> +
+
+
+ <%= link_to task.organization.name, task.organization, class: "font-medium text-zinc-900 hover:underline dark:text-zinc-100" %> + <%= priority_badge(task.priority) %> + <%= task.task_type_label %> +
+
<%= task.title %>
+
+ <%= task.contact&.full_name || "—" %> · <%= format_dt(task.due_at) %> + <% if task.assigned_user %>· <%= task.assigned_user.full_name %><% end %> +
+
+
+ <%= button_to "Completa", complete_task_path(task), method: :patch, class: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700" %> + <%= link_to "Apri", task.organization, class: "rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800" %> +
+
+ <% end %> + <% if tasks.blank? %> +
Nessun task
+ <% end %> +
+
diff --git a/app/views/shared/_theme_toggle.html.erb b/app/views/shared/_theme_toggle.html.erb new file mode 100644 index 0000000..26aef39 --- /dev/null +++ b/app/views/shared/_theme_toggle.html.erb @@ -0,0 +1,14 @@ + diff --git a/app/views/shared/_user_menu.html.erb b/app/views/shared/_user_menu.html.erb new file mode 100644 index 0000000..024f215 --- /dev/null +++ b/app/views/shared/_user_menu.html.erb @@ -0,0 +1,14 @@ +
+ + +
diff --git a/app/views/shared/_wysiwyg_field.html.erb b/app/views/shared/_wysiwyg_field.html.erb new file mode 100644 index 0000000..5f85a5b --- /dev/null +++ b/app/views/shared/_wysiwyg_field.html.erb @@ -0,0 +1,10 @@ +
+ + <%= form.hidden_field method, data: { wysiwyg_target: "input" } %> + " + class="trix-content block min-h-56 w-full" + data-wysiwyg-target="editor" + data-action="trix-file-accept->wysiwyg#acceptFile trix-attachment-add->wysiwyg#upload"> +

Formato, elenchi, link e immagini. Clicca un’immagine e trascina gli angoli per ridimensionarla. Le variabili a destra si inseriscono nel punto del cursore.

+
diff --git a/app/views/tasks/_form.html.erb b/app/views/tasks/_form.html.erb new file mode 100644 index 0000000..f5167e1 --- /dev/null +++ b/app/views/tasks/_form.html.erb @@ -0,0 +1,18 @@ +
+

<%= @task.new_record? ? "Nuovo task" : "Modifica task" %>

+ <%= form_with model: @task, class: "#{card_class} p-6 space-y-4" do |f| %> + <%= render "shared/errors", object: @task %> + <%= f.text_field :title, placeholder: "Titolo", required: true, class: input_class %> + <%= f.text_area :description, rows: 3, placeholder: "Descrizione", class: input_class %> + <%= f.collection_select :organization_id, @organizations, :id, :name, {}, class: input_class %> + <%= f.collection_select :assigned_user_id, @users, :id, :full_name, { include_blank: true }, class: input_class %> + <%= f.datetime_local_field :due_at, class: input_class %> + <%= f.select :priority, Catalog::TASK_PRIORITIES.map { |k,v| [v,k] }, {}, class: input_class %> + <%= f.select :task_type, Catalog::TASK_TYPES.map { |k,v| [v,k] }, {}, class: input_class %> + <%= f.select :status, Catalog::TASK_STATUSES.map { |k,v| [v,k] }, {}, class: input_class %> +
+ <%= f.submit class: btn_primary %> + <%= link_to "Annulla", tasks_path, class: btn_secondary %> +
+ <% end %> +
diff --git a/app/views/tasks/edit.html.erb b/app/views/tasks/edit.html.erb new file mode 100644 index 0000000..e0f80e7 --- /dev/null +++ b/app/views/tasks/edit.html.erb @@ -0,0 +1 @@ +<%= render "form" %> diff --git a/app/views/tasks/index.html.erb b/app/views/tasks/index.html.erb new file mode 100644 index 0000000..78ee5bd --- /dev/null +++ b/app/views/tasks/index.html.erb @@ -0,0 +1,18 @@ +
+
+

Task

+ <%= link_to "Nuovo task", new_task_path, class: btn_primary %> +
+
+

In ritardo

+ <%= render "shared/task_list", tasks: @overdue_tasks %> +
+
+

Oggi

+ <%= render "shared/task_list", tasks: @today_tasks %> +
+
+

Prossimi 30 giorni

+ <%= render "shared/task_list", tasks: @upcoming_tasks %> +
+
diff --git a/app/views/tasks/new.html.erb b/app/views/tasks/new.html.erb new file mode 100644 index 0000000..e0f80e7 --- /dev/null +++ b/app/views/tasks/new.html.erb @@ -0,0 +1 @@ +<%= render "form" %> diff --git a/app/views/today/show.html.erb b/app/views/today/show.html.erb new file mode 100644 index 0000000..7fb3706 --- /dev/null +++ b/app/views/today/show.html.erb @@ -0,0 +1,53 @@ +
+
+

Oggi

+

<%= l(Time.zone.today, format: :long) %> · pagina operativa del mattino

+
+ +
+

In ritardo

+ <%= render "shared/task_list", tasks: @overdue_tasks %> +
+ +
+

Oggi

+ <%= render "shared/task_list", tasks: @today_tasks %> +
+ +
+

Prossimi follow-up

+ <%= render "shared/task_list", tasks: @upcoming_tasks %> +
+ +
+

Opportunità ferme

+
+ <% @stalled.each do |opp| %> +
+
+ <%= link_to opp.organization.name, opp.organization, class: "font-medium hover:underline" %> +
<%= opp.name %> · <%= stage_badge(opp.pipeline_stage) %> · <%= opp.days_in_current_stage %> giorni
+
+ <%= link_to "Apri", opp.organization, class: "text-sm text-zinc-600 hover:underline dark:text-zinc-300" %> +
+ <% end %> + <% if @stalled.blank? %>

Nessuna opportunità ferma.

<% end %> +
+
+ +
+

Nuovi prospect da contattare

+
+ <% @new_prospects.each do |org| %> +
+
+ <%= link_to org.name, org, class: "font-medium hover:underline" %> +
<%= org.city %> · <%= org.lead_source_label %> · <%= org.assigned_user&.full_name || "Non assegnato" %>
+
+ <%= link_to "Contatta", org, class: "#{btn_primary} px-3 py-1.5 text-xs" %> +
+ <% end %> + <% if @new_prospects.blank? %>

Nessun nuovo prospect.

<% end %> +
+
+
diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb new file mode 100644 index 0000000..5541309 --- /dev/null +++ b/app/views/users/_form.html.erb @@ -0,0 +1,71 @@ +<%= form_with model: user, class: "space-y-6 rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6" do |f| %> + <%= render "shared/errors", object: user %> + +
+
+ + <%= f.text_field :first_name, required: true, class: input_class %> +
+
+ + <%= f.text_field :last_name, required: true, class: input_class %> +
+
+ + <%= f.email_field :email, required: true, class: input_class %> +
+
+ + <%= f.select :role, [["Utente", "user"], ["Admin", "admin"]], {}, class: input_class, disabled: user.last_active_admin? %> + <% if user.last_active_admin? %> + <%= f.hidden_field :role, value: "admin" %> +

Questo è l’unico admin attivo: il ruolo non si può cambiare.

+ <% end %> +
+
+ + <%= f.select :active, [["Attivo", true], ["Disabilitato", false]], {}, class: input_class, disabled: user.last_active_admin? %> + <% if user.last_active_admin? %> + <%= f.hidden_field :active, value: "true" %> + <% end %> +
+
+ + <%= f.password_field :password, required: user.new_record?, autocomplete: "new-password", class: input_class %> +
+
+ + <%= f.password_field :password_confirmation, required: user.new_record?, autocomplete: "new-password", class: input_class %> +
+
+ +
+
Progetti abilitati
+

Ignorato per gli admin, che hanno sempre accesso a tutti i progetti.

+
+ <% @projects.each do |project| %> + + <% end %> +
+
+ +
+ <%= f.submit user.new_record? ? "Crea utente" : "Salva", class: btn_primary %> + <%= link_to "Annulla", users_path, class: btn_secondary %> +
+<% end %> + +<% unless user.new_record? || user == current_user %> +
+ <% if user.can_be_destroyed? %> + <%= button_to "Elimina utente", user_path(user), method: :delete, + class: btn_danger, + form: { data: { turbo_confirm: "Eliminare #{user.full_name}? Lo storico delle attività resta, senza questo utente." } } %> + <% else %> +

Elimina non disponibile: è l’unico amministratore attivo.

+ <% end %> +
+<% end %> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb new file mode 100644 index 0000000..306f963 --- /dev/null +++ b/app/views/users/edit.html.erb @@ -0,0 +1,8 @@ +
+
+

Modifica utente

+

<%= @user.email %>

+
+ + <%= render "form", user: @user %> +
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb new file mode 100644 index 0000000..f04e23a --- /dev/null +++ b/app/views/users/index.html.erb @@ -0,0 +1,57 @@ +
+
+
+

Utenti

+

+ <%= link_to "Impostazioni", admin_path, class: "hover:underline" %> + · account globali. Gli admin vedono tutti i progetti; gli utenti solo quelli abilitati. +

+
+ <%= link_to "Nuovo utente", new_user_path, class: btn_primary %> +
+ +
+ + + + + + + + + + + + + <% @users.each do |user| %> + "> + + + + + + + + <% end %> + +
NomeEmailRuoloStatoProgetti
+ <%= user.full_name %> + <% if user == current_user %>(tu)<% end %> + <%= user.email %><%= user.admin? ? "Admin" : "Utente" %> + <% if user.active? %> + Attivo + <% else %> + Disabilitato + <% end %> + + <% if user.admin? %> + Tutti + <% else %> + <% names = user.user_projects.select(&:enabled?).filter_map { |up| up.project&.name } %> + <%= names.any? ? names.join(", ") : "—" %> + <% end %> + + <%= link_to "Modifica", edit_user_path(user), class: "text-zinc-700 hover:underline dark:text-zinc-300" %> +
+
+
diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb new file mode 100644 index 0000000..00b6c00 --- /dev/null +++ b/app/views/users/new.html.erb @@ -0,0 +1,8 @@ +
+
+

Nuovo utente

+

Crea un account e, se non è admin, scegli i progetti a cui può accedere.

+
+ + <%= render "form", user: @user %> +
diff --git a/bin/backup b/bin/backup new file mode 100755 index 0000000..0e960fd --- /dev/null +++ b/bin/backup @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STAMP="$(date +%Y%m%d_%H%M%S)" +OUT_DIR="${BACKUP_DIR:-$ROOT/tmp/backups}" +mkdir -p "$OUT_DIR" + +# Prefer Docker Compose postgres service when available +if docker compose -f "$ROOT/docker-compose.yml" ps postgres 2>/dev/null | grep -q "running\|Up"; then + FILE="$OUT_DIR/simplecrm_${STAMP}.sql.gz" + echo "Backup via docker compose → $FILE" + docker compose -f "$ROOT/docker-compose.yml" exec -T postgres \ + pg_dump -U "${POSTGRES_USER:-simplecrm}" "${POSTGRES_DB:-simplecrm_development}" | gzip > "$FILE" +else + FILE="$OUT_DIR/simplecrm_${STAMP}.sql.gz" + echo "Backup via local pg_dump → $FILE" + PGPASSWORD="${POSTGRES_PASSWORD:-simplecrm_dev}" pg_dump \ + -h "${DATABASE_HOST:-localhost}" \ + -U "${POSTGRES_USER:-simplecrm}" \ + "${POSTGRES_DB:-simplecrm_development}" | gzip > "$FILE" +fi + +echo "OK: $FILE" diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundler-audit b/bin/bundler-audit new file mode 100755 index 0000000..e2ef226 --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/bin/ci b/bin/ci new file mode 100755 index 0000000..4137ad5 --- /dev/null +++ b/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..ad72c7d --- /dev/null +++ b/bin/dev @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +if ! gem list foreman -i --silent; then + echo "Installing foreman..." + gem install foreman +fi + +# Default to port 3000 if not specified +export PORT="${PORT:-3000}" + +# Let the debug gem allow remote connections, +# but avoid loading until `debugger` is called +export RUBY_DEBUG_OPEN="true" +export RUBY_DEBUG_LAZY="true" + +exec foreman start -f Procfile.dev "$@" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000..e099818 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +# Wait for postgres if DATABASE_HOST is set +if [ -n "${DATABASE_HOST}" ]; then + echo "Waiting for PostgreSQL at ${DATABASE_HOST}:${DATABASE_PORT:-5432}..." + until pg_isready -h "${DATABASE_HOST}" -p "${DATABASE_PORT:-5432}" -U "${POSTGRES_USER:-simplecrm}" >/dev/null 2>&1; do + sleep 1 + done + echo "PostgreSQL is ready." +fi + +# Prepare DB (create + migrate). Seed only when explicitly requested. +if [ "${RAILS_ENV}" = "production" ] || [ "${RAILS_ENV}" = "development" ]; then + ./bin/rails db:prepare + if [ "${RUN_SEEDS}" = "true" ]; then + ./bin/rails db:seed + fi +fi + +exec "$@" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 0000000..36502ab --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/restore b/bin/restore new file mode 100755 index 0000000..602e3d6 --- /dev/null +++ b/bin/restore @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FILE="${1:-}" + +if [[ -z "$FILE" ]]; then + echo "Uso: bin/restore " + exit 1 +fi + +if [[ ! -f "$FILE" ]]; then + echo "File non trovato: $FILE" + exit 1 +fi + +echo "ATTENZIONE: il restore sovrascrive il database corrente." +read -r -p "Continuare? [y/N] " confirm +[[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1 + +if docker compose -f "$ROOT/docker-compose.yml" ps postgres 2>/dev/null | grep -q "running\|Up"; then + gunzip -c "$FILE" | docker compose -f "$ROOT/docker-compose.yml" exec -T postgres \ + psql -U "${POSTGRES_USER:-simplecrm}" -d "${POSTGRES_DB:-simplecrm_development}" +else + gunzip -c "$FILE" | PGPASSWORD="${POSTGRES_PASSWORD:-simplecrm_dev}" psql \ + -h "${DATABASE_HOST:-localhost}" \ + -U "${POSTGRES_USER:-simplecrm}" \ + -d "${POSTGRES_DB:-simplecrm_development}" +fi + +echo "Restore completato." diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 0000000..5a20504 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..81be011 --- /dev/null +++ b/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..4a3c09a --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 0000000..33854e6 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,27 @@ +require_relative "boot" + +require "rails" +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "active_storage/engine" +require "action_controller/railtie" +require "action_mailer/railtie" +require "action_view/railtie" +require "action_cable/engine" +require "action_text/trix" +require "rails/test_unit/railtie" + +Bundler.require(*Rails.groups) + +module Simplecrm + class Application < Rails::Application + config.load_defaults 8.1 + config.autoload_lib(ignore: %w[assets tasks]) + config.time_zone = "Europe/Rome" + config.i18n.default_locale = :it + config.i18n.available_locales = %i[it en] + config.generators.system_tests = nil + config.x.app_name = "eminuxCRM" + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..988a5dd --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/bundler-audit.yml b/config/bundler-audit.yml new file mode 100644 index 0000000..e74b3af --- /dev/null +++ b/config/bundler-audit.yml @@ -0,0 +1,5 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: + - CVE-THAT-DOES-NOT-APPLY diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..f39dc04 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: app_production diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 0000000..239b343 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,20 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 0000000..577b7bd --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +5NspSSxWg4jYdRx5haY2znA/ik7gtI/quqyi/kkr+ilHOgwKhRyzQR2dS3ibSktDADY0BQh8V8Wd5aDH2sIRwo4hDKpT5ptmVNfEVUMjS8bpONxHxz1CchuyynpLp4COvu1Osnfbn4XZn9djx3rVJucq1/11ImlmthmieZP7oPCyMLOaB73EtIkgSqlwKGEaq8X1BleZzl6DvwA8/airYLjACIgycBeG2r8zdcTI/SKTI82BYxIFRXOccf+YPE3fNCefTi9ymNTne9eaQhBNXf4knaShXCokqawPLgZDc3rqSKaaIm15HdLC25OQBX57GbBAXj2PbC8wl1BbYh10ajXXFXaW8JiboA0cJdbBlMY8RTkUFsrgjgXg6MNnf/hbw6svVAdQYUueC8MRZXM/z99aRiSEdAzz4MqlQL/Hlh9s2VHHmy270PfVc/q+kVyJBZBRJbkwqlr3XLHyk1tWhofsYUNKt5vvZhzcnuXYCM5dnhorBQ5P34nH--nFSjTb/kdgiBtBhY--FI3OFfoWUpmlQu9RsHGqUQ== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..7211bc3 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,22 @@ +# PostgreSQL +default: &default + adapter: postgresql + encoding: unicode + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + host: <%= ENV.fetch("DATABASE_HOST", "localhost") %> + port: <%= ENV.fetch("DATABASE_PORT", 5432) %> + username: <%= ENV.fetch("POSTGRES_USER", "simplecrm") %> + password: <%= ENV.fetch("POSTGRES_PASSWORD", "simplecrm_dev") %> + +development: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "simplecrm_development") %> + +test: + <<: *default + database: simplecrm_test + +production: + <<: *default + database: <%= ENV.fetch("POSTGRES_DB", "simplecrm_production") %> + url: <%= ENV["DATABASE_URL"] %> diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..df4c386 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,84 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { + host: ENV.fetch("APP_HOST", "localhost"), + port: ENV.fetch("APP_PORT", 3000) + } + + config.active_job.queue_adapter = :async + + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..983fa0a --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,106 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Assume SSL quando il TLS termina sul reverse proxy (NPM) e inoltra X-Forwarded-Proto. + # Separato da force_ssl per non rompere healthcheck HTTP su :3000. + config.assume_ssl = ENV["ASSUME_SSL"] == "true" || ENV["FORCE_SSL"] == "true" + + # Force SSL only when FORCE_SSL=true (LAN/HTTP o TLS su NPM: lasciare disabilitato). + config.force_ssl = ENV["FORCE_SSL"] == "true" + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + # config.cache_store = :mem_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + # config.active_job.queue_adapter = :resque + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { + host: ENV.fetch("APP_HOST", "localhost"), + port: ENV["APP_PORT"].presence&.to_i + }.compact + + # Host authorization: allow configured hosts (comma-separated) or clear for LAN. + if ENV["RAILS_ALLOWED_HOSTS"].present? + config.hosts.clear + ENV["RAILS_ALLOWED_HOSTS"].split(",").map(&:strip).each { |h| config.hosts << h } + else + config.hosts.clear + end + # Healthcheck Docker e reverse proxy interni + config.hosts << "localhost" + config.hosts << "127.0.0.1" + config.hosts << "web" + config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..c2095b1 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,53 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 0000000..3e57efa --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,8 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "trix" +pin "@hotwired/turbo-rails", to: "turbo.min.js" +pin "@hotwired/stimulus", to: "stimulus.min.js" +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" diff --git a/config/initializers/active_record_encryption.rb b/config/initializers/active_record_encryption.rb new file mode 100644 index 0000000..4120fd2 --- /dev/null +++ b/config/initializers/active_record_encryption.rb @@ -0,0 +1,25 @@ +# Chiavi derivate da SECRET_KEY_BASE così le password SMTP restano cifrate +# anche senza rails credentials:edit (Docker / deploy). +# +# In Rails 8 il getter di ActiveRecord::Encryption.config.primary_key solleva +# se la chiave manca: non usare ||= su quell'oggetto. +require "digest" + +secret = Rails.application.secret_key_base.to_s +keys = { + primary_key: Digest::SHA256.hexdigest("#{secret}/ar-enc-primary"), + deterministic_key: Digest::SHA256.hexdigest("#{secret}/ar-enc-deterministic"), + key_derivation_salt: Digest::SHA256.hexdigest("#{secret}/ar-enc-salt") +} + +cfg = Rails.application.config.active_record.encryption +keys.each { |name, value| cfg[name] = value } +cfg.support_unencrypted_data = true + +Rails.application.config.after_initialize do + enc = ActiveRecord::Encryption.config + enc.primary_key = keys[:primary_key] + enc.deterministic_key = keys[:deterministic_key] + enc.key_derivation_salt = keys[:key_derivation_salt] + enc.support_unencrypted_data = true +end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 0000000..4873244 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..d51d713 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,29 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` +# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. +# # config.content_security_policy_nonce_auto = true +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..c0b717f --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..3860f65 --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb new file mode 100644 index 0000000..f94349f --- /dev/null +++ b/config/initializers/pagy.rb @@ -0,0 +1,5 @@ +require "pagy" +require "pagy/extras/overflow" + +Pagy::DEFAULT[:limit] = 25 +Pagy::DEFAULT[:overflow] = :empty_page diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 0000000..e96323f --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,12 @@ +# Be sure to restart your server when you modify this file. +# +# Cookie Secure solo con FORCE_SSL/SESSION_COOKIE_SECURE. +# Dietro NPM→Caddy→Rails (HTTP interno) request.ssl? è false: con secure:true +# Rails non emette _simplecrm_session → CSRF 422 al login su HTTPS. +secure_cookie = ENV["FORCE_SSL"] == "true" || ENV["SESSION_COOKIE_SECURE"] == "true" + +Rails.application.config.session_store :cookie_store, + key: "_simplecrm_session", + secure: secure_cookie, + httponly: true, + same_site: :lax diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/config/locales/it.yml b/config/locales/it.yml new file mode 100644 index 0000000..91c2dbd --- /dev/null +++ b/config/locales/it.yml @@ -0,0 +1,143 @@ +it: + date: + formats: + default: "%d/%m/%Y" + long: "%d %B %Y" + short: "%d/%m/%Y" + day_names: [domenica, lunedì, martedì, mercoledì, giovedì, venerdì, sabato] + abbr_day_names: [dom, lun, mar, mer, gio, ven, sab] + month_names: [~, gennaio, febbraio, marzo, aprile, maggio, giugno, luglio, agosto, settembre, ottobre, novembre, dicembre] + abbr_month_names: [~, gen, feb, mar, apr, mag, giu, lug, ago, set, ott, nov, dic] + time: + formats: + default: "%d/%m/%Y %H:%M" + short: "%d/%m/%Y %H:%M" + long: "%d %B %Y %H:%M" + am: "am" + pm: "pm" + number: + currency: + format: + unit: "€" + separator: "," + delimiter: "." + format: "%n %u" + activerecord: + models: + organization: Organizzazione + contact: Contatto + opportunity: Opportunità + task: Task + activity: Attività + user: Utente + sales_goal: Obiettivo + mail_identity: Account email + mail_template: Template email + mailing: Invio email + mailing_recipient: Destinatario + attributes: + organization: + name: Nome + legal_name: Ragione sociale + organization_type: Tipologia + sport: Sport + country: Paese + region: Regione + province: Provincia + city: Città + address: Indirizzo + website: Sito / profilo + phone: Telefono + email: Email verificata + vat_number: P. IVA + notes: Note follow-up + status: Stato + lead_source: Fonte + assigned_user_id: Owner + list_position: N. + team_gender: M/F + streaming_status: Streaming rilevato + commercial_fit: Evidenza / fit commerciale + source_url: Fonte contatto / ricerca + verified_at: Data verifica + contact: + first_name: Nome + last_name: Cognome + role: Ruolo + email: Email + phone: Telefono + mobile: Cellulare + preferred_contact_method: Contatto preferito + primary_contact: Contatto principale + notes: Note + opportunity: + name: Nome + pipeline_stage: Stage + estimated_value: Valore stimato + probability: Probabilità + expected_close_date: Chiusura prevista + product: Piano + lost_reason: Motivo perdita + notes: Note + ab_variant: Test A/B + send_status: Stato invio + sent_on: Data invio + outcome: Esito + demo_trial: Demo / Trial + converted: Conversione + task: + title: Titolo + description: Descrizione + due_at: Scadenza + priority: Priorità + task_type: Tipo + status: Stato + user: + email: Email + first_name: Nome + last_name: Cognome + password: Password + password_confirmation: Conferma password + role: Ruolo + mail_identity: + name: Nome account + from_name: Nome mittente + from_email: Email mittente + reply_to: Reply-To + smtp_host: Host SMTP + smtp_port: Porta + smtp_username: Utente SMTP + smtp_password: Password SMTP + smtp_authentication: Autenticazione + encryption: Crittografia + verify_ssl: Verifica certificato SSL + active: Attivo + mail_template: + name: Nome + subject: Oggetto + body_html: Corpo HTML + mailing: + name: Nome invio + subject: Oggetto variante A + body_html: Corpo HTML variante A + subject_b: Oggetto variante B + body_html_b: Corpo HTML variante B + audience: Destinatari + ab_test: Test A/B + ab_assignment: Come assegnare A e B + interval_seconds: Pausa tra un invio e l'altro (secondi) + mail_identity: Account mittente + mail_template: Template A + mail_template_b: Template B + errors: + format: "%{attribute} %{message}" + messages: + blank: non può essere vuoto + taken: è già in uso + invalid: non è valido + confirmation: non coincide + too_short: è troppo corto (minimo %{count} caratteri) + helpers: + submit: + create: Crea + update: Salva diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..1c317b4 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,39 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..9be75bb --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,62 @@ +Rails.application.routes.draw do + get "up" => "rails/health#show", as: :rails_health_check + + get "login", to: "sessions#new" + post "login", to: "sessions#create" + delete "logout", to: "sessions#destroy" + + resource :password, only: %i[edit update] + resources :password_resets, only: %i[new create], path: "password_reset" do + collection do + get ":token/edit", action: :edit, as: :edit + patch ":token", action: :update, as: :update + end + end + + # Launcher: scegli il progetto (istanza CRM) + root "home#index" + get "impostazioni", to: "admin#show", as: :admin + + # CRUD progetti anche fuori da un'istanza (bootstrap admin) + resources :projects, only: %i[create edit update destroy] + resources :users, except: %i[show] + resources :mail_identities, except: %i[show] + + # Ogni progetto = istanza CRM dedicata + scope "/p/:project_code" do + get "/", to: "dashboard#show", as: :project_root + get "/today", to: "today#show", as: :today + get "/pipeline", to: "pipeline#show", as: :pipeline + get "/search", to: "search#show", as: :search + get "/reports", to: "reports#index", as: :reports + get "/settings", to: "settings#show", as: :settings + patch "/settings/goal(/:id)", to: "settings#update_goal", as: :update_goal_settings + + resources :organizations do + resources :activities, only: %i[create] + end + resources :contacts + resources :opportunities do + member do + patch :update_stage + end + end + resources :tasks do + member do + patch :complete + end + end + resources :imports, only: %i[new create] + resources :mail_templates, except: %i[show] + resources :mail_images, only: %i[create] + resources :mailings do + member do + post :queue + post :test_send + post :refresh_recipients + patch :update_recipients + get :preview + end + end + end +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..927dc53 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,27 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/data/MatchLiveTV_Campagna_Lancio_100_Societa.xlsx b/db/data/MatchLiveTV_Campagna_Lancio_100_Societa.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..bc59f39040a733133d50228f8addde7fd3193fa0 GIT binary patch literal 24026 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 dI%sHrCiao {{contatto_nome}},

+

scriviamo a {{societa}} ({{regione}}) per presentarti MatchLiveTV.

+

Possiamo aiutarvi a trasmettere le giovanili in modo semplice, con un piano {{piano}}.

+

A presto,
Il team MatchLiveTV

+ HTML +) +template.save! + +puts "Seed completato (utenti, progetti, prodotti, obiettivo, template email)." +puts "Censimento MatchLiveTV: bin/rails matchlivetv:reset_campaign" +puts "Progetti: #{Project.ordered.map(&:name).join(', ')}" +puts "Utenti:" +puts " admin@simplecrm.local / password123 (admin — tutti i progetti)" +puts " marco@simplecrm.local / password123 (MatchLiveTV, RiskMeter)" +puts " lucia@simplecrm.local / password123 (MatchLiveTV, Cardoo)" diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..4901cb5 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,22 @@ +:80 { + encode gzip + + # Health diretto (opzionale) + handle /up { + reverse_proxy web:3000 + } + + handle { + # Conserva lo schema originale dal proxy esterno (NPM → HTTPS). + reverse_proxy web:3000 { + header_up X-Forwarded-Proto {http.request.header.X-Forwarded-Proto} + header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host} + header_up X-Forwarded-For {http.request.header.X-Forwarded-For} + } + } + + log { + output stdout + format console + } +} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 0000000..f8824fe --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,87 @@ +# eminuxCRM — stack Docker completo (postgres + web + edge) +# Uso: docker compose -f docker-compose.deploy.yml --env-file .env up -d --build + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-simplecrm} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required} + POSTGRES_DB: ${POSTGRES_DB:-simplecrm_production} + TZ: Europe/Rome + PGTZ: Europe/Rome + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-simplecrm} -d ${POSTGRES_DB:-simplecrm_production}"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + + web: + build: + context: . + dockerfile: Dockerfile + target: production + restart: unless-stopped + environment: + RAILS_ENV: production + FORCE_SSL: ${FORCE_SSL:-false} + ASSUME_SSL: ${ASSUME_SSL:-true} + RUN_SEEDS: ${RUN_SEEDS:-false} + + DATABASE_HOST: postgres + DATABASE_PORT: "5432" + POSTGRES_USER: ${POSTGRES_USER:-simplecrm} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-simplecrm_production} + SECRET_KEY_BASE: ${SECRET_KEY_BASE:?SECRET_KEY_BASE required} + MAILER_FROM: ${MAILER_FROM:-noreply@simplecrm.local} + APP_HOST: ${APP_HOST:-192.168.1.158} + APP_PORT: "80" + RAILS_ALLOWED_HOSTS: ${RAILS_ALLOWED_HOSTS:-192.168.1.158,localhost} + RAILS_LOG_LEVEL: info + TZ: Europe/Rome + volumes: + - storage_data:/rails/storage + networks: + - internal + depends_on: + postgres: + condition: service_healthy + expose: + - "3000" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/up >/dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 90s + + edge: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: + - internal + depends_on: + web: + condition: service_healthy + +networks: + internal: + +volumes: + postgres_data: + storage_data: + caddy_data: + caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..81cbe6a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-simplecrm} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-simplecrm_dev} + POSTGRES_DB: ${POSTGRES_DB:-simplecrm_development} + TZ: Europe/Rome + PGTZ: Europe/Rome + volumes: + - postgres_data:/var/lib/postgresql/data + # Non esporre 5432 sull'host per evitare conflitti; web raggiunge postgres via rete Docker. + # Per accesso esterno: ports: ["5433:5432"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-simplecrm} -d ${POSTGRES_DB:-simplecrm_development}"] + interval: 5s + timeout: 5s + retries: 10 + + web: + build: + context: . + dockerfile: Dockerfile + target: development + restart: unless-stopped + command: bash -c "bundle check || bundle install && bin/rails tailwindcss:build && rm -f tmp/pids/server.pid && bin/rails db:prepare && bin/rails db:seed && bin/rails server -b 0.0.0.0 -p 3000" + environment: + RAILS_ENV: ${RAILS_ENV:-development} + DATABASE_HOST: postgres + DATABASE_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER:-simplecrm} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-simplecrm_dev} + POSTGRES_DB: ${POSTGRES_DB:-simplecrm_development} + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + MAILER_FROM: ${MAILER_FROM:-noreply@simplecrm.local} + APP_HOST: ${APP_HOST:-localhost} + APP_PORT: ${APP_PORT:-3000} + TZ: Europe/Rome + volumes: + - .:/rails + - bundle_cache:/usr/local/bundle + ports: + - "${APP_PORT:-3001}:3000" + depends_on: + postgres: + condition: service_healthy + stdin_open: true + tty: true + +volumes: + postgres_data: + bundle_cache: diff --git a/examples/organizations_import_sample.csv b/examples/organizations_import_sample.csv new file mode 100644 index 0000000..f538a7c --- /dev/null +++ b/examples/organizations_import_sample.csv @@ -0,0 +1,3 @@ +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 +ASD Esempio Calcio,societa_sportiva,Calcio,Italia,Lombardia,MI,Milano,asdesempio.example.it,info@asdesempio.example.it,Paolo,Neri,Presidente,paolo@asdesempio.example.it,+39 333 1112233,outbound,Lead da CSV esempio +Eventi Sportivi SRL,organizzatore_evento,Basket,Italia,Lazio,RM,Roma,eventisportivi.example.it,hello@eventisportivi.example.it,Elena,Bruni,CEO,elena@eventisportivi.example.it,+39 06 1234567,event,Organizzatore torneo diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/lib/tasks/matchlivetv.rake b/lib/tasks/matchlivetv.rake new file mode 100644 index 0000000..9fd383e --- /dev/null +++ b/lib/tasks/matchlivetv.rake @@ -0,0 +1,19 @@ +namespace :matchlivetv do + desc "Svuota MatchLiveTV e importa il foglio Campagna 100" + task reset_campaign: :environment do + path = ENV["CAMPAIGN_CSV"].presence || + Rails.root.join("db/data/matchlivetv_campagna_100.csv") + user = User.find_by(email: "admin@simplecrm.local") || User.find_by!(role: "admin") + project = Project.find_by!(code: "matchlivetv") + + puts "File: #{path}" + result = CampaignImport::MatchlivetvLaunch.new(path: path, user: user, project: project, wipe: true).call + puts "Organizzazioni rimosse dal solo MatchLiveTV: #{result.wiped_organizations}" + puts "Società importate: #{result.imported}" + if result.errors.any? + puts "Errori: #{result.errors.size}" + result.errors.first(10).each { |err| puts " riga #{err[:line]} #{err[:name]}: #{err[:message]}" } + abort "Import incompleto" + end + end +end diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/public/400.html b/public/400.html new file mode 100644 index 0000000..640de03 --- /dev/null +++ b/public/400.html @@ -0,0 +1,135 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..d7f0f14 --- /dev/null +++ b/public/404.html @@ -0,0 +1,135 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 0000000..43d2811 --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,135 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..f12fb4a --- /dev/null +++ b/public/422.html @@ -0,0 +1,135 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..e4eb18a --- /dev/null +++ b/public/500.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..f388521 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 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 literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..04b34bf --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/script/.keep b/script/.keep new file mode 100644 index 0000000..e69de29 diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/controllers/authentication_test.rb b/test/controllers/authentication_test.rb new file mode 100644 index 0000000..65ab7c7 --- /dev/null +++ b/test/controllers/authentication_test.rb @@ -0,0 +1,89 @@ +require "test_helper" + +class AuthenticationTest < ActionDispatch::IntegrationTest + test "requires login" do + get root_path + assert_redirected_to login_path + end + + test "login and logout" do + login_as users(:admin) + assert_response :redirect + follow_redirect! + assert_response :success + + delete logout_path + assert_redirected_to login_path + end + + test "non admin cannot create users" do + login_as users(:marco) + follow_redirect! if response.redirect? + post users_path, params: { + user: { email: "x@example.com", first_name: "X", last_name: "Y", role: "user", password: "password123", password_confirmation: "password123" } + } + assert_response :redirect + end + + test "project space is isolated by url" do + login_as users(:admin) + follow_redirect! if response.redirect? + get project_root_path(project_code: "matchlivetv") + assert_response :success + assert_match(/MatchLiveTV/, response.body) + + get project_root_path(project_code: "riskmeter") + assert_response :success + assert_match(/RiskMeter/, response.body) + end + + test "user cannot open disabled project" do + login_as users(:marco) + follow_redirect! if response.redirect? + get project_root_path(project_code: "riskmeter") + assert_redirected_to root_path + end + + test "disabled user cannot login" do + admin = users(:admin) + User.create!( + email: "keeper@example.com", + first_name: "Keep", + last_name: "Admin", + role: "admin", + password: "password123", + password_confirmation: "password123" + ) + assert admin.update(active: false) + + post login_path, params: { email: admin.email, password: "password123" } + assert_response :unprocessable_entity + assert_match(/Email o password non validi/i, response.body) + end + + test "admin can open platform settings" do + login_as users(:admin) + follow_redirect! if response.redirect? + get admin_path + assert_response :success + assert_match(/Impostazioni piattaforma/, response.body) + assert_match(/Utenti/, response.body) + end + + test "non admin cannot open platform settings" do + login_as users(:marco) + follow_redirect! if response.redirect? + get admin_path + assert_redirected_to root_path + end + + test "admin home has new project and settings actions" do + login_as users(:admin) + follow_redirect! if response.redirect? + get root_path + assert_response :success + assert_match(/Nuovo progetto/, response.body) + assert_match(/Impostazioni/, response.body) + assert_no_match(/Amministrazione progetti/, response.body) + end +end diff --git a/test/controllers/mail_identities_controller_test.rb b/test/controllers/mail_identities_controller_test.rb new file mode 100644 index 0000000..1e0850b --- /dev/null +++ b/test/controllers/mail_identities_controller_test.rb @@ -0,0 +1,37 @@ +require "test_helper" + +class MailIdentitiesControllerTest < ActionDispatch::IntegrationTest + test "non admin cannot manage smtp accounts" do + login_as users(:marco) + follow_redirect! if response.redirect? + + get mail_identities_path + assert_redirected_to root_path + end + + test "admin can create an smtp account" do + login_as users(:admin) + follow_redirect! if response.redirect? + + assert_difference "MailIdentity.count", 1 do + post mail_identities_path, params: { + mail_identity: { + name: "MatchLiveTV", + from_name: "MatchLiveTV", + from_email: "hello@matchlivetv.test", + smtp_host: "smtp.test", + smtp_port: 587, + smtp_username: "hello@matchlivetv.test", + smtp_password: "super-secret", + smtp_authentication: "plain", + encryption: "starttls", + verify_ssl: "1", + active: "1" + } + } + end + assert_redirected_to mail_identities_path + identity = MailIdentity.find_by!(from_email: "hello@matchlivetv.test") + assert_equal "super-secret", identity.smtp_password + end +end diff --git a/test/controllers/mail_images_controller_test.rb b/test/controllers/mail_images_controller_test.rb new file mode 100644 index 0000000..2a945da --- /dev/null +++ b/test/controllers/mail_images_controller_test.rb @@ -0,0 +1,44 @@ +require "test_helper" +require "base64" + +class MailImagesControllerTest < ActionDispatch::IntegrationTest + PNG = Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==") + + test "admin can upload an image for the editor" do + login_as users(:admin) + follow_redirect! if response.redirect? + + upload = png_upload + assert_difference -> { ActiveStorage::Blob.count }, 1 do + post mail_images_path(project_code: "matchlivetv"), params: { file: upload } + end + assert_response :success + json = JSON.parse(response.body) + assert json["url"].present? + assert_match(/active_storage/, json["url"]) + end + + test "rejects non image files" do + login_as users(:admin) + follow_redirect! if response.redirect? + + file = Tempfile.new(["note", ".txt"]) + file.write("not-an-image") + file.rewind + + post mail_images_path(project_code: "matchlivetv"), params: { + file: Rack::Test::UploadedFile.new(file.path, "text/plain") + } + assert_response :unprocessable_entity + end + + private + + def png_upload + file = Tempfile.new(["pixel", ".png"]) + file.binmode + file.write(PNG) + file.rewind + Rack::Test::UploadedFile.new(file.path, "image/png") + end +end diff --git a/test/controllers/mailings_controller_test.rb b/test/controllers/mailings_controller_test.rb new file mode 100644 index 0000000..b874db2 --- /dev/null +++ b/test/controllers/mailings_controller_test.rb @@ -0,0 +1,107 @@ +require "test_helper" + +class MailingsControllerTest < ActionDispatch::IntegrationTest + setup do + @project = projects(:matchlivetv) + @identity = create_mail_identity + opportunities(:deal).update!(send_status: "to_send", pipeline_stage: "to_contact") + end + + test "lists mailings in project space" do + login_as users(:admin) + follow_redirect! if response.redirect? + + get mailings_path(project_code: @project.code) + assert_response :success + assert_match(/Email/, response.body) + end + + test "creates a draft and builds recipients" do + login_as users(:admin) + follow_redirect! if response.redirect? + + assert_difference -> { Mailing.count } => 1, -> { MailingRecipient.count } => 1 do + post mailings_path(project_code: @project.code), params: { + mailing: { + name: "Lancio", + mail_identity_id: @identity.id, + audience: "to_send", + subject: "Ciao {{societa}}", + body_html: "

Ciao {{contatto_nome}}

", + interval_seconds: 0 + } + } + end + mailing = Mailing.last + assert_redirected_to mailing_path(mailing, project_code: @project.code) + assert_equal "pending", mailing.mailing_recipients.first.status + end + + test "check recipients then queue send" do + login_as users(:admin) + follow_redirect! if response.redirect? + mailing = create_mailing(identity: @identity, audience: "to_send") + mailing.rebuild_recipients! + recipient = mailing.mailing_recipients.first + + patch update_recipients_mailing_path(mailing, project_code: @project.code), params: { pending_ids: [recipient.id] } + assert_redirected_to mailing_path(mailing, project_code: @project.code) + assert_equal "pending", recipient.reload.status + + perform_enqueued_jobs do + post queue_mailing_path(mailing, project_code: @project.code) + end + + assert_equal "sent", mailing.reload.status + assert_equal "sent", recipient.reload.status + assert_equal "sent", opportunities(:deal).reload.send_status + assert_equal "contacted", opportunities(:deal).pipeline_stage + assert_equal 1, ActionMailer::Base.deliveries.size + assert_equal "Ciao ASD Test Calcio", ActionMailer::Base.deliveries.last.subject + end + + test "test send goes to current user" do + login_as users(:admin) + follow_redirect! if response.redirect? + mailing = create_mailing(identity: @identity, audience: "all") + mailing.rebuild_recipients! + + assert_emails 1 do + post test_send_mailing_path(mailing, project_code: @project.code) + end + mail = ActionMailer::Base.deliveries.last + assert_equal [users(:admin).email], mail.to + assert_match(/\[TEST\]/, mail.subject) + end + + test "creates an A/B mailing and sends variant B as test" do + login_as users(:admin) + follow_redirect! if response.redirect? + + post mailings_path(project_code: @project.code), params: { + mailing: { + name: "AB lancio", + mail_identity_id: @identity.id, + audience: "to_send", + ab_test: "1", + ab_assignment: "from_record", + subject: "Oggetto A {{societa}}", + body_html: "

Versione A

", + subject_b: "Oggetto B {{societa}}", + body_html_b: "

Versione B

", + interval_seconds: 0 + } + } + mailing = Mailing.last + assert mailing.ab_test? + assert_equal "A", mailing.mailing_recipients.first.ab_variant + + assert_emails 1 do + post test_send_mailing_path(mailing, project_code: @project.code, variant: "B") + end + mail = ActionMailer::Base.deliveries.last + assert_match(/\[TEST B\] Oggetto B ASD Test Calcio/, mail.subject) + body = (mail.html_part || mail).body.to_s + assert_match(/Versione B/, body) + end +end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb new file mode 100644 index 0000000..974a05c --- /dev/null +++ b/test/controllers/users_controller_test.rb @@ -0,0 +1,117 @@ +require "test_helper" + +class UsersControllerTest < ActionDispatch::IntegrationTest + test "non admin cannot list or create users" do + login_as users(:marco) + follow_redirect! if response.redirect? + + get users_path + assert_redirected_to root_path + + post users_path, params: { + user: { email: "x@example.com", first_name: "X", last_name: "Y", role: "user", password: "password123", password_confirmation: "password123" } + } + assert_redirected_to root_path + end + + test "admin can create a user" do + login_as users(:admin) + follow_redirect! if response.redirect? + + assert_difference "User.count", 1 do + post users_path, params: { + user: { + email: "nuovo@example.com", + first_name: "Nuovo", + last_name: "Utente", + role: "user", + password: "password123", + password_confirmation: "password123" + }, + project_ids: [ projects(:matchlivetv).id ] + } + end + assert_redirected_to users_path + user = User.find_by!(email: "nuovo@example.com") + assert user.project_enabled?(projects(:matchlivetv)) + assert_not user.project_enabled?(projects(:riskmeter)) + end + + test "admin can disable another admin when a second admin exists" do + login_as users(:admin) + follow_redirect! if response.redirect? + + other = User.create!( + email: "emiliano@example.com", + first_name: "Emiliano", + last_name: "Admin", + role: "admin", + password: "password123", + password_confirmation: "password123" + ) + + patch user_path(other), params: { + user: { first_name: other.first_name, last_name: other.last_name, email: other.email, role: "admin", active: "0" } + } + assert_redirected_to users_path + assert_not other.reload.active? + end + + test "admin cannot disable last active admin" do + login_as users(:admin) + follow_redirect! if response.redirect? + admin = users(:admin) + + patch user_path(admin), params: { + user: { first_name: admin.first_name, last_name: admin.last_name, email: admin.email, role: "admin", active: "0" } + } + assert_response :unprocessable_entity + assert admin.reload.active? + assert_match(/almeno un amministratore attivo/i, response.body) + end + + test "admin cannot delete self" do + login_as users(:admin) + follow_redirect! if response.redirect? + + assert_no_difference "User.count" do + delete user_path(users(:admin)) + end + assert_redirected_to users_path + follow_redirect! + assert_match(/Non puoi eliminare il tuo account/i, response.body) + end + + test "admin can delete another admin when a second remains" do + login_as users(:admin) + follow_redirect! if response.redirect? + + other = User.create!( + email: "other-admin@example.com", + first_name: "Other", + last_name: "Admin", + role: "admin", + password: "password123", + password_confirmation: "password123" + ) + + assert_difference "User.count", -1 do + delete user_path(other) + end + assert_redirected_to users_path + end + + test "admin can delete another user and nullify activities" do + login_as users(:admin) + follow_redirect! if response.redirect? + marco = users(:marco) + activity = activities(:note) + assert_equal marco.id, activity.user_id + + assert_difference "User.count", -1 do + delete user_path(marco) + end + assert_redirected_to users_path + assert_nil activity.reload.user_id + end +end diff --git a/test/fixtures/activities.yml b/test/fixtures/activities.yml new file mode 100644 index 0000000..c470021 --- /dev/null +++ b/test/fixtures/activities.yml @@ -0,0 +1,9 @@ +note: + activity_type: note + subject: Prima nota + description: Test + happened_at: <%= 2.days.ago %> + user: marco + organization: acme + contact: primary + opportunity: deal diff --git a/test/fixtures/contacts.yml b/test/fixtures/contacts.yml new file mode 100644 index 0000000..f784203 --- /dev/null +++ b/test/fixtures/contacts.yml @@ -0,0 +1,9 @@ +primary: + organization: acme + first_name: Mario + last_name: Rossi + role: Presidente + email: mario@asdtest.example.it + phone: "+39 333 0001111" + primary_contact: true + preferred_contact_method: email diff --git a/test/fixtures/opportunities.yml b/test/fixtures/opportunities.yml new file mode 100644 index 0000000..0d8ed8c --- /dev/null +++ b/test/fixtures/opportunities.yml @@ -0,0 +1,11 @@ +deal: + organization: acme + project: matchlivetv + name: Licenza Full + pipeline_stage: interested + estimated_value: 1990 + probability: 40 + product: Full + assigned_user: marco + stage_changed_at: <%= 3.days.ago %> + first_contacted_at: <%= 10.days.ago %> diff --git a/test/fixtures/organization_projects.yml b/test/fixtures/organization_projects.yml new file mode 100644 index 0000000..8c4708a --- /dev/null +++ b/test/fixtures/organization_projects.yml @@ -0,0 +1,3 @@ +acme_matchlivetv: + organization: acme + project: matchlivetv diff --git a/test/fixtures/organizations.yml b/test/fixtures/organizations.yml new file mode 100644 index 0000000..9c57363 --- /dev/null +++ b/test/fixtures/organizations.yml @@ -0,0 +1,12 @@ +acme: + name: ASD Test Calcio + organization_type: societa_sportiva + sport: Calcio + country: Italia + region: Lombardia + city: Milano + status: prospect + lead_source: outbound + assigned_user: marco + email: info@asdtest.example.it + website: asdtest.example.it diff --git a/test/fixtures/products.yml b/test/fixtures/products.yml new file mode 100644 index 0000000..62dc9d3 --- /dev/null +++ b/test/fixtures/products.yml @@ -0,0 +1,5 @@ +light: + name: Light + code: light + active: true + position: 0 diff --git a/test/fixtures/projects.yml b/test/fixtures/projects.yml new file mode 100644 index 0000000..04d48f9 --- /dev/null +++ b/test/fixtures/projects.yml @@ -0,0 +1,13 @@ +matchlivetv: + name: MatchLiveTV + code: matchlivetv + description: Streaming + active: true + position: 0 + +riskmeter: + name: RiskMeter + code: riskmeter + description: Risk + active: true + position: 1 diff --git a/test/fixtures/sales_goals.yml b/test/fixtures/sales_goals.yml new file mode 100644 index 0000000..91a387e --- /dev/null +++ b/test/fixtures/sales_goals.yml @@ -0,0 +1,8 @@ +september: + name: Prime 10 società paganti + metric: customers_acquired + target_value: 10 + start_date: 2026-08-16 + end_date: 2026-09-30 + active: true + project: matchlivetv diff --git a/test/fixtures/tasks.yml b/test/fixtures/tasks.yml new file mode 100644 index 0000000..0a17977 --- /dev/null +++ b/test/fixtures/tasks.yml @@ -0,0 +1,10 @@ +follow_up: + title: Richiama presidente + organization: acme + contact: primary + opportunity: deal + assigned_user: marco + due_at: <%= Time.zone.now.change(hour: 15) %> + priority: high + task_type: follow_up + status: pending diff --git a/test/fixtures/user_projects.yml b/test/fixtures/user_projects.yml new file mode 100644 index 0000000..d45ffe7 --- /dev/null +++ b/test/fixtures/user_projects.yml @@ -0,0 +1,9 @@ +marco_matchlivetv: + user: marco + project: matchlivetv + enabled: true + +marco_riskmeter: + user: marco + project: riskmeter + enabled: false diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..53207b6 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,17 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +admin: + email: admin@example.com + password_digest: <%= BCrypt::Password.create("password123") %> + first_name: Admin + last_name: User + role: admin + active: true + +marco: + email: marco@example.com + password_digest: <%= BCrypt::Password.create("password123") %> + first_name: Marco + last_name: Test + role: user + active: true diff --git a/test/jobs/send_mailing_recipient_job_test.rb b/test/jobs/send_mailing_recipient_job_test.rb new file mode 100644 index 0000000..e582d6d --- /dev/null +++ b/test/jobs/send_mailing_recipient_job_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class SendMailingRecipientJobTest < ActiveJob::TestCase + test "sends pending recipients in sequence" do + opportunities(:deal).update!(send_status: "to_send") + mailing = create_mailing(audience: "to_send") + mailing.rebuild_recipients! + mailing.update!(status: "sending", queued_at: Time.current) + + perform_enqueued_jobs do + SendMailingRecipientJob.perform_later(mailing.mailing_recipients.first.id) + end + + assert_equal "sent", mailing.reload.status + assert_equal "sent", mailing.mailing_recipients.first.status + end +end diff --git a/test/mailers/campaign_mailer_test.rb b/test/mailers/campaign_mailer_test.rb new file mode 100644 index 0000000..af77989 --- /dev/null +++ b/test/mailers/campaign_mailer_test.rb @@ -0,0 +1,47 @@ +require "test_helper" +require "base64" + +class CampaignMailerTest < ActionMailer::TestCase + test "sends html with merge and attachments from the identity" do + mailing = create_mailing + mailing.rebuild_recipients! + mailing.files.attach(io: StringIO.new("brochure"), filename: "brochure.pdf", content_type: "application/pdf") + recipient = mailing.mailing_recipients.first + + email = CampaignMailer.outreach( + recipient, + html: recipient.rendered_html, + subject: recipient.rendered_subject_line + ) + + assert_emails 1 do + email.deliver_now + end + + assert_equal ["hello@example.com"], email.from + assert_equal ["mario@asdtest.example.it"], email.to + assert_equal "Ciao ASD Test Calcio", email.subject + body = (email.html_part || email).body.to_s + assert_match(/Ciao Mario Rossi di ASD Test Calcio/, body) + assert_equal 1, email.attachments.size + assert_equal "brochure.pdf", email.attachments.first.filename + end + + test "inlines active storage images as cid attachments" do + png = Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==") + blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(png), filename: "pixel.png", content_type: "image/png") + src = Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true) + mailing = create_mailing(body_html: %(

Foto

)) + mailing.rebuild_recipients! + recipient = mailing.mailing_recipients.first + + email = CampaignMailer.outreach(recipient, html: recipient.rendered_html, subject: "Foto") + email.deliver_now + + assert email.attachments.any? { |att| att.filename.to_s.include?("pixel.png") } + body = (email.html_part || email).body.to_s + assert_match(/cid:/, body) + assert_match(/width:\s*240px/, body) + assert_no_match(/
{ Activity.count }, 1 do + assert @task.complete!(user: @user) + end + assert @task.completed? + assert_not_nil @task.completed_at + end +end diff --git a/test/models/user_management_test.rb b/test/models/user_management_test.rb new file mode 100644 index 0000000..005d0ec --- /dev/null +++ b/test/models/user_management_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class UserManagementTest < ActiveSupport::TestCase + test "cannot deactivate the last active admin" do + admin = users(:admin) + assert admin.last_active_admin? + assert_not admin.can_be_deactivated? + + admin.active = false + assert_not admin.valid? + assert_includes admin.errors[:base].join, "almeno un amministratore attivo" + end + + test "cannot demote the last active admin" do + admin = users(:admin) + admin.role = "user" + assert_not admin.valid? + assert_includes admin.errors[:base].join, "almeno un amministratore attivo" + end + + test "can deactivate seed admin when another admin exists" do + other = User.create!( + email: "second-admin@example.com", + first_name: "Second", + last_name: "Admin", + role: "admin", + password: "password123", + password_confirmation: "password123", + active: true + ) + admin = users(:admin) + + assert_not admin.last_active_admin? + assert admin.can_be_deactivated? + assert admin.update(active: false) + assert_not admin.reload.active? + assert other.reload.active? + end + + test "cannot destroy the last active admin" do + admin = users(:admin) + assert_not admin.destroy + assert admin.errors[:base].join.include?("unico amministratore attivo") || User.exists?(admin.id) + end + + test "can destroy a regular user" do + marco = users(:marco) + assert marco.destroy + assert_not User.exists?(marco.id) + end +end diff --git a/test/services/campaign_import_matchlivetv_launch_test.rb b/test/services/campaign_import_matchlivetv_launch_test.rb new file mode 100644 index 0000000..9037f0d --- /dev/null +++ b/test/services/campaign_import_matchlivetv_launch_test.rb @@ -0,0 +1,51 @@ +require "test_helper" +require "tempfile" + +class CampaignImportMatchlivetvLaunchTest < ActiveSupport::TestCase + test "imports campaign rows onto MatchLiveTV" do + csv = Tempfile.new(["campagna", ".csv"]) + csv.write(<<~CSV) + N.,Regione,Prov.,Società,M/F,Email verificata,Sito / profilo,Streaming rilevato,Evidenza / fit commerciale,Test A/B,Stato invio,Data invio,Esito,Demo / Trial,Conversione,Piano,Fonte contatto / ricerca,Data verifica,Note follow-up + 1,Piemonte,TO,Vol-Ley Academy Volpiano,F,volley.academy.to@gmail.com,https://www.facebook.com/p/Vol-Ley-Academy-100063526780569/,NON RILEVATO,Academy giovanile,A,DA INVIARE,,,NO,NO,,https://example.com/fonte,2026-08-17, + 7,Lombardia,MB,Volley Brianza Est,F,ds@volleybrianzaest.it,https://www.volleybrianzaest.it/,SÌ / PARZIALE,U16/U18 nazionale,A,DA INVIARE,,,NO,NO,,https://www.volleybrianzaest.it/,46251, + 100,Valle d'Aosta,AO,Cogne Aosta Volley,F,cogneaostavolley@gmail.com,https://www.youtube.com/@CogneAostaVolley,SÌ – SPORTCAM,Vivaio prolifico,B,DA INVIARE,,,NO,NO,,https://www.fipav-vda.com/societa,2026-08-17, + CSV + csv.flush + + result = CampaignImport::MatchlivetvLaunch.new( + path: csv.path, + user: users(:admin), + project: projects(:matchlivetv), + wipe: false + ).call + + assert_empty result.errors, result.errors.inspect + assert_equal 3, result.imported + + org = Organization.find_by!(name: "Vol-Ley Academy Volpiano") + assert_equal "female", org.team_gender + assert_equal "not_detected", org.streaming_status + assert_equal 1, org.list_position + assert_equal Date.new(2026, 8, 17), org.verified_at + assert_equal "campaign", org.lead_source + assert org.projects.exists?(code: "matchlivetv") + assert_equal "volley.academy.to@gmail.com", org.contacts.primary_first.first.email + + opp = org.campaign_opportunity(projects(:matchlivetv)) + assert_equal "A", opp.ab_variant + assert_equal "to_send", opp.send_status + assert_equal "to_contact", opp.pipeline_stage + assert_not opp.demo_trial + assert_not opp.converted + + brianza = Organization.find_by!(name: "Volley Brianza Est") + assert_equal "yes_partial", brianza.streaming_status + assert_equal Date.new(2026, 8, 17), brianza.verified_at + + cogne = Organization.find_by!(name: "Cogne Aosta Volley") + assert_equal "yes_sportcam", cogne.streaming_status + assert_equal "B", cogne.campaign_opportunity(projects(:matchlivetv)).ab_variant + ensure + csv.close! + end +end diff --git a/test/services/mail_merge_test.rb b/test/services/mail_merge_test.rb new file mode 100644 index 0000000..accae58 --- /dev/null +++ b/test/services/mail_merge_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +class MailMergeTest < ActiveSupport::TestCase + test "replaces tokens from organization and contact" do + html = MailMerge.render( + "Ciao {{contatto_nome}} di {{societa}} in {{regione}}", + organization: organizations(:acme), + contact: contacts(:primary), + project: projects(:matchlivetv), + opportunity: opportunities(:deal) + ) + + assert_equal "Ciao Mario Rossi di ASD Test Calcio in Lombardia", html + end + + test "falls back to primary contact and org email" do + vars = MailMerge.variables_for(organization: organizations(:acme), project: projects(:matchlivetv)) + assert_equal "Mario Rossi", vars["contatto_nome"] + assert_equal "mario@asdtest.example.it", vars["email"] + assert_equal "MatchLiveTV", vars["progetto"] + assert_equal "Full", vars["piano"] + end + + test "test_ab prefers explicit variant" do + vars = MailMerge.variables_for( + organization: organizations(:acme), + project: projects(:matchlivetv), + ab_variant: "B" + ) + assert_equal "B", vars["test_ab"] + end + + test "unknown tokens become empty string" do + assert_equal "X Y", MailMerge.render("X {{sconosciuto}} Y") + end +end diff --git a/test/services/mailings_recipient_builder_test.rb b/test/services/mailings_recipient_builder_test.rb new file mode 100644 index 0000000..6612c1d --- /dev/null +++ b/test/services/mailings_recipient_builder_test.rb @@ -0,0 +1,75 @@ +require "test_helper" + +class MailingsRecipientBuilderTest < ActiveSupport::TestCase + setup do + opportunities(:deal).update!(send_status: "to_send", ab_variant: "A") + end + + test "audience to_send includes only pending campaign orgs with valid email" do + mailing = create_mailing(audience: "to_send") + mailing.rebuild_recipients! + + recipient = mailing.mailing_recipients.find_by!(organization: organizations(:acme)) + assert_equal "pending", recipient.status + assert_equal "mario@asdtest.example.it", recipient.email + end + + test "skips organizations without email" do + org = organizations(:acme) + org.update!(email: nil) + org.contacts.update_all(email: nil) + + mailing = create_mailing(audience: "all") + mailing.rebuild_recipients! + + recipient = mailing.mailing_recipients.find_by!(organization: org) + assert_equal "skipped", recipient.status + assert_equal "manca email", recipient.skip_reason + end + + test "audience test_a filters by variant" do + mailing = create_mailing(audience: "test_a") + mailing.rebuild_recipients! + assert_equal 1, mailing.mailing_recipients.count + + mailing.update!(audience: "test_b") + mailing.rebuild_recipients! + assert_equal 0, mailing.mailing_recipients.count + end + + test "ab test from_record uses opportunity variant" do + create_campaign_org(name: "Beta Volley", email: "beta@example.com", ab_variant: "B", list_position: 2) + mailing = create_mailing( + audience: "all", + ab_test: true, + ab_assignment: "from_record", + subject_b: "Offerta B {{societa}}", + body_html_b: "

Versione B

" + ) + mailing.rebuild_recipients! + + acme = mailing.mailing_recipients.find_by!(organization: organizations(:acme)) + beta = mailing.mailing_recipients.joins(:organization).find_by!(organizations: { name: "Beta Volley" }) + assert_equal "A", acme.ab_variant + assert_equal "B", beta.ab_variant + assert_match(/Ciao Mario Rossi/, acme.rendered_html) + assert_equal "Offerta B Beta Volley", beta.rendered_subject_line + assert_match(/Versione B/, beta.rendered_html) + end + + test "ab test split alternates A and B" do + create_campaign_org(name: "Beta Volley", email: "beta@example.com", ab_variant: "A", list_position: 2) + organizations(:acme).update!(list_position: 1) + mailing = create_mailing( + audience: "all", + ab_test: true, + ab_assignment: "split", + subject_b: "B {{societa}}", + body_html_b: "

B

" + ) + mailing.rebuild_recipients! + + variants = mailing.mailing_recipients.pending.ordered.map(&:ab_variant) + assert_equal %w[A B], variants + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..da77788 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,85 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + parallelize(workers: :number_of_processors) + fixtures :all + end +end + +class ActionDispatch::IntegrationTest + def login_as(user, password: "password123") + post login_path, params: { email: user.email, password: password } + end +end + +module MailMarketingTestHelper + def create_mail_identity(**attrs) + MailIdentity.create!( + { + name: "SMTP test", + from_name: "eminuxCRM", + from_email: "hello@example.com", + smtp_host: "smtp.example.com", + smtp_port: 587, + smtp_username: "user", + smtp_password: "secret", + smtp_authentication: "plain", + encryption: "starttls", + verify_ssl: true, + active: true + }.merge(attrs) + ) + end + + def create_mailing(project: projects(:matchlivetv), identity: nil, **attrs) + Mailing.create!( + { + project: project, + mail_identity: identity || create_mail_identity, + name: "Campagna test", + subject: "Ciao {{societa}}", + body_html: "

Ciao {{contatto_nome}} di {{societa}}

", + audience: "all", + interval_seconds: 0, + ab_test: false, + ab_assignment: "from_record" + }.merge(attrs) + ) + end + + def create_campaign_org(name:, email:, ab_variant: nil, send_status: "to_send", list_position: nil) + project = projects(:matchlivetv) + org = Organization.new( + name: name, + status: "prospect", + organization_type: "societa_sportiva", + email: email, + list_position: list_position + ) + org.organization_projects.build(project: project) + org.save! + Contact.create!( + organization: org, + first_name: "Contatto", + last_name: name, + email: email, + primary_contact: true + ) + Opportunity.create!( + organization: org, + project: project, + name: "Campagna", + pipeline_stage: "to_contact", + send_status: send_status, + ab_variant: ab_variant + ) + org + end +end + +ActiveSupport::TestCase.include MailMarketingTestHelper +ActionDispatch::IntegrationTest.include MailMarketingTestHelper + diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 0000000..e69de29