Roberto Santini 1 week ago
parent
commit
1124fa949c

+ 6
- 0
.env.example View File

@@ -114,4 +114,10 @@ SKEBBY_MESSAGE_TYPE=GP
114 114
 # Token manuale con scope entity.clients:r e entity.clients:a
115 115
 # FATTUREINCLOUD_COMPANY_ID=
116 116
 # FATTUREINCLOUD_ACCESS_TOKEN=
117
+
118
+# Task da Telegram (in alternativa: Configurazione > Task)
119
+# TELEGRAM_BOT_TOKEN=
120
+# TELEGRAM_WEBHOOK_SECRET=
121
+# OPENAI_API_KEY=
122
+# OPENAI_TRANSCRIBE_MODEL=whisper-1
117 123
 # SKEBBY_BASE_URL=https://api.skebby.it/API/v1.0/REST

+ 63
- 1
app/Http/Controllers/ConfigController.php View File

@@ -9,10 +9,12 @@ use App\Models\Azienda;
9 9
 use App\Models\Config;
10 10
 use App\Models\ConfiguraSegnalazione;
11 11
 use App\Services\FattureInCloudClient;
12
+use App\Services\TelegramBotService;
12 13
 use RuntimeException;
13 14
 use Illuminate\Support\Facades\Auth;
14 15
 use Illuminate\Support\Facades\Log;
15 16
 use Illuminate\Support\Facades\Storage;
17
+use Illuminate\Support\Str;
16 18
 use Intervention\Image\ImageManager;
17 19
 use Intervention\Image\Drivers\Imagick\Driver;
18 20
 
@@ -28,7 +30,7 @@ class ConfigController extends Controller implements HasMiddleware
28 30
   {
29 31
     return [
30 32
       new Middleware('permission:view-config', only: ['index', 'admin_index']),
31
-      new Middleware('permission:edit-config', only: ['store', 'admin_save', 'admin_sync_aliquote']),
33
+      new Middleware('permission:edit-config', only: ['store', 'admin_save', 'admin_sync_aliquote', 'admin_telegram_webhook']),
32 34
     ];
33 35
   }
34 36
 
@@ -131,6 +133,51 @@ class ConfigController extends Controller implements HasMiddleware
131 133
       ->with('success', 'Aliquote IVA aggiornate da Fatture in Cloud ('.$n.'). Segna quella da usare di default e salva.');
132 134
   }
133 135
 
136
+  public function admin_telegram_webhook(Request $request, TelegramBotService $telegram)
137
+  {
138
+    Config::syncDefinitions();
139
+
140
+    $postedToken = trim((string) $request->input('value.'.Config::TELEGRAM_BOT_TOKEN, ''));
141
+    if ($postedToken !== '') {
142
+      $tokenRow = Config::find(Config::TELEGRAM_BOT_TOKEN);
143
+      if ($tokenRow) {
144
+        $tokenRow->value = $postedToken;
145
+        $tokenRow->save();
146
+      }
147
+    }
148
+
149
+    if ($telegram->token() === null) {
150
+      return redirect()->route('admin.config.index')
151
+        ->with('config_group', 'Task')
152
+        ->withErrors(['telegram' => 'Salva prima il token del bot Telegram.']);
153
+    }
154
+
155
+    $secret = Config::telegramWebhookSecret();
156
+    if ($secret === null) {
157
+      $secret = Str::random(40);
158
+      $row = Config::find(Config::TELEGRAM_WEBHOOK_SECRET);
159
+      if (! $row) {
160
+        return redirect()->route('admin.config.index')
161
+          ->with('config_group', 'Task')
162
+          ->withErrors(['telegram' => 'Voce di configurazione del secret non trovata.']);
163
+      }
164
+      $row->value = $secret;
165
+      $row->save();
166
+    }
167
+
168
+    try {
169
+      $telegram->setWebhook(route('telegram.task.webhook'), $secret);
170
+    } catch (RuntimeException $e) {
171
+      return redirect()->route('admin.config.index')
172
+        ->with('config_group', 'Task')
173
+        ->withErrors(['telegram' => $e->getMessage()]);
174
+    }
175
+
176
+    return redirect()->route('admin.config.index')
177
+      ->with('config_group', 'Task')
178
+      ->with('success', 'Bot Telegram collegato. I messaggi arrivano su '.route('telegram.task.webhook'));
179
+  }
180
+
134 181
   protected function normalizeValue(?string $type, mixed $value): ?string
135 182
   {
136 183
     return match ($type) {
@@ -141,6 +188,7 @@ class ConfigController extends Controller implements HasMiddleware
141 188
       'stato' => $value === null || $value === '' ? null : (string) $value,
142 189
       'stati' => json_encode($this->normalizeStati($value), JSON_UNESCAPED_UNICODE),
143 190
       'tipo' => $value === null || $value === '' ? null : (string) $value,
191
+      'lines' => json_encode($this->normalizeLines($value), JSON_UNESCAPED_UNICODE),
144 192
       default => $value === null ? null : (string) $value,
145 193
     };
146 194
   }
@@ -168,6 +216,20 @@ class ConfigController extends Controller implements HasMiddleware
168 216
     return array_values(array_unique($stati));
169 217
   }
170 218
 
219
+  protected function normalizeLines(mixed $value): array
220
+  {
221
+    $lines = preg_split('/\r\n|\r|\n/', (string) $value) ?: [];
222
+    $out = [];
223
+    foreach ($lines as $line) {
224
+      $line = trim($line);
225
+      if ($line !== '') {
226
+        $out[] = $line;
227
+      }
228
+    }
229
+
230
+    return array_values(array_unique($out));
231
+  }
232
+
171 233
   public function createToken(Request $request){
172 234
     $token = $request->user()->createToken("tokenEventi");
173 235
 

+ 25
- 0
app/Http/Controllers/HomePageController.php View File

@@ -10,6 +10,7 @@ use App\Models\Config;
10 10
 use App\Models\ConfiguraSegnalazione;
11 11
 use App\Models\Prodotto;
12 12
 use App\Models\Segnalazione;
13
+use App\Models\Task;
13 14
 use App\Models\User;
14 15
 use Carbon\Carbon;
15 16
 use Illuminate\Database\Eloquent\Builder;
@@ -119,6 +120,26 @@ class HomePageController extends Controller
119 120
       ->values()
120 121
       ->all();
121 122
 
123
+    $mostraTasks = Auth::user()?->can('view-task') ?? false;
124
+    $tasksVista = 'inserimento';
125
+    $tasksPresentazione = ['prioritari' => collect(), 'sezioni' => []];
126
+    $tipoLabels = [];
127
+    if ($mostraTasks) {
128
+      $tasksVista = (string) request()->query('vista', session('tasks_vista', 'inserimento'));
129
+      if (! in_array($tasksVista, ['inserimento', 'settore'], true)) {
130
+        $tasksVista = 'inserimento';
131
+      }
132
+      session(['tasks_vista' => $tasksVista]);
133
+      $tipi = Config::tipiSegnalazione();
134
+      $tipoLabels = Config::tipiSegnalazioneLabels();
135
+      $tasksPresentazione = Task::presenta(
136
+        Task::query()->aperti()->with('segnalazione')->orderByDesc('prioritario')->orderBy('id')->get(),
137
+        $tasksVista,
138
+        array_column($tipi, 'value'),
139
+        $tipoLabels
140
+      );
141
+    }
142
+
122 143
     return [
123 144
       'totaleSegnalazioni' => $totaleSegnalazioni,
124 145
       'segnalazioniAperte' => $segnalazioniAperte,
@@ -131,6 +152,10 @@ class HomePageController extends Controller
131 152
       'timerUsers' => $timerUsers,
132 153
       'slotPrenotazioni' => $slotPrenotazioni,
133 154
       'tempoLavorato' => $this->getTempoLavoratoStats(),
155
+      'mostraTasks' => $mostraTasks,
156
+      'tasksVista' => $tasksVista,
157
+      'tasksPresentazione' => $tasksPresentazione,
158
+      'tipoLabels' => $tipoLabels,
134 159
     ];
135 160
   }
136 161
 

+ 272
- 0
app/Http/Controllers/TaskController.php View File

@@ -0,0 +1,272 @@
1
+<?php
2
+
3
+namespace App\Http\Controllers;
4
+
5
+use App\Models\Annotazione;
6
+use App\Models\Config;
7
+use App\Models\Segnalazione;
8
+use App\Models\Task;
9
+use Illuminate\Http\Request;
10
+use Illuminate\Routing\Controllers\HasMiddleware;
11
+use Illuminate\Routing\Controllers\Middleware;
12
+use Illuminate\Support\Facades\Auth;
13
+use Illuminate\Support\Facades\DB;
14
+use Illuminate\Validation\Rule;
15
+use Illuminate\Validation\ValidationException;
16
+
17
+class TaskController extends Controller implements HasMiddleware
18
+{
19
+    public static $permission_group = 'Task';
20
+
21
+    public static $permissions = [
22
+        'view-task' => 'Vedi',
23
+        'create-task' => 'Crea',
24
+        'edit-task' => 'Modifica',
25
+        'delete-task' => 'Elimina',
26
+    ];
27
+
28
+    public static function middleware(): array
29
+    {
30
+        return [
31
+            new Middleware('permission:view-task', only: ['admin_index', 'admin_segnalazioni']),
32
+            new Middleware('permission:create-task', only: ['admin_store']),
33
+            new Middleware('permission:edit-task', only: ['admin_edit', 'admin_update', 'admin_toggle']),
34
+            new Middleware('permission:delete-task', only: ['admin_destroy']),
35
+        ];
36
+    }
37
+
38
+    public function admin_index(Request $request)
39
+    {
40
+        return view('task.index', $this->pageData($request));
41
+    }
42
+
43
+    public function admin_edit(Request $request, $id)
44
+    {
45
+        $task = Task::query()->findOrFail($id);
46
+
47
+        return view('task.index', $this->pageData($request, $task));
48
+    }
49
+
50
+    public function admin_store(Request $request)
51
+    {
52
+        $data = $this->validated($request);
53
+        $task = new Task();
54
+        $task->user_id = Auth::id();
55
+        $task->origine = 'web';
56
+        $this->fillAndSave($task, $data, $request);
57
+
58
+        return redirect()->route('admin.task.index')->with('success', 'Task aggiunto.');
59
+    }
60
+
61
+    public function admin_update(Request $request, $id)
62
+    {
63
+        $task = Task::query()->findOrFail($id);
64
+        $data = $this->validated($request, $task);
65
+        $this->fillAndSave($task, $data, $request);
66
+
67
+        return redirect()->route('admin.task.index')->with('success', 'Task aggiornato.');
68
+    }
69
+
70
+    public function admin_toggle($id)
71
+    {
72
+        $task = Task::query()->findOrFail($id);
73
+        $task->completato_at = $task->isCompletato() ? null : now();
74
+        $task->save();
75
+
76
+        return redirect()->back()->with('success', $task->isCompletato() ? 'Task completato.' : 'Task riaperto.');
77
+    }
78
+
79
+    public function admin_destroy($id)
80
+    {
81
+        Task::query()->findOrFail($id)->delete();
82
+
83
+        return redirect()->route('admin.task.index')->with('success', 'Task eliminato.');
84
+    }
85
+
86
+    public function admin_segnalazioni(Request $request)
87
+    {
88
+        $q = trim((string) $request->query('q', ''));
89
+        $rows = Segnalazione::query()
90
+            ->when($q !== '', function ($query) use ($q) {
91
+                $query->where(function ($inner) use ($q) {
92
+                    $inner->where('titolo', 'like', '%'.$q.'%');
93
+                    if (ctype_digit($q)) {
94
+                        $inner->orWhere('id', (int) $q);
95
+                    }
96
+                });
97
+            })
98
+            ->orderByDesc('id')
99
+            ->limit(15)
100
+            ->get(['id', 'titolo', 'stato', 'tipo']);
101
+
102
+        return response()->json($rows->map(fn (Segnalazione $segnalazione) => [
103
+            'id' => $segnalazione->id,
104
+            'label' => $segnalazione->getLabel(),
105
+            'stato' => $segnalazione->stato,
106
+            'tipo' => $segnalazione->tipo,
107
+        ])->values());
108
+    }
109
+
110
+    /**
111
+     * @return array<string, mixed>
112
+     */
113
+    private function pageData(Request $request, ?Task $task = null): array
114
+    {
115
+        $task ??= new Task();
116
+        $stato = $request->query('stato', 'aperte');
117
+        if (! in_array($stato, ['aperte', 'completate', 'tutte'], true)) {
118
+            $stato = 'aperte';
119
+        }
120
+
121
+        $query = Task::query()->with('segnalazione')->inElenco();
122
+        if ($stato === 'aperte') {
123
+            $query->whereNull('completato_at');
124
+        } elseif ($stato === 'completate') {
125
+            $query->whereNotNull('completato_at');
126
+        }
127
+
128
+        $tasks = $query->get();
129
+        $tipi = Config::tipiSegnalazione();
130
+        $settori = array_column($tipi, 'value');
131
+        $tipoLabels = Config::tipiSegnalazioneLabels();
132
+        $vista = $this->vista($request);
133
+        $selezionataId = old('segnalazione_id', $task->segnalazione_id);
134
+        $selezionata = $selezionataId ? Segnalazione::query()->find($selezionataId) : null;
135
+
136
+        return [
137
+            'task' => $task,
138
+            'stato' => $stato,
139
+            'vista' => $vista,
140
+            'tipi' => $tipi,
141
+            'settori' => $settori,
142
+            'tipoLabels' => $tipoLabels,
143
+            'presentazione' => Task::presenta($tasks, $vista, $settori, $tipoLabels),
144
+            'totale' => $tasks->count(),
145
+            'segnalazioneSelezionata' => $selezionata,
146
+            'puoCreareSegnalazione' => Auth::user()?->can('create-segnalazione') ?? false,
147
+            'canCreate' => Auth::user()?->can('create-task') ?? false,
148
+            'canEdit' => Auth::user()?->can('edit-task') ?? false,
149
+            'canDelete' => Auth::user()?->can('delete-task') ?? false,
150
+        ];
151
+    }
152
+
153
+    private function vista(Request $request): string
154
+    {
155
+        $vista = (string) $request->query('vista', session('tasks_vista', 'inserimento'));
156
+        if (! in_array($vista, ['inserimento', 'settore'], true)) {
157
+            $vista = 'inserimento';
158
+        }
159
+        session(['tasks_vista' => $vista]);
160
+
161
+        return $vista;
162
+    }
163
+
164
+    /**
165
+     * @return array{testo: string, settore: ?string, tempo_minuti: ?int, prioritario: bool, mode: string}
166
+     */
167
+    private function validated(Request $request, ?Task $task = null): array
168
+    {
169
+        $ammessi = array_column(Config::tipiSegnalazione(), 'value');
170
+        if ($task?->settore) {
171
+            $ammessi[] = $task->settore;
172
+        }
173
+
174
+        $validated = $request->validate([
175
+            'testo' => 'required|string|max:5000',
176
+            'settore' => ['nullable', 'string', Rule::in($ammessi)],
177
+            'tempo' => 'nullable|string|max:20',
178
+            'prioritario' => 'sometimes|boolean',
179
+            'segnalazione_mode' => 'required|in:nessuna,esistente,nuova',
180
+            'segnalazione_id' => 'nullable|integer|exists:segnalazione,id',
181
+            'nuova_segnalazione_titolo' => 'nullable|string|max:255',
182
+        ]);
183
+
184
+        if ($validated['segnalazione_mode'] === 'esistente' && empty($validated['segnalazione_id'])) {
185
+            throw ValidationException::withMessages([
186
+                'segnalazione_id' => 'Seleziona la segnalazione da collegare.',
187
+            ]);
188
+        }
189
+
190
+        if ($validated['segnalazione_mode'] === 'nuova' && ! Auth::user()?->can('create-segnalazione')) {
191
+            throw ValidationException::withMessages([
192
+                'segnalazione_mode' => 'Non hai il permesso per creare una segnalazione.',
193
+            ]);
194
+        }
195
+
196
+        $tempo = null;
197
+        $tempoRaw = trim((string) ($validated['tempo'] ?? ''));
198
+        if ($tempoRaw !== '') {
199
+            $tempo = Config::parseTempoToMinuti($tempoRaw);
200
+            if ($tempo === null) {
201
+                throw ValidationException::withMessages([
202
+                    'tempo' => 'Tempo non valido. Usa 45m, 1h30m oppure 1,5.',
203
+                ]);
204
+            }
205
+        }
206
+
207
+        return [
208
+            'testo' => trim($validated['testo']),
209
+            'settore' => ($validated['settore'] ?? '') !== '' ? $validated['settore'] : null,
210
+            'tempo_minuti' => $tempo,
211
+            'prioritario' => $request->boolean('prioritario'),
212
+            'mode' => $validated['segnalazione_mode'],
213
+        ];
214
+    }
215
+
216
+    /**
217
+     * @param  array{testo: string, settore: ?string, tempo_minuti: ?int, prioritario: bool, mode: string}  $data
218
+     */
219
+    private function fillAndSave(Task $task, array $data, Request $request): void
220
+    {
221
+        DB::transaction(function () use ($task, $data, $request) {
222
+            $task->testo = $data['testo'];
223
+            $task->settore = $data['settore'];
224
+            $task->tempo_stimato_minuti = $data['tempo_minuti'];
225
+            $task->prioritario = $data['prioritario'];
226
+
227
+            if ($data['mode'] === 'nessuna') {
228
+                $task->segnalazione_id = null;
229
+            } elseif ($data['mode'] === 'esistente') {
230
+                $segnalazione = Segnalazione::query()->findOrFail((int) $request->input('segnalazione_id'));
231
+                if ($data['settore']) {
232
+                    $segnalazione->tipo = $data['settore'];
233
+                    $segnalazione->save();
234
+                } else {
235
+                    $task->settore = $segnalazione->tipo ?: null;
236
+                }
237
+                $task->segnalazione_id = $segnalazione->id;
238
+            } else {
239
+                $titolo = trim((string) $request->input('nuova_segnalazione_titolo', ''));
240
+                if ($titolo === '') {
241
+                    $titolo = preg_replace('/\s+/u', ' ', $data['testo']) ?? $data['testo'];
242
+                }
243
+                $task->segnalazione_id = $this->creaSegnalazione($titolo, $data['testo'], $data['settore'])->id;
244
+            }
245
+
246
+            $task->save();
247
+        });
248
+    }
249
+
250
+    private function creaSegnalazione(string $titolo, string $testo, ?string $tipo): Segnalazione
251
+    {
252
+        $segnalazione = new Segnalazione();
253
+        $segnalazione->titolo = mb_substr($titolo, 0, 255);
254
+        $segnalazione->stato = Config::getStatoSegnalazioneDefault();
255
+        $segnalazione->priorita = Config::getPrioritaSegnalazioneDefault();
256
+        $segnalazione->tipo = $tipo;
257
+        $segnalazione->origine = 'task';
258
+        $segnalazione->user_id = Auth::id();
259
+        $segnalazione->notificaApertaDa = false;
260
+        $segnalazione->notificaAdmin = false;
261
+        $segnalazione->save();
262
+
263
+        $annotazione = new Annotazione();
264
+        $annotazione->segnalazione_id = $segnalazione->id;
265
+        $annotazione->user_id = Auth::id();
266
+        $annotazione->text = nl2br(e($testo));
267
+        $annotazione->riservata = true;
268
+        $annotazione->save();
269
+
270
+        return $segnalazione;
271
+    }
272
+}

+ 27
- 0
app/Http/Controllers/TelegramTaskController.php View File

@@ -0,0 +1,27 @@
1
+<?php
2
+
3
+namespace App\Http\Controllers;
4
+
5
+use App\Models\Config;
6
+use App\Services\TelegramTaskIntake;
7
+use Illuminate\Http\Request;
8
+
9
+class TelegramTaskController extends Controller
10
+{
11
+    public function webhook(Request $request, TelegramTaskIntake $intake)
12
+    {
13
+        $expected = Config::telegramWebhookSecret();
14
+        $given = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '');
15
+        if ($expected === null || $given === '' || ! hash_equals($expected, $given)) {
16
+            abort(403);
17
+        }
18
+
19
+        $payload = $request->all();
20
+        if (! is_array($payload)) {
21
+            $payload = [];
22
+        }
23
+        $intake->handle($payload);
24
+
25
+        return response()->json(['ok' => true]);
26
+    }
27
+}

+ 105
- 0
app/Models/Config.php View File

@@ -34,6 +34,12 @@ class Config extends \App\Models\AbstractModels\AbstractConfig
34 34
   const FIC_ACCESS_TOKEN = 'FIC_ACCESS_TOKEN';
35 35
   const FIC_ALIQUOTE = 'FIC_ALIQUOTE';
36 36
 
37
+  const TELEGRAM_BOT_TOKEN = 'TELEGRAM_BOT_TOKEN';
38
+  const TELEGRAM_WEBHOOK_SECRET = 'TELEGRAM_WEBHOOK_SECRET';
39
+  const TELEGRAM_ALLOWED_IDS = 'TELEGRAM_ALLOWED_IDS';
40
+  const OPENAI_API_KEY = 'OPENAI_API_KEY';
41
+  const OPENAI_TRANSCRIBE_MODEL = 'OPENAI_TRANSCRIBE_MODEL';
42
+
37 43
   public static function getConfig(){
38 44
     $config = [];
39 45
 
@@ -82,6 +88,14 @@ class Config extends \App\Models\AbstractModels\AbstractConfig
82 88
       ['key' => self::FIC_ALIQUOTE, 'value' => null, 'display_name' => 'Aliquote IVA', 'type' => 'aliquote'],
83 89
     ];
84 90
 
91
+    $config['Task'] = [
92
+      ['key' => self::TELEGRAM_BOT_TOKEN, 'value' => null, 'display_name' => 'Token bot Telegram', 'type' => 'password'],
93
+      ['key' => self::TELEGRAM_WEBHOOK_SECRET, 'value' => null, 'display_name' => 'Secret webhook Telegram', 'type' => 'password'],
94
+      ['key' => self::TELEGRAM_ALLOWED_IDS, 'value' => null, 'display_name' => 'ID Telegram autorizzati', 'type' => 'text'],
95
+      ['key' => self::OPENAI_API_KEY, 'value' => null, 'display_name' => 'Chiave OpenAI per i vocali', 'type' => 'password'],
96
+      ['key' => self::OPENAI_TRANSCRIBE_MODEL, 'value' => 'whisper-1', 'display_name' => 'Modello di trascrizione', 'type' => 'text'],
97
+    ];
98
+
85 99
     return $config;
86 100
   }
87 101
 
@@ -91,6 +105,7 @@ class Config extends \App\Models\AbstractModels\AbstractConfig
91 105
   public static function syncDefinitions(): void
92 106
   {
93 107
     self::where('key', 'colore_ruolo')->delete();
108
+    self::where('key', 'TASK_SETTORI')->delete();
94 109
 
95 110
     foreach (self::getConfig() as $group => $items) {
96 111
       $order = 1;
@@ -459,6 +474,96 @@ class Config extends \App\Models\AbstractModels\AbstractConfig
459 474
     return $date;
460 475
   }
461 476
 
477
+  /**
478
+   * Tipi segnalazione, usati anche come settore dei task.
479
+   *
480
+   * @return list<array{value: string, label: string}>
481
+   */
482
+  public static function tipiSegnalazione(): array
483
+  {
484
+    $tipi = [];
485
+    foreach (ConfiguraSegnalazione::query()->where('gruppo', 'Tipo')->orderBy('order')->orderBy('label')->get() as $tipo) {
486
+      $value = trim((string) $tipo->value);
487
+      if ($value === '') {
488
+        continue;
489
+      }
490
+      $label = trim((string) ($tipo->label ?: $value));
491
+      $tipi[] = [
492
+        'value' => $value,
493
+        'label' => $label !== '' ? $label : $value,
494
+      ];
495
+    }
496
+
497
+    return $tipi;
498
+  }
499
+
500
+  /**
501
+   * @return array<string, string>
502
+   */
503
+  public static function tipiSegnalazioneLabels(): array
504
+  {
505
+    $labels = [];
506
+    foreach (self::tipiSegnalazione() as $tipo) {
507
+      $labels[$tipo['value']] = $tipo['label'];
508
+    }
509
+
510
+    return $labels;
511
+  }
512
+
513
+  public static function tipoSegnalazioneLabel(?string $value): ?string
514
+  {
515
+    if ($value === null || trim($value) === '') {
516
+      return null;
517
+    }
518
+
519
+    return self::tipiSegnalazioneLabels()[$value] ?? $value;
520
+  }
521
+
522
+  public static function secretOrEnv(string $key, ?string $envValue): ?string
523
+  {
524
+    $value = trim((string) self::valueOf($key, ''));
525
+    if ($value === '') {
526
+      $value = trim((string) $envValue);
527
+    }
528
+
529
+    return $value !== '' ? $value : null;
530
+  }
531
+
532
+  public static function telegramBotToken(): ?string
533
+  {
534
+    return self::secretOrEnv(self::TELEGRAM_BOT_TOKEN, config('services.telegram.bot_token'));
535
+  }
536
+
537
+  public static function telegramWebhookSecret(): ?string
538
+  {
539
+    return self::secretOrEnv(self::TELEGRAM_WEBHOOK_SECRET, config('services.telegram.webhook_secret'));
540
+  }
541
+
542
+  /**
543
+   * @return list<int>
544
+   */
545
+  public static function telegramAllowedUserIds(): array
546
+  {
547
+    preg_match_all('/\d+/', (string) self::valueOf(self::TELEGRAM_ALLOWED_IDS, ''), $match);
548
+
549
+    return array_values(array_unique(array_map('intval', $match[0] ?? [])));
550
+  }
551
+
552
+  public static function openaiApiKey(): ?string
553
+  {
554
+    return self::secretOrEnv(self::OPENAI_API_KEY, config('services.openai.api_key'));
555
+  }
556
+
557
+  public static function openaiTranscribeModel(): string
558
+  {
559
+    $model = trim((string) self::valueOf(self::OPENAI_TRANSCRIBE_MODEL, ''));
560
+    if ($model === '') {
561
+      $model = trim((string) config('services.openai.transcribe_model', 'whisper-1'));
562
+    }
563
+
564
+    return $model !== '' ? $model : 'whisper-1';
565
+  }
566
+
462 567
   public static function formatCosto($value, $decimals = 2){
463 568
     return number_format($value, $decimals, ",",".",)." €";
464 569
   }

+ 105
- 0
app/Models/Task.php View File

@@ -0,0 +1,105 @@
1
+<?php
2
+
3
+namespace App\Models;
4
+
5
+use Illuminate\Database\Eloquent\Builder;
6
+use Illuminate\Database\Eloquent\Model;
7
+use Illuminate\Support\Collection;
8
+
9
+class Task extends Model
10
+{
11
+    protected $table = 'task';
12
+
13
+    protected $fillable = [
14
+        'testo',
15
+        'settore',
16
+        'tempo_stimato_minuti',
17
+        'prioritario',
18
+        'completato_at',
19
+        'segnalazione_id',
20
+        'user_id',
21
+        'origine',
22
+        'telegram_update_id',
23
+    ];
24
+
25
+    protected $casts = [
26
+        'prioritario' => 'boolean',
27
+        'tempo_stimato_minuti' => 'integer',
28
+        'completato_at' => 'datetime',
29
+        'telegram_update_id' => 'integer',
30
+    ];
31
+
32
+    public function segnalazione()
33
+    {
34
+        return $this->belongsTo(Segnalazione::class, 'segnalazione_id');
35
+    }
36
+
37
+    public function user()
38
+    {
39
+        return $this->belongsTo(User::class, 'user_id');
40
+    }
41
+
42
+    public function isCompletato(): bool
43
+    {
44
+        return $this->completato_at !== null;
45
+    }
46
+
47
+    public function scopeAperti(Builder $query): Builder
48
+    {
49
+        return $query->whereNull('completato_at');
50
+    }
51
+
52
+    public function scopeInElenco(Builder $query): Builder
53
+    {
54
+        return $query
55
+            ->orderByDesc('prioritario')
56
+            ->orderByRaw('CASE WHEN completato_at IS NULL THEN 0 ELSE 1 END')
57
+            ->orderBy('id');
58
+    }
59
+
60
+    /**
61
+     * I prioritari stanno sempre in cima. Il resto segue l'ordine di inserimento
62
+     * oppure viene raggruppato per settore.
63
+     *
64
+     * @param  Collection<int, self>  $tasks
65
+     * @param  list<string>  $settori
66
+     * @param  array<string, string>  $etichette
67
+     * @return array{prioritari: Collection<int, self>, sezioni: list<array{label: ?string, tasks: Collection<int, self>}>}
68
+     */
69
+    public static function presenta(Collection $tasks, string $vista, array $settori, array $etichette = []): array
70
+    {
71
+        $prioritari = $tasks->filter(fn (self $task) => $task->prioritario)->values();
72
+        $resto = $tasks->reject(fn (self $task) => $task->prioritario)->values();
73
+
74
+        if ($vista !== 'settore') {
75
+            return [
76
+                'prioritari' => $prioritari,
77
+                'sezioni' => [
78
+                    ['label' => null, 'tasks' => $resto],
79
+                ],
80
+            ];
81
+        }
82
+
83
+        $sezioni = [];
84
+        $noti = [];
85
+        foreach ($settori as $settore) {
86
+            $noti[$settore] = true;
87
+            $gruppo = $resto->filter(fn (self $task) => $task->settore === $settore)->values();
88
+            if ($gruppo->isNotEmpty()) {
89
+                $sezioni[] = ['label' => $etichette[$settore] ?? $settore, 'tasks' => $gruppo];
90
+            }
91
+        }
92
+
93
+        $altri = $resto->filter(function (self $task) use ($noti) {
94
+            return $task->settore === null || $task->settore === '' || ! isset($noti[$task->settore]);
95
+        })->values();
96
+        if ($altri->isNotEmpty()) {
97
+            $sezioni[] = ['label' => 'Senza tipo', 'tasks' => $altri];
98
+        }
99
+
100
+        return [
101
+            'prioritari' => $prioritari,
102
+            'sezioni' => $sezioni,
103
+        ];
104
+    }
105
+}

+ 48
- 0
app/Services/AudioTranscriptionService.php View File

@@ -0,0 +1,48 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Models\Config;
6
+use Illuminate\Support\Facades\Http;
7
+use RuntimeException;
8
+
9
+class AudioTranscriptionService
10
+{
11
+    public function transcribe(string $contents, ?string $mime = null): string
12
+    {
13
+        $key = Config::openaiApiKey();
14
+        if ($key === null) {
15
+            throw new RuntimeException('Chiave OpenAI non configurata: non posso trascrivere l\'audio.');
16
+        }
17
+
18
+        $response = Http::withToken($key)
19
+            ->timeout(90)
20
+            ->attach('file', $contents, $this->filenameForMime($mime))
21
+            ->post('https://api.openai.com/v1/audio/transcriptions', [
22
+                'model' => Config::openaiTranscribeModel(),
23
+                'language' => 'it',
24
+            ]);
25
+
26
+        if (! $response->successful()) {
27
+            throw new RuntimeException('Trascrizione non riuscita.');
28
+        }
29
+
30
+        $text = trim((string) $response->json('text'));
31
+        if ($text === '') {
32
+            throw new RuntimeException('L\'audio non contiene un testo riconoscibile.');
33
+        }
34
+
35
+        return $text;
36
+    }
37
+
38
+    private function filenameForMime(?string $mime): string
39
+    {
40
+        return match ($mime) {
41
+            'audio/mpeg', 'audio/mp3' => 'audio.mp3',
42
+            'audio/mp4', 'audio/m4a', 'audio/x-m4a' => 'audio.m4a',
43
+            'audio/wav', 'audio/x-wav' => 'audio.wav',
44
+            'audio/webm' => 'audio.webm',
45
+            default => 'audio.ogg',
46
+        };
47
+    }
48
+}

+ 81
- 0
app/Services/TelegramBotService.php View File

@@ -0,0 +1,81 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Models\Config;
6
+use Illuminate\Support\Facades\Http;
7
+use RuntimeException;
8
+
9
+class TelegramBotService
10
+{
11
+    public function token(): ?string
12
+    {
13
+        return Config::telegramBotToken();
14
+    }
15
+
16
+    /**
17
+     * @param  array<string, mixed>  $params
18
+     * @return array<string, mixed>
19
+     */
20
+    public function call(string $method, array $params = []): array
21
+    {
22
+        $token = $this->token();
23
+        if ($token === null) {
24
+            throw new RuntimeException('Token del bot Telegram non configurato.');
25
+        }
26
+
27
+        $response = Http::timeout(20)
28
+            ->acceptJson()
29
+            ->post('https://api.telegram.org/bot'.$token.'/'.$method, $params);
30
+
31
+        $body = $response->json();
32
+        if (! is_array($body) || ($body['ok'] ?? false) !== true) {
33
+            $description = is_array($body) ? (string) ($body['description'] ?? '') : '';
34
+            throw new RuntimeException($description !== '' ? $description : 'Telegram ha risposto con un errore.');
35
+        }
36
+
37
+        return $body;
38
+    }
39
+
40
+    public function sendMessage(int|string $chatId, string $text): void
41
+    {
42
+        $text = trim($text);
43
+        if ($text === '') {
44
+            return;
45
+        }
46
+        if (mb_strlen($text) > 4000) {
47
+            $text = mb_substr($text, 0, 3990).'…';
48
+        }
49
+
50
+        $this->call('sendMessage', [
51
+            'chat_id' => $chatId,
52
+            'text' => $text,
53
+        ]);
54
+    }
55
+
56
+    public function downloadFile(string $fileId): string
57
+    {
58
+        $body = $this->call('getFile', ['file_id' => $fileId]);
59
+        $path = (string) data_get($body, 'result.file_path', '');
60
+        if ($path === '') {
61
+            throw new RuntimeException('File Telegram non disponibile.');
62
+        }
63
+
64
+        $token = $this->token();
65
+        $response = Http::timeout(60)->get('https://api.telegram.org/file/bot'.$token.'/'.$path);
66
+        if (! $response->successful() || $response->body() === '') {
67
+            throw new RuntimeException('Download del vocale non riuscito.');
68
+        }
69
+
70
+        return $response->body();
71
+    }
72
+
73
+    public function setWebhook(string $url, string $secret): void
74
+    {
75
+        $this->call('setWebhook', [
76
+            'url' => $url,
77
+            'secret_token' => $secret,
78
+            'allowed_updates' => ['message'],
79
+        ]);
80
+    }
81
+}

+ 183
- 0
app/Services/TelegramTaskIntake.php View File

@@ -0,0 +1,183 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Models\Config;
6
+use App\Models\Task;
7
+use App\Support\TaskMessageParser;
8
+use Illuminate\Database\UniqueConstraintViolationException;
9
+use Illuminate\Support\Facades\Log;
10
+use Throwable;
11
+
12
+class TelegramTaskIntake
13
+{
14
+    public function __construct(
15
+        private TelegramBotService $telegram,
16
+        private AudioTranscriptionService $transcription,
17
+    ) {}
18
+
19
+    /**
20
+     * @param  array<string, mixed>  $update
21
+     */
22
+    public function handle(array $update): void
23
+    {
24
+        $message = $update['message'] ?? null;
25
+        if (! is_array($message)) {
26
+            return;
27
+        }
28
+
29
+        $chatId = data_get($message, 'chat.id');
30
+        $userId = (int) data_get($message, 'from.id', 0);
31
+        if ($chatId === null || $userId <= 0) {
32
+            return;
33
+        }
34
+
35
+        if (! $this->isAllowed($userId)) {
36
+            $this->reply($chatId, 'Accesso non abilitato. Comunica questo ID Telegram a chi gestisce MyAzienda: '.$userId);
37
+
38
+            return;
39
+        }
40
+
41
+        $updateId = isset($update['update_id']) ? (int) $update['update_id'] : 0;
42
+        if ($updateId > 0 && Task::query()->where('telegram_update_id', $updateId)->exists()) {
43
+            return;
44
+        }
45
+
46
+        $text = trim((string) ($message['text'] ?? ''));
47
+        $command = $this->command($text);
48
+        if ($command !== null) {
49
+            if (in_array($command['name'], ['start', 'help'], true) || $command['rest'] === '') {
50
+                $this->reply($chatId, $this->helpText());
51
+
52
+                return;
53
+            }
54
+            $text = $command['name'] === 'urgente' ? '! '.$command['rest'] : $command['rest'];
55
+        }
56
+
57
+        $audio = $message['voice'] ?? $message['audio'] ?? null;
58
+        if (is_array($audio) && isset($audio['file_id'])) {
59
+            try {
60
+                $binary = $this->telegram->downloadFile((string) $audio['file_id']);
61
+                $text = $this->transcription->transcribe($binary, isset($audio['mime_type']) ? (string) $audio['mime_type'] : null);
62
+            } catch (Throwable $e) {
63
+                Log::warning('Trascrizione task Telegram non riuscita', ['error' => $e->getMessage()]);
64
+                $this->reply($chatId, $e->getMessage());
65
+
66
+                return;
67
+            }
68
+        }
69
+
70
+        if ($text === '') {
71
+            return;
72
+        }
73
+
74
+        $text = preg_replace('/\b(?:hashtag|cancelletto)\s+/iu', '#', $text) ?? $text;
75
+        [$values, $alias] = $this->tipiParser();
76
+        $parsed = TaskMessageParser::parse($text, $values, $alias);
77
+        if ($parsed['testo'] === '') {
78
+            $this->reply($chatId, "Manca il testo del task.\n\n".$this->helpText());
79
+
80
+            return;
81
+        }
82
+
83
+        $task = new Task();
84
+        $task->testo = $parsed['testo'];
85
+        $task->settore = $parsed['settore'];
86
+        $task->tempo_stimato_minuti = $parsed['tempo_minuti'];
87
+        $task->prioritario = $parsed['prioritario'];
88
+        $task->origine = 'telegram';
89
+        $task->telegram_update_id = $updateId > 0 ? $updateId : null;
90
+
91
+        try {
92
+            $task->save();
93
+        } catch (UniqueConstraintViolationException) {
94
+            return;
95
+        }
96
+
97
+        $this->reply($chatId, $this->conferma($task));
98
+    }
99
+
100
+    private function isAllowed(int $userId): bool
101
+    {
102
+        $allowed = Config::telegramAllowedUserIds();
103
+
104
+        return $allowed !== [] && in_array($userId, $allowed, true);
105
+    }
106
+
107
+    /**
108
+     * @return array{name: string, rest: string}|null
109
+     */
110
+    private function command(string $text): ?array
111
+    {
112
+        if (! preg_match('/^\/(start|help|task|urgente)(?:@\S+)?(?:\s+([\s\S]*))?$/u', $text, $match)) {
113
+            return null;
114
+        }
115
+
116
+        return [
117
+            'name' => $match[1],
118
+            'rest' => trim((string) ($match[2] ?? '')),
119
+        ];
120
+    }
121
+
122
+    /**
123
+     * @return array{0: list<string>, 1: array<string, string>}
124
+     */
125
+    private function tipiParser(): array
126
+    {
127
+        $values = [];
128
+        $alias = [];
129
+        foreach (Config::tipiSegnalazione() as $tipo) {
130
+            $values[] = $tipo['value'];
131
+            $alias[$tipo['label']] = $tipo['value'];
132
+        }
133
+
134
+        return [$values, $alias];
135
+    }
136
+
137
+    private function helpText(): string
138
+    {
139
+        [$values] = $this->tipiParser();
140
+        $esempi = [];
141
+        foreach ($values as $value) {
142
+            $esempi[] = '#'.TaskMessageParser::slug($value);
143
+        }
144
+
145
+        $linee = [
146
+            'Inviami un testo o un vocale: lo salvo come cosa da fare.',
147
+            'Inizia con ! oppure con urgente per marcarlo prioritario.',
148
+        ];
149
+        if ($esempi !== []) {
150
+            $linee[] = 'Tipo: '.implode(', ', $esempi);
151
+        }
152
+        $linee[] = 'Tempo stimato: ~45m, ~1h30m oppure ~1,5';
153
+        $linee[] = 'Esempio: ! #manutenzione ~30m chiamare Rossi';
154
+
155
+        return implode("\n", $linee);
156
+    }
157
+
158
+    private function conferma(Task $task): string
159
+    {
160
+        $linee = ['Task #'.$task->id.' salvato.'];
161
+        if ($task->prioritario) {
162
+            $linee[] = 'Prioritario';
163
+        }
164
+        if ($task->settore) {
165
+            $linee[] = 'Tipo: '.Config::tipoSegnalazioneLabel($task->settore);
166
+        }
167
+        if ($task->tempo_stimato_minuti) {
168
+            $linee[] = 'Tempo: '.Config::formatTempo($task->tempo_stimato_minuti);
169
+        }
170
+        $linee[] = $task->testo;
171
+
172
+        return implode("\n", $linee);
173
+    }
174
+
175
+    private function reply(int|string $chatId, string $text): void
176
+    {
177
+        try {
178
+            $this->telegram->sendMessage($chatId, $text);
179
+        } catch (Throwable $e) {
180
+            Log::warning('Risposta Telegram non inviata', ['error' => $e->getMessage()]);
181
+        }
182
+    }
183
+}

+ 139
- 0
app/Support/TaskMessageParser.php View File

@@ -0,0 +1,139 @@
1
+<?php
2
+
3
+namespace App\Support;
4
+
5
+use App\Models\Config;
6
+
7
+class TaskMessageParser
8
+{
9
+    /**
10
+     * Ricava settore, priorità e tempo da un messaggio rapido.
11
+     *
12
+     * ! oppure la prima parola "urgente"/"prioritario" segnano il task come prioritario.
13
+     * #tipo assegna un tipo segnalazione. ~45m, ~1h30m o ~1,5 indicano il tempo stimato.
14
+     *
15
+     * @param  list<string>  $settori  valori canonici (il value del tipo)
16
+     * @param  array<string, string>  $alias  etichetta o sinonimo => value
17
+     * @return array{testo: string, settore: ?string, prioritario: bool, tempo_minuti: ?int}
18
+     */
19
+    public static function parse(string $raw, array $settori, array $alias = []): array
20
+    {
21
+        $settoriBySlug = [];
22
+        foreach ($settori as $value) {
23
+            $slug = self::slug((string) $value);
24
+            if ($slug !== '') {
25
+                $settoriBySlug[$slug] = (string) $value;
26
+            }
27
+        }
28
+        foreach ($alias as $from => $to) {
29
+            $to = (string) $to;
30
+            if (! in_array($to, $settori, true)) {
31
+                continue;
32
+            }
33
+            $slug = self::slug((string) $from);
34
+            if ($slug !== '') {
35
+                $settoriBySlug[$slug] = $to;
36
+            }
37
+        }
38
+
39
+        $state = [
40
+            'prioritario' => false,
41
+            'settore' => null,
42
+            'tempo_minuti' => null,
43
+        ];
44
+        $kept = [];
45
+        $isFirst = true;
46
+
47
+        foreach (preg_split('/\s+/u', trim($raw)) ?: [] as $token) {
48
+            if ($token === '') {
49
+                continue;
50
+            }
51
+
52
+            if ($isFirst && (strcasecmp($token, 'urgente') === 0 || strcasecmp($token, 'prioritario') === 0)) {
53
+                $state['prioritario'] = true;
54
+                $isFirst = false;
55
+                continue;
56
+            }
57
+            $isFirst = false;
58
+
59
+            $rest = self::peel($token, $state, $settoriBySlug);
60
+            if ($rest !== null && $rest !== '') {
61
+                $kept[] = $rest;
62
+            }
63
+        }
64
+
65
+        return [
66
+            'testo' => trim(implode(' ', $kept)),
67
+            'settore' => $state['settore'],
68
+            'prioritario' => $state['prioritario'],
69
+            'tempo_minuti' => $state['tempo_minuti'],
70
+        ];
71
+    }
72
+
73
+    public static function slug(string $value): string
74
+    {
75
+        $value = mb_strtolower(trim($value));
76
+        $value = strtr($value, [
77
+            'à' => 'a', 'á' => 'a', 'è' => 'e', 'é' => 'e', 'ì' => 'i', 'í' => 'i',
78
+            'ò' => 'o', 'ó' => 'o', 'ù' => 'u', 'ú' => 'u',
79
+        ]);
80
+
81
+        return preg_replace('/[^a-z0-9]+/u', '', $value) ?? '';
82
+    }
83
+
84
+    /**
85
+     * @param  array{prioritario: bool, settore: ?string, tempo_minuti: ?int}  $state
86
+     * @param  array<string, string>  $settoriBySlug
87
+     */
88
+    private static function peel(string $token, array &$state, array $settoriBySlug): ?string
89
+    {
90
+        $guard = 0;
91
+        while ($token !== '' && $guard++ < 6) {
92
+            if ($token === '!') {
93
+                $state['prioritario'] = true;
94
+
95
+                return null;
96
+            }
97
+
98
+            if (str_starts_with($token, '!')) {
99
+                $state['prioritario'] = true;
100
+                $token = mb_substr($token, 1);
101
+                continue;
102
+            }
103
+
104
+            if (str_starts_with($token, '#')) {
105
+                $hash = $token;
106
+                $gluedTime = null;
107
+                if (str_contains($token, '~')) {
108
+                    [$hash, $gluedTime] = explode('~', $token, 2);
109
+                }
110
+                $slug = self::slug(mb_substr($hash, 1));
111
+                if ($slug !== '' && isset($settoriBySlug[$slug])) {
112
+                    $state['settore'] = $settoriBySlug[$slug];
113
+                    if ($gluedTime === null) {
114
+                        return null;
115
+                    }
116
+                    $token = '~'.$gluedTime;
117
+                    continue;
118
+                }
119
+
120
+                return $token;
121
+            }
122
+
123
+            if (str_starts_with($token, '~') && mb_strlen($token) > 1) {
124
+                $parsed = Config::parseTempoToMinuti(mb_substr($token, 1));
125
+                if ($parsed !== null) {
126
+                    $state['tempo_minuti'] = $parsed;
127
+
128
+                    return null;
129
+                }
130
+
131
+                return $token;
132
+            }
133
+
134
+            return $token;
135
+        }
136
+
137
+        return $token !== '' ? $token : null;
138
+    }
139
+}

+ 3
- 0
bootstrap/app.php View File

@@ -17,6 +17,9 @@ return Application::configure(basePath: dirname(__DIR__))
17 17
         $middleware->validateCsrfTokens(except: [
18 18
             'webhook/stripe/acquisto-servizio',
19 19
             'webhook/paypal/acquisto-servizio',
20
+            'webhook/stripe/shop',
21
+            'webhook/paypal/shop',
22
+            'webhook/telegram/tasks',
20 23
         ]);
21 24
     })
22 25
     ->withMiddleware(function (Middleware $middleware) {

+ 14
- 0
config/services.php View File

@@ -83,6 +83,20 @@ return [
83 83
         'access_token' => env('FATTUREINCLOUD_ACCESS_TOKEN'),
84 84
     ],
85 85
 
86
+    /*
87
+    | Bot Telegram per creare task da testo o vocale.
88
+    | I valori salvati in Configurazione > Task hanno la precedenza.
89
+    */
90
+    'telegram' => [
91
+        'bot_token' => env('TELEGRAM_BOT_TOKEN'),
92
+        'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'),
93
+    ],
94
+
95
+    'openai' => [
96
+        'api_key' => env('OPENAI_API_KEY'),
97
+        'transcribe_model' => env('OPENAI_TRANSCRIBE_MODEL', 'whisper-1'),
98
+    ],
99
+
86 100
     'skebby' => [
87 101
         'username' => env('SKEBBY_USERNAME'),
88 102
         'password' => env('SKEBBY_PASSWORD'),

+ 34
- 0
database/migrations/2026_10_20_230000_create_task_table.php View File

@@ -0,0 +1,34 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    public function up(): void
10
+    {
11
+        Schema::create('task', function (Blueprint $table) {
12
+            $table->bigIncrements('id');
13
+            $table->text('testo');
14
+            $table->string('settore')->nullable();
15
+            $table->unsignedInteger('tempo_stimato_minuti')->nullable();
16
+            $table->boolean('prioritario')->default(false);
17
+            $table->timestamp('completato_at')->nullable();
18
+            $table->unsignedBigInteger('segnalazione_id')->nullable();
19
+            $table->unsignedBigInteger('user_id')->nullable();
20
+            $table->string('origine')->default('web');
21
+            $table->unsignedBigInteger('telegram_update_id')->nullable();
22
+            $table->timestamps();
23
+
24
+            $table->index('completato_at');
25
+            $table->index('segnalazione_id');
26
+            $table->unique('telegram_update_id');
27
+        });
28
+    }
29
+
30
+    public function down(): void
31
+    {
32
+        Schema::dropIfExists('task');
33
+    }
34
+};

+ 7
- 0
resources/menu/verticalMenu.json View File

@@ -37,6 +37,13 @@
37 37
       "url": "admin/segnalazione",
38 38
       "can": "view-segnalazione|view-segnalazione-assegnate"
39 39
     },
40
+    {
41
+      "name": "Cose da fare",
42
+      "icon": "menu-icon icon-base bx bx-task",
43
+      "slug": "admin.task.index",
44
+      "url": "admin/tasks",
45
+      "can": "view-task"
46
+    },
40 47
     {
41 48
       "name": "Tempo Impiegato",
42 49
       "icon": "menu-icon icon-base bx bx-time",

+ 29
- 0
resources/views/config/index.blade.php View File

@@ -7,6 +7,7 @@ $tabIcons = [
7 7
   'Acquisto servizio' => 'bx-cart',
8 8
   'Fatturazione' => 'bx-receipt',
9 9
   'Fatture in Cloud' => 'bx-cloud',
10
+  'Task' => 'bx-task',
10 11
 ];
11 12
 $groupKeys = array_keys($groups);
12 13
 $activeGroup = session('config_group');
@@ -151,6 +152,11 @@ if (is_string($activeGroup)) {
151 152
               </select>
152 153
               @break
153 154
 
155
+              @case('lines')
156
+              <textarea class="form-control" name="value[{{ $config->key }}]" id="{{ $fid }}-control" rows="6" {{ $disabled }}>{{ implode("\n", Config::decodeList($config->value)) }}</textarea>
157
+              <div class="form-text">Un valore per riga, nell'ordine in cui vuoi vederlo.</div>
158
+              @break
159
+
154 160
               @case('stato')
155 161
               <select class="form-select" name="value[{{ $config->key }}]" id="{{ $fid }}-control" {{ $disabled }}>
156 162
                 <option value="">— Seleziona —</option>
@@ -238,6 +244,15 @@ if (is_string($activeGroup)) {
238 244
               @if($config->key === Config::FIC_ACCESS_TOKEN)
239 245
               <div class="form-text">Token manuale di <a href="https://developers.fattureincloud.it/docs/getting-started/" target="_blank" rel="noopener">Fatture in Cloud</a> con scope <code>entity.clients:r</code>, <code>entity.clients:a</code>, <code>settings:r</code> e <code>issued_documents.invoices:a</code>. Lascia vuoto per non sostituirlo.</div>
240 246
               @endif
247
+              @if($config->key === Config::TELEGRAM_BOT_TOKEN)
248
+              <div class="form-text">Token di @BotFather. Salvalo, poi usa Collega bot Telegram. L'indirizzo dell'app deve essere pubblico e in HTTPS.</div>
249
+              @endif
250
+              @if($config->key === Config::TELEGRAM_WEBHOOK_SECRET)
251
+              <div class="form-text">Se lo lasci vuoto viene creato al collegamento del bot. Un valore già salvato non viene cancellato.</div>
252
+              @endif
253
+              @if($config->key === Config::OPENAI_API_KEY)
254
+              <div class="form-text">Serve per trascrivere i vocali inviati al bot. Lascia vuoto per non sostituirla.</div>
255
+              @endif
241 256
               @break
242 257
 
243 258
               @default
@@ -245,11 +260,25 @@ if (is_string($activeGroup)) {
245 260
               @if($config->key === Config::FIC_COMPANY_ID)
246 261
               <div class="form-text">Identificativo azienda su Fatture in Cloud. Si legge anche da Impostazioni → API, oppure con il metodo List User Companies.</div>
247 262
               @endif
263
+              @if($config->key === Config::TELEGRAM_ALLOWED_IDS)
264
+              <div class="form-text">ID numerici, separati da virgola o spazio. Se chi scrive non è in elenco, il bot risponde solo con il suo ID.</div>
265
+              @endif
266
+              @if($config->key === Config::OPENAI_TRANSCRIBE_MODEL)
267
+              <div class="form-text">Default <code>whisper-1</code>. Un messaggio può essere testo oppure audio: l'audio viene trascritto e poi salvato come task.</div>
268
+              @endif
248 269
             @endswitch
249 270
           </div>
250 271
           @empty
251 272
           <p class="text-muted mb-0">Nessuna impostazione in questo gruppo.</p>
252 273
           @endforelse
274
+          @if($group === 'Task' && $canEdit)
275
+          <div class="config-field">
276
+            <p class="mb-2">Collega il bot all'indirizzo pubblico HTTPS dell'app. Se hai appena incollato il token, questo pulsante lo salva prima di registrare il webhook.</p>
277
+            <button type="submit" class="btn btn-sm btn-outline-primary" formaction="{{ route('admin.config.telegram') }}">
278
+              <i class="bx bxl-telegram me-1"></i>Collega bot Telegram
279
+            </button>
280
+          </div>
281
+          @endif
253 282
         </div>
254 283
         @endforeach
255 284
       </div>

+ 40
- 0
resources/views/dashboard/dashboards-manutenzioni.blade.php View File

@@ -18,6 +18,42 @@ $configData = Helper::appClasses();
18 18
 @endsection
19 19
 
20 20
 @section('content')
21
+@if(!empty($mostraTasks))
22
+<div class="card mb-4">
23
+  <div class="card-header border-bottom">
24
+    <div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
25
+      <div class="d-flex align-items-center">
26
+        <div class="avatar avatar-sm me-2">
27
+          <span class="avatar-initial rounded bg-label-danger">
28
+            <i class="bx bx-task fs-4"></i>
29
+          </span>
30
+        </div>
31
+        <div>
32
+          <h5 class="mb-0">Cose da fare</h5>
33
+          <p class="text-body-secondary mb-0">I prioritari restano in alto, evidenziati in rosso</p>
34
+        </div>
35
+      </div>
36
+      <div class="d-flex flex-wrap gap-2">
37
+        <div class="btn-group btn-group-sm">
38
+          <a href="{{ route('admin.dashboard', ['vista' => 'inserimento']) }}" class="btn {{ ($tasksVista ?? 'inserimento') === 'inserimento' ? 'btn-primary' : 'btn-outline-primary' }}">Ordine di inserimento</a>
39
+          <a href="{{ route('admin.dashboard', ['vista' => 'settore']) }}" class="btn {{ ($tasksVista ?? '') === 'settore' ? 'btn-primary' : 'btn-outline-primary' }}">Per tipo</a>
40
+        </div>
41
+        @can('view-task')
42
+        <a href="{{ route('admin.task.index', ['vista' => $tasksVista ?? 'inserimento']) }}" class="btn btn-sm btn-label-primary">Gestisci</a>
43
+        @endcan
44
+      </div>
45
+    </div>
46
+  </div>
47
+  <div class="card-body p-0">
48
+    @include('task._elenco', [
49
+      'presentazione' => $tasksPresentazione,
50
+      'gestione' => false,
51
+      'vista' => $tasksVista ?? 'inserimento',
52
+    ])
53
+  </div>
54
+</div>
55
+@endif
56
+
21 57
 <div class="card mb-4">
22 58
   <div class="card-header border-bottom">
23 59
     <div class="d-flex align-items-center">
@@ -390,6 +426,10 @@ $configData = Helper::appClasses();
390 426
   #calendarioSlotPrenotazioni .fc-event {
391 427
     border: none;
392 428
   }
429
+  .task-prioritario {
430
+    background: rgba(220, 53, 69, 0.08);
431
+    box-shadow: inset 4px 0 0 #dc3545;
432
+  }
393 433
 </style>
394 434
 @endsection
395 435
 

+ 23
- 0
resources/views/task/_elenco.blade.php View File

@@ -0,0 +1,23 @@
1
+@php
2
+  $prioritari = $presentazione['prioritari'] ?? collect();
3
+  $sezioni = $presentazione['sezioni'] ?? [];
4
+  $vuoto = $prioritari->isEmpty() && collect($sezioni)->every(fn ($sezione) => ($sezione['tasks'] ?? collect())->isEmpty());
5
+@endphp
6
+
7
+@if($vuoto)
8
+<div class="p-4 text-center text-muted">Nessun task in questo elenco.</div>
9
+@else
10
+  @if($prioritari->isNotEmpty())
11
+  <div class="px-4 pt-3 pb-1 small text-danger fw-semibold text-uppercase">Prioritari</div>
12
+  @include('task._riga', ['righe' => $prioritari, 'gestione' => $gestione ?? false, 'canEdit' => $canEdit ?? false, 'canDelete' => $canDelete ?? false, 'stato' => $stato ?? 'aperte', 'vista' => $vista ?? 'inserimento'])
13
+  @endif
14
+
15
+  @foreach($sezioni as $sezione)
16
+    @if(($sezione['tasks'] ?? collect())->isNotEmpty())
17
+      @if(!empty($sezione['label']))
18
+      <div class="px-4 pt-3 pb-1 small text-muted fw-semibold text-uppercase">{{ $sezione['label'] }}</div>
19
+      @endif
20
+      @include('task._riga', ['righe' => $sezione['tasks'], 'gestione' => $gestione ?? false, 'canEdit' => $canEdit ?? false, 'canDelete' => $canDelete ?? false, 'stato' => $stato ?? 'aperte', 'vista' => $vista ?? 'inserimento'])
21
+    @endif
22
+  @endforeach
23
+@endif

+ 60
- 0
resources/views/task/_riga.blade.php View File

@@ -0,0 +1,60 @@
1
+@php
2
+  $righe = $righe ?? collect();
3
+@endphp
4
+@foreach($righe as $task)
5
+@php
6
+  $fatto = $task->isCompletato();
7
+@endphp
8
+<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2 px-4 py-3 border-bottom {{ $task->prioritario ? 'task-prioritario' : '' }}">
9
+  <div class="min-w-0">
10
+    <div class="task-testo {{ $fatto ? 'text-decoration-line-through text-muted' : '' }}">
11
+      @if($task->prioritario)
12
+      <i class="bx bx-error-circle me-1"></i>
13
+      @endif
14
+      @if(($gestione ?? false) && ($canEdit ?? false))
15
+      <a href="{{ route('admin.task.edit', ['id' => $task->id, 'stato' => $stato ?? 'aperte', 'vista' => $vista ?? 'inserimento']) }}" class="{{ $task->prioritario && ! $fatto ? 'text-danger' : 'text-body' }}">{{ $task->testo }}</a>
16
+      @else
17
+      <span class="{{ $task->prioritario && ! $fatto ? 'text-danger fw-semibold' : '' }}">{{ $task->testo }}</span>
18
+      @endif
19
+    </div>
20
+    <div class="small mt-1 d-flex flex-wrap align-items-center gap-1">
21
+      @if($task->settore)
22
+      <span class="badge bg-label-primary">{{ ($tipoLabels ?? [])[$task->settore] ?? $task->settore }}</span>
23
+      @endif
24
+      @if($task->tempo_stimato_minuti)
25
+      <span class="badge bg-label-secondary">{{ \App\Models\Config::formatTempo($task->tempo_stimato_minuti) }}</span>
26
+      @endif
27
+      @if($task->prioritario)
28
+      <span class="badge bg-danger">Prioritario</span>
29
+      @endif
30
+      @if($task->origine === 'telegram')
31
+      <span class="badge bg-label-info" title="Creato da Telegram"><i class="bx bxl-telegram"></i> Telegram</span>
32
+      @endif
33
+      @if($task->segnalazione)
34
+      <a href="{{ route('admin.segnalazione.show', ['id' => $task->segnalazione->id]) }}" class="badge bg-label-warning text-decoration-none">{{ $task->segnalazione->getLabel() }}</a>
35
+      @endif
36
+      <span class="text-muted">{{ $task->created_at?->format('d/m/Y H:i') }}</span>
37
+    </div>
38
+  </div>
39
+  @if($gestione ?? false)
40
+  <div class="d-flex flex-wrap gap-1 flex-shrink-0">
41
+    @if($canEdit ?? false)
42
+    <form method="POST" action="{{ route('admin.task.toggle', $task->id) }}">
43
+      @csrf
44
+      <button type="submit" class="btn btn-sm {{ $fatto ? 'btn-label-secondary' : 'btn-label-success' }}">
45
+        <i class="bx {{ $fatto ? 'bx-undo' : 'bx-check' }} me-1"></i>{{ $fatto ? 'Riapri' : 'Fatto' }}
46
+      </button>
47
+    </form>
48
+    <a href="{{ route('admin.task.edit', ['id' => $task->id, 'stato' => $stato ?? 'aperte', 'vista' => $vista ?? 'inserimento']) }}" class="btn btn-sm btn-label-primary">Modifica</a>
49
+    @endif
50
+    @if($canDelete ?? false)
51
+    <form method="POST" action="{{ route('admin.task.destroy', $task->id) }}" onsubmit="return confirm('Eliminare questo task?')">
52
+      @csrf
53
+      @method('DELETE')
54
+      <button type="submit" class="btn btn-sm btn-label-danger">Elimina</button>
55
+    </form>
56
+    @endif
57
+  </div>
58
+  @endif
59
+</div>
60
+@endforeach

+ 224
- 0
resources/views/task/index.blade.php View File

@@ -0,0 +1,224 @@
1
+@php
2
+$configData = Helper::appClasses();
3
+$mode = old('segnalazione_mode', $task->exists && $task->segnalazione_id ? 'esistente' : 'nessuna');
4
+$tempoValue = old('tempo', $task->tempo_stimato_minuti ? \App\Models\Config::formatTempo($task->tempo_stimato_minuti) : '');
5
+$prioritarioVal = old('prioritario', $task->prioritario);
6
+$prioritarioChecked = $prioritarioVal === true || $prioritarioVal === 1 || $prioritarioVal === '1';
7
+@endphp
8
+
9
+@extends('layouts/layoutMaster')
10
+
11
+@section('title', 'Cose da fare')
12
+
13
+@section('pageTitle')
14
+<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
15
+  <h4 class="mb-0">Cose da fare</h4>
16
+  <div class="btn-group btn-group-sm">
17
+    <a href="{{ route('admin.task.index', ['vista' => 'inserimento', 'stato' => $stato]) }}" class="btn {{ $vista === 'inserimento' ? 'btn-primary' : 'btn-outline-primary' }}">Ordine di inserimento</a>
18
+    <a href="{{ route('admin.task.index', ['vista' => 'settore', 'stato' => $stato]) }}" class="btn {{ $vista === 'settore' ? 'btn-primary' : 'btn-outline-primary' }}">Per tipo</a>
19
+  </div>
20
+</div>
21
+@endsection
22
+
23
+@section('content')
24
+<style>
25
+  .task-prioritario {
26
+    background: rgba(220, 53, 69, 0.08);
27
+    box-shadow: inset 4px 0 0 #dc3545;
28
+  }
29
+</style>
30
+
31
+@if(session('success'))
32
+<div class="alert alert-success alert-dismissible fade show" role="alert">
33
+  {{ session('success') }}
34
+  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
35
+</div>
36
+@endif
37
+
38
+@if($errors->any())
39
+<div class="alert alert-danger">
40
+  <ul class="mb-0">
41
+    @foreach($errors->all() as $error)
42
+    <li>{{ $error }}</li>
43
+    @endforeach
44
+  </ul>
45
+</div>
46
+@endif
47
+
48
+@if($canCreate || $task->exists)
49
+<div class="card mb-4">
50
+  <div class="card-header border-bottom">
51
+    <h5 class="mb-0">{{ $task->exists ? 'Modifica task' : 'Nuovo task' }}</h5>
52
+  </div>
53
+  <div class="card-body">
54
+    <form method="POST" action="{{ $task->exists ? route('admin.task.update', $task->id) : route('admin.task.store') }}">
55
+      @csrf
56
+      @if($task->exists) @method('PUT') @endif
57
+      <div class="row g-3">
58
+        <div class="col-12">
59
+          <label class="form-label" for="task-testo">Cosa c'è da fare <span class="text-danger">*</span></label>
60
+          <textarea class="form-control" id="task-testo" name="testo" rows="3" required>{{ old('testo', $task->testo) }}</textarea>
61
+        </div>
62
+        <div class="col-md-4">
63
+          <label class="form-label" for="task-settore">Tipo</label>
64
+          <select class="form-select" id="task-settore" name="settore">
65
+            <option value="">— Nessuno —</option>
66
+            @foreach($tipi as $tipo)
67
+            <option value="{{ $tipo['value'] }}" @selected(old('settore', $task->settore) === $tipo['value'])>{{ $tipo['label'] }}</option>
68
+            @endforeach
69
+            @if($task->settore && ! in_array($task->settore, $settori, true))
70
+            <option value="{{ $task->settore }}" selected>{{ $tipoLabels[$task->settore] ?? $task->settore }}</option>
71
+            @endif
72
+          </select>
73
+          <div class="form-text">Stesso tipo delle segnalazioni. Se colleghi o crei una segnalazione, il tipo resta allineato.</div>
74
+        </div>
75
+        <div class="col-md-4">
76
+          <label class="form-label" for="task-tempo">Tempo stimato</label>
77
+          <input type="text" class="form-control" id="task-tempo" name="tempo" value="{{ $tempoValue }}" placeholder="45m, 1h30m, 1,5">
78
+        </div>
79
+        <div class="col-md-4 d-flex align-items-end">
80
+          <div class="form-check mb-2">
81
+            <input type="hidden" name="prioritario" value="0">
82
+            <input class="form-check-input" type="checkbox" name="prioritario" id="task-prioritario" value="1" @checked($prioritarioChecked)>
83
+            <label class="form-check-label text-danger fw-semibold" for="task-prioritario">Prioritario</label>
84
+          </div>
85
+        </div>
86
+        <div class="col-12">
87
+          <label class="form-label d-block">Segnalazione</label>
88
+          <div class="d-flex flex-wrap gap-3">
89
+            <div class="form-check">
90
+              <input class="form-check-input" type="radio" name="segnalazione_mode" id="mode-nessuna" value="nessuna" @checked($mode === 'nessuna')>
91
+              <label class="form-check-label" for="mode-nessuna">Nessuna</label>
92
+            </div>
93
+            <div class="form-check">
94
+              <input class="form-check-input" type="radio" name="segnalazione_mode" id="mode-esistente" value="esistente" @checked($mode === 'esistente')>
95
+              <label class="form-check-label" for="mode-esistente">Collega una esistente</label>
96
+            </div>
97
+            @if($puoCreareSegnalazione)
98
+            <div class="form-check">
99
+              <input class="form-check-input" type="radio" name="segnalazione_mode" id="mode-nuova" value="nuova" @checked($mode === 'nuova')>
100
+              <label class="form-check-label" for="mode-nuova">Creane una nuova</label>
101
+            </div>
102
+            @endif
103
+          </div>
104
+        </div>
105
+        <div class="col-12 {{ $mode === 'esistente' ? '' : 'd-none' }}" id="task-esistente">
106
+          <label class="form-label" for="task-cerca-segnalazione">Cerca segnalazione</label>
107
+          <input type="hidden" name="segnalazione_id" id="task-segnalazione-id" value="{{ old('segnalazione_id', $task->segnalazione_id) }}">
108
+          <input type="text" class="form-control" id="task-cerca-segnalazione" autocomplete="off" placeholder="Numero o titolo" value="{{ $segnalazioneSelezionata ? $segnalazioneSelezionata->getLabel() : '' }}">
109
+          <div class="list-group mt-1 d-none" id="task-segnalazione-risultati"></div>
110
+        </div>
111
+        <div class="col-12 {{ $mode === 'nuova' ? '' : 'd-none' }}" id="task-nuova">
112
+          <label class="form-label" for="task-titolo-segnalazione">Titolo della nuova segnalazione</label>
113
+          <input type="text" class="form-control" id="task-titolo-segnalazione" name="nuova_segnalazione_titolo" maxlength="255" value="{{ old('nuova_segnalazione_titolo') }}" placeholder="Se lo lasci vuoto uso il testo del task">
114
+          <div class="form-text">La segnalazione nasce interna, senza notifica al cliente.</div>
115
+        </div>
116
+        <div class="col-12 d-flex flex-wrap gap-2">
117
+          <button type="submit" class="btn btn-primary">
118
+            <i class="bx bx-save me-1"></i>{{ $task->exists ? 'Salva modifiche' : 'Aggiungi' }}
119
+          </button>
120
+          @if($task->exists)
121
+          <a href="{{ route('admin.task.index', ['vista' => $vista, 'stato' => $stato]) }}" class="btn btn-label-secondary">Annulla</a>
122
+          @endif
123
+        </div>
124
+      </div>
125
+    </form>
126
+    @if(! $task->exists)
127
+    <p class="text-muted small mb-0 mt-3">
128
+      Da Telegram puoi mandare un testo o un vocale.
129
+      @can('view-config')
130
+      Il bot si collega da <a href="{{ route('admin.config.index') }}">Configurazione</a>, gruppo Task.
131
+      @endcan
132
+    </p>
133
+    @endif
134
+  </div>
135
+</div>
136
+@endif
137
+
138
+<div class="card mb-4">
139
+  <div class="card-header border-bottom d-flex flex-wrap justify-content-between align-items-center gap-2">
140
+    <div>
141
+      <h5 class="mb-0">Elenco</h5>
142
+      <p class="text-body-secondary mb-0">{{ $totale }} {{ $totale === 1 ? 'task' : 'task' }} · i prioritari restano in alto, in rosso</p>
143
+    </div>
144
+    <div class="btn-group btn-group-sm">
145
+      @foreach(['aperte' => 'Aperti', 'completate' => 'Fatti', 'tutte' => 'Tutti'] as $key => $label)
146
+      <a href="{{ route('admin.task.index', ['stato' => $key, 'vista' => $vista]) }}" class="btn {{ $stato === $key ? 'btn-primary' : 'btn-outline-primary' }}">{{ $label }}</a>
147
+      @endforeach
148
+    </div>
149
+  </div>
150
+  <div class="card-body p-0">
151
+    @include('task._elenco', [
152
+      'presentazione' => $presentazione,
153
+      'gestione' => true,
154
+      'canEdit' => $canEdit,
155
+      'canDelete' => $canDelete,
156
+      'stato' => $stato,
157
+      'vista' => $vista,
158
+    ])
159
+  </div>
160
+</div>
161
+@endsection
162
+
163
+@section('page-script')
164
+<script>
165
+(function () {
166
+  var radios = document.querySelectorAll('input[name="segnalazione_mode"]');
167
+  var esistente = document.getElementById('task-esistente');
168
+  var nuova = document.getElementById('task-nuova');
169
+  function syncMode() {
170
+    var selected = document.querySelector('input[name="segnalazione_mode"]:checked');
171
+    var mode = selected ? selected.value : 'nessuna';
172
+    if (esistente) esistente.classList.toggle('d-none', mode !== 'esistente');
173
+    if (nuova) nuova.classList.toggle('d-none', mode !== 'nuova');
174
+  }
175
+  radios.forEach(function (radio) { radio.addEventListener('change', syncMode); });
176
+  syncMode();
177
+
178
+  var input = document.getElementById('task-cerca-segnalazione');
179
+  var hidden = document.getElementById('task-segnalazione-id');
180
+  var risultati = document.getElementById('task-segnalazione-risultati');
181
+  if (!input || !hidden || !risultati) return;
182
+
183
+  var timer = null;
184
+  input.addEventListener('input', function () {
185
+    hidden.value = '';
186
+    clearTimeout(timer);
187
+    var q = input.value.trim();
188
+    if (q.length < 1) {
189
+      risultati.classList.add('d-none');
190
+      risultati.innerHTML = '';
191
+      return;
192
+    }
193
+    timer = setTimeout(function () {
194
+      fetch(@json(route('admin.task.segnalazioni')) + '?q=' + encodeURIComponent(q), {
195
+        headers: { 'Accept': 'application/json' }
196
+      }).then(function (response) { return response.json(); }).then(function (rows) {
197
+        risultati.innerHTML = '';
198
+        if (!rows.length) {
199
+          risultati.classList.add('d-none');
200
+          return;
201
+        }
202
+        rows.forEach(function (row) {
203
+          var button = document.createElement('button');
204
+          button.type = 'button';
205
+          button.className = 'list-group-item list-group-item-action';
206
+          button.textContent = row.label;
207
+          button.addEventListener('click', function () {
208
+            hidden.value = row.id;
209
+            input.value = row.label;
210
+            var tipo = document.getElementById('task-settore');
211
+            if (tipo && row.tipo) tipo.value = row.tipo;
212
+            risultati.classList.add('d-none');
213
+          });
214
+          risultati.appendChild(button);
215
+        });
216
+        risultati.classList.remove('d-none');
217
+      }).catch(function () {
218
+        risultati.classList.add('d-none');
219
+      });
220
+    }, 250);
221
+  });
222
+})();
223
+</script>
224
+@endsection

+ 13
- 0
routes/web.php View File

@@ -14,6 +14,7 @@ use App\Http\Controllers\NazioneController;
14 14
 use App\Http\Controllers\RolePermissionsController;
15 15
 use App\Http\Controllers\SegnalazioneClienteController;
16 16
 use App\Http\Controllers\SegnalazioneController;
17
+use App\Http\Controllers\TelegramTaskController;
17 18
 use App\Http\Controllers\TempoImpiegatoController;
18 19
 use App\Http\Controllers\UserController;
19 20
 use App\Http\Controllers\AziendaController;
@@ -31,6 +32,7 @@ use App\Http\Controllers\AcquistoServizioOperatoreController;
31 32
 use App\Http\Controllers\AcquistoServizioFirmaController;
32 33
 use App\Http\Controllers\SegnalazioneRapportoFirmaController;
33 34
 use App\Http\Controllers\ShortUrlController;
35
+use App\Http\Controllers\TaskController;
34 36
 use Illuminate\Support\Facades\Route;
35 37
 
36 38
 Route::get('/', [HomePageController::class, 'welcome'])->name('welcome');
@@ -171,6 +173,15 @@ Route::middleware(['auth:sanctum', config('jetstream.auth_session')])->prefix('a
171 173
     Route::post('segnalazione/{id}/pdf/invia', [SegnalazioneController::class, 'admin_pdf_invia'])->name('admin.segnalazione.pdf.invia');
172 174
     Route::get('segnalazione/{id}/annotazione/{annotazione}/rapportino', [SegnalazioneController::class, 'admin_rapportino_annotazione'])->name('admin.segnalazione.annotazione.rapportino');
173 175
 
176
+    // Cose da fare
177
+    Route::get('tasks', [TaskController::class, 'admin_index'])->name('admin.task.index');
178
+    Route::post('tasks', [TaskController::class, 'admin_store'])->name('admin.task.store');
179
+    Route::get('tasks/segnalazioni', [TaskController::class, 'admin_segnalazioni'])->name('admin.task.segnalazioni');
180
+    Route::get('tasks/{id}/edit', [TaskController::class, 'admin_edit'])->name('admin.task.edit')->whereNumber('id');
181
+    Route::put('tasks/{id}', [TaskController::class, 'admin_update'])->name('admin.task.update')->whereNumber('id');
182
+    Route::post('tasks/{id}/toggle', [TaskController::class, 'admin_toggle'])->name('admin.task.toggle')->whereNumber('id');
183
+    Route::delete('tasks/{id}', [TaskController::class, 'admin_destroy'])->name('admin.task.destroy')->whereNumber('id');
184
+
174 185
     // Dashboard
175 186
     Route::get('dashboard', [HomePageController::class, 'admin_dashboard'])->name('admin.dashboard');
176 187
     Route::get('statistiche', [HomePageController::class, 'admin_statistiche'])->name('admin.statistiche');
@@ -190,6 +201,7 @@ Route::middleware(['auth:sanctum', config('jetstream.auth_session')])->prefix('a
190 201
     Route::get('config', [ConfigController::class, 'admin_index'])->name('admin.config.index');
191 202
     Route::post('config', [ConfigController::class, 'admin_save'])->name('admin.config.save');
192 203
     Route::post('config/fic-aliquote', [ConfigController::class, 'admin_sync_aliquote'])->name('admin.config.fic_aliquote');
204
+    Route::post('config/telegram', [ConfigController::class, 'admin_telegram_webhook'])->name('admin.config.telegram');
193 205
 
194 206
     // Configura Segnalazione
195 207
     Route::get('configura-segnalazione', [ConfiguraSegnalazioneController::class, 'admin_index'])->name('admin.configura-segnalazione.index');
@@ -281,6 +293,7 @@ Route::post('shop/firma-riconsegna/{token}', [AcquistoServizioFirmaController::c
281 293
 Route::get('shop/paypal/return/{token}', [AcquistoServizioPublicController::class, 'paypalReturn'])->name('shop.paypal.return');
282 294
 Route::post('webhook/stripe/shop', [AcquistoServizioPublicController::class, 'stripeWebhook'])->name('shop.webhook.stripe');
283 295
 Route::post('webhook/paypal/shop', [AcquistoServizioPublicController::class, 'paypalWebhook'])->name('shop.webhook.paypal');
296
+Route::post('webhook/telegram/tasks', [TelegramTaskController::class, 'webhook'])->name('telegram.task.webhook');
284 297
 
285 298
 // Alias legacy acquisto-servizio → shop
286 299
 Route::redirect('acquisto-servizio', '/shop', 301);

+ 112
- 0
tests/Unit/TaskMessageParserTest.php View File

@@ -0,0 +1,112 @@
1
+<?php
2
+
3
+namespace Tests\Unit;
4
+
5
+use App\Models\Task;
6
+use App\Support\TaskMessageParser;
7
+use Illuminate\Support\Collection;
8
+use PHPUnit\Framework\TestCase;
9
+
10
+class TaskMessageParserTest extends TestCase
11
+{
12
+    /** @var list<string> */
13
+    private array $settori = ['Tecnico', 'Sviluppatore', 'Personale', 'Commerciale', 'Amministrativo'];
14
+
15
+    public function test_plain_text_stays_a_task(): void
16
+    {
17
+        $parsed = TaskMessageParser::parse('Chiamare Rossi per il preventivo', $this->settori);
18
+
19
+        $this->assertSame('Chiamare Rossi per il preventivo', $parsed['testo']);
20
+        $this->assertFalse($parsed['prioritario']);
21
+        $this->assertNull($parsed['settore']);
22
+        $this->assertNull($parsed['tempo_minuti']);
23
+    }
24
+
25
+    public function test_markers_set_priority_sector_and_time(): void
26
+    {
27
+        $parsed = TaskMessageParser::parse('! #sviluppatore ~1h30m sistemare export fatture', $this->settori);
28
+
29
+        $this->assertSame('sistemare export fatture', $parsed['testo']);
30
+        $this->assertTrue($parsed['prioritario']);
31
+        $this->assertSame('Sviluppatore', $parsed['settore']);
32
+        $this->assertSame(90, $parsed['tempo_minuti']);
33
+    }
34
+
35
+    public function test_glued_markers_and_decimal_hours(): void
36
+    {
37
+        $parsed = TaskMessageParser::parse('!#personale~1,5 aggiornare turni', $this->settori);
38
+
39
+        $this->assertSame('aggiornare turni', $parsed['testo']);
40
+        $this->assertTrue($parsed['prioritario']);
41
+        $this->assertSame('Personale', $parsed['settore']);
42
+        $this->assertSame(90, $parsed['tempo_minuti']);
43
+    }
44
+
45
+    public function test_urgente_counts_only_as_first_word(): void
46
+    {
47
+        $flagged = TaskMessageParser::parse('urgente richiamare il cliente', $this->settori);
48
+        $this->assertTrue($flagged['prioritario']);
49
+        $this->assertSame('richiamare il cliente', $flagged['testo']);
50
+
51
+        $plain = TaskMessageParser::parse('richiamare il cliente urgente', $this->settori);
52
+        $this->assertFalse($plain['prioritario']);
53
+        $this->assertSame('richiamare il cliente urgente', $plain['testo']);
54
+    }
55
+
56
+    public function test_label_alias_stores_the_tipo_value(): void
57
+    {
58
+        $parsed = TaskMessageParser::parse(
59
+            '#manutenzione controllare la caldaia',
60
+            ['manutenzione', 'sicurezza'],
61
+            ['Manutenzione' => 'manutenzione', 'Sicurezza' => 'sicurezza']
62
+        );
63
+
64
+        $this->assertSame('manutenzione', $parsed['settore']);
65
+        $this->assertSame('controllare la caldaia', $parsed['testo']);
66
+    }
67
+
68
+    public function test_unknown_sector_stays_in_the_text(): void
69
+    {
70
+        $parsed = TaskMessageParser::parse('#magazzino controllare lo stock', $this->settori);
71
+
72
+        $this->assertNull($parsed['settore']);
73
+        $this->assertSame('#magazzino controllare lo stock', $parsed['testo']);
74
+    }
75
+
76
+    public function test_priority_tasks_stay_on_top_in_both_views(): void
77
+    {
78
+        $tasks = new Collection([
79
+            $this->task(1, 'Secondo', 'Tecnico', false),
80
+            $this->task(2, 'Prima cosa', 'Commerciale', true),
81
+            $this->task(3, 'Terzo', 'Tecnico', false),
82
+            $this->task(4, 'Altro urgente', null, true),
83
+            $this->task(5, 'Senza gruppo', null, false),
84
+        ]);
85
+
86
+        $perInserimento = Task::presenta($tasks, 'inserimento', $this->settori);
87
+        $this->assertSame([2, 4], $perInserimento['prioritari']->pluck('id')->all());
88
+        $this->assertSame([1, 3, 5], $perInserimento['sezioni'][0]['tasks']->pluck('id')->all());
89
+
90
+        $perSettore = Task::presenta($tasks, 'settore', $this->settori);
91
+        $this->assertSame([2, 4], $perSettore['prioritari']->pluck('id')->all());
92
+        $this->assertSame('Tecnico', $perSettore['sezioni'][0]['label']);
93
+        $this->assertSame([1, 3], $perSettore['sezioni'][0]['tasks']->pluck('id')->all());
94
+        $this->assertSame('Senza tipo', $perSettore['sezioni'][1]['label']);
95
+        $this->assertSame([5], $perSettore['sezioni'][1]['tasks']->pluck('id')->all());
96
+
97
+        $conEtichette = Task::presenta($tasks, 'settore', $this->settori, ['Tecnico' => 'Manutenzione']);
98
+        $this->assertSame('Manutenzione', $conEtichette['sezioni'][0]['label']);
99
+    }
100
+
101
+    private function task(int $id, string $testo, ?string $settore, bool $prioritario): Task
102
+    {
103
+        $task = new Task([
104
+            'testo' => $testo,
105
+            'settore' => $settore,
106
+            'prioritario' => $prioritario,
107
+        ]);
108
+        $task->id = $id;
109
+
110
+        return $task;
111
+    }
112
+}

Loading…
Cancel
Save