Compare commits
4
Commits
1f946f63ba
...
e492c43bf8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e492c43bf8 | ||
|
|
b5737a7ee7 | ||
|
|
8f73105dfe | ||
|
|
936930e691 |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"eminuxcrm": {
|
||||||
|
"url": "http://localhost:3001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${env:CRM_API_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description: Come gli agenti usano eminuxCRM (MCP) per task, pipeline e follow-up
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Quando l'utente chiede di interagire con il CRM, usa i tool MCP **eminuxcrm** (non curl). Serve il CRM su localhost:3001 e `CRM_API_TOKEN`.
|
||||||
|
|
||||||
|
Flusso: `list_projects` → `today` → `search` / `get_organization` → `create_activity` / `complete_task` / `create_task` / `update_opportunity_stage`.
|
||||||
|
|
||||||
|
Non inviare mailing, non cancellare record, non gestire utenti. Annota in timeline quello che fai.
|
||||||
@@ -27,3 +27,7 @@ SMTP_DOMAIN=
|
|||||||
|
|
||||||
# Timezone
|
# Timezone
|
||||||
TZ=Europe/Rome
|
TZ=Europe/Rome
|
||||||
|
|
||||||
|
# Agenti (Cursor CLI / Claude Code / Codex). Crea il token in CRM → Token API.
|
||||||
|
# CRM_API_TOKEN=
|
||||||
|
# CRM_MCP_URL=http://localhost:3001/mcp
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"eminuxcrm": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://localhost:3001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${CRM_API_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# eminuxCRM — istruzioni per agenti (Cursor CLI, Claude Code, Codex)
|
||||||
|
|
||||||
|
Quando l'utente chiede di **interagire con il CRM**, lavorare su prospect, pipeline, task, follow-up o "cosa fare oggi", usa i tool MCP **eminuxcrm**. Non usare `curl` sull'API se i tool sono disponibili.
|
||||||
|
|
||||||
|
## Come collegarti
|
||||||
|
|
||||||
|
1. Il CRM deve essere avviato (`docker compose up`, porta **3001**).
|
||||||
|
2. Crea un token in UI: **Token API** (menu utente).
|
||||||
|
3. Esporta il token **prima** di lanciare l'agente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CRM_API_TOKEN='crm_…'
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint MCP: `http://localhost:3001/mcp` (Bearer). In produzione usa l’URL pubblico del CRM (`https://<host>/mcp`). Config già nel repo: `.cursor/mcp.json` (Cursor / Cursor CLI) e `.mcp.json` (Claude Code). Per il CRM in LAN/produzione cambia `url` e tieni il token in `CRM_API_TOKEN`.
|
||||||
|
|
||||||
|
## Flusso di lavoro
|
||||||
|
|
||||||
|
1. `list_projects` — ottieni i `project_code` (matchlivetv, riskmeter, cardoo, …).
|
||||||
|
2. `today` con quel codice — task scaduti / oggi / in arrivo e opportunità ferme.
|
||||||
|
3. `search` o `get_organization` per il contesto.
|
||||||
|
4. Agisci con `create_activity`, `complete_task`, `create_task`, `update_opportunity_stage`.
|
||||||
|
|
||||||
|
## Limiti
|
||||||
|
|
||||||
|
- Non inviare mailing, non cancellare record, non gestire utenti o SMTP.
|
||||||
|
- Restai nello scope dei progetti abilitati per il token.
|
||||||
|
- Annota sempre in timeline (`create_activity`) quello che fai, così l'utente lo vede in scheda.
|
||||||
|
|
||||||
|
Frase tipo: *«Interagisci col CRM su MatchLiveTV: dimmi cosa c'è da fare oggi e completa i follow-up ovvi.»*
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# eminuxCRM — Claude Code
|
||||||
|
|
||||||
|
Leggi [AGENTS.md](AGENTS.md). I tool MCP `eminuxcrm` sono in `.mcp.json` (`http://localhost:3001/mcp`). Serve `CRM_API_TOKEN` nell'ambiente e il CRM avviato.
|
||||||
@@ -11,6 +11,7 @@ gem "tailwindcss-rails"
|
|||||||
gem "bcrypt", "~> 3.1.7"
|
gem "bcrypt", "~> 3.1.7"
|
||||||
gem "pagy", "~> 9.3"
|
gem "pagy", "~> 9.3"
|
||||||
gem "csv"
|
gem "csv"
|
||||||
|
gem "mcp"
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
gem "bootsnap", require: false
|
gem "bootsnap", require: false
|
||||||
gem "image_processing", "~> 1.2"
|
gem "image_processing", "~> 1.2"
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ GEM
|
|||||||
ffi (1.17.4-x86_64-linux-gnu)
|
ffi (1.17.4-x86_64-linux-gnu)
|
||||||
globalid (1.4.0)
|
globalid (1.4.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
|
hana (1.3.7)
|
||||||
i18n (1.15.2)
|
i18n (1.15.2)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
@@ -129,6 +130,11 @@ GEM
|
|||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
json (2.21.2)
|
json (2.21.2)
|
||||||
|
json_schemer (2.5.0)
|
||||||
|
bigdecimal
|
||||||
|
hana (~> 1.3)
|
||||||
|
regexp_parser (~> 2.0)
|
||||||
|
simpleidn (~> 0.2)
|
||||||
language_server-protocol (3.17.0.6)
|
language_server-protocol (3.17.0.6)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
@@ -143,6 +149,8 @@ GEM
|
|||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.2.1)
|
marcel (1.2.1)
|
||||||
matrix (0.4.3)
|
matrix (0.4.3)
|
||||||
|
mcp (1.4.0)
|
||||||
|
json_schemer (>= 2.4)
|
||||||
mini_magick (5.3.3)
|
mini_magick (5.3.3)
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
@@ -273,6 +281,7 @@ GEM
|
|||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
|
simpleidn (0.3.0)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
tailwindcss-rails (4.6.0)
|
tailwindcss-rails (4.6.0)
|
||||||
@@ -318,6 +327,7 @@ DEPENDENCIES
|
|||||||
debug
|
debug
|
||||||
image_processing (~> 1.2)
|
image_processing (~> 1.2)
|
||||||
importmap-rails
|
importmap-rails
|
||||||
|
mcp
|
||||||
pagy (~> 9.3)
|
pagy (~> 9.3)
|
||||||
pg (~> 1.1)
|
pg (~> 1.1)
|
||||||
propshaft
|
propshaft
|
||||||
|
|||||||
@@ -163,6 +163,66 @@ Pipeline stages: Da contattare → Contattato → Ha risposto → Interessato
|
|||||||
4. Metti un reverse proxy (Caddy/Nginx) con HTTPS davanti alla porta 3000
|
4. Metti un reverse proxy (Caddy/Nginx) con HTTPS davanti alla porta 3000
|
||||||
5. Esegui backup periodici con `bin/backup`
|
5. Esegui backup periodici con `bin/backup`
|
||||||
|
|
||||||
|
## API JSON per agenti
|
||||||
|
|
||||||
|
I client HTML restano su cookie di sessione. Gli agenti (Cursor, Claude Code, Codex) usano un token Bearer.
|
||||||
|
|
||||||
|
1. Nel CRM: **Token API** (menu utente, oppure Impostazioni piattaforma se sei admin)
|
||||||
|
2. Crea un token: il valore `crm_…` si vede **una sola volta**
|
||||||
|
3. Header: `Authorization: Bearer crm_…`
|
||||||
|
|
||||||
|
Endpoint v1 (tutti tranne `GET /api/v1/projects` richiedono `:project_code`):
|
||||||
|
|
||||||
|
| Metodo | Path | Ruolo |
|
||||||
|
|--------|------|--------|
|
||||||
|
| GET | `/api/v1/projects` | progetti accessibili |
|
||||||
|
| GET | `/api/v1/p/:project_code/today` | task scaduti / oggi / in arrivo + opportunità ferme |
|
||||||
|
| GET | `/api/v1/p/:project_code/search?q=` | org, contatti, opportunità |
|
||||||
|
| GET | `/api/v1/p/:project_code/organizations/:id` | scheda operativa |
|
||||||
|
| POST | `/api/v1/p/:project_code/tasks` | crea task |
|
||||||
|
| POST | `/api/v1/p/:project_code/tasks/:id/complete` | completa task |
|
||||||
|
| POST | `/api/v1/p/:project_code/activities` | annota timeline |
|
||||||
|
| PATCH | `/api/v1/p/:project_code/opportunities/:id/stage` | cambia stage pipeline |
|
||||||
|
|
||||||
|
L’agente agisce come l’utente del token (stessi progetti, stesso `created_by`). Fuori da questa v1: mailing, utenti, delete, import CSV.
|
||||||
|
|
||||||
|
Esempio:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -H "Authorization: Bearer crm_…" \
|
||||||
|
http://localhost:3001/api/v1/p/matchlivetv/today
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP (Cursor CLI / Claude Code / Codex)
|
||||||
|
|
||||||
|
Gli agenti parlano con il CRM su **HTTP**: `http://localhost:3001/mcp` (stesso Bearer del token API). Non serve Ruby sull’host.
|
||||||
|
|
||||||
|
1. Avvia il CRM (`docker compose up`)
|
||||||
|
2. Crea un token in **Token API**
|
||||||
|
3. Esporta il token e lancia l’agente dalla root del repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CRM_API_TOKEN='crm_…'
|
||||||
|
# Cursor CLI:
|
||||||
|
agent "Interagisci col CRM su MatchLiveTV: cosa c'è da fare oggi?"
|
||||||
|
# Claude Code:
|
||||||
|
claude "Interagisci col CRM su MatchLiveTV: cosa c'è da fare oggi?"
|
||||||
|
```
|
||||||
|
|
||||||
|
Config già nel repo (il token sta solo in env, non nei file):
|
||||||
|
|
||||||
|
- Cursor / Cursor CLI: [`.cursor/mcp.json`](.cursor/mcp.json)
|
||||||
|
- Claude Code: [`.mcp.json`](.mcp.json)
|
||||||
|
- Codex: copia [mcp/codex.config.toml.example](mcp/codex.config.toml.example) in `~/.codex/config.toml`
|
||||||
|
|
||||||
|
Istruzioni per gli agenti: [AGENTS.md](AGENTS.md).
|
||||||
|
|
||||||
|
Tool: `list_projects`, `today`, `search`, `get_organization`, `create_task`, `complete_task`, `create_activity`, `update_opportunity_stage`.
|
||||||
|
|
||||||
|
Stdio (`mcp/server.rb` / `bin/crm-mcp`) resta come alternativa se un client non parla HTTP.
|
||||||
|
|
||||||
|
Esempio in chat: *«Interagisci col CRM e fai alcune cose per me su MatchLiveTV.»*
|
||||||
|
|
||||||
## Sviluppo locale senza Docker (opzionale)
|
## Sviluppo locale senza Docker (opzionale)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -174,8 +234,7 @@ bin/dev # server + tailwind watch
|
|||||||
## Volutamente fuori scope (fase successiva)
|
## Volutamente fuori scope (fase successiva)
|
||||||
|
|
||||||
- Integrazioni Gmail/SMTP avanzate, sync email, calendario
|
- Integrazioni Gmail/SMTP avanzate, sync email, calendario
|
||||||
- Stripe, webhook, API REST pubblica
|
- Stripe, webhook
|
||||||
- Multi-tenant / multi-progetto
|
|
||||||
- Dark mode, BI avanzata, microservizi
|
- Dark mode, BI avanzata, microservizi
|
||||||
|
|
||||||
## Licenza
|
## Licenza
|
||||||
|
|||||||
@@ -123,6 +123,28 @@ html.dark .hover\:bg-slate-50:hover {
|
|||||||
background-color: #27272a;
|
background-color: #27272a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Testi “light-only” usati ovunque come mute/alert: in dark restano leggibili. */
|
||||||
|
html.dark .text-slate-500,
|
||||||
|
html.dark .text-slate-400 {
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-slate-600 {
|
||||||
|
color: #d4d4d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-rose-700 {
|
||||||
|
color: #fb7185;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-amber-700 {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .text-emerald-700 {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]),
|
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 select,
|
||||||
html.dark textarea {
|
html.dark textarea {
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ class AdminController < ApplicationController
|
|||||||
@active_users_count = User.active.count
|
@active_users_count = User.active.count
|
||||||
@projects_count = Project.count
|
@projects_count = Project.count
|
||||||
@mail_identities_count = MailIdentity.count
|
@mail_identities_count = MailIdentity.count
|
||||||
|
@api_tokens_count = current_user.api_tokens.active.count
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
module Api
|
||||||
|
class BaseController < ActionController::API
|
||||||
|
wrap_parameters false
|
||||||
|
before_action :authenticate_api_token!
|
||||||
|
|
||||||
|
attr_reader :current_user, :current_api_token
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def authenticate_api_token!
|
||||||
|
token = ApiToken.authenticate(bearer_token)
|
||||||
|
unless token
|
||||||
|
render json: { error: "Non autenticato" }, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@current_api_token = token
|
||||||
|
@current_user = token.user
|
||||||
|
Current.user = @current_user
|
||||||
|
token.touch_last_used!
|
||||||
|
end
|
||||||
|
|
||||||
|
def bearer_token
|
||||||
|
header = request.authorization.to_s.presence || request.headers["Authorization"].to_s
|
||||||
|
header[/Bearer\s+(.+)/i, 1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class ActivitiesController < BaseController
|
||||||
|
def create
|
||||||
|
render_agent Crm::AgentSession.new(current_user).create_activity(params[:project_code], activity_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def activity_params
|
||||||
|
source = params[:activity].presence || params
|
||||||
|
source.permit(:organization_id, :activity_type, :subject, :description, :happened_at, :contact_id, :opportunity_id, :lost_reason)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class BaseController < Api::BaseController
|
||||||
|
private
|
||||||
|
|
||||||
|
def render_agent(result)
|
||||||
|
render json: result.body, status: result.status
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class OpportunitiesController < BaseController
|
||||||
|
def update_stage
|
||||||
|
source = params[:opportunity].presence || params
|
||||||
|
render_agent Crm::AgentSession.new(current_user).update_opportunity_stage(
|
||||||
|
params[:project_code],
|
||||||
|
params[:id],
|
||||||
|
pipeline_stage: source[:pipeline_stage],
|
||||||
|
lost_reason: source[:lost_reason],
|
||||||
|
notes: source[:notes]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class OrganizationsController < BaseController
|
||||||
|
def show
|
||||||
|
render_agent Crm::AgentSession.new(current_user).organization(params[:project_code], params[:id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class ProjectsController < BaseController
|
||||||
|
def index
|
||||||
|
render_agent Crm::AgentSession.new(current_user).projects
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class SearchController < BaseController
|
||||||
|
def show
|
||||||
|
render_agent Crm::AgentSession.new(current_user).search(params[:project_code], params[:q])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class TasksController < BaseController
|
||||||
|
def create
|
||||||
|
render_agent Crm::AgentSession.new(current_user).create_task(params[:project_code], task_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def complete
|
||||||
|
render_agent Crm::AgentSession.new(current_user).complete_task(params[:project_code], params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def task_params
|
||||||
|
source = params[:task].presence || params
|
||||||
|
source.permit(
|
||||||
|
:title, :description, :organization_id, :contact_id, :opportunity_id,
|
||||||
|
:assigned_user_id, :due_at, :priority, :task_type
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class TodayController < BaseController
|
||||||
|
def show
|
||||||
|
render_agent Crm::AgentSession.new(current_user).today(params[:project_code])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class ApiTokensController < ApplicationController
|
||||||
|
before_action :set_api_token, only: :destroy
|
||||||
|
|
||||||
|
def index
|
||||||
|
@page_title = "Token API"
|
||||||
|
@api_tokens = current_user.api_tokens.active.order(created_at: :desc)
|
||||||
|
@revealed_token = session.delete(:revealed_api_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
token = ApiToken.issue!(user: current_user, name: params[:name])
|
||||||
|
session[:revealed_api_token] = token.plaintext
|
||||||
|
redirect_to api_tokens_path, notice: "Token creato. Copialo ora: non sarà più visibile."
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@api_token.revoke!
|
||||||
|
redirect_to api_tokens_path, notice: "Token revocato."
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_api_token
|
||||||
|
@api_token = current_user.api_tokens.active.find(params[:id])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class McpController < Api::BaseController
|
||||||
|
def handle
|
||||||
|
request.body.rewind if request.body.respond_to?(:rewind)
|
||||||
|
|
||||||
|
server = EminuxCrmMcp.server_for(user: current_user)
|
||||||
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
|
||||||
|
server,
|
||||||
|
stateless: true,
|
||||||
|
serve_subscriptions_listen: false,
|
||||||
|
enable_json_response: true,
|
||||||
|
allowed_hosts: EminuxCrmMcp.allowed_hosts,
|
||||||
|
dns_rebinding_protection: !Rails.env.test?
|
||||||
|
)
|
||||||
|
|
||||||
|
status, headers, body = transport.handle_request(request)
|
||||||
|
headers.each { |key, value| response.set_header(key, value) }
|
||||||
|
self.status = status
|
||||||
|
self.response_body = body
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,6 +5,7 @@ class OrganizationsController < ApplicationController
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
@page_title = "Organizzazioni"
|
@page_title = "Organizzazioni"
|
||||||
|
@sport_options = organizations_for_current_project.where.not(sport: [ nil, "" ]).distinct.order(:sport).pluck(:sport)
|
||||||
scope = organizations_for_current_project.includes(:assigned_user, :contacts, :opportunities, :tasks, :projects)
|
scope = organizations_for_current_project.includes(:assigned_user, :contacts, :opportunities, :tasks, :projects)
|
||||||
scope = scope.search(params[:q]) if params[:q].present?
|
scope = scope.search(params[:q]) if params[:q].present?
|
||||||
scope = apply_filters(scope)
|
scope = apply_filters(scope)
|
||||||
|
|||||||
@@ -79,47 +79,53 @@ module ApplicationHelper
|
|||||||
|
|
||||||
def status_badge(status)
|
def status_badge(status)
|
||||||
colors = {
|
colors = {
|
||||||
"prospect" => "bg-sky-100 text-sky-800",
|
"prospect" => "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-200",
|
||||||
"active_customer" => "bg-emerald-100 text-emerald-800",
|
"active_customer" => "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
|
||||||
"inactive_customer" => "bg-slate-100 text-slate-700",
|
"inactive_customer" => "bg-slate-100 text-slate-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||||
"partner" => "bg-violet-100 text-violet-800",
|
"partner" => "bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-200",
|
||||||
"lost" => "bg-rose-100 text-rose-800"
|
"lost" => "bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-200"
|
||||||
}
|
}
|
||||||
content_tag :span, Catalog.label_for(Catalog::ORGANIZATION_STATUSES, status),
|
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'}"
|
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[status] || 'bg-slate-100 text-slate-700 dark:bg-zinc-800 dark:text-zinc-300'}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def stage_badge(stage)
|
def stage_badge(stage)
|
||||||
colors = {
|
colors = {
|
||||||
"to_contact" => "bg-slate-100 text-slate-700",
|
"to_contact" => "bg-slate-100 text-slate-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||||
"contacted" => "bg-sky-100 text-sky-800",
|
"contacted" => "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-200",
|
||||||
"replied" => "bg-cyan-100 text-cyan-800",
|
"replied" => "bg-cyan-100 text-cyan-800 dark:bg-cyan-950 dark:text-cyan-200",
|
||||||
"interested" => "bg-amber-100 text-amber-800",
|
"interested" => "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
|
||||||
"demo_trial" => "bg-orange-100 text-orange-800",
|
"demo_trial" => "bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-200",
|
||||||
"first_use" => "bg-lime-100 text-lime-800",
|
"first_use" => "bg-lime-100 text-lime-800 dark:bg-lime-950 dark:text-lime-200",
|
||||||
"proposal" => "bg-indigo-100 text-indigo-800",
|
"proposal" => "bg-indigo-100 text-indigo-800 dark:bg-indigo-950 dark:text-indigo-200",
|
||||||
"won" => "bg-emerald-100 text-emerald-800",
|
"won" => "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
|
||||||
"lost" => "bg-rose-100 text-rose-800"
|
"lost" => "bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-200"
|
||||||
}
|
}
|
||||||
content_tag :span, Catalog.label_for(Catalog::PIPELINE_STAGES, stage),
|
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'}"
|
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[stage] || 'bg-slate-100 text-slate-700 dark:bg-zinc-800 dark:text-zinc-300'}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def priority_badge(priority)
|
def priority_badge(priority)
|
||||||
colors = {
|
colors = {
|
||||||
"low" => "bg-slate-100 text-slate-600",
|
"low" => "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-300",
|
||||||
"normal" => "bg-sky-100 text-sky-700",
|
"normal" => "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-200",
|
||||||
"high" => "bg-amber-100 text-amber-800",
|
"high" => "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
|
||||||
"urgent" => "bg-rose-100 text-rose-800"
|
"urgent" => "bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-200"
|
||||||
}
|
}
|
||||||
content_tag :span, Catalog.label_for(Catalog::TASK_PRIORITIES, priority),
|
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'}"
|
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[priority] || 'bg-slate-100 text-slate-700 dark:bg-zinc-800 dark:text-zinc-300'}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_money(value)
|
def format_money(value)
|
||||||
number_to_currency(value.to_f, unit: "€", separator: ",", delimiter: ".", format: "%n %u")
|
number_to_currency(value.to_f, unit: "€", separator: ",", delimiter: ".", format: "%n %u")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def format_conversion_rate(value)
|
||||||
|
return "—" if value.nil?
|
||||||
|
|
||||||
|
"#{value}%"
|
||||||
|
end
|
||||||
|
|
||||||
def format_dt(value)
|
def format_dt(value)
|
||||||
return "—" if value.blank?
|
return "—" if value.blank?
|
||||||
|
|
||||||
@@ -133,7 +139,7 @@ module ApplicationHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def progress_bar(percentage, color: "bg-emerald-500")
|
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-3 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-600" do
|
||||||
content_tag :div, "", class: "h-full #{color} transition-all", style: "width: #{percentage.to_i}%"
|
content_tag :div, "", class: "h-full #{color} transition-all", style: "width: #{percentage.to_i}%"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -161,7 +167,7 @@ module ApplicationHelper
|
|||||||
"cancelled" => "bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-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),
|
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'}"
|
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[status] || 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200'}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def mailing_recipient_status_badge(status)
|
def mailing_recipient_status_badge(status)
|
||||||
@@ -173,7 +179,7 @@ module ApplicationHelper
|
|||||||
"failed" => "bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-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),
|
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'}"
|
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[status] || 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200'}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def mailing_open_badge(recipient)
|
def mailing_open_badge(recipient)
|
||||||
@@ -197,7 +203,7 @@ module ApplicationHelper
|
|||||||
"B" => "bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-950 dark:text-fuchsia-200"
|
"B" => "bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-950 dark:text-fuchsia-200"
|
||||||
}
|
}
|
||||||
content_tag :span, "Test #{variant}",
|
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'}"
|
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[variant] || 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200'}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def sanitize_email_html(html)
|
def sanitize_email_html(html)
|
||||||
@@ -212,14 +218,14 @@ module ApplicationHelper
|
|||||||
return if status.blank?
|
return if status.blank?
|
||||||
|
|
||||||
colors = {
|
colors = {
|
||||||
"not_detected" => "bg-zinc-100 text-zinc-700",
|
"not_detected" => "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||||
"limited" => "bg-amber-100 text-amber-800",
|
"limited" => "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
|
||||||
"yes_partial" => "bg-sky-100 text-sky-800",
|
"yes_partial" => "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-200",
|
||||||
"yes" => "bg-emerald-100 text-emerald-800",
|
"yes" => "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
|
||||||
"yes_sportcam" => "bg-emerald-100 text-emerald-800"
|
"yes_sportcam" => "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
|
||||||
}
|
}
|
||||||
content_tag :span, Catalog.label_for(Catalog::STREAMING_STATUSES, status),
|
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'}"
|
class: "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium #{colors[status] || 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300'}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def yes_no(value)
|
def yes_no(value)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
class ApiToken < ApplicationRecord
|
||||||
|
PREFIX = "crm_"
|
||||||
|
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
attr_accessor :plaintext
|
||||||
|
|
||||||
|
validates :name, presence: true
|
||||||
|
validates :token_digest, presence: true, uniqueness: true
|
||||||
|
validates :token_prefix, presence: true
|
||||||
|
|
||||||
|
scope :active, -> { where(revoked_at: nil) }
|
||||||
|
|
||||||
|
def self.digest(token)
|
||||||
|
Digest::SHA256.hexdigest(token.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.authenticate(plaintext)
|
||||||
|
return if plaintext.blank?
|
||||||
|
|
||||||
|
token = active.find_by(token_digest: digest(plaintext))
|
||||||
|
return unless token&.user&.active?
|
||||||
|
|
||||||
|
token
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.issue!(user:, name:)
|
||||||
|
raw = "#{PREFIX}#{SecureRandom.urlsafe_base64(32)}"
|
||||||
|
record = create!(
|
||||||
|
user: user,
|
||||||
|
name: name.to_s.strip.presence || "Agente",
|
||||||
|
token_digest: digest(raw),
|
||||||
|
token_prefix: raw[0, 8]
|
||||||
|
)
|
||||||
|
record.plaintext = raw
|
||||||
|
record
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoked?
|
||||||
|
revoked_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke!
|
||||||
|
update!(revoked_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
def touch_last_used!
|
||||||
|
update_column(:last_used_at, Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
def masked
|
||||||
|
"#{token_prefix}…"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,6 +9,7 @@ class User < ApplicationRecord
|
|||||||
has_many :activities, dependent: :nullify
|
has_many :activities, dependent: :nullify
|
||||||
has_many :user_projects, dependent: :destroy
|
has_many :user_projects, dependent: :destroy
|
||||||
has_many :projects, through: :user_projects
|
has_many :projects, through: :user_projects
|
||||||
|
has_many :api_tokens, dependent: :destroy
|
||||||
|
|
||||||
ROLES = %w[admin user].freeze
|
ROLES = %w[admin user].freeze
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ require "csv"
|
|||||||
class CampaignImport::MatchlivetvLaunch
|
class CampaignImport::MatchlivetvLaunch
|
||||||
CAMPAIGN_NAME = "Campagna lancio 01".freeze
|
CAMPAIGN_NAME = "Campagna lancio 01".freeze
|
||||||
SPORT = "Pallavolo".freeze
|
SPORT = "Pallavolo".freeze
|
||||||
|
LEGAL_PREFIXES = /\b(ssdarl|ssd arl|a\.?s\.?d\.?|asd|s\.?s\.?d\.?|ssd|s\.?s\.?|a\.?f\.?|a\.?p\.?|c\.?s\.?|polisportiva)\b/i
|
||||||
|
|
||||||
Result = Struct.new(:wiped_organizations, :imported, :errors, keyword_init: true)
|
Result = Struct.new(:wiped_organizations, :imported, :skipped, :errors, keyword_init: true)
|
||||||
|
|
||||||
STREAMING_MAP = {
|
STREAMING_MAP = {
|
||||||
"NON RILEVATO" => "not_detected",
|
"NON RILEVATO" => "not_detected",
|
||||||
@@ -27,11 +28,13 @@ class CampaignImport::MatchlivetvLaunch
|
|||||||
"NON INVIARE" => "no_send"
|
"NON INVIARE" => "no_send"
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def initialize(path:, user: nil, project: nil, wipe: true)
|
def initialize(path:, user: nil, project: nil, wipe: true, sport: SPORT, campaign_name: CAMPAIGN_NAME)
|
||||||
@path = Pathname.new(path)
|
@path = Pathname.new(path)
|
||||||
@user = user
|
@user = user
|
||||||
@project = project
|
@project = project
|
||||||
@wipe = wipe
|
@wipe = wipe
|
||||||
|
@sport = sport
|
||||||
|
@campaign_name = campaign_name
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
@@ -43,17 +46,27 @@ class CampaignImport::MatchlivetvLaunch
|
|||||||
Current.project = @project
|
Current.project = @project
|
||||||
|
|
||||||
wiped = @wipe ? wipe_project! : 0
|
wiped = @wipe ? wipe_project! : 0
|
||||||
|
index_existing_names!
|
||||||
imported = 0
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
rows.each_with_index do |row, idx|
|
rows.each_with_index do |row, idx|
|
||||||
import_row!(row)
|
name = cell(row, "Società")
|
||||||
imported += 1
|
if duplicate_name?(name)
|
||||||
rescue StandardError => e
|
skipped += 1
|
||||||
errors << { line: idx + 2, name: row["Società"], message: e.message }
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
Result.new(wiped_organizations: wiped, imported: imported, errors: errors)
|
import_row!(row)
|
||||||
|
remember_name!(name)
|
||||||
|
imported += 1
|
||||||
|
rescue StandardError => e
|
||||||
|
message = e.is_a?(ActiveRecord::RecordInvalid) ? e.record.errors.full_messages.to_sentence : e.message
|
||||||
|
errors << { line: idx + 2, name: row["Società"], message: message }
|
||||||
|
end
|
||||||
|
|
||||||
|
Result.new(wiped_organizations: wiped, imported: imported, skipped: skipped, errors: errors)
|
||||||
ensure
|
ensure
|
||||||
Current.user = nil
|
Current.user = nil
|
||||||
Current.project = nil
|
Current.project = nil
|
||||||
@@ -88,11 +101,11 @@ class CampaignImport::MatchlivetvLaunch
|
|||||||
org = Organization.new(
|
org = Organization.new(
|
||||||
name: name,
|
name: name,
|
||||||
organization_type: "societa_sportiva",
|
organization_type: "societa_sportiva",
|
||||||
sport: SPORT,
|
sport: @sport,
|
||||||
country: "Italia",
|
country: "Italia",
|
||||||
region: cell(row, "Regione"),
|
region: cell(row, "Regione"),
|
||||||
province: cell(row, "Prov."),
|
province: cell(row, "Prov."),
|
||||||
email: cell(row, "Email verificata")&.downcase,
|
email: normalize_email(cell(row, "Email verificata")),
|
||||||
website: cell(row, "Sito / profilo"),
|
website: cell(row, "Sito / profilo"),
|
||||||
source_url: cell(row, "Fonte contatto / ricerca"),
|
source_url: cell(row, "Fonte contatto / ricerca"),
|
||||||
commercial_fit: cell(row, "Evidenza / fit commerciale"),
|
commercial_fit: cell(row, "Evidenza / fit commerciale"),
|
||||||
@@ -129,10 +142,10 @@ class CampaignImport::MatchlivetvLaunch
|
|||||||
end
|
end
|
||||||
|
|
||||||
org.opportunities.create!(
|
org.opportunities.create!(
|
||||||
name: CAMPAIGN_NAME,
|
name: @campaign_name,
|
||||||
project: @project,
|
project: @project,
|
||||||
pipeline_stage: stage,
|
pipeline_stage: stage,
|
||||||
product: cell(row, "Piano"),
|
product: blank_if_no(cell(row, "Piano")),
|
||||||
assigned_user: @user,
|
assigned_user: @user,
|
||||||
ab_variant: cell(row, "Test A/B").to_s.upcase.presence,
|
ab_variant: cell(row, "Test A/B").to_s.upcase.presence,
|
||||||
send_status: SEND_MAP.fetch(normalize_key(cell(row, "Stato invio")), "to_send"),
|
send_status: SEND_MAP.fetch(normalize_key(cell(row, "Stato invio")), "to_send"),
|
||||||
@@ -144,6 +157,43 @@ class CampaignImport::MatchlivetvLaunch
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def index_existing_names!
|
||||||
|
@existing_names = {}
|
||||||
|
Organization.find_each { |org| remember_name!(org.name) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def remember_name!(name)
|
||||||
|
key = normalize_org_name(name)
|
||||||
|
@existing_names[key] = true if key.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def duplicate_name?(name)
|
||||||
|
key = normalize_org_name(name)
|
||||||
|
key.blank? || @existing_names[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_org_name(name)
|
||||||
|
I18n.transliterate(name.to_s)
|
||||||
|
.downcase
|
||||||
|
.gsub("'", " ")
|
||||||
|
.gsub(LEGAL_PREFIXES, " ")
|
||||||
|
.gsub(/[^a-z0-9]+/, " ")
|
||||||
|
.squeeze(" ")
|
||||||
|
.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def blank_if_no(value)
|
||||||
|
return if value.blank?
|
||||||
|
return if %w[NO N/A].include?(normalize_key(value))
|
||||||
|
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_email(value)
|
||||||
|
email = value.to_s.strip.downcase.gsub(/\s+/, "")
|
||||||
|
email.presence
|
||||||
|
end
|
||||||
|
|
||||||
def cell(row, header)
|
def cell(row, header)
|
||||||
value = row[header].to_s.strip
|
value = row[header].to_s.strip
|
||||||
value.presence
|
value.presence
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
module Crm
|
||||||
|
class AgentSession
|
||||||
|
Result = Struct.new(:ok, :status, :body, keyword_init: true)
|
||||||
|
|
||||||
|
def initialize(user)
|
||||||
|
@user = user
|
||||||
|
Current.user = user
|
||||||
|
end
|
||||||
|
|
||||||
|
def projects
|
||||||
|
ok(projects: @user.accessible_projects.map { |project| project_json(project) })
|
||||||
|
end
|
||||||
|
|
||||||
|
def today(project_code)
|
||||||
|
with_project(project_code) do
|
||||||
|
tasks = tasks_scope
|
||||||
|
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)
|
||||||
|
|
||||||
|
ok(
|
||||||
|
project: project_json(@project),
|
||||||
|
overdue_tasks: tasks.overdue.includes(:organization, :contact, :opportunity, :assigned_user).ordered.map { |t| task_json(t) },
|
||||||
|
today_tasks: tasks.due_today.includes(:organization, :contact, :opportunity, :assigned_user).ordered.map { |t| task_json(t) },
|
||||||
|
upcoming_tasks: tasks.upcoming.includes(:organization, :contact, :opportunity, :assigned_user).ordered.map { |t| task_json(t) },
|
||||||
|
stalled_opportunities: stalled.map { |o| opportunity_json(o) }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def search(project_code, query)
|
||||||
|
with_project(project_code) do
|
||||||
|
query = query.to_s.strip
|
||||||
|
if query.blank?
|
||||||
|
return ok(query: query, organizations: [], contacts: [], opportunities: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
organizations = organizations_scope.search(query).includes(:assigned_user).limit(20)
|
||||||
|
contacts = Contact.joins(:organization).merge(organizations_scope).search(query).includes(:organization).limit(20)
|
||||||
|
opportunities = opportunities_scope.joins(:organization)
|
||||||
|
.where("opportunities.name ILIKE :q OR organizations.name ILIKE :q", q: "%#{ActiveRecord::Base.sanitize_sql_like(query)}%")
|
||||||
|
.includes(:organization, :assigned_user)
|
||||||
|
.limit(20)
|
||||||
|
|
||||||
|
ok(
|
||||||
|
query: query,
|
||||||
|
organizations: organizations.map { |o| organization_summary(o) },
|
||||||
|
contacts: contacts.map { |c| contact_json(c).merge(organization_name: c.organization.name) },
|
||||||
|
opportunities: opportunities.map { |o| opportunity_json(o) }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def organization(project_code, id)
|
||||||
|
with_project(project_code) do
|
||||||
|
org = organizations_scope.includes(:assigned_user, :contacts).find(id)
|
||||||
|
open_opportunities = org.opportunities.for_project(@project).open_stage.includes(:assigned_user).order(updated_at: :desc)
|
||||||
|
pending_tasks = org.tasks.pending.ordered.includes(:assigned_user, :contact, :opportunity)
|
||||||
|
recent_activities = org.activities.includes(:user, :contact, :opportunity).recent_first.limit(20)
|
||||||
|
|
||||||
|
ok(
|
||||||
|
organization: organization_json(org),
|
||||||
|
contacts: org.contacts.primary_first.map { |c| contact_json(c) },
|
||||||
|
open_opportunities: open_opportunities.map { |o| opportunity_json(o) },
|
||||||
|
pending_tasks: pending_tasks.map { |t| task_json(t) },
|
||||||
|
recent_activities: recent_activities.map { |a| activity_json(a) }
|
||||||
|
)
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
err("Organizzazione non trovata", status: :not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_task(project_code, attrs)
|
||||||
|
with_project(project_code) do
|
||||||
|
attrs = attrs.to_h.symbolize_keys
|
||||||
|
organization = organizations_scope.find(attrs[:organization_id])
|
||||||
|
task = Task.new(attrs.slice(:title, :description, :contact_id, :opportunity_id, :assigned_user_id, :due_at, :priority, :task_type))
|
||||||
|
task.organization = organization
|
||||||
|
task.assigned_user ||= @user
|
||||||
|
task.priority = "normal" if task.priority.blank?
|
||||||
|
task.task_type = "follow_up" if task.task_type.blank?
|
||||||
|
|
||||||
|
if task.opportunity_id.present? && opportunities_scope.where(id: task.opportunity_id).none?
|
||||||
|
return err("Opportunità non trovata", status: :not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
if task.save
|
||||||
|
ok({ task: task_json(task) }, status: :created)
|
||||||
|
else
|
||||||
|
validation_error(task)
|
||||||
|
end
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
err("Organizzazione non trovata", status: :not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def complete_task(project_code, id)
|
||||||
|
with_project(project_code) do
|
||||||
|
task = tasks_scope.find(id)
|
||||||
|
unless task.complete!(user: @user)
|
||||||
|
return err("Il task non può essere completato", status: :unprocessable_entity)
|
||||||
|
end
|
||||||
|
|
||||||
|
ok(task: task_json(task.reload))
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
err("Task non trovato", status: :not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_activity(project_code, attrs)
|
||||||
|
with_project(project_code) do
|
||||||
|
attrs = attrs.to_h.symbolize_keys
|
||||||
|
organization = organizations_scope.find(attrs[:organization_id])
|
||||||
|
activity = organization.activities.build(attrs.slice(:activity_type, :subject, :description, :happened_at, :contact_id, :opportunity_id))
|
||||||
|
activity.user = @user
|
||||||
|
activity.happened_at ||= Time.current
|
||||||
|
|
||||||
|
if activity.opportunity_id.present? && opportunities_scope.where(id: activity.opportunity_id).none?
|
||||||
|
return err("Opportunità non trovata", status: :not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
if activity.save
|
||||||
|
maybe_update_pipeline_from_activity!(activity, lost_reason: attrs[:lost_reason])
|
||||||
|
ok({ activity: activity_json(activity) }, status: :created)
|
||||||
|
else
|
||||||
|
validation_error(activity)
|
||||||
|
end
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
err("Organizzazione non trovata", status: :not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_opportunity_stage(project_code, id, pipeline_stage:, lost_reason: nil, notes: nil)
|
||||||
|
with_project(project_code) do
|
||||||
|
opportunity = opportunities_scope.find(id)
|
||||||
|
new_stage = pipeline_stage.to_s
|
||||||
|
unless Catalog::PIPELINE_STAGES.key?(new_stage)
|
||||||
|
return err("Stage non valido", status: :unprocessable_entity)
|
||||||
|
end
|
||||||
|
|
||||||
|
opportunity.move_to_stage!(new_stage, lost_reason: lost_reason, notes: notes, user: @user)
|
||||||
|
ok(opportunity: opportunity_json(opportunity.reload))
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
err("Opportunità non trovata", status: :not_found)
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
validation_error(e.record)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def with_project(code)
|
||||||
|
project = Project.active.find_by(code: code.to_s)
|
||||||
|
return err("Progetto non trovato", status: :not_found) if project.nil?
|
||||||
|
return err("Progetto non accessibile", status: :forbidden) unless @user.can_access_project?(project)
|
||||||
|
|
||||||
|
@project = project
|
||||||
|
Current.project = project
|
||||||
|
yield
|
||||||
|
end
|
||||||
|
|
||||||
|
def organizations_scope
|
||||||
|
Organization.for_project(@project)
|
||||||
|
end
|
||||||
|
|
||||||
|
def opportunities_scope
|
||||||
|
Opportunity.for_project(@project)
|
||||||
|
end
|
||||||
|
|
||||||
|
def tasks_scope
|
||||||
|
Task.for_project(@project)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ok(body = nil, status: :ok, **fields)
|
||||||
|
Result.new(ok: true, status: status, body: body || fields)
|
||||||
|
end
|
||||||
|
|
||||||
|
def err(message, status:, extra: {})
|
||||||
|
Result.new(ok: false, status: status, body: { error: message }.merge(extra))
|
||||||
|
end
|
||||||
|
|
||||||
|
def validation_error(record)
|
||||||
|
err(record.errors.full_messages.to_sentence, status: :unprocessable_entity, extra: { errors: record.errors.full_messages })
|
||||||
|
end
|
||||||
|
|
||||||
|
def maybe_update_pipeline_from_activity!(activity, lost_reason: nil)
|
||||||
|
opportunity = activity.opportunity || activity.organization.opportunities.for_project(@project).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
|
||||||
|
|
||||||
|
reason = lost_reason.presence || "other" if target == "lost"
|
||||||
|
opportunity.move_to_stage!(target, lost_reason: reason, user: @user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_json(user)
|
||||||
|
return if user.nil?
|
||||||
|
|
||||||
|
{ id: user.id, name: user.full_name }
|
||||||
|
end
|
||||||
|
|
||||||
|
def project_json(project)
|
||||||
|
{ id: project.id, code: project.code, name: project.name, description: project.description }
|
||||||
|
end
|
||||||
|
|
||||||
|
def organization_summary(org)
|
||||||
|
{
|
||||||
|
id: org.id,
|
||||||
|
name: org.name,
|
||||||
|
status: org.status,
|
||||||
|
status_label: org.status_label,
|
||||||
|
organization_type: org.organization_type,
|
||||||
|
organization_type_label: org.organization_type_label,
|
||||||
|
sport: org.sport,
|
||||||
|
city: org.city,
|
||||||
|
region: org.region,
|
||||||
|
country: org.country,
|
||||||
|
email: org.email,
|
||||||
|
phone: org.phone,
|
||||||
|
website: org.website,
|
||||||
|
lead_source: org.lead_source,
|
||||||
|
assigned_user: user_json(org.assigned_user)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def organization_json(org)
|
||||||
|
organization_summary(org).merge(
|
||||||
|
address: org.address,
|
||||||
|
province: org.province,
|
||||||
|
legal_name: org.legal_name,
|
||||||
|
vat_number: org.vat_number,
|
||||||
|
notes: org.notes,
|
||||||
|
commercial_fit: org.commercial_fit,
|
||||||
|
streaming_status: org.streaming_status,
|
||||||
|
team_gender: org.team_gender
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def contact_json(contact)
|
||||||
|
{
|
||||||
|
id: contact.id,
|
||||||
|
organization_id: contact.organization_id,
|
||||||
|
first_name: contact.first_name,
|
||||||
|
last_name: contact.last_name,
|
||||||
|
full_name: contact.full_name,
|
||||||
|
role: contact.role,
|
||||||
|
email: contact.email,
|
||||||
|
phone: contact.phone,
|
||||||
|
mobile: contact.mobile,
|
||||||
|
primary_contact: contact.primary_contact,
|
||||||
|
preferred_contact_method: contact.preferred_contact_method
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def opportunity_json(opportunity)
|
||||||
|
{
|
||||||
|
id: opportunity.id,
|
||||||
|
organization_id: opportunity.organization_id,
|
||||||
|
organization_name: opportunity.organization&.name,
|
||||||
|
project_id: opportunity.project_id,
|
||||||
|
name: opportunity.name,
|
||||||
|
pipeline_stage: opportunity.pipeline_stage,
|
||||||
|
pipeline_stage_label: opportunity.pipeline_stage_label,
|
||||||
|
estimated_value: opportunity.estimated_value,
|
||||||
|
probability: opportunity.probability,
|
||||||
|
product: opportunity.product,
|
||||||
|
lost_reason: opportunity.lost_reason,
|
||||||
|
notes: opportunity.notes,
|
||||||
|
assigned_user: user_json(opportunity.assigned_user),
|
||||||
|
stage_changed_at: opportunity.stage_changed_at&.iso8601,
|
||||||
|
expected_close_date: opportunity.expected_close_date&.iso8601
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def task_json(task)
|
||||||
|
{
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
description: task.description,
|
||||||
|
due_at: task.due_at&.iso8601,
|
||||||
|
priority: task.priority,
|
||||||
|
priority_label: task.priority_label,
|
||||||
|
task_type: task.task_type,
|
||||||
|
task_type_label: task.task_type_label,
|
||||||
|
status: task.status,
|
||||||
|
status_label: task.status_label,
|
||||||
|
organization_id: task.organization_id,
|
||||||
|
organization_name: task.organization&.name,
|
||||||
|
contact_id: task.contact_id,
|
||||||
|
opportunity_id: task.opportunity_id,
|
||||||
|
assigned_user: user_json(task.assigned_user),
|
||||||
|
completed_at: task.completed_at&.iso8601
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def activity_json(activity)
|
||||||
|
{
|
||||||
|
id: activity.id,
|
||||||
|
activity_type: activity.activity_type,
|
||||||
|
activity_type_label: activity.activity_type_label,
|
||||||
|
subject: activity.subject,
|
||||||
|
description: activity.description,
|
||||||
|
happened_at: activity.happened_at&.iso8601,
|
||||||
|
organization_id: activity.organization_id,
|
||||||
|
contact_id: activity.contact_id,
|
||||||
|
opportunity_id: activity.opportunity_id,
|
||||||
|
user: user_json(activity.user)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
class Dashboard::Metrics
|
class Dashboard::Metrics
|
||||||
|
FUNNEL_STAGES = (Catalog::OPEN_PIPELINE_STAGES + %w[won]).freeze
|
||||||
|
ATTENTION_PREVIEW = 8
|
||||||
|
|
||||||
def initialize(scope: Opportunity.all, project: nil)
|
def initialize(scope: Opportunity.all, project: nil)
|
||||||
@scope = scope
|
@scope = scope
|
||||||
@project = project
|
@project = project
|
||||||
@@ -21,7 +24,7 @@ class Dashboard::Metrics
|
|||||||
end
|
end
|
||||||
|
|
||||||
def conversion_rates
|
def conversion_rates
|
||||||
stages = Catalog::OPEN_PIPELINE_STAGES + %w[won]
|
stages = FUNNEL_STAGES
|
||||||
rates = {}
|
rates = {}
|
||||||
stages.each_cons(2) do |from, to|
|
stages.each_cons(2) do |from, to|
|
||||||
from_count = cumulative_reached(from)
|
from_count = cumulative_reached(from)
|
||||||
@@ -32,22 +35,29 @@ class Dashboard::Metrics
|
|||||||
end
|
end
|
||||||
|
|
||||||
def funnel_steps
|
def funnel_steps
|
||||||
counts = {
|
previous_reached = nil
|
||||||
"contacted" => reached_or_beyond("contacted"),
|
previous_label = nil
|
||||||
"replied" => reached_or_beyond("replied"),
|
|
||||||
"interested" => reached_or_beyond("interested"),
|
FUNNEL_STAGES.map do |stage|
|
||||||
"first_use" => reached_or_beyond("first_use"),
|
current = stage_counts[stage].to_i
|
||||||
"won" => @scope.won.count
|
reached = reached_or_beyond(stage)
|
||||||
|
label = Catalog.label_for(Catalog::PIPELINE_STAGES, stage)
|
||||||
|
step = {
|
||||||
|
stage: stage,
|
||||||
|
label: label,
|
||||||
|
current: current,
|
||||||
|
reached: reached
|
||||||
}
|
}
|
||||||
|
|
||||||
steps = []
|
if previous_reached&.positive?
|
||||||
previous = nil
|
step[:rate] = percentage(reached, previous_reached)
|
||||||
counts.each do |stage, count|
|
step[:conversion_label] = "#{reached} su #{previous_reached} hanno superato #{previous_label}"
|
||||||
rate = previous ? percentage(count, previous) : nil
|
end
|
||||||
steps << { stage: stage, label: Catalog.label_for(Catalog::PIPELINE_STAGES, stage), count: count, rate: rate }
|
|
||||||
previous = count
|
previous_reached = reached
|
||||||
|
previous_label = label
|
||||||
|
step
|
||||||
end
|
end
|
||||||
steps
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def avg_days_to_won
|
def avg_days_to_won
|
||||||
@@ -83,17 +93,15 @@ class Dashboard::Metrics
|
|||||||
end
|
end
|
||||||
|
|
||||||
def opportunities_without_next_action
|
def opportunities_without_next_action
|
||||||
opportunities_missing_next_action.count
|
in_trattativa_without_next_action.count
|
||||||
end
|
end
|
||||||
|
|
||||||
def demo_to_won_conversion
|
def demo_to_won_conversion
|
||||||
demo = reached_or_beyond("demo_trial")
|
stage_to_won_conversion("demo_trial")
|
||||||
percentage(@scope.won.count, demo)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def first_use_to_won_conversion
|
def first_use_to_won_conversion
|
||||||
first_use = reached_or_beyond("first_use")
|
stage_to_won_conversion("first_use")
|
||||||
percentage(@scope.won.count, first_use)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def arpa
|
def arpa
|
||||||
@@ -104,14 +112,16 @@ class Dashboard::Metrics
|
|||||||
end
|
end
|
||||||
|
|
||||||
def attention_items
|
def attention_items
|
||||||
|
overdue = tasks_scope.overdue.includes(:organization, :contact, :assigned_user).ordered
|
||||||
|
|
||||||
{
|
{
|
||||||
to_send_count: @scope.where(send_status: "to_send").count,
|
to_send_count: @scope.where(send_status: "to_send").count,
|
||||||
interested_without_followup: interested_without_followup,
|
interested_without_followup: preview_attention(interested_without_followup_scope.includes(:organization)),
|
||||||
stalled_opportunities: stalled_opportunities,
|
stalled_opportunities: preview_attention(stalled_opportunities_scope),
|
||||||
overdue_tasks: tasks_scope.overdue.includes(:organization, :contact, :assigned_user).ordered.limit(20),
|
overdue_tasks: preview_attention(overdue),
|
||||||
organizations_without_contacts: organizations_scope.left_joins(:contacts).where(contacts: { id: nil }).limit(20),
|
organizations_without_contacts: preview_attention(organizations_without_contacts_scope),
|
||||||
opportunities_without_value: advanced_open_stage.where(estimated_value: [nil, 0]).includes(:organization).limit(20),
|
opportunities_without_value: preview_attention(opportunities_without_value_scope),
|
||||||
opportunities_without_next_action: opportunities_missing_next_action.merge(advanced_open_stage).limit(20)
|
opportunities_without_next_action: preview_attention(in_trattativa_without_next_action.includes(:organization))
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -133,7 +143,7 @@ class Dashboard::Metrics
|
|||||||
idx = Catalog::PIPELINE_ORDER.index(stage)
|
idx = Catalog::PIPELINE_ORDER.index(stage)
|
||||||
return 0 unless idx
|
return 0 unless idx
|
||||||
|
|
||||||
stages = Catalog::PIPELINE_ORDER[idx..] - ["lost"]
|
stages = Catalog::PIPELINE_ORDER[idx..].without("lost")
|
||||||
@scope.where(pipeline_stage: stages).count
|
@scope.where(pipeline_stage: stages).count
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -143,25 +153,49 @@ class Dashboard::Metrics
|
|||||||
((part.to_f / whole) * 100).round
|
((part.to_f / whole) * 100).round
|
||||||
end
|
end
|
||||||
|
|
||||||
def interested_without_followup
|
def stage_to_won_conversion(stage)
|
||||||
base = @scope.where(pipeline_stage: "interested")
|
return nil if stage_counts[stage].to_i.zero? && @scope.won.count.zero?
|
||||||
with_pending = Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
|
|
||||||
base.where.not(id: with_pending).includes(:organization).limit(20)
|
reached = reached_or_beyond(stage)
|
||||||
|
return nil if reached.zero?
|
||||||
|
|
||||||
|
percentage(@scope.won.count, reached)
|
||||||
end
|
end
|
||||||
|
|
||||||
def stalled_opportunities
|
def interested_without_followup_scope
|
||||||
|
@scope.where(pipeline_stage: "interested").where.not(id: pending_opportunity_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def stalled_opportunities_scope
|
||||||
@scope.open_stage
|
@scope.open_stage
|
||||||
.where("stage_changed_at < ? OR (stage_changed_at IS NULL AND opportunities.created_at < ?)", 7.days.ago, 7.days.ago)
|
.where("stage_changed_at < ? OR (stage_changed_at IS NULL AND opportunities.created_at < ?)", 7.days.ago, 7.days.ago)
|
||||||
.includes(:organization, :assigned_user)
|
.includes(:organization, :assigned_user)
|
||||||
.limit(20)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def opportunities_missing_next_action
|
def organizations_without_contacts_scope
|
||||||
with_pending = Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
|
organizations_scope.left_joins(:contacts).where(contacts: { id: nil })
|
||||||
@scope.open_stage.where.not(id: with_pending).includes(:organization)
|
end
|
||||||
|
|
||||||
|
def opportunities_without_value_scope
|
||||||
|
advanced_open_stage.where("estimated_value IS NULL OR estimated_value = 0").includes(:organization)
|
||||||
|
end
|
||||||
|
|
||||||
|
def in_trattativa_without_next_action
|
||||||
|
advanced_open_stage.where.not(id: pending_opportunity_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def pending_opportunity_ids
|
||||||
|
Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def advanced_open_stage
|
def advanced_open_stage
|
||||||
@scope.open_stage.where.not(pipeline_stage: "to_contact")
|
@scope.open_stage.where.not(pipeline_stage: "to_contact")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def preview_attention(relation)
|
||||||
|
total = relation.unscope(:includes, :preload, :eager_load, :order, :limit).count
|
||||||
|
total = total.size if total.is_a?(Hash)
|
||||||
|
|
||||||
|
{ items: relation.limit(ATTENTION_PREVIEW).to_a, total: total }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,5 +28,13 @@
|
|||||||
<p class="mt-4 text-sm text-zinc-700 dark:text-zinc-300"><%= @mail_identities_count %> account</p>
|
<p class="mt-4 text-sm text-zinc-700 dark:text-zinc-300"><%= @mail_identities_count %> account</p>
|
||||||
<div class="mt-4 text-sm font-medium">Configura SMTP →</div>
|
<div class="mt-4 text-sm font-medium">Configura SMTP →</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to api_tokens_path, class: "rounded-2xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-6 transition hover:border-zinc-400 dark:hover:border-zinc-500" do %>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-zinc-400">Agenti</div>
|
||||||
|
<h2 class="mt-2 text-xl font-semibold tracking-tight">Token API</h2>
|
||||||
|
<p class="mt-2 text-sm text-zinc-500">Autenticazione Bearer per Cursor, Claude Code e Codex. Il valore in chiaro si vede una sola volta.</p>
|
||||||
|
<p class="mt-4 text-sm text-zinc-700 dark:text-zinc-300"><%= @api_tokens_count %> attivi</p>
|
||||||
|
<div class="mt-4 text-sm font-medium">Gestisci token →</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight">Token API</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
|
Servono agli agenti (Cursor, Claude Code, Codex) per lavorare sul CRM. Il valore in chiaro si vede una sola volta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @revealed_token.present? %>
|
||||||
|
<div class="rounded-xl border border-amber-300 bg-amber-50 p-5 text-amber-950 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-100">
|
||||||
|
<p class="text-sm font-semibold">Nuovo token — copialo ora</p>
|
||||||
|
<p class="mt-1 text-xs">Non verrà più mostrato. Incollalo in <code>CRM_API_TOKEN</code> nella config MCP.</p>
|
||||||
|
<pre class="mt-3 overflow-x-auto rounded-lg bg-white px-3 py-2 text-sm text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100"><%= @revealed_token %></pre>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<section class="<%= card_class %> p-5">
|
||||||
|
<h2 class="text-lg font-semibold tracking-tight">Nuovo token</h2>
|
||||||
|
<%= form_with url: api_tokens_path, method: :post, class: "mt-4 flex flex-col gap-3 sm:flex-row sm:items-end" do %>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="mb-1 block text-xs font-medium text-zinc-500" for="name">Nome</label>
|
||||||
|
<%= text_field_tag :name, nil, placeholder: "es. Cursor sul portatile", required: true, class: input_class %>
|
||||||
|
</div>
|
||||||
|
<%= submit_tag "Crea token", class: "#{btn_primary} sm:mb-0" %>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="<%= card_class %>">
|
||||||
|
<% if @api_tokens.empty? %>
|
||||||
|
<p class="p-5 text-sm text-zinc-500">Nessun token attivo.</p>
|
||||||
|
<% else %>
|
||||||
|
<ul class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
|
<% @api_tokens.each do |token| %>
|
||||||
|
<li class="flex flex-wrap items-center justify-between gap-3 p-4">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium"><%= token.name %></div>
|
||||||
|
<div class="mt-0.5 text-xs text-zinc-500">
|
||||||
|
<%= token.masked %>
|
||||||
|
· creato <%= format_dt(token.created_at) %>
|
||||||
|
<% if token.last_used_at %>
|
||||||
|
· usato <%= format_dt(token.last_used_at) %>
|
||||||
|
<% else %>
|
||||||
|
· mai usato
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= button_to "Revoca", api_token_path(token), method: :delete, class: "#{btn_danger} text-sm", data: { turbo_confirm: "Revocare questo token?" } %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<% items = items.to_a %>
|
<% items = items.to_a %>
|
||||||
|
<% total = local_assigns.fetch(:total) { items.size } %>
|
||||||
<% empty_class = "mt-2 text-sm text-amber-900/75 dark:text-amber-100/75" %>
|
<% empty_class = "mt-2 text-sm text-amber-900/75 dark:text-amber-100/75" %>
|
||||||
<% if items.blank? %>
|
<% if total.to_i.zero? %>
|
||||||
<p class="<%= empty_class %>"><%= empty %></p>
|
<p class="<%= empty_class %>"><%= empty %></p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<ul class="mt-2 space-y-1 text-sm">
|
<ul class="mt-2 space-y-1 text-sm">
|
||||||
<% items.first(8).each do |item| %>
|
<% items.each do |item| %>
|
||||||
<li>
|
<li>
|
||||||
<% if block_given? %>
|
<% if block_given? %>
|
||||||
<%= yield item %>
|
<%= yield item %>
|
||||||
@@ -13,8 +14,8 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if items.size > 8 %>
|
<% if total > items.size %>
|
||||||
<li class="text-xs text-amber-900/70 dark:text-amber-100/70">+ altre <%= items.size - 8 %></li>
|
<li class="text-xs text-amber-900/70 dark:text-amber-100/70">+ altre <%= total - items.size %></li>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
<h2 class="mb-3 text-lg font-semibold">Cosa devo fare oggi</h2>
|
<h2 class="mb-3 text-lg font-semibold">Cosa devo fare oggi</h2>
|
||||||
<div class="grid gap-4 lg:grid-cols-3">
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-rose-700">In ritardo (<%= @overdue_tasks.size %>)</h3>
|
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-rose-700 dark:text-rose-400">In ritardo (<%= @overdue_tasks.size %>)</h3>
|
||||||
<%= render "shared/task_list", tasks: @overdue_tasks %>
|
<%= render "shared/task_list", tasks: @overdue_tasks %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-amber-700">Da fare oggi (<%= @today_tasks.size %>)</h3>
|
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-400">Da fare oggi (<%= @today_tasks.size %>)</h3>
|
||||||
<%= render "shared/task_list", tasks: @today_tasks %>
|
<%= render "shared/task_list", tasks: @today_tasks %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -36,9 +36,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
<section>
|
||||||
|
<p class="mb-3 text-sm text-slate-500">Conteggio per stage attuale: quante opportunità sono in quello stato ora.</p>
|
||||||
|
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||||
<% [
|
<% [
|
||||||
["Prospect totali", @metrics.prospect_count],
|
["Prospect totali", @metrics.prospect_count],
|
||||||
|
["Da contattare", @stage_counts["to_contact"]],
|
||||||
["Contattati", @stage_counts["contacted"]],
|
["Contattati", @stage_counts["contacted"]],
|
||||||
["Risposte", @stage_counts["replied"]],
|
["Risposte", @stage_counts["replied"]],
|
||||||
["Interessati", @stage_counts["interested"]],
|
["Interessati", @stage_counts["interested"]],
|
||||||
@@ -52,23 +55,28 @@
|
|||||||
["Task scaduti", @metrics.overdue_tasks_count]
|
["Task scaduti", @metrics.overdue_tasks_count]
|
||||||
].each do |label, value| %>
|
].each do |label, value| %>
|
||||||
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-4">
|
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-4">
|
||||||
<div class="text-xs font-medium uppercase tracking-wide text-slate-500"><%= label %></div>
|
<div class="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"><%= label %></div>
|
||||||
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= value %></div>
|
<div class="mt-1 text-2xl font-semibold tabular-nums"><%= value %></div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid gap-4 lg:grid-cols-2">
|
<section class="grid gap-4 lg:grid-cols-2">
|
||||||
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-5">
|
<div class="rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 p-5">
|
||||||
<h2 class="text-lg font-semibold">Funnel commerciale</h2>
|
<h2 class="text-lg font-semibold">Funnel commerciale</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Numero grande: in questo stage ora. In piccolo: arrivati almeno a questo punto (stage attuale + successivi, esclusi i persi).</p>
|
||||||
<div class="mt-4 space-y-2">
|
<div class="mt-4 space-y-2">
|
||||||
<% @funnel.each_with_index do |step, idx| %>
|
<% @funnel.each do |step| %>
|
||||||
<% if step[:rate] %>
|
<% if step[:conversion_label] %>
|
||||||
<div class="text-center text-xs font-medium text-slate-500">↓ <%= step[:rate] %>%</div>
|
<div class="text-center text-xs font-medium text-slate-500"><%= step[:conversion_label] %> (<%= step[:rate] %>%)</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="flex items-center justify-between rounded-lg bg-zinc-50 px-4 py-3 dark:bg-zinc-800">
|
<div class="flex items-center justify-between rounded-lg bg-zinc-50 px-4 py-3 dark:bg-zinc-800">
|
||||||
<span class="font-medium text-zinc-800 dark:text-zinc-100"><%= step[:label].upcase %></span>
|
<span class="font-medium text-zinc-800 dark:text-zinc-100"><%= step[:label].upcase %></span>
|
||||||
<span class="text-xl font-semibold tabular-nums"><%= step[:count] %></span>
|
<span class="text-right">
|
||||||
|
<span class="text-xl font-semibold tabular-nums"><%= step[:current] %></span>
|
||||||
|
<span class="ml-2 text-xs font-medium text-slate-500"><%= step[:reached] %> da qui in poi</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,10 +87,10 @@
|
|||||||
<dl class="mt-4 space-y-3 text-sm break-anywhere">
|
<dl class="mt-4 space-y-3 text-sm break-anywhere">
|
||||||
<div class="flex justify-between"><dt class="text-slate-500">Giorni medi contatto → WON</dt><dd class="font-semibold"><%= @metrics.avg_days_to_won || "—" %></dd></div>
|
<div class="flex justify-between"><dt class="text-slate-500">Giorni medi contatto → WON</dt><dd class="font-semibold"><%= @metrics.avg_days_to_won || "—" %></dd></div>
|
||||||
<div class="flex justify-between"><dt class="text-slate-500">Giorni medi dall'ultima attività</dt><dd class="font-semibold"><%= @metrics.avg_days_since_last_activity || "—" %></dd></div>
|
<div class="flex justify-between"><dt class="text-slate-500">Giorni medi dall'ultima attività</dt><dd class="font-semibold"><%= @metrics.avg_days_since_last_activity || "—" %></dd></div>
|
||||||
<div class="flex justify-between"><dt class="text-slate-500">Prospect senza attività > 7gg</dt><dd class="font-semibold"><%= @metrics.prospects_without_activity_over_7_days %></dd></div>
|
<div class="flex justify-between"><dt class="text-slate-500">Senza attività in timeline da > 7 giorni</dt><dd class="font-semibold"><%= @metrics.prospects_without_activity_over_7_days %></dd></div>
|
||||||
<div class="flex justify-between"><dt class="text-slate-500">Opp. senza next action</dt><dd class="font-semibold"><%= @metrics.opportunities_without_next_action %></dd></div>
|
<div class="flex justify-between"><dt class="text-slate-500">In trattativa senza next action</dt><dd class="font-semibold"><%= @metrics.opportunities_without_next_action %></dd></div>
|
||||||
<div class="flex justify-between"><dt class="text-slate-500">Demo/Trial → WON</dt><dd class="font-semibold"><%= @metrics.demo_to_won_conversion %>%</dd></div>
|
<div class="flex justify-between"><dt class="text-slate-500">Demo/Trial → WON</dt><dd class="font-semibold"><%= format_conversion_rate(@metrics.demo_to_won_conversion) %></dd></div>
|
||||||
<div class="flex justify-between"><dt class="text-slate-500">First Use → WON</dt><dd class="font-semibold"><%= @metrics.first_use_to_won_conversion %>%</dd></div>
|
<div class="flex justify-between"><dt class="text-slate-500">First Use → WON</dt><dd class="font-semibold"><%= format_conversion_rate(@metrics.first_use_to_won_conversion) %></dd></div>
|
||||||
<div class="flex justify-between"><dt class="text-slate-500">ARPA (WON)</dt><dd class="font-semibold"><%= format_money(@metrics.arpa) %></dd></div>
|
<div class="flex justify-between"><dt class="text-slate-500">ARPA (WON)</dt><dd class="font-semibold"><%= format_money(@metrics.arpa) %></dd></div>
|
||||||
<div class="flex justify-between"><dt class="text-slate-500">Pipeline value</dt><dd class="font-semibold"><%= format_money(@metrics.open_pipeline_value) %></dd></div>
|
<div class="flex justify-between"><dt class="text-slate-500">Pipeline value</dt><dd class="font-semibold"><%= format_money(@metrics.open_pipeline_value) %></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -111,40 +119,40 @@
|
|||||||
|
|
||||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Interessati senza follow-up</h3>
|
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Interessati senza follow-up (<%= @attention[:interested_without_followup][:total] %>)</h3>
|
||||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Hanno detto sì in linea: manca la prossima chiamata/email in agenda.</p>
|
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Hanno detto sì in linea: manca la prossima chiamata/email in agenda.</p>
|
||||||
<%= render "dashboard/attention_list", items: @attention[:interested_without_followup], empty: "Nessuno: nessun interessato scoperto." %>
|
<%= render "dashboard/attention_list", items: @attention[:interested_without_followup][:items], total: @attention[:interested_without_followup][:total], empty: "Nessuno: nessun interessato scoperto." %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Opportunità ferme da oltre 7 giorni</h3>
|
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Opportunità ferme da oltre 7 giorni (<%= @attention[:stalled_opportunities][:total] %>)</h3>
|
||||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Lo stage non si è mosso: va rilanciato o chiuso come perso.</p>
|
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Lo stage non si è mosso: va rilanciato o chiuso come perso.</p>
|
||||||
<%= render "dashboard/attention_list", items: @attention[:stalled_opportunities], empty: "Nessuna: nessuna trattativa ferma." do |opp| %>
|
<%= render "dashboard/attention_list", items: @attention[:stalled_opportunities][:items], total: @attention[:stalled_opportunities][:total], empty: "Nessuna: nessuna trattativa ferma." do |opp| %>
|
||||||
<%= link_to "#{opp.organization.name} (#{opp.days_in_current_stage}gg)", opp.organization, class: "hover:underline" %>
|
<%= link_to "#{opp.organization.name} (#{opp.days_in_current_stage}gg)", opp.organization, class: "hover:underline" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Task scaduti</h3>
|
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Task scaduti (<%= @attention[:overdue_tasks][:total] %>)</h3>
|
||||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Promemoria già passati: completarli o ripianificarli.</p>
|
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Promemoria già passati: completarli o ripianificarli.</p>
|
||||||
<%= render "dashboard/attention_list", items: @attention[:overdue_tasks], empty: "Nessuno: nessun task in ritardo." do |task| %>
|
<%= render "dashboard/attention_list", items: @attention[:overdue_tasks][:items], total: @attention[:overdue_tasks][:total], empty: "Nessuno: nessun task in ritardo." do |task| %>
|
||||||
<%= link_to "#{task.organization.name}: #{task.title}", task.organization, class: "hover:underline" %>
|
<%= link_to "#{task.organization.name}: #{task.title}", task.organization, class: "hover:underline" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza contatto</h3>
|
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza contatto (<%= @attention[:organizations_without_contacts][:total] %>)</h3>
|
||||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Manca una persona a cui scrivere o telefonare.</p>
|
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Manca una persona a cui scrivere o telefonare.</p>
|
||||||
<%= render "dashboard/attention_list", items: @attention[:organizations_without_contacts], empty: "Nessuna: tutte hanno un contatto." do |org| %>
|
<%= render "dashboard/attention_list", items: @attention[:organizations_without_contacts][:items], total: @attention[:organizations_without_contacts][:total], empty: "Nessuna: tutte hanno un contatto." do |org| %>
|
||||||
<%= link_to org.name, org, class: "hover:underline" %>
|
<%= link_to org.name, org, class: "hover:underline" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza valore (già in trattativa)</h3>
|
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza valore (già in trattativa) (<%= @attention[:opportunities_without_value][:total] %>)</h3>
|
||||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Dopo il primo contatto serve un importo stimato (Light/Full…) per la pipeline.</p>
|
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Dopo il primo contatto serve un importo stimato (Light/Full…) per la pipeline.</p>
|
||||||
<%= render "dashboard/attention_list", items: @attention[:opportunities_without_value], empty: "Nessuna in trattativa senza valore." %>
|
<%= render "dashboard/attention_list", items: @attention[:opportunities_without_value][:items], total: @attention[:opportunities_without_value][:total], empty: "Nessuna in trattativa senza valore." %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza prossima azione (già in trattativa)</h3>
|
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Senza prossima azione (già in trattativa) (<%= @attention[:opportunities_without_next_action][:total] %>)</h3>
|
||||||
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Dopo l’invio o la risposta manca un task in agenda: la trattativa si ferma.</p>
|
<p class="mt-0.5 text-xs text-amber-900/70 dark:text-amber-100/70">Dopo l’invio o la risposta manca un task in agenda: la trattativa si ferma.</p>
|
||||||
<%= render "dashboard/attention_list", items: @attention[:opportunities_without_next_action], empty: "Nessuna in trattativa senza next action." %>
|
<%= render "dashboard/attention_list", items: @attention[:opportunities_without_next_action][:items], total: @attention[:opportunities_without_next_action][:total], empty: "Nessuna in trattativa senza next action." %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<h1 class="text-2xl font-semibold">Anteprima import</h1>
|
<h1 class="text-2xl font-semibold">Anteprima import</h1>
|
||||||
<div class="overflow-x-auto rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100">
|
<div class="overflow-x-auto rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100">
|
||||||
<table class="min-w-full text-xs">
|
<table class="min-w-full text-xs">
|
||||||
<thead class="bg-slate-50">
|
<thead class="bg-zinc-50 dark:bg-zinc-800">
|
||||||
<tr><% @preview[:headers].each do |h| %><th class="px-2 py-2 text-left"><%= h %></th><% end %></tr>
|
<tr><% @preview[:headers].each do |h| %><th class="px-2 py-2 text-left"><%= h %></th><% end %></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
<% if mailing.draft? && mailing.tested? %>
|
<% if mailing.draft? && mailing.tested? %>
|
||||||
<div class="mt-1 text-xs text-emerald-700 dark:text-emerald-300">Prova ok</div>
|
<div class="mt-1 text-xs text-emerald-700 dark:text-emerald-300">Prova ok</div>
|
||||||
<% elsif mailing.draft? %>
|
<% elsif mailing.draft? %>
|
||||||
<div class="mt-1 text-xs text-zinc-500">Manca prova</div>
|
<div class="mt-1 text-xs text-amber-700 dark:text-amber-400">Manca prova</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-zinc-500">
|
<td class="px-4 py-3 text-sm text-zinc-500">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<%= 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 :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 :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 %>
|
<%= 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 %>
|
<%= select_tag :sport, options_for_select(@sport_options, params[:sport]), include_blank: "Sport", class: input_class %>
|
||||||
<%= text_field_tag :country, params[:country], placeholder: "Paese", 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 %>
|
<%= 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 :customer, options_for_select([["Cliente/non cliente", ""], ["Solo clienti", "yes"], ["Non clienti", "no"]], params[:customer]), class: input_class %>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
<%= contact.full_name %>
|
<%= contact.full_name %>
|
||||||
<% if contact.primary_contact? %><span class="ml-1 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-emerald-800">Principale</span><% end %>
|
<% if contact.primary_contact? %><span class="ml-1 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">Principale</span><% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="break-anywhere text-sm text-slate-500"><%= [contact.role, contact.email, contact.phone.presence || contact.mobile].compact_blank.join(" · ") %></div>
|
<div class="break-anywhere text-sm text-slate-500"><%= [contact.role, contact.email, contact.phone.presence || contact.mobile].compact_blank.join(" · ") %></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,22 +8,35 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left text-xs uppercase tracking-wide text-zinc-500">
|
<tr class="text-left text-xs uppercase tracking-wide text-zinc-500">
|
||||||
<th class="py-2">Stage</th>
|
<th class="py-2">Stage</th>
|
||||||
<th>Count</th>
|
<th>In questo stage</th>
|
||||||
|
<th>Da qui in poi</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
<% @funnel[:stages].each do |s| %>
|
<% @funnel[:funnel].each do |step| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="py-2"><%= s[:label] %></td>
|
<td class="py-2"><%= step[:label] %></td>
|
||||||
<td class="font-semibold"><%= s[:count] %></td>
|
<td class="font-semibold"><%= step[:current] %></td>
|
||||||
|
<td><%= step[:reached] %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
<% lost = @funnel[:stages].find { |s| s[:stage] == "lost" } %>
|
||||||
|
<% if lost %>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2"><%= lost[:label] %></td>
|
||||||
|
<td class="font-semibold"><%= lost[:count] %></td>
|
||||||
|
<td>—</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-zinc-500">In questo stage = foto attuale. Da qui in poi = stage attuale + successivi, esclusi i persi.</p>
|
||||||
<div class="mt-4 space-y-1 text-sm text-zinc-600 dark:text-zinc-300">
|
<div class="mt-4 space-y-1 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
<% @funnel[:funnel].each do |step| %>
|
<% @funnel[:funnel].each do |step| %>
|
||||||
<div><%= step[:label] %>: <strong class="text-zinc-900 dark:text-zinc-50"><%= step[:count] %></strong><% if step[:rate] %> (<%= step[:rate] %>% dal passo precedente)<% end %></div>
|
<% if step[:conversion_label] %>
|
||||||
|
<div><%= step[:conversion_label] %> <strong class="text-zinc-900 dark:text-zinc-50">(<%= step[:rate] %>%)</strong></div>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<li><%= link_to "Email e campagne", dashboard_mailings_path, class: "hover:underline" %></li>
|
<li><%= link_to "Email e campagne", dashboard_mailings_path, class: "hover:underline" %></li>
|
||||||
<li><%= link_to "Import CSV organizzazioni", new_import_path, class: "hover:underline" %></li>
|
<li><%= link_to "Import CSV organizzazioni", new_import_path, class: "hover:underline" %></li>
|
||||||
|
<li><%= link_to "Token API (agenti)", api_tokens_path, class: "hover:underline" %></li>
|
||||||
<li><%= link_to "Cambio password", edit_password_path, class: "hover:underline" %></li>
|
<li><%= link_to "Cambio password", edit_password_path, class: "hover:underline" %></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100">
|
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100">
|
||||||
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||||
<% tasks.each do |task| %>
|
<% tasks.each do |task| %>
|
||||||
<div class="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center <%= 'bg-rose-50' if task.overdue? %> <%= 'bg-amber-50' if task.due_today? %>">
|
<div class="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center <%= 'bg-rose-50 dark:bg-rose-950' if task.overdue? %> <%= 'bg-amber-50 dark:bg-amber-950' if task.due_today? && !task.overdue? %>">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<%= link_to task.organization.name, task.organization, class: "font-medium text-zinc-900 hover:underline dark:text-zinc-100" %>
|
<%= link_to task.organization.name, task.organization, class: "font-medium text-zinc-900 hover:underline dark:text-zinc-100" %>
|
||||||
<%= priority_badge(task.priority) %>
|
<%= priority_badge(task.priority) %>
|
||||||
<span class="text-xs text-zinc-500"><%= task.task_type_label %></span>
|
<span class="text-xs text-zinc-500 dark:text-zinc-400"><%= task.task_type_label %></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 text-sm text-zinc-700 dark:text-zinc-200"><%= task.title %></div>
|
<div class="mt-0.5 text-sm text-zinc-700 dark:text-zinc-200"><%= task.title %></div>
|
||||||
<div class="mt-0.5 text-xs text-zinc-500">
|
<div class="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
<%= task.contact&.full_name || "—" %> · <%= format_dt(task.due_at) %>
|
<%= task.contact&.full_name || "—" %> · <%= format_dt(task.due_at) %>
|
||||||
<% if task.assigned_user %>· <%= task.assigned_user.full_name %><% end %>
|
<% if task.assigned_user %>· <%= task.assigned_user.full_name %><% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
<div class="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||||
<%= 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" %>
|
<%= 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 dark:bg-emerald-700 dark:hover:bg-emerald-600" %>
|
||||||
<%= link_to "Modifica", edit_task_path(task), 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" %>
|
<%= link_to "Modifica", edit_task_path(task), class: "rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-500 dark:bg-zinc-700 dark:text-zinc-50 dark:hover:bg-zinc-600" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if tasks.blank? %>
|
<% if tasks.blank? %>
|
||||||
<div class="px-4 py-6 text-center text-sm text-slate-500">Nessun task</div>
|
<div class="px-4 py-6 text-center text-sm text-zinc-500 dark:text-zinc-400">Nessun task</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<%= link_to "Impostazioni", admin_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
<%= link_to "Impostazioni", admin_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
||||||
<%= link_to "Utenti", users_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
<%= link_to "Utenti", users_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= link_to "Token API", api_tokens_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
||||||
<%= link_to "Password", edit_password_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
<%= link_to "Password", edit_password_path, class: "block px-4 py-2.5 text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
||||||
<%= button_to "Esci", logout_path, method: :delete, class: "block w-full px-4 py-2.5 text-left text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
<%= button_to "Esci", logout_path, method: :delete, class: "block w-full px-4 py-2.5 text-left text-sm text-zinc-700 hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800" %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
<%= link_to "Nuovo task", new_task_path, class: "#{btn_primary} w-full sm:w-auto" %>
|
<%= link_to "Nuovo task", new_task_path, class: "#{btn_primary} w-full sm:w-auto" %>
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-2 text-sm font-semibold uppercase text-rose-700">In ritardo</h2>
|
<h2 class="mb-2 text-sm font-semibold uppercase text-rose-700 dark:text-rose-400">In ritardo</h2>
|
||||||
<%= render "shared/task_list", tasks: @overdue_tasks %>
|
<%= render "shared/task_list", tasks: @overdue_tasks %>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-2 text-sm font-semibold uppercase text-amber-700">Oggi</h2>
|
<h2 class="mb-2 text-sm font-semibold uppercase text-amber-700 dark:text-amber-400">Oggi</h2>
|
||||||
<%= render "shared/task_list", tasks: @today_tasks %>
|
<%= render "shared/task_list", tasks: @today_tasks %>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-rose-700">In ritardo</h2>
|
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-rose-700 dark:text-rose-400">In ritardo</h2>
|
||||||
<%= render "shared/task_list", tasks: @overdue_tasks %>
|
<%= render "shared/task_list", tasks: @overdue_tasks %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-amber-700">Oggi</h2>
|
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-400">Oggi</h2>
|
||||||
<%= render "shared/task_list", tasks: @today_tasks %>
|
<%= render "shared/task_list", tasks: @today_tasks %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
||||||
|
require "bundler/setup"
|
||||||
|
load File.expand_path("../mcp/server.rb", __dir__)
|
||||||
@@ -4,5 +4,6 @@
|
|||||||
# Use this to limit dissemination of sensitive information.
|
# Use this to limit dissemination of sensitive information.
|
||||||
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
|
||||||
Rails.application.config.filter_parameters += [
|
Rails.application.config.filter_parameters += [
|
||||||
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
|
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc,
|
||||||
|
:authorization, :plaintext
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ it:
|
|||||||
mail_template: Template email
|
mail_template: Template email
|
||||||
mailing: Invio email
|
mailing: Invio email
|
||||||
mailing_recipient: Destinatario
|
mailing_recipient: Destinatario
|
||||||
|
api_token: Token API
|
||||||
attributes:
|
attributes:
|
||||||
organization:
|
organization:
|
||||||
name: Nome
|
name: Nome
|
||||||
|
|||||||
@@ -22,6 +22,24 @@ Rails.application.routes.draw do
|
|||||||
resources :projects, only: %i[create edit update destroy]
|
resources :projects, only: %i[create edit update destroy]
|
||||||
resources :users, except: %i[show]
|
resources :users, except: %i[show]
|
||||||
resources :mail_identities, except: %i[show]
|
resources :mail_identities, except: %i[show]
|
||||||
|
resources :api_tokens, only: %i[index create destroy]
|
||||||
|
match "/mcp", to: "mcp#handle", via: %i[get post delete]
|
||||||
|
|
||||||
|
namespace :api do
|
||||||
|
namespace :v1 do
|
||||||
|
get "projects", to: "projects#index"
|
||||||
|
|
||||||
|
scope "/p/:project_code" do
|
||||||
|
get "today", to: "today#show"
|
||||||
|
get "search", to: "search#show"
|
||||||
|
get "organizations/:id", to: "organizations#show", as: :organization
|
||||||
|
post "tasks", to: "tasks#create"
|
||||||
|
post "tasks/:id/complete", to: "tasks#complete", as: :complete_task
|
||||||
|
post "activities", to: "activities#create"
|
||||||
|
patch "opportunities/:id/stage", to: "opportunities#update_stage", as: :opportunity_stage
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Ogni progetto = istanza CRM dedicata
|
# Ogni progetto = istanza CRM dedicata
|
||||||
scope "/p/:project_code" do
|
scope "/p/:project_code" do
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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,ABSOLUTE S.G. LA LOGGIA,M,carlo.ferrero727@gmail.com,absolute5.it,NON RILEVATO,Società iscritta alla Serie C1 Piemonte 2025/26.,A,DA INVIARE,,,NO,NO,,sprintesport.it,2026-09-06,Email trovata su articolo del 2021
|
||||||
|
2,Piemonte,TO,ACADEMY TORINO FUTSAL SSD,M,info@topfive.torino.it,topfive.torino.it,NON RILEVATO,Iscritta alla Serie C1 Piemonte 2025/26.,B,DA INVIARE,,,NO,NO,,topfive.torino.it,2026-09-06,Contatto tramite TOP FIVE
|
||||||
|
3,Piemonte,TO,ATLETICO TAURINENSE,M,lapaglia.roberto@libero.it,atleticotaurinense.it,NON RILEVATO,Iscritta alla Serie C1 Piemonte 2025/26.,A,DA INVIARE,,,NO,NO,,uisp.it,2026-09-06,Email dal referente Roberto Lapaglia
|
||||||
|
4,Piemonte,TO,CASTELLAMONTE CALCIO A5,M,vernit@libero.it,castellamontecalcioacinque.it,NON RILEVATO,Iscritta alla Serie C1 Piemonte 2025/26.,B,DA INVIARE,,,NO,NO,,unionevallesacra.to.it,2026-09-06,
|
||||||
|
5,Piemonte,TO,C.U.S. PIEMONTE ORIENTALE,M,info@cuspo.it,cuspo.it,NON RILEVATO,Iscritta alla Serie C1 Piemonte 2025/26.,A,DA INVIARE,,,NO,NO,,cuspo.it,2026-09-06,Da verificare sul sito
|
||||||
|
7,Piemonte,TO,FUTSAL SAVIGLIANO,M,info@futsalsavigliano.com,futsalsavigliano.com,NON RILEVATO,Iscritta alla Serie C1 Piemonte 2025/26.,A,DA INVIARE,,,NO,NO,,piemontevda.lnd.it,2026-09-06,
|
||||||
|
10,Piemonte,TO,POLISPORTIVA DRUENTO ASD,M,asdpolisportivadruento@gmail.com,polisportivadruento.it,NON RILEVATO,Iscritta alla Serie C1 Piemonte 2025/26.,B,DA INVIARE,,,NO,NO,,prd-trasparenza-crp.portali.csi.it,2026-09-06,
|
||||||
|
11,Piemonte,TO,TOP FIVE S.R.L.,M,info@topfive.torino.it,topfive.torino.it,NON RILEVATO,Società organizzatrice di campionati.,A,DA INVIARE,,,NO,NO,,topfive.torino.it,2026-09-06,
|
||||||
|
12,Piemonte,TO,A.S.D. STEAUA TORINO,M,info@topfive.torino.it,topfive.torino.it,NON RILEVATO,Iscritta alla Serie C2 Piemonte 2025/26.,B,DA INVIARE,,,NO,NO,,topfive.torino.it,2026-09-06,Contatto tramite TOP FIVE
|
||||||
|
13,Piemonte,TO,AREA CALCIO ALBA ROERO,M,bessonealessio88@gmail.com,areacalcioalbaroero.it,NON RILEVATO,Iscritta alla Serie C2 Piemonte 2025/26.,A,DA INVIARE,,,NO,NO,,sprintesport.it,2026-09-06,
|
||||||
|
14,Piemonte,TO,AURORA NICHELINO,M,info@topfive.torino.it,topfive.torino.it,NON RILEVATO,Iscritta alla Serie C2 Piemonte 2025/26.,B,DA INVIARE,,,NO,NO,,topfive.torino.it,2026-09-06,Contatto tramite TOP FIVE
|
||||||
|
18,Piemonte,TO,LEINI 96,M,info@topfive.torino.it,topfive.torino.it,NON RILEVATO,Iscritta alla Serie C2 Piemonte 2025/26.,B,DA INVIARE,,,NO,NO,,topfive.torino.it,2026-09-06,Contatto tramite TOP FIVE
|
||||||
|
20,Piemonte,TO,POLISPORTIVA PASTA,M,futsal@polisportivapasta.org,polisportivapasta.org,NON RILEVATO,Iscritta alla Serie C2 Piemonte 2025/26.,B,DA INVIARE,,,NO,NO,,polisportivapasta.org,2026-09-06,
|
||||||
|
21,Piemonte,TO,PRO IVREA CALCIO S.C.,M,ivrea@lega-calcio-serie-c.it,ivreacalcio.it,NON RILEVATO,Iscritta alla Serie C2 Piemonte 2025/26.,A,DA INVIARE,,,NO,NO,,lega-pro.com,2026-09-06,
|
||||||
|
22,Piemonte,TO,U.S. BORGONUOVO SETTIMO,M,info@borgonuovocalcio5.it,borgonuovocalcio5.it,NON RILEVATO,Iscritta alla Serie C2 Piemonte 2025/26.,B,DA INVIARE,,,NO,NO,,uisp.it,2026-09-06,
|
||||||
|
23,Piemonte,TO,VALDOSTANA,M,asdvaldostana@gmail.com,asdvaldostana.it,NON RILEVATO,Iscritta alla Serie C2 Piemonte 2025/26.,A,DA INVIARE,,,NO,NO,,golee.it,2026-09-06,
|
||||||
|
25,Piemonte,TO,A.S.D. VAIA F.C.,M,info@vaiafc.it,vaiafc.it,NON RILEVATO,Società di calcio a 5 piemontese.,A,DA INVIARE,,,NO,NO,,vaiafc.it,2026-09-06,
|
||||||
|
26,Piemonte,TO,JUVENTUS DOMO,M,usdjuvedomo1929@libero.it,piemontevda.lnd.it,NON RILEVATO,Società piemontese.,B,DA INVIARE,,,NO,NO,,piemontevda.lnd.it,2026-09-06,
|
||||||
|
27,Piemonte,TO,A.S.D. PAVAROLO SPORT APS,M,asdpavorolocalcio@gmail.com,piemontevda.lnd.it,NON RILEVATO,Società piemontese.,A,DA INVIARE,,,NO,NO,,piemontevda.lnd.it,2026-09-06,
|
||||||
|
28,Piemonte,TO,SERMIG,M,palasermig@sermig.org,divisionecalcioa5.it,SÌ,Canale YouTube ufficiale con contenuti.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
29,Liguria,GE,AIROLE F.C.,M,info@airolefc.it,liguria.lnd.it,NON RILEVATO,Società finalista Coppa Italia Serie C Liguria 2025/26.,A,DA INVIARE,,,NO,NO,,liguria.lnd.it,2026-09-06,Da verificare
|
||||||
|
30,Liguria,GE,GENOVA CALCIO A CINQUE,M,info@genovacalcioacinque.it,liguria.lnd.it,NON RILEVATO,Finalista Coppa Italia Serie C Liguria 2025/26.,B,DA INVIARE,,,NO,NO,,liguria.lnd.it,2026-09-06,Da verificare
|
||||||
|
31,Liguria,GE,Calcio Liguria S.S.D.,M,info@calcioliguria.com,calcioliguria.com,NON RILEVATO,Società organizzatrice di campionati liguri.,A,DA INVIARE,,,NO,NO,,calcioliguria.com,2026-09-06,
|
||||||
|
32,Liguria,GE,POLISPORTIVA PSM RAPALLO,M/F,info@psmrapallo.it,coni.it,NON RILEVATO,Polisportiva ligure con sezione futsal.,B,DA INVIARE,,,NO,NO,,coni.it,2026-09-06,Da verificare
|
||||||
|
33,Liguria,SV,VIRTUS DON BOSCO S.S.D. SRL,M,info@virtusdonbosco.it,coni.it,NON RILEVATO,Società ligure con disciplina futsal.,A,DA INVIARE,,,NO,NO,,coni.it,2026-09-06,Da verificare
|
||||||
|
34,Marche,AN,ACSS MONDOLFO C5,M,info@acssmondolfoc5.it,figcmarche.it,NON RILEVATO,Vincitrice Coppa Disciplina Serie C1 Marche 2025/26.,B,DA INVIARE,,,NO,NO,,figcmarche.it,2026-09-06,Da verificare
|
||||||
|
35,Marche,AN,A.S.D. MONTECOSARO,M,info@montecosarocalcioa5.it,figcmarche.it,NON RILEVATO,Iscritta alla Serie C1 Marche 2025/26.,A,DA INVIARE,,,NO,NO,,figcmarche.it,2026-09-06,Da verificare
|
||||||
|
36,Marche,AN,U.S. FORTUNA FANO,M,info@fortunafano.it,figcmarche.it,NON RILEVATO,Iscritta alla Serie C2 Marche 2025/26.,B,DA INVIARE,,,NO,NO,,figcmarche.it,2026-09-06,Da verificare
|
||||||
|
37,Marche,AN,A.S.D. Futsal Caselle,M,futsalcaselle@gmail.com,consultadellosport.it,NON RILEVATO,Società di calcio a 5 marchigiana.,A,DA INVIARE,,,NO,NO,,consultadellosport.it,2026-09-06,
|
||||||
|
38,Marche,AP,ASD Monteprandone,M,asd.monteprandone@gmail.com,consultadellosport.it,NON RILEVATO,Società marchigiana di futsal.,B,DA INVIARE,,,NO,NO,,consultadellosport.it,2026-09-06,
|
||||||
|
39,Marche,MC,Maran Nursia,M,hogworts@libero.it,pallaalcentro.org,NON RILEVATO,Società di calcio a 5 marchigiana.,A,DA INVIARE,,,NO,NO,,pallaalcentro.org,2026-09-06,
|
||||||
|
40,Marche,MC,Recanati Calcio a 5,M,ing.graziano.bravi@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
41,Umbria,PG,A.S.D. MONTELEONE,M,info@monteleonecalcioa5.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,A,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
42,Umbria,PG,A.S.D. AVVERA GUBBIO,M,info@avveragubbio.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,B,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
43,Umbria,PG,A.S.D. CTS GRAFICA CALCIO A 5,M,info@ctsgrafica.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,A,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
44,Umbria,PG,A.S.D. DUCATO SPOLETO,M,info@ducatospoleto.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,B,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
45,Umbria,PG,A.S.D. FUTSAL LIDARNO,M,info@futsallidarno.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,A,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
46,Umbria,PG,A.S.D. GADTCH 2000,M,info@gadtch2000.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,B,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
47,Umbria,PG,MIRIANO CALCIO A 5,M,info@mirianocalcioa5.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,A,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
48,Umbria,PG,A.S.D. MONTEGABBIONE,M,info@montegabbione.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,B,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
49,Umbria,PG,SSDARL POLISPORTIVA TERNANA,M,info@polisportivaternana.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,A,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
50,Umbria,PG,A.S.D. REAL CANNARA CALCIO A 5,M,info@realcannara.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,B,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
51,Umbria,PG,A.S.D. RIVO SUBASIO,M,info@rivosubasio.it,figc-cru.com,NON RILEVATO,Iscritta alla Serie C1 Umbria 2025/26.,A,DA INVIARE,,,NO,NO,,figc-cru.com,2026-09-06,Da verificare
|
||||||
|
52,Umbria,PG,Foligno Calcio a 5,M,info@folignocalcioa5.it,umbriain.it,NON RILEVATO,Partecipa al campionato nazionale di Serie B.,B,DA INVIARE,,,NO,NO,,umbriain.it,2026-09-06,
|
||||||
|
54,Abruzzo,TE,ATLETICO SILVI FUTSAL,M,info@atleticosilvifutsal.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,B,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
55,Abruzzo,AQ,CALCETTO AVEZZANO,M,info@calcettoavezzano.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,A,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
56,Abruzzo,CH,FUTSAL VASTO,M,info@futsalvasto.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,B,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
57,Abruzzo,TE,HATRIA TEAM CALCIO A 5,M,info@hatriateam.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,A,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
58,Abruzzo,CH,GIOVENTU' BIANCOROSSA,M,info@gioventubiancorossa.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,B,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
59,Abruzzo,CH,LA FENICE CALCIO A 5,M,info@lafenicecalcioa5.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,A,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
60,Abruzzo,CH,MIGLIANISPORT,M,info@miglianisport.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,B,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
61,Abruzzo,CH,MINERVA C5,M,info@minervac5.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,A,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
62,Abruzzo,PE,MONTESILVANO CALCIO A 5,M,info@montesilvanocalcioa5.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,B,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
63,Abruzzo,TE,REAL DEM CALCIO A 5,M,info@realdemcalcioa5.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,A,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
64,Abruzzo,CH,RIPA TEATINA,M,info@ripateatina.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,B,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
65,Abruzzo,AQ,SCANNO CALCIO A 5,M,info@scannocalcioa5.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,A,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
66,Abruzzo,TE,SGT SPORT SSD ARL,M,info@sgt-sport.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Abruzzo 2025/26.,B,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
67,Abruzzo,TE,ATLETICO SAN NICOLÒ 1968,M,info@atleticosannicolo.it,abruzzo.lnd.it,NON RILEVATO,Iscritta alla Serie C2 Abruzzo 2025/26.,A,DA INVIARE,,,NO,NO,,abruzzo.lnd.it,2026-09-06,Da verificare
|
||||||
|
68,Abruzzo,CH,ES Chieti Calcio a 5,M,segreteria@escalcioa5.it,eschieticalcioa5.it,NON RILEVATO,Società con sede a Chieti.,B,DA INVIARE,,,NO,NO,,eschieticalcioa5.it,2026-09-06,
|
||||||
|
69,Abruzzo,CH,ASD Futsal Treglio,M,futsaltreglio@gmail.com,comune.treglio.chieti.it,NON RILEVATO,Società abruzzese di calcio a 5.,A,DA INVIARE,,,NO,NO,,Comune di Treglio,2026-09-06,
|
||||||
|
70,Puglia,BT,Itria Football Club,M,itria.fc@libero.it,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali 2025/26.,B,DA INVIARE,,,NO,NO,,divisionecalcioa5.it,2026-09-06,
|
||||||
|
71,Emilia-Romagna,MO,S.S. Pro Patria S. Felice A.S.D.,M,propatriacalcioa5@gmail.com,emiliaromagnasport.com,NON RILEVATO,"Società con lunga tradizione, attiva in Serie C1.",A,DA INVIARE,,,NO,NO,,emiliaromagnasport.com,2026-09-06,
|
||||||
|
72,Emilia-Romagna,PC,A.S.D. Corte Calcio,M,cortecalcio@libero.it,emiliaromagnasport.com,NON RILEVATO,Iscritta alla Serie D 2025/26.,B,DA INVIARE,,,NO,NO,,emiliaromagnasport.com,2026-09-06,
|
||||||
|
73,Basilicata,PZ,Città di Potenza,M,asdpotenzacalcioa5@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati 2025/26.,A,DA INVIARE,,,NO,NO,,divisionecalcioa5.it,2026-09-06,
|
||||||
|
74,Nazionale,-,Active Network Futsal,M,ACTIVENETWORKFUTSAL@GMAIL.COM,divisionecalcioa5.it,LIMITATO,Società di Serie A. Trasmette su canale Divisione C5.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
75,Calabria,CS,ASD Pirossigeno Cosenza,M,cittadicosenzacalcio5@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società di Serie A.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
76,Campania,BN,Benevento 5,M,benevento5@virgilio.it,divisionecalcioa5.it,LIMITATO,Società di Serie A. Trasmette su canale Divisione C5.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
77,Puglia,FG,Calcio a 5 Manfredonia,M,SEGRETERIA@MANFREDONIAC5.IT,divisionecalcioa5.it,SÌ,Canale YouTube attivo (ManfredoniaC5TV) con dirette.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
78,Veneto,TV,Came Treviso CS,M,info@camecs.it,divisionecalcioa5.it,LIMITATO,Società di Serie A. Trasmette su canale Divisione C5. | Società attiva in campionati veneti.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
79,Lazio,RM,Ecocity Futsal Genzano,M,info@ecocityfutsal.it,ecocityfutsal.it,LIMITATO,Società attiva 2025/26. Trasmette su canale Divisione. | PEC della società. Trasmette su canale Divisione.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,Email aggiuntiva: amministrazione@pec.ecocityfutsal.it
|
||||||
|
80,Campania,SA,Feldi Eboli ARL,M,segreteria@feldieboli.it,divisionecalcioa5.it,NON RILEVATO,Società di Serie A.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
81,Lazio,RM,Fortitudo Pomezia1957,M,fortitudopomezia1957@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
82,Veneto,VI,Città di Thiene,M/F,info@cittadithiene.it,calcioa5sinnai.it,NON RILEVATO,Società con squadra femminile in Serie A2.,B,DA INVIARE,,,NO,NO,,calcioa5sinnai.it,2026-09-06,
|
||||||
|
83,Sicilia,PA,Monreale Calcio a 5,M,monrealecalcioa5@gmail.com,calcioa5sinnai.it,NON RILEVATO,Iscritta alla Serie B.,A,DA INVIARE,,,NO,NO,,calcioa5sinnai.it,2026-09-06,
|
||||||
|
84,Veneto,RO,ASD GS Ariano,M,sgarianocalcioa5@gmail.com,figcvenetocalcio.it,NON RILEVATO,Iscritta alla Serie D Veneto 2025/26.,B,DA INVIARE,,,NO,NO,,Annuario FIGC Veneto,2026-09-06,
|
||||||
|
85,Veneto,PD,SSDARL Calcio Padova C5,M,info@calciopadovac5.it,figcvenetocalcio.it,SÌ,Canale YouTube attivo (CalcioPadova1910) con contenuti.,A,DA INVIARE,,,NO,NO,,Annuario FIGC Veneto,2026-09-06,
|
||||||
|
86,Veneto,RO,Cathedral Academy A.S.D,M/F,amministrazione@cathedralacademy.it,figcvenetocalcio.it,NON RILEVATO,Società con attività giovanile e squadra in Serie D.,B,DA INVIARE,,,NO,NO,,Annuario FIGC Veneto,2026-09-06,
|
||||||
|
87,Toscana,SI,Alberino G.S. A.S.D.,M,info@gsalberino.it,toscana.lnd.it,SÌ,Canale YouTube ufficiale indicato sul sito.,A,DA INVIARE,,,NO,NO,,LND Toscana,2026-09-06,
|
||||||
|
88,Toscana,FI,Atletico Fucecchio,M,info@atleticofucecchio.it,toscana.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Toscana.,B,DA INVIARE,,,NO,NO,,LND Toscana,2026-09-06,
|
||||||
|
89,Toscana,PI,C.U.S. PISA,M,segreteria@cuspisa.it,toscana.lnd.it,NON RILEVATO,Società universitaria iscritta alla Serie C1.,A,DA INVIARE,,,NO,NO,,LND Toscana,2026-09-06,
|
||||||
|
90,Toscana,FI,Calcio a Cinque Remole,M,edelsol@rag-delsoldato.it,toscana.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Toscana.,B,DA INVIARE,,,NO,NO,,LND Toscana,2026-09-06,
|
||||||
|
91,Toscana,MS,Città di Massa Calcio a 5,M,cittadimassacalcioa5@gmail.com,toscana.lnd.it,LIMITATO,Trasmette su Facebook.,A,DA INVIARE,,,NO,NO,,LND Toscana,2026-09-06,
|
||||||
|
92,Toscana,FI,Deportivo Chiesanuova VP,M,deportivochiesanuova@hotmail.it,toscana.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Toscana.,B,DA INVIARE,,,NO,NO,,LND Toscana,2026-09-06,
|
||||||
|
93,Toscana,LU,Futsal Lucchese A.S.D.,M,futsal.lucchese@gmail.com,toscana.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Toscana.,A,DA INVIARE,,,NO,NO,,LND Toscana,2026-09-06,
|
||||||
|
94,Toscana,SI,IBS Le Crete,M,asdlcreterapolano@libero.it,toscana.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Toscana.,B,DA INVIARE,,,NO,NO,,LND Toscana,2026-09-06,
|
||||||
|
95,Toscana,LI,LA 10 SOCCER,M,info@la10soccer.it,toscana.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Toscana.,A,DA INVIARE,,,NO,NO,,LND Toscana,2026-09-06,
|
||||||
|
96,Toscana,PT,Monsummano Calcioacinqu,M,info@monsummano-c5.org,toscana.lnd.it,NON RILEVATO,Iscritta alla Serie C1 Toscana.,B,DA INVIARE,,,NO,NO,,LND Toscana,2026-09-06,
|
||||||
|
97,Valle d'Aosta,AO,Aosta Calcio 511,M/F,aostacalcio511@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società Under 19 Femminile 2025/26. | Società valdostana.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
98,Emilia-Romagna,BO,Futsal Molinella 2018,M/F,futsalmolinella@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società Under 19 Femminile 2025/26.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
99,Lombardia,MI,Kick Off CS Femminile,F,kickoffc5@hotmail.it,divisionecalcioa5.it,NON RILEVATO,Società di calcio a 5 femminile.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
100,Valle d'Aosta,AO,U.S. Saint Pierre,M/F,segreteriaussp@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società Under 19 Femminile.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
101,Veneto,VR,1998 Audace CS Verona,M/F,audacec5.vr@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società Under 19 Femminile.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
102,Toscana,GR,ASD Follonica Gavorrano SDRL,M/F,info@gavrornacolcio.it,toscana.lnd.it,NON RILEVATO,Iscritta al campionato femminile Serie C Toscana.,B,DA INVIARE,,,NO,NO,,LND Toscana Femm.,2026-09-06,
|
||||||
|
104,Toscana,PI,AS Santa Maria a Monte,M/F,s.mariaamonte@gmail.com,toscana.lnd.it,NON RILEVATO,Iscritta al campionato femminile Serie C Toscana.,B,DA INVIARE,,,NO,NO,,LND Toscana Femm.,2026-09-06,
|
||||||
|
105,Toscana,LU,ASD Atletico Viareggio,M/F,atletico.viareggio@gmail.com,toscana.lnd.it,SÌ,Canale YouTube con l'inno della squadra.,A,DA INVIARE,,,NO,NO,,LND Toscana Femm.,2026-09-06,
|
||||||
|
106,Toscana,FI,ASD GF Rione,M/F,giocafirenze@gmail.com,toscana.lnd.it,NON RILEVATO,Iscritta al campionato femminile Serie C Toscana.,B,DA INVIARE,,,NO,NO,,LND Toscana Femm.,2026-09-06,
|
||||||
|
107,Toscana,LI,ASD Isola d'Elba Futsal Club,M/F,isoladelbafutsal@gmail.com,toscana.lnd.it,NON RILEVATO,Iscritta al campionato femminile Serie C Toscana.,A,DA INVIARE,,,NO,NO,,LND Toscana Femm.,2026-09-06,
|
||||||
|
108,Toscana,SI,ASD Olimpiacolle,M/F,funsportweb@gmail.com,toscana.lnd.it,NON RILEVATO,Iscritta al campionato femminile Serie C Toscana.,B,DA INVIARE,,,NO,NO,,LND Toscana Femm.,2026-09-06,
|
||||||
|
109,Toscana,PT,ASD Pistoia Women C5,F,pistoiawomenc5@gmail.com,toscana.lnd.it,NON RILEVATO,Società di calcio a 5 femminile.,A,DA INVIARE,,,NO,NO,,LND Toscana Femm.,2026-09-06,
|
||||||
|
110,Toscana,AR,ASD San Giovanni Calcio a 5,M,mirkobossini@hotmail.it,toscana.lnd.it,NON RILEVATO,Iscritta al campionato femminile Serie C Toscana.,B,DA INVIARE,,,NO,NO,,LND Toscana Femm.,2026-09-06,
|
||||||
|
111,Emilia-Romagna,BO,A.S.D. Fossolo 76,M,segreteria@fossolo76.it,emiliaromagnasport.com,NON RILEVATO,Iscritta alla Serie C1 Emilia-Romagna.,A,DA INVIARE,,,NO,NO,,emiliaromagnasport.com,2026-09-06,
|
||||||
|
112,Emilia-Romagna,FC,A.S.D. Calcio a 5 Forlì,M,calcioacinqueforli@libero.it,emiliaromagnasport.com,SÌ,Canale YouTube attivo con dirette delle partite.,B,DA INVIARE,,,NO,NO,,emiliaromagnasport.com,2026-09-06,
|
||||||
|
113,Emilia-Romagna,RE,Guastalla Calcio,M,info@guastallacalcio.it,romagnasport.com,NON RILEVATO,"Società storica, attiva in Serie C1.",A,DA INVIARE,,,NO,NO,,romagnasport.com,2026-09-06,
|
||||||
|
114,Emilia-Romagna,FC,F.C. Young Santarcangelo S.S.D.,M,info@santarcangelocalcio.com,marchesport.info,NON RILEVATO,Iscritta alla Serie C2.,B,DA INVIARE,,,NO,NO,,marchesport.info,2026-09-06,
|
||||||
|
115,Emilia-Romagna,FC,A.S.D. Polisportiva 1980,M,polisportiva1980@gmail.com,emiliaromagnasport.com,NON RILEVATO,Iscritta alla Serie C2 2025/26.,A,DA INVIARE,,,NO,NO,,emiliaromagnasport.com,2026-09-06,
|
||||||
|
116,Lombardia,BG,Bergamo Calcio a 5,M,INFO@ASBERGAMOC5.IT,divisionecalcioa5.it,NON RILEVATO,Società di calcio a 5 con sede a Bergamo.,B,DA INVIARE,,,NO,NO,,divisionecalcioa5.it,2026-09-06,
|
||||||
|
118,Emilia-Romagna,BO,A.S.D. Serraglio,M,asd.serraglio@alice.it,emiliaromagnasport.com,NON RILEVATO,Società attiva in campionati regionali.,B,DA INVIARE,,,NO,NO,,emiliaromagnasport.com,2026-09-06,
|
||||||
|
119,Toscana,FI,Isolotto Fondiaria,M,info@isolottocalcioa5.it,pallaalcentro.org,NON RILEVATO,Società con sede a Firenze.,A,DA INVIARE,,,NO,NO,,pallaalcentro.org,2026-09-06,
|
||||||
|
120,Toscana,FI,Firenze C5,M/F,luca.mondi@firenzec5.it,toscana.lnd.it,SÌ,Iscritta al campionato femminile Serie C Toscana. | Canale YouTube (Firenze C5 Channel) con video delle partite.,B,DA INVIARE,,,NO,NO,,pallaalcentro.org,2026-09-06,Email aggiuntiva: firenzec5@girezec5.it
|
||||||
|
121,Lazio,RM,SS Lazio Calcio a 5,M/F,info@sslaziocalcioa5.it,sslaziocalcioa5.it,NON RILEVATO,Società di vertice del calcio a 5 laziale. | Contatto per affiliazioni e collaborazioni.,A,DA INVIARE,,,NO,NO,,sslaziocalcioa5.it,2026-09-06,Email aggiuntiva: affiliazioni@sslaziocalcioa5.it
|
||||||
|
123,Sicilia,AG,Atletico Canicattì,M,futsalcanicatti5@gmail.com,calcioa5sinnai.it,NON RILEVATO,Società attiva in campionati nazionali.,A,DA INVIARE,,,NO,NO,,calcioa5sinnai.it,2026-09-06,
|
||||||
|
124,Piemonte,TO,A.S.D. G.S.S.Torino,M,gsst.calcioa5@hotmail.it,gsstorino.it,NON RILEVATO,Società con attività di calcio a 5.,B,DA INVIARE,,,NO,NO,,gsstorino.it,2026-09-06,
|
||||||
|
126,Nazionale,-,Conti Calcio A5,M,cistemacalcioa5@libero.it,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,B,DA INVIARE,,,NO,NO,,divisionecalcioa5.it,2026-09-06,
|
||||||
|
127,Toscana,PT,Futsal Pistoia,M/F,info@futsalpistoia.it,pallaalcentro.org,NON RILEVATO,Società con sede a Pistoia.,A,DA INVIARE,,,NO,NO,,pallaalcentro.org,2026-09-06,
|
||||||
|
128,Emilia-Romagna,FC,Energy Saving Futsal,M,info@energysavingfutsal.com,divisionecalcioa5.it,NON RILEVATO,Società attiva 2025/26.,B,DA INVIARE,,,NO,NO,,divisionecalcioa5.it,2026-09-06,
|
||||||
|
130,Puglia,BA,Sport Five Putignano,M,sportfiveputignano@gmail.com,comune.putignano.ba.it,NON RILEVATO,Società dedicata al futsal.,B,DA INVIARE,,,NO,NO,,Comune di Putignano,2026-09-06,
|
||||||
|
131,Emilia-Romagna,RE,Olimpia Regium,M,sagabeppe@alice.it,emiliaromagnasport.com,SÌ,Partite trasmesse in diretta sul canale YouTube della squadra.,A,DA INVIARE,,,NO,NO,,emiliaromagnasport.com,2026-09-06,
|
||||||
|
132,Veneto,PD,A.P. Due Stelle A.S.D.,M,apduestelle@gmail.com,Annuario FIGC Veneto,NON RILEVATO,Iscritta alla Serie D veneto.,B,DA INVIARE,,,NO,NO,,Annuario FIGC Veneto,2026-09-06,
|
||||||
|
133,Veneto,PD,Futsal Este SME 1999,M,futsalsme1999@gmail.com,Annuario FIGC Veneto,NON RILEVATO,Iscritta alla Serie D veneto.,A,DA INVIARE,,,NO,NO,,Annuario FIGC Veneto,2026-09-06,
|
||||||
|
134,Veneto,VE,A.S.D. Gazzera Olimpia Chirignago,M/F,segreteria@calciogoc.it,Annuario FIGC Veneto,NON RILEVATO,Società con sede a Mestre.,B,DA INVIARE,,,NO,NO,,Annuario FIGC Veneto,2026-09-06,
|
||||||
|
135,Trentino,TN,A.S.D. Cembra Futsal,M,cembrafutsal@gmail.com,comune.cembralisignago.tn.it,NON RILEVATO,Società attiva in Trentino.,A,DA INVIARE,,,NO,NO,,Comune Cembra Lisignago,2026-09-06,
|
||||||
|
136,Friuli,PN,ASD Naonis Futsal,M,paolo.dirosa@gmail.com,comune.pordenone.it,NON RILEVATO,Società con sede a Pordenone.,B,DA INVIARE,,,NO,NO,,Comune di Pordenone,2026-09-06,
|
||||||
|
137,Campania,BN,A.S.D. Boca Futsal,M,bocafutsalts@gmail.com,comune.teleseterme.bn.it,NON RILEVATO,Società campana di futsal.,A,DA INVIARE,,,NO,NO,,Comune Telese Terme,2026-09-06,
|
||||||
|
138,Veneto,VE,A.s.d. Futsal Jesolo,M/F,terreoantonio@gmail.com,6sport.cittametropolitana.ve.it,NON RILEVATO,Società con attività giovanile.,B,DA INVIARE,,,NO,NO,,6sport,2026-09-06,
|
||||||
|
139,Lazio,RM,ASD Valentia,M,asdvalentia97@gmail.com,calcioa5live.com,NON RILEVATO,Società laziale di calcio a 5.,A,DA INVIARE,,,NO,NO,,calcioa5live.com,2026-09-06,
|
||||||
|
141,Veneto,VI,ASD Altovicentino Futsal,M,altovicentino.futsal@gmail.com,figcvenetocalcio.it,NON RILEVATO,Società con attività giovanile.,A,DA INVIARE,,,NO,NO,,Annuario FIGC Veneto,2026-09-06,
|
||||||
|
142,Veneto,PD,ASD Antenore Sport Padova,M,info@antenoresportpadova.com,figcvenetocalcio.it,NON RILEVATO,Società con attività giovanile.,B,DA INVIARE,,,NO,NO,,Annuario FIGC Veneto,2026-09-06,
|
||||||
|
143,Veneto,VI,Calcio 2000 Vicenza,M,calcio2000vicenza@gmail.com,figcvenetocalcio.it,NON RILEVATO,Società vicentina di calcio a 5.,A,DA INVIARE,,,NO,NO,,figcvenetocalcio.it,2026-09-06,
|
||||||
|
144,Emilia-Romagna,RE,Real Casalgrandese C5,M,realcasalgrandesec5@libero.it,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
145,Lazio,RM,Real Ciampino Academy,M,REALCIAMPINO.STAMPA@GMAIL.COM,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
146,Lombardia,MI,Real Five Rho,M,realfiverho@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
147,Lombardia,MI,Real Sesto,M,asdrealsesto@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
148,Basilicata,PZ,Senise,M,fcsenisea5@virgilio.it,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
149,Sicilia,EN,Sicurlube Futsal A R.L.,M,futsalregalbuto@virgilio.it,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
150,Puglia,FG,Spes PF Calcio a 5 A.S.D.,M,spescalcioa51@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
151,Emilia-Romagna,FC,Futsal Cesena,M,info@futsalcesena.it,romagnasport.com,SÌ,Canale YouTube attivo. Possibile interesse per soluzione più semplice.,A,DA INVIARE,,,NO,NO,,romagnasport.com,2026-09-06,
|
||||||
|
152,Emilia-Romagna,RN,Calcio a 5 Rimini,M,segreteria@calcioa5rimini.com,romagnasport.com,NON RILEVATO,Iscritta alla Serie C2 2025/26.,B,DA INVIARE,,,NO,NO,,romagnasport.com,2026-09-06,
|
||||||
|
153,Emilia-Romagna,FC,Sporting Valsanterno,M,sportingvalsanterno@gmail.com,romagnasport.com,NON RILEVATO,Iscritta alla Serie D.,A,DA INVIARE,,,NO,NO,,romagnasport.com,2026-09-06,
|
||||||
|
154,Emilia-Romagna,RE,Real Bubi Merano C5,M,info@realtubimerano.it,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
155,Puglia,BR,Real Carovigno,M,colucciandrealondon@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,A,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
156,Lazio,RM,Real Castel Fontana,M,castelfontana2004@gmail.com,divisionecalcioa5.it,NON RILEVATO,Società attiva in campionati nazionali.,B,DA INVIARE,,,NO,NO,,CU Divisione C5,2026-09-06,
|
||||||
|
158,Puglia,BR,ASD Futsal Cisternino,M,futsal.cisternino@libero.it,old.comunecisternino.it,NON RILEVATO,Società pugliese di futsal.,B,DA INVIARE,,,NO,NO,,Comune Cisternino,2026-09-06,
|
||||||
|
@@ -0,0 +1,16 @@
|
|||||||
|
class CreateApiTokens < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :api_tokens do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.string :name, null: false
|
||||||
|
t.string :token_digest, null: false
|
||||||
|
t.string :token_prefix, null: false
|
||||||
|
t.datetime :last_used_at
|
||||||
|
t.datetime :revoked_at
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :api_tokens, :token_digest, unique: true
|
||||||
|
add_index :api_tokens, :revoked_at
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+16
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2026_08_24_190000) do
|
ActiveRecord::Schema[8.1].define(version: 2026_09_02_220000) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
|
|
||||||
@@ -64,6 +64,20 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_24_190000) do
|
|||||||
t.index ["user_id"], name: "index_activities_on_user_id"
|
t.index ["user_id"], name: "index_activities_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "api_tokens", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "last_used_at"
|
||||||
|
t.string "name", null: false
|
||||||
|
t.datetime "revoked_at"
|
||||||
|
t.string "token_digest", null: false
|
||||||
|
t.string "token_prefix", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.bigint "user_id", null: false
|
||||||
|
t.index ["revoked_at"], name: "index_api_tokens_on_revoked_at"
|
||||||
|
t.index ["token_digest"], name: "index_api_tokens_on_token_digest", unique: true
|
||||||
|
t.index ["user_id"], name: "index_api_tokens_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "contacts", force: :cascade do |t|
|
create_table "contacts", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.bigint "created_by_id"
|
t.bigint "created_by_id"
|
||||||
@@ -384,6 +398,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_24_190000) do
|
|||||||
add_foreign_key "activities", "opportunities"
|
add_foreign_key "activities", "opportunities"
|
||||||
add_foreign_key "activities", "organizations"
|
add_foreign_key "activities", "organizations"
|
||||||
add_foreign_key "activities", "users"
|
add_foreign_key "activities", "users"
|
||||||
|
add_foreign_key "api_tokens", "users"
|
||||||
add_foreign_key "contacts", "organizations"
|
add_foreign_key "contacts", "organizations"
|
||||||
add_foreign_key "mail_templates", "projects"
|
add_foreign_key "mail_templates", "projects"
|
||||||
add_foreign_key "mailing_recipients", "contacts"
|
add_foreign_key "mailing_recipients", "contacts"
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "json"
|
||||||
|
require "mcp"
|
||||||
|
|
||||||
|
module EminuxCrmMcp
|
||||||
|
INSTRUCTIONS = <<~TEXT.freeze
|
||||||
|
Sei collegato a eminuxCRM. Quando l'utente chiede di lavorare su prospect, pipeline, task o follow-up, usa questi tool (non curl).
|
||||||
|
1. list_projects per i project_code (matchlivetv, riskmeter, cardoo, …).
|
||||||
|
2. today per capire cosa fare oggi.
|
||||||
|
3. search / get_organization per il contesto.
|
||||||
|
4. create_activity, complete_task, create_task, update_opportunity_stage per agire.
|
||||||
|
Non inviare mailing, non cancellare record, non gestire utenti.
|
||||||
|
TEXT
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def allowed_hosts
|
||||||
|
hosts = %w[localhost 127.0.0.1 [::1] web]
|
||||||
|
hosts.concat(configured_public_hosts)
|
||||||
|
hosts << "www.example.com" if defined?(Rails) && Rails.env.test?
|
||||||
|
hosts.map { |host| normalize_host(host) }.compact.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def configured_public_hosts
|
||||||
|
values = [ ENV["APP_HOST"] ]
|
||||||
|
values.concat(ENV.fetch("RAILS_ALLOWED_HOSTS", "").split(","))
|
||||||
|
values
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_host(value)
|
||||||
|
host = value.to_s.strip
|
||||||
|
return if host.blank?
|
||||||
|
|
||||||
|
host = host.sub(%r{\Ahttps?://}i, "")
|
||||||
|
host.split("/").first
|
||||||
|
end
|
||||||
|
|
||||||
|
def server_for(user:)
|
||||||
|
MCP::Server.new(
|
||||||
|
name: "eminuxcrm",
|
||||||
|
version: "1.0.0",
|
||||||
|
instructions: INSTRUCTIONS,
|
||||||
|
server_context: { user: user },
|
||||||
|
tools: [
|
||||||
|
ListProjects,
|
||||||
|
Today,
|
||||||
|
Search,
|
||||||
|
GetOrganization,
|
||||||
|
CreateTask,
|
||||||
|
CompleteTask,
|
||||||
|
CreateActivity,
|
||||||
|
UpdateOpportunityStage
|
||||||
|
]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def json_response(result)
|
||||||
|
MCP::Tool::Response.new(
|
||||||
|
[ { type: "text", text: JSON.pretty_generate(result.body.as_json) } ],
|
||||||
|
error: !result.ok
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def session_from(server_context)
|
||||||
|
user = extract_user(server_context)
|
||||||
|
raise "MCP senza utente autenticato" if user.nil?
|
||||||
|
|
||||||
|
Crm::AgentSession.new(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_user(ctx)
|
||||||
|
return if ctx.nil?
|
||||||
|
return ctx[:user] if ctx.is_a?(Hash)
|
||||||
|
return ctx.user if ctx.respond_to?(:user) && ctx.method(:user).arity.zero?
|
||||||
|
|
||||||
|
inner = ctx.instance_variable_get(:@context) if ctx.respond_to?(:instance_variable_get)
|
||||||
|
return inner[:user] if inner.is_a?(Hash)
|
||||||
|
|
||||||
|
ctx[:user] if ctx.respond_to?(:[])
|
||||||
|
end
|
||||||
|
|
||||||
|
class ListProjects < MCP::Tool
|
||||||
|
description "Elenca i progetti CRM accessibili all'utente del token (codici da usare negli altri tool)."
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context: nil)
|
||||||
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).projects)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Today < MCP::Tool
|
||||||
|
description "Cosa fare oggi: task scaduti, di oggi, in arrivo e opportunità ferme da 7+ giorni."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string", description: "Codice progetto, es. matchlivetv" }
|
||||||
|
},
|
||||||
|
required: %w[project_code]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, server_context: nil)
|
||||||
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).today(project_code))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Search < MCP::Tool
|
||||||
|
description "Cerca organizzazioni, contatti e opportunità nel progetto."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
q: { type: "string", description: "Testo di ricerca" }
|
||||||
|
},
|
||||||
|
required: %w[project_code q]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, q:, server_context: nil)
|
||||||
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).search(project_code, q))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class GetOrganization < MCP::Tool
|
||||||
|
description "Scheda organizzazione: anagrafica, contatti, opportunità aperte, task pending e ultime attività."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
id: { type: "integer", description: "ID organizzazione" }
|
||||||
|
},
|
||||||
|
required: %w[project_code id]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, id:, server_context: nil)
|
||||||
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).organization(project_code, id))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CreateTask < MCP::Tool
|
||||||
|
description "Crea un task (follow-up, chiamata, email, …) su un'organizzazione del progetto."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
organization_id: { type: "integer" },
|
||||||
|
title: { type: "string" },
|
||||||
|
due_at: { type: "string", description: "ISO8601, es. 2026-09-03T10:00:00+02:00" },
|
||||||
|
description: { type: "string" },
|
||||||
|
contact_id: { type: "integer" },
|
||||||
|
opportunity_id: { type: "integer" },
|
||||||
|
assigned_user_id: { type: "integer" },
|
||||||
|
priority: { type: "string", description: "low, normal, high, urgent" },
|
||||||
|
task_type: { type: "string", description: "follow_up, call, email, meeting, demo, proposal, generic" }
|
||||||
|
},
|
||||||
|
required: %w[project_code organization_id title due_at]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, organization_id:, title:, due_at:, description: nil, contact_id: nil,
|
||||||
|
opportunity_id: nil, assigned_user_id: nil, priority: nil, task_type: nil, server_context: nil)
|
||||||
|
attrs = {
|
||||||
|
organization_id: organization_id,
|
||||||
|
title: title,
|
||||||
|
due_at: due_at,
|
||||||
|
description: description,
|
||||||
|
contact_id: contact_id,
|
||||||
|
opportunity_id: opportunity_id,
|
||||||
|
assigned_user_id: assigned_user_id,
|
||||||
|
priority: priority,
|
||||||
|
task_type: task_type
|
||||||
|
}.compact
|
||||||
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).create_task(project_code, attrs))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CompleteTask < MCP::Tool
|
||||||
|
description "Segna un task come completato e registra l'attività in timeline."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
id: { type: "integer", description: "ID task" }
|
||||||
|
},
|
||||||
|
required: %w[project_code id]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, id:, server_context: nil)
|
||||||
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).complete_task(project_code, id))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CreateActivity < MCP::Tool
|
||||||
|
description "Annota un'attività in timeline (nota, chiamata, email inviata/ricevuta, demo, proposta, …)."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
organization_id: { type: "integer" },
|
||||||
|
activity_type: { type: "string", description: "note, email_sent, email_received, call, meeting, demo, follow_up, proposal_sent, other" },
|
||||||
|
subject: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
happened_at: { type: "string", description: "ISO8601; default ora" },
|
||||||
|
contact_id: { type: "integer" },
|
||||||
|
opportunity_id: { type: "integer" }
|
||||||
|
},
|
||||||
|
required: %w[project_code organization_id activity_type subject]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, organization_id:, activity_type:, subject:, description: nil, happened_at: nil,
|
||||||
|
contact_id: nil, opportunity_id: nil, server_context: nil)
|
||||||
|
attrs = {
|
||||||
|
organization_id: organization_id,
|
||||||
|
activity_type: activity_type,
|
||||||
|
subject: subject,
|
||||||
|
description: description,
|
||||||
|
happened_at: happened_at,
|
||||||
|
contact_id: contact_id,
|
||||||
|
opportunity_id: opportunity_id
|
||||||
|
}.compact
|
||||||
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).create_activity(project_code, attrs))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class UpdateOpportunityStage < MCP::Tool
|
||||||
|
description "Sposta un'opportunità di pipeline (to_contact, contacted, replied, interested, demo_trial, first_use, proposal, won, lost)."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
id: { type: "integer", description: "ID opportunità" },
|
||||||
|
pipeline_stage: { type: "string" },
|
||||||
|
lost_reason: { type: "string", description: "Obbligatorio se stage = lost" },
|
||||||
|
notes: { type: "string" }
|
||||||
|
},
|
||||||
|
required: %w[project_code id pipeline_stage]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, id:, pipeline_stage:, lost_reason: nil, notes: nil, server_context: nil)
|
||||||
|
EminuxCrmMcp.json_response(
|
||||||
|
EminuxCrmMcp.session_from(server_context).update_opportunity_stage(
|
||||||
|
project_code,
|
||||||
|
id,
|
||||||
|
pipeline_stage: pipeline_stage,
|
||||||
|
lost_reason: lost_reason,
|
||||||
|
notes: notes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -16,4 +16,32 @@ namespace :matchlivetv do
|
|||||||
abort "Import incompleto"
|
abort "Import incompleto"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
desc "Importa un foglio campagna senza cancellare le società già in CRM"
|
||||||
|
task import_campaign: :environment do
|
||||||
|
path = ENV["CAMPAIGN_CSV"].presence or abort "Imposta CAMPAIGN_CSV"
|
||||||
|
sport = ENV["SPORT"].presence or abort "Imposta SPORT (es. Calcio a 5)"
|
||||||
|
campaign = ENV["CAMPAIGN_NAME"].presence || "Campagna #{sport}"
|
||||||
|
user = User.find_by(email: "admin@simplecrm.local") || User.find_by!(role: "admin")
|
||||||
|
project = Project.find_by!(code: "matchlivetv")
|
||||||
|
|
||||||
|
puts "File: #{path}"
|
||||||
|
puts "Sport: #{sport}"
|
||||||
|
puts "Campagna: #{campaign}"
|
||||||
|
result = CampaignImport::MatchlivetvLaunch.new(
|
||||||
|
path: path,
|
||||||
|
user: user,
|
||||||
|
project: project,
|
||||||
|
wipe: false,
|
||||||
|
sport: sport,
|
||||||
|
campaign_name: campaign
|
||||||
|
).call
|
||||||
|
puts "Società importate: #{result.imported}"
|
||||||
|
puts "Duplicati saltati: #{result.skipped}"
|
||||||
|
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
|
end
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"eminuxcrm": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://localhost:3001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${CRM_API_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Codex legge ~/.codex/config.toml (o CODEX_HOME).
|
||||||
|
# Crea un token in CRM → Token API, poi:
|
||||||
|
|
||||||
|
[mcp_servers.eminuxcrm]
|
||||||
|
url = "http://localhost:3001/mcp"
|
||||||
|
bearer_token_env_var = "CRM_API_TOKEN"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"eminuxcrm": {
|
||||||
|
"url": "http://localhost:3001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${env:CRM_API_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "json"
|
||||||
|
require "net/http"
|
||||||
|
require "uri"
|
||||||
|
require "mcp"
|
||||||
|
|
||||||
|
module EminuxCrmMcp
|
||||||
|
module Client
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def crm_url
|
||||||
|
ENV.fetch("CRM_URL", "http://localhost:3001").to_s.sub(%r{/+\z}, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_token
|
||||||
|
ENV.fetch("CRM_API_TOKEN", "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def request(method, path, body: nil)
|
||||||
|
if api_token.strip.empty?
|
||||||
|
return error_payload("Imposta CRM_API_TOKEN (token creato in CRM → Token API).")
|
||||||
|
end
|
||||||
|
|
||||||
|
uri = URI.parse("#{crm_url}#{path}")
|
||||||
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
|
http.use_ssl = uri.scheme == "https"
|
||||||
|
http.open_timeout = 10
|
||||||
|
http.read_timeout = 30
|
||||||
|
|
||||||
|
request = http_class(method).new(uri)
|
||||||
|
request["Authorization"] = "Bearer #{api_token}"
|
||||||
|
request["Accept"] = "application/json"
|
||||||
|
if body
|
||||||
|
request["Content-Type"] = "application/json"
|
||||||
|
request.body = JSON.generate(body)
|
||||||
|
end
|
||||||
|
|
||||||
|
response = http.request(request)
|
||||||
|
payload = parse_json(response.body)
|
||||||
|
text = JSON.pretty_generate(payload)
|
||||||
|
MCP::Tool::Response.new([ { type: "text", text: text } ], error: response.code.to_i >= 400)
|
||||||
|
rescue StandardError => e
|
||||||
|
error_payload("#{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def http_class(method)
|
||||||
|
{
|
||||||
|
get: Net::HTTP::Get,
|
||||||
|
post: Net::HTTP::Post,
|
||||||
|
patch: Net::HTTP::Patch
|
||||||
|
}.fetch(method)
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_json(raw)
|
||||||
|
JSON.parse(raw.to_s)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{ error: "Risposta non JSON", body: raw.to_s[0, 2000] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def error_payload(message)
|
||||||
|
MCP::Tool::Response.new([ { type: "text", text: message } ], error: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def compact(hash)
|
||||||
|
hash.each_with_object({}) do |(key, value), acc|
|
||||||
|
acc[key] = value unless value.nil? || value == ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class ListProjects < MCP::Tool
|
||||||
|
description "Elenca i progetti CRM accessibili all'utente del token."
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context: nil)
|
||||||
|
Client.request(:get, "/api/v1/projects")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Today < MCP::Tool
|
||||||
|
description "Cosa fare oggi nel progetto: task scaduti, di oggi, in arrivo e opportunità ferme da 7+ giorni."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string", description: "Codice progetto, es. matchlivetv" }
|
||||||
|
},
|
||||||
|
required: %w[project_code]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, server_context: nil)
|
||||||
|
Client.request(:get, "/api/v1/p/#{encode(project_code)}/today")
|
||||||
|
end
|
||||||
|
|
||||||
|
def encode(value)
|
||||||
|
URI.encode_www_form_component(value.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Search < MCP::Tool
|
||||||
|
description "Cerca organizzazioni, contatti e opportunità nel progetto."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string", description: "Codice progetto, es. matchlivetv" },
|
||||||
|
q: { type: "string", description: "Testo di ricerca" }
|
||||||
|
},
|
||||||
|
required: %w[project_code q]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, q:, server_context: nil)
|
||||||
|
query = URI.encode_www_form(q: q)
|
||||||
|
Client.request(:get, "/api/v1/p/#{Today.encode(project_code)}/search?#{query}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class GetOrganization < MCP::Tool
|
||||||
|
description "Scheda organizzazione: anagrafica, contatti, opportunità aperte, task pending e ultime attività."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
id: { type: "integer", description: "ID organizzazione" }
|
||||||
|
},
|
||||||
|
required: %w[project_code id]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, id:, server_context: nil)
|
||||||
|
Client.request(:get, "/api/v1/p/#{Today.encode(project_code)}/organizations/#{id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CreateTask < MCP::Tool
|
||||||
|
description "Crea un task (follow-up, chiamata, email, …) su un'organizzazione del progetto."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
organization_id: { type: "integer" },
|
||||||
|
title: { type: "string" },
|
||||||
|
due_at: { type: "string", description: "ISO8601, es. 2026-09-03T10:00:00+02:00" },
|
||||||
|
description: { type: "string" },
|
||||||
|
contact_id: { type: "integer" },
|
||||||
|
opportunity_id: { type: "integer" },
|
||||||
|
assigned_user_id: { type: "integer" },
|
||||||
|
priority: { type: "string", description: "low, normal, high, urgent" },
|
||||||
|
task_type: { type: "string", description: "follow_up, call, email, meeting, demo, proposal, generic" }
|
||||||
|
},
|
||||||
|
required: %w[project_code organization_id title due_at]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, organization_id:, title:, due_at:, description: nil, contact_id: nil,
|
||||||
|
opportunity_id: nil, assigned_user_id: nil, priority: nil, task_type: nil, server_context: nil)
|
||||||
|
Client.request(
|
||||||
|
:post,
|
||||||
|
"/api/v1/p/#{Today.encode(project_code)}/tasks",
|
||||||
|
body: Client.compact(
|
||||||
|
organization_id: organization_id,
|
||||||
|
title: title,
|
||||||
|
due_at: due_at,
|
||||||
|
description: description,
|
||||||
|
contact_id: contact_id,
|
||||||
|
opportunity_id: opportunity_id,
|
||||||
|
assigned_user_id: assigned_user_id,
|
||||||
|
priority: priority,
|
||||||
|
task_type: task_type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CompleteTask < MCP::Tool
|
||||||
|
description "Segna un task come completato e registra l'attività in timeline."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
id: { type: "integer", description: "ID task" }
|
||||||
|
},
|
||||||
|
required: %w[project_code id]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, id:, server_context: nil)
|
||||||
|
Client.request(:post, "/api/v1/p/#{Today.encode(project_code)}/tasks/#{id}/complete")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CreateActivity < MCP::Tool
|
||||||
|
description "Annota un'attività in timeline (nota, chiamata, email inviata/ricevuta, demo, proposta, …)."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
organization_id: { type: "integer" },
|
||||||
|
activity_type: { type: "string", description: "note, email_sent, email_received, call, meeting, demo, follow_up, proposal_sent, other, …" },
|
||||||
|
subject: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
happened_at: { type: "string", description: "ISO8601; default ora" },
|
||||||
|
contact_id: { type: "integer" },
|
||||||
|
opportunity_id: { type: "integer" }
|
||||||
|
},
|
||||||
|
required: %w[project_code organization_id activity_type subject]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, organization_id:, activity_type:, subject:, description: nil, happened_at: nil,
|
||||||
|
contact_id: nil, opportunity_id: nil, server_context: nil)
|
||||||
|
Client.request(
|
||||||
|
:post,
|
||||||
|
"/api/v1/p/#{Today.encode(project_code)}/activities",
|
||||||
|
body: Client.compact(
|
||||||
|
organization_id: organization_id,
|
||||||
|
activity_type: activity_type,
|
||||||
|
subject: subject,
|
||||||
|
description: description,
|
||||||
|
happened_at: happened_at,
|
||||||
|
contact_id: contact_id,
|
||||||
|
opportunity_id: opportunity_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class UpdateOpportunityStage < MCP::Tool
|
||||||
|
description "Sposta un'opportunità di pipeline (to_contact, contacted, replied, interested, demo_trial, first_use, proposal, won, lost)."
|
||||||
|
input_schema(
|
||||||
|
properties: {
|
||||||
|
project_code: { type: "string" },
|
||||||
|
id: { type: "integer", description: "ID opportunità" },
|
||||||
|
pipeline_stage: { type: "string" },
|
||||||
|
lost_reason: { type: "string", description: "Obbligatorio se stage = lost" },
|
||||||
|
notes: { type: "string" }
|
||||||
|
},
|
||||||
|
required: %w[project_code id pipeline_stage]
|
||||||
|
)
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(project_code:, id:, pipeline_stage:, lost_reason: nil, notes: nil, server_context: nil)
|
||||||
|
Client.request(
|
||||||
|
:patch,
|
||||||
|
"/api/v1/p/#{Today.encode(project_code)}/opportunities/#{id}/stage",
|
||||||
|
body: Client.compact(
|
||||||
|
pipeline_stage: pipeline_stage,
|
||||||
|
lost_reason: lost_reason,
|
||||||
|
notes: notes
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
server = MCP::Server.new(
|
||||||
|
name: "eminuxcrm",
|
||||||
|
version: "1.0.0",
|
||||||
|
tools: [
|
||||||
|
EminuxCrmMcp::ListProjects,
|
||||||
|
EminuxCrmMcp::Today,
|
||||||
|
EminuxCrmMcp::Search,
|
||||||
|
EminuxCrmMcp::GetOrganization,
|
||||||
|
EminuxCrmMcp::CreateTask,
|
||||||
|
EminuxCrmMcp::CompleteTask,
|
||||||
|
EminuxCrmMcp::CreateActivity,
|
||||||
|
EminuxCrmMcp::UpdateOpportunityStage
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = MCP::Server::Transports::StdioTransport.new(server)
|
||||||
|
transport.open if $PROGRAM_NAME == __FILE__ || File.basename($PROGRAM_NAME.to_s) == "crm-mcp"
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Api::V1::ApiTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@admin_token = ApiToken.issue!(user: users(:admin), name: "test-admin")
|
||||||
|
@marco_token = ApiToken.issue!(user: users(:marco), name: "test-marco")
|
||||||
|
@org = organizations(:acme)
|
||||||
|
@task = tasks(:follow_up)
|
||||||
|
@opportunity = opportunities(:deal)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects missing token" do
|
||||||
|
get "/api/v1/projects", as: :json
|
||||||
|
assert_response :unauthorized
|
||||||
|
assert_equal "Non autenticato", json_body["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid token" do
|
||||||
|
get "/api/v1/projects", headers: bearer("crm_invalid"), as: :json
|
||||||
|
assert_response :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
test "lists accessible projects" do
|
||||||
|
get "/api/v1/projects", headers: bearer(@marco_token.plaintext), as: :json
|
||||||
|
assert_response :success
|
||||||
|
codes = json_body["projects"].map { |p| p["code"] }
|
||||||
|
assert_includes codes, "matchlivetv"
|
||||||
|
assert_not_includes codes, "riskmeter"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "forbids project the user cannot access" do
|
||||||
|
get "/api/v1/p/riskmeter/today", headers: bearer(@marco_token.plaintext), as: :json
|
||||||
|
assert_response :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 404 for unknown project" do
|
||||||
|
get "/api/v1/p/sconosciuto/today", headers: bearer(@admin_token.plaintext), as: :json
|
||||||
|
assert_response :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
test "today includes due tasks" do
|
||||||
|
get "/api/v1/p/matchlivetv/today", headers: bearer(@admin_token.plaintext), as: :json
|
||||||
|
assert_response :success
|
||||||
|
ids = json_body["today_tasks"].map { |t| t["id"] }
|
||||||
|
assert_includes ids, @task.id
|
||||||
|
assert_equal "matchlivetv", json_body["project"]["code"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "search finds organization" do
|
||||||
|
get "/api/v1/p/matchlivetv/search", params: { q: "ASD Test" }, headers: bearer(@admin_token.plaintext), as: :json
|
||||||
|
assert_response :success
|
||||||
|
org_ids = json_body["organizations"].map { |o| o["id"] }
|
||||||
|
assert_includes org_ids, @org.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows organization card" do
|
||||||
|
get "/api/v1/p/matchlivetv/organizations/#{@org.id}", headers: bearer(@admin_token.plaintext), as: :json
|
||||||
|
assert_response :success
|
||||||
|
assert_equal @org.name, json_body["organization"]["name"]
|
||||||
|
assert json_body["contacts"].any?
|
||||||
|
assert json_body["open_opportunities"].any?
|
||||||
|
assert json_body["pending_tasks"].any?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates activity" do
|
||||||
|
assert_difference -> { Activity.count }, 1 do
|
||||||
|
post "/api/v1/p/matchlivetv/activities",
|
||||||
|
params: {
|
||||||
|
organization_id: @org.id,
|
||||||
|
activity_type: "note",
|
||||||
|
subject: "Nota agente",
|
||||||
|
description: "Creato via API"
|
||||||
|
},
|
||||||
|
headers: bearer(@admin_token.plaintext),
|
||||||
|
as: :json
|
||||||
|
end
|
||||||
|
assert_response :created
|
||||||
|
assert_equal "Nota agente", json_body["activity"]["subject"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates and completes task" do
|
||||||
|
post "/api/v1/p/matchlivetv/tasks",
|
||||||
|
params: {
|
||||||
|
organization_id: @org.id,
|
||||||
|
title: "Richiamare dopo demo",
|
||||||
|
due_at: 1.day.from_now.iso8601,
|
||||||
|
task_type: "call",
|
||||||
|
priority: "high"
|
||||||
|
},
|
||||||
|
headers: bearer(@admin_token.plaintext),
|
||||||
|
as: :json
|
||||||
|
assert_response :created
|
||||||
|
task_id = json_body["task"]["id"]
|
||||||
|
|
||||||
|
post "/api/v1/p/matchlivetv/tasks/#{task_id}/complete",
|
||||||
|
headers: bearer(@admin_token.plaintext),
|
||||||
|
as: :json
|
||||||
|
assert_response :success
|
||||||
|
assert_equal "completed", json_body["task"]["status"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates opportunity stage" do
|
||||||
|
patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage",
|
||||||
|
params: { pipeline_stage: "demo_trial", notes: "Demo fissata dall'agente" },
|
||||||
|
headers: bearer(@admin_token.plaintext),
|
||||||
|
as: :json
|
||||||
|
assert_response :success
|
||||||
|
assert_equal "demo_trial", json_body["opportunity"]["pipeline_stage"]
|
||||||
|
@opportunity.reload
|
||||||
|
assert_equal "demo_trial", @opportunity.pipeline_stage
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects lost stage without reason" do
|
||||||
|
patch "/api/v1/p/matchlivetv/opportunities/#{@opportunity.id}/stage",
|
||||||
|
params: { pipeline_stage: "lost" },
|
||||||
|
headers: bearer(@admin_token.plaintext),
|
||||||
|
as: :json
|
||||||
|
assert_response :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def bearer(plaintext)
|
||||||
|
{ "Authorization" => "Bearer #{plaintext}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def json_body
|
||||||
|
JSON.parse(response.body)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApiTokensControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
test "requires login" do
|
||||||
|
get api_tokens_path
|
||||||
|
assert_redirected_to login_path
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user can create and revoke own token" do
|
||||||
|
login_as users(:marco)
|
||||||
|
follow_redirect! if response.redirect?
|
||||||
|
|
||||||
|
assert_difference -> { users(:marco).api_tokens.active.count }, 1 do
|
||||||
|
post api_tokens_path, params: { name: "Codex" }
|
||||||
|
end
|
||||||
|
assert_redirected_to api_tokens_path
|
||||||
|
follow_redirect!
|
||||||
|
assert_response :success
|
||||||
|
assert_match(/crm_/, response.body)
|
||||||
|
|
||||||
|
token = users(:marco).api_tokens.active.last
|
||||||
|
delete api_token_path(token)
|
||||||
|
assert_redirected_to api_tokens_path
|
||||||
|
assert token.reload.revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non admin can open token page" do
|
||||||
|
login_as users(:marco)
|
||||||
|
follow_redirect! if response.redirect?
|
||||||
|
get api_tokens_path
|
||||||
|
assert_response :success
|
||||||
|
assert_match(/Token API/, response.body)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -87,6 +87,7 @@ class AuthenticationTest < ActionDispatch::IntegrationTest
|
|||||||
assert_response :success
|
assert_response :success
|
||||||
assert_match(/Impostazioni piattaforma/, response.body)
|
assert_match(/Impostazioni piattaforma/, response.body)
|
||||||
assert_match(/Utenti/, response.body)
|
assert_match(/Utenti/, response.body)
|
||||||
|
assert_match(/Token API/, response.body)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non admin cannot open platform settings" do
|
test "non admin cannot open platform settings" do
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class DashboardControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
test "dashboard shows snapshot cards and dual funnel counts" do
|
||||||
|
login_as users(:admin)
|
||||||
|
get project_root_path(project_code: "matchlivetv")
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_match(/Da contattare/, response.body)
|
||||||
|
assert_match(/Conteggio per stage attuale/, response.body)
|
||||||
|
assert_match(/da qui in poi/, response.body)
|
||||||
|
assert_match(/In trattativa senza next action/, response.body)
|
||||||
|
assert_match(/Senza attività in timeline/, response.body)
|
||||||
|
assert_no_match(/↓ \d+%/, response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reports funnel uses current and reached columns" do
|
||||||
|
login_as users(:admin)
|
||||||
|
get reports_path(project_code: "matchlivetv")
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_match(/In questo stage/, response.body)
|
||||||
|
assert_match(/Da qui in poi/, response.body)
|
||||||
|
assert_no_match(/dal passo precedente/, response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "today task rows use dark-mode surfaces" do
|
||||||
|
login_as users(:admin)
|
||||||
|
get project_root_path(project_code: "matchlivetv")
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_includes response.body, "dark:bg-amber-950"
|
||||||
|
assert_includes response.body, "dark:bg-amber-950 dark:text-amber-200"
|
||||||
|
assert_includes response.body, "dark:bg-zinc-700 dark:text-zinc-50"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class McpControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@token = ApiToken.issue!(user: users(:admin), name: "mcp-test")
|
||||||
|
@marco = ApiToken.issue!(user: users(:marco), name: "mcp-marco")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects missing token" do
|
||||||
|
post "/mcp", params: rpc("initialize"), as: :json, headers: mcp_headers(nil)
|
||||||
|
assert_response :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
test "initialize advertises CRM tools" do
|
||||||
|
post "/mcp",
|
||||||
|
params: rpc("initialize", {
|
||||||
|
protocolVersion: "2025-06-18",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "test", version: "1.0" }
|
||||||
|
}),
|
||||||
|
as: :json,
|
||||||
|
headers: mcp_headers(@token.plaintext)
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
names = Array(json_rpc.dig("result", "capabilities", "tools")).presence
|
||||||
|
listed = tool_names_from_initialize.presence || fetch_tool_names
|
||||||
|
assert_includes listed, "list_projects"
|
||||||
|
assert_includes listed, "today"
|
||||||
|
assert_includes listed, "create_activity"
|
||||||
|
assert names || listed.any?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "list_projects returns accessible projects" do
|
||||||
|
result = call_tool("list_projects", {}, token: @marco.plaintext)
|
||||||
|
codes = result.fetch("projects").map { |p| p["code"] }
|
||||||
|
assert_includes codes, "matchlivetv"
|
||||||
|
assert_not_includes codes, "riskmeter"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "today requires project access" do
|
||||||
|
result = call_tool("today", { project_code: "riskmeter" }, token: @marco.plaintext, expect_error: true)
|
||||||
|
assert_equal "Progetto non accessibile", result["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "today returns tasks for matchlivetv" do
|
||||||
|
result = call_tool("today", { project_code: "matchlivetv" })
|
||||||
|
ids = result.fetch("today_tasks").map { |t| t["id"] }
|
||||||
|
assert_includes ids, tasks(:follow_up).id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allowed_hosts includes production APP_HOST and RAILS_ALLOWED_HOSTS" do
|
||||||
|
previous_app = ENV["APP_HOST"]
|
||||||
|
previous_allowed = ENV["RAILS_ALLOWED_HOSTS"]
|
||||||
|
ENV["APP_HOST"] = "crm.eminux.it"
|
||||||
|
ENV["RAILS_ALLOWED_HOSTS"] = "crm.eminux.it, 192.168.1.158, localhost"
|
||||||
|
|
||||||
|
hosts = EminuxCrmMcp.allowed_hosts
|
||||||
|
assert_includes hosts, "crm.eminux.it"
|
||||||
|
assert_includes hosts, "192.168.1.158"
|
||||||
|
assert_includes hosts, "localhost"
|
||||||
|
ensure
|
||||||
|
ENV["APP_HOST"] = previous_app
|
||||||
|
ENV["RAILS_ALLOWED_HOSTS"] = previous_allowed
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def mcp_headers(plaintext)
|
||||||
|
headers = {
|
||||||
|
"Accept" => "application/json, text/event-stream",
|
||||||
|
"MCP-Protocol-Version" => "2025-06-18"
|
||||||
|
}
|
||||||
|
headers["Authorization"] = "Bearer #{plaintext}" if plaintext.present?
|
||||||
|
headers
|
||||||
|
end
|
||||||
|
|
||||||
|
def rpc(method, params = {}, id: 1)
|
||||||
|
{ jsonrpc: "2.0", id: id, method: method, params: params }
|
||||||
|
end
|
||||||
|
|
||||||
|
def json_rpc
|
||||||
|
JSON.parse(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize_mcp!(token)
|
||||||
|
post "/mcp",
|
||||||
|
params: rpc("initialize", {
|
||||||
|
protocolVersion: "2025-06-18",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "test", version: "1.0" }
|
||||||
|
}),
|
||||||
|
as: :json,
|
||||||
|
headers: mcp_headers(token)
|
||||||
|
assert_response :success, response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_tool_names
|
||||||
|
initialize_mcp!(@token.plaintext)
|
||||||
|
post "/mcp", params: rpc("tools/list", {}, id: 2), as: :json, headers: mcp_headers(@token.plaintext)
|
||||||
|
assert_response :success, response.body
|
||||||
|
Array(json_rpc.dig("result", "tools")).map { |t| t["name"] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def tool_names_from_initialize
|
||||||
|
Array(json_rpc.dig("result", "tools")).map { |t| t["name"] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def call_tool(name, arguments, token: @token.plaintext, expect_error: false)
|
||||||
|
initialize_mcp!(token)
|
||||||
|
post "/mcp",
|
||||||
|
params: rpc("tools/call", { name: name, arguments: arguments }, id: 2),
|
||||||
|
as: :json,
|
||||||
|
headers: mcp_headers(token)
|
||||||
|
assert_response :success, response.body
|
||||||
|
payload = json_rpc
|
||||||
|
text = payload.dig("result", "content", 0, "text") || payload.dig("result", "content", 0, :text)
|
||||||
|
refute_nil text, payload.inspect
|
||||||
|
parsed = JSON.parse(text)
|
||||||
|
if expect_error
|
||||||
|
assert payload.dig("result", "isError") || parsed["error"].present?, payload.inspect
|
||||||
|
else
|
||||||
|
assert_nil parsed["error"], payload.inspect
|
||||||
|
end
|
||||||
|
parsed
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OrganizationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
test "sport filter is a dropdown of values already in the project" do
|
||||||
|
login_as users(:admin)
|
||||||
|
create_campaign_org(name: "Futsal Test", email: "futsal@example.com", sport: "Calcio a 5")
|
||||||
|
|
||||||
|
get organizations_path(project_code: "matchlivetv")
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_select "select[name=sport]" do
|
||||||
|
assert_select "option[value='']", text: "Sport"
|
||||||
|
assert_select "option[value=Calcio]", text: "Calcio"
|
||||||
|
assert_select "option[value='Calcio a 5']", text: "Calcio a 5"
|
||||||
|
end
|
||||||
|
assert_select "input[name=sport]", count: 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sport filter keeps only organizations of that sport" do
|
||||||
|
login_as users(:admin)
|
||||||
|
create_campaign_org(name: "Futsal Test", email: "futsal@example.com", sport: "Calcio a 5")
|
||||||
|
|
||||||
|
get organizations_path(project_code: "matchlivetv", sport: "Calcio a 5")
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_match(/Futsal Test/, response.body)
|
||||||
|
assert_no_match(/ASD Test Calcio/, response.body)
|
||||||
|
assert_select "select[name=sport] option[value='Calcio a 5'][selected]"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApplicationHelperTest < ActionView::TestCase
|
||||||
|
test "priority badge includes dark theme colors" do
|
||||||
|
html = priority_badge("high")
|
||||||
|
|
||||||
|
assert_includes html, "bg-amber-100"
|
||||||
|
assert_includes html, "dark:bg-amber-950"
|
||||||
|
assert_includes html, "dark:text-amber-200"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stage badge includes dark theme colors" do
|
||||||
|
html = stage_badge("interested")
|
||||||
|
|
||||||
|
assert_includes html, "dark:bg-amber-950"
|
||||||
|
assert_includes html, "dark:text-amber-200"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "status badge includes dark theme colors" do
|
||||||
|
html = status_badge("prospect")
|
||||||
|
|
||||||
|
assert_includes html, "dark:bg-sky-950"
|
||||||
|
assert_includes html, "dark:text-sky-200"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApiTokenTest < ActiveSupport::TestCase
|
||||||
|
test "issue returns plaintext once and authenticates" do
|
||||||
|
token = ApiToken.issue!(user: users(:marco), name: "Cursor")
|
||||||
|
assert token.plaintext.start_with?("crm_")
|
||||||
|
assert_equal token.plaintext[0, 8], token.token_prefix
|
||||||
|
|
||||||
|
found = ApiToken.authenticate(token.plaintext)
|
||||||
|
assert_equal token.id, found.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "revoked or inactive user does not authenticate" do
|
||||||
|
token = ApiToken.issue!(user: users(:marco), name: "tmp")
|
||||||
|
raw = token.plaintext
|
||||||
|
token.revoke!
|
||||||
|
assert_nil ApiToken.authenticate(raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "blank token does not authenticate" do
|
||||||
|
assert_nil ApiToken.authenticate(nil)
|
||||||
|
assert_nil ApiToken.authenticate("")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,16 +1,114 @@
|
|||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class DashboardMetricsTest < ActiveSupport::TestCase
|
class DashboardMetricsTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@project = projects(:matchlivetv)
|
||||||
|
Current.user = users(:marco)
|
||||||
|
Current.project = @project
|
||||||
|
@opps = []
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
Current.user = nil
|
||||||
|
Current.project = nil
|
||||||
|
end
|
||||||
|
|
||||||
test "computes stage counts and pipeline value" do
|
test "computes stage counts and pipeline value" do
|
||||||
metrics = Dashboard::Metrics.new
|
metrics = Dashboard::Metrics.new
|
||||||
assert metrics.stage_counts.key?("interested")
|
assert metrics.stage_counts.key?("interested")
|
||||||
|
assert metrics.stage_counts.key?("to_contact")
|
||||||
assert_kind_of Numeric, metrics.open_pipeline_value
|
assert_kind_of Numeric, metrics.open_pipeline_value
|
||||||
assert_kind_of Array, metrics.funnel_steps
|
assert_kind_of Array, metrics.funnel_steps
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "funnel shows current snapshot and reached-or-beyond without skipping stages" do
|
||||||
|
add_opp("to_contact")
|
||||||
|
2.times { add_opp("contacted") }
|
||||||
|
add_opp("replied")
|
||||||
|
add_opp("interested")
|
||||||
|
2.times { add_opp("proposal", estimated_value: 100) }
|
||||||
|
add_opp("lost", lost_reason: "price")
|
||||||
|
|
||||||
|
steps = metrics.funnel_steps.index_by { |step| step[:stage] }
|
||||||
|
|
||||||
|
assert_equal Dashboard::Metrics::FUNNEL_STAGES, metrics.funnel_steps.map { |step| step[:stage] }
|
||||||
|
|
||||||
|
assert_equal 2, steps["contacted"][:current]
|
||||||
|
assert_equal 6, steps["contacted"][:reached]
|
||||||
|
assert_equal 0, steps["first_use"][:current]
|
||||||
|
assert_equal 2, steps["first_use"][:reached]
|
||||||
|
assert_equal 2, steps["proposal"][:current]
|
||||||
|
assert_equal 2, steps["proposal"][:reached]
|
||||||
|
assert_equal 0, steps["won"][:current]
|
||||||
|
refute steps.key?("lost")
|
||||||
|
|
||||||
|
contacted_step = steps["replied"]
|
||||||
|
assert_equal "4 su 6 hanno superato Contattato", contacted_step[:conversion_label]
|
||||||
|
assert_equal 67, contacted_step[:rate]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "next action KPI excludes da contattare and attention remainder uses real total" do
|
||||||
|
2.times { add_opp("to_contact", send_status: "to_send") }
|
||||||
|
10.times { add_opp("contacted") }
|
||||||
|
interested = add_opp("interested")
|
||||||
|
Task.create!(
|
||||||
|
title: "Richiama",
|
||||||
|
organization: interested.organization,
|
||||||
|
opportunity: interested,
|
||||||
|
assigned_user: users(:marco),
|
||||||
|
due_at: 1.day.from_now,
|
||||||
|
priority: "normal",
|
||||||
|
task_type: "call",
|
||||||
|
status: "pending"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 10, metrics.opportunities_without_next_action
|
||||||
|
|
||||||
|
preview = metrics.attention_items[:opportunities_without_next_action]
|
||||||
|
assert_equal 10, preview[:total]
|
||||||
|
assert_equal 8, preview[:items].size
|
||||||
|
end
|
||||||
|
|
||||||
|
test "demo and first use conversions are blank when those stages were never used" do
|
||||||
|
add_opp("proposal")
|
||||||
|
|
||||||
|
assert_nil metrics.demo_to_won_conversion
|
||||||
|
assert_nil metrics.first_use_to_won_conversion
|
||||||
|
end
|
||||||
|
|
||||||
|
test "demo conversion is zero when someone is in demo and nobody won" do
|
||||||
|
add_opp("demo_trial")
|
||||||
|
|
||||||
|
assert_equal 0, metrics.demo_to_won_conversion
|
||||||
|
end
|
||||||
|
|
||||||
test "goal progress" do
|
test "goal progress" do
|
||||||
goal = sales_goals(:september)
|
goal = sales_goals(:september)
|
||||||
assert_equal 0, goal.current_value
|
assert_equal 0, goal.current_value
|
||||||
assert_equal 0, goal.progress_percentage
|
assert_equal 0, goal.progress_percentage
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def metrics
|
||||||
|
Dashboard::Metrics.new(scope: Opportunity.where(id: @opps.map(&:id)), project: @project)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_opp(stage, **attrs)
|
||||||
|
org = Organization.create!(
|
||||||
|
name: "Soc #{stage} #{SecureRandom.hex(3)}",
|
||||||
|
status: "prospect",
|
||||||
|
organization_type: "societa_sportiva"
|
||||||
|
)
|
||||||
|
opp = Opportunity.create!(
|
||||||
|
{
|
||||||
|
organization: org,
|
||||||
|
project: @project,
|
||||||
|
name: "Deal #{stage}",
|
||||||
|
pipeline_stage: stage
|
||||||
|
}.merge(attrs)
|
||||||
|
)
|
||||||
|
@opps << opp
|
||||||
|
opp
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -48,4 +48,63 @@ class CampaignImportMatchlivetvLaunchTest < ActiveSupport::TestCase
|
|||||||
ensure
|
ensure
|
||||||
csv.close!
|
csv.close!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "imports with a distinct sport and skips duplicate clubs" do
|
||||||
|
csv = Tempfile.new(["calcioa5", ".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
|
||||||
|
101,Lazio,RM,Ecocity Futsal Genzano,M,info@ecocityfutsal.it,ecocityfutsal.it,LIMITATO,Attiva,A,DA INVIARE,,,NO,NO,,fonte,2026-09-06,
|
||||||
|
129,Lazio,RM,Ecocity Futsal Genzano,M,amministrazione@pec.ecocityfutsal.it,ecocityfutsal.it,LIMITATO,PEC,A,DA INVIARE,,,NO,NO,,fonte,2026-09-06,
|
||||||
|
157,Valle d'Aosta,AO,SSDARL AOSTA CALCIO 511,M/F,aostacalcio511@gmail.com,divisionecalcioa5.it,NON RILEVATO,U19,A,DA INVIARE,,,NO,NO,NO,fonte,2026-09-06,
|
||||||
|
CSV
|
||||||
|
csv.flush
|
||||||
|
|
||||||
|
create_campaign_org(name: "Aosta Calcio 511", email: "esistente@example.com", list_position: 97)
|
||||||
|
|
||||||
|
result = CampaignImport::MatchlivetvLaunch.new(
|
||||||
|
path: csv.path,
|
||||||
|
user: users(:admin),
|
||||||
|
project: projects(:matchlivetv),
|
||||||
|
wipe: false,
|
||||||
|
sport: "Calcio a 5",
|
||||||
|
campaign_name: "Campagna Calcio a 5"
|
||||||
|
).call
|
||||||
|
|
||||||
|
assert_empty result.errors, result.errors.inspect
|
||||||
|
assert_equal 1, result.imported
|
||||||
|
assert_equal 2, result.skipped
|
||||||
|
|
||||||
|
org = Organization.find_by!(name: "Ecocity Futsal Genzano")
|
||||||
|
assert_equal "Calcio a 5", org.sport
|
||||||
|
assert_equal "Campagna Calcio a 5", org.campaign_opportunity(projects(:matchlivetv)).name
|
||||||
|
assert_nil org.campaign_opportunity(projects(:matchlivetv)).product
|
||||||
|
assert_equal 1, Organization.where("LOWER(name) = ?", "ecocity futsal genzano").count
|
||||||
|
assert_nil Organization.find_by(name: "SSDARL AOSTA CALCIO 511")
|
||||||
|
ensure
|
||||||
|
csv.close!
|
||||||
|
end
|
||||||
|
|
||||||
|
test "strips spaces inside campaign emails" do
|
||||||
|
csv = Tempfile.new(["email", ".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
|
||||||
|
144,Emilia-Romagna,RE,Real Casalgrandese C5,M,REAL CASALGRANDESEC5@LIBERO.IT,divisionecalcioa5.it,NON RILEVATO,Attiva,B,DA INVIARE,,,NO,NO,,fonte,2026-09-06,
|
||||||
|
CSV
|
||||||
|
csv.flush
|
||||||
|
|
||||||
|
result = CampaignImport::MatchlivetvLaunch.new(
|
||||||
|
path: csv.path,
|
||||||
|
user: users(:admin),
|
||||||
|
project: projects(:matchlivetv),
|
||||||
|
wipe: false,
|
||||||
|
sport: "Calcio a 5",
|
||||||
|
campaign_name: "Campagna Calcio a 5"
|
||||||
|
).call
|
||||||
|
|
||||||
|
assert_empty result.errors, result.errors.inspect
|
||||||
|
org = Organization.find_by!(name: "Real Casalgrandese C5")
|
||||||
|
assert_equal "realcasalgrandesec5@libero.it", org.email
|
||||||
|
ensure
|
||||||
|
csv.close!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user