++ fix statistiche campagne

- Dettaglio campagna: mostra tutti i territori conteggiati (stessa base
  della percentuale), non solo quelli assegnati dopo l'inizio.
- Rientro: se più campagne corrispondono propone la più recente invece
  della prima creata; testo del banner chiarito.
- Dettaglio campagna: nuova sezione "In corso" con i territori fuori
  durante la campagna e non ancora rientrati.
- README: regola di conteggio documentata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-19 10:18:09 +02:00
parent 754adf9878
commit 223d44300c
6 changed files with 87 additions and 13 deletions

View File

@@ -684,8 +684,16 @@ sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla
quando la soglia è scattata.
### Percentuale di percorrenza delle campagne
- **Denominatore**: tutti i territori assegnati nel periodo della campagna
- **Numeratore**: le assegnazioni contrassegnate come conteggiate
- **Denominatore**: tutti i territori attivi
- **Numeratore**: le assegnazioni collegate alla campagna e contrassegnate come
conteggiate al rientro
Al rientro la campagna viene proposta se il territorio era fuori almeno in
parte del periodo: assegnato durante la campagna, rientrato durante la
campagna, oppure fuori per tutta la sua durata. Se sia stato davvero percorso
lo decide l'operatore con la spunta. Se più campagne corrispondono viene
proposta la più recente. I territori fuori durante la campagna e non ancora
rientrati compaiono nel dettaglio come "in corso".
---

View File

@@ -18,16 +18,27 @@ class CampagnaShow extends Component
public function render()
{
// Assignments counted for this campaign:
// - assigned on or after campaign start
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed)
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id)
->where('counted_in_campaign', true)
->where('assigned_at', '>=', $this->campagna->start_date)
// Assignments counted for this campaign: linked via campaign_id and
// flagged as counted — the same set used by the percentage
// (Campagna::percentuale_percorrenza). Which campaign is offered on
// return is decided by Assegnazione::campagnaApplicabile().
$conteggiate = $this->campagna->assegnazioniConteggiate()
->with(['territorio', 'proclamatore'])
->orderBy('returned_at')
->get();
// Open assignments can still be counted for this campaign only while
// no later campaign has started: on return the most recent one is
// offered (see Assegnazione::campagnaApplicabile()).
$superata = Campagna::where('start_date', '>', $this->campagna->start_date)
->where('start_date', '<=', now()->toDateString())
->exists();
$inCorso = $superata ? null : $this->campagna->assegnazioniInCorso()
->with(['territorio', 'proclamatore'])
->orderBy('assigned_at')
->get();
// All assignments that were active during this campaign range
$assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date)
->where(function ($q) {
@@ -38,6 +49,7 @@ class CampagnaShow extends Component
return view('livewire.campagne.campagna-show', [
'conteggiate' => $conteggiate,
'inCorso' => $inCorso,
'assegnateNelRange' => $assegnateNelRange,
]);
}

View File

@@ -203,10 +203,18 @@ class Assegnazione extends Model
*/
public function campagnaApplicabile(?\Carbon\Carbon $returnDate = null): ?Campagna
{
$returnDate = $returnDate ?? now();
$returnDate = ($returnDate ?? now())->toDateString();
$assignedAt = $this->assigned_at->toDateString();
// Il territorio può essere conteggiato per una campagna se è rimasto
// in mano al proclamatore almeno in parte del periodo: assegnato
// durante la campagna, rientrato durante la campagna, oppure fuori per
// tutta la sua durata (assegnato prima e rientrato dopo). Se sia stato
// davvero percorso lo decide l'operatore con la spunta.
// Se più campagne corrispondono si propone la più recente.
return Campagna::where('start_date', '<=', $returnDate)
->where('end_date', '>=', $this->assigned_at)
->where('end_date', '>=', $assignedAt)
->orderByDesc('start_date')
->first();
}
}

View File

@@ -60,6 +60,16 @@ class Campagna extends Model
->where('counted_in_campaign', true);
}
/**
* Assignments still open that were out during this campaign (assigned
* on or before its end): they can be counted for it when they come back.
*/
public function assegnazioniInCorso()
{
return Assegnazione::whereNull('returned_at')
->where('assigned_at', '<=', $this->end_date);
}
/**
* All assignments with assigned_at in this campaign's range.
*/

View File

@@ -40,12 +40,12 @@
@if($showCampaignPrompt && $campagna)
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p class="text-sm font-medium text-amber-800 mb-2">
⚠️ Campagna Attiva: <strong>{{ $campagna->descrizione }}</strong>
⚠️ Campagna: <strong>{{ $campagna->descrizione }}</strong>
</p>
<p class="text-xs text-amber-700 mb-3">
Questo territorio è stato assegnato durante la campagna "{{ $campagna->descrizione }}"
Questo territorio era assegnato durante la campagna "{{ $campagna->descrizione }}"
({{ $campagna->start_date->format('d/m/Y') }} — {{ $campagna->end_date->format('d/m/Y') }}).
Vuoi conteggiarlo nella percentuale di percorrenza?
È stato percorso per la campagna? Se sì, conteggialo nella percentuale di percorrenza.
</p>
<div class="flex items-center gap-2">
<input wire:model="counted_in_campaign" type="checkbox" id="counted_in_campaign" class="rounded border-gray-300 text-amber-600 focus:ring-amber-500">

View File

@@ -78,4 +78,40 @@
</table>
</div>
</div>
{{-- Out during the campaign, not yet returned (hidden once a later campaign has started) --}}
@if($inCorso !== null)
<div class="mt-6 bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 border-b">
<h3 class="text-sm font-semibold text-gray-700">In corso ({{ $inCorso->count() }})</h3>
<p class="text-xs text-gray-500 mt-0.5">Fuori durante la campagna e non ancora rientrati: si potranno conteggiare al rientro.</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($inCorso as $a)
<tr>
<td data-label="Territorio" class="px-4 py-2"><a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline">N° {{ $a->territorio?->numero }}</a></td>
<td data-label="Proclamatore" class="px-4 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
<td data-label="Assegnato" class="px-4 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td data-label="Giorni" class="px-4 py-2">{{ $a->giorni }}</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-gray-500">Nessun territorio in corso.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
</div>