Roberto Santini 3 周前
父节点
当前提交
7d03d2fa10
共有 100 个文件被更改,包括 12517 次插入484 次删除
  1. 11
    0
      .env.example
  2. 2510
    6
      GestioneManutenzioni.skipper
  3. 39
    0
      app/Console/Commands/ChiudiSegnalazioniInattive.php
  4. 60
    0
      app/Console/Commands/ImportaEmailSegnalazioni.php
  5. 257
    0
      app/Console/Commands/ImportaRedmine.php
  6. 2
    4
      app/DataTables/ContrattoServizioDataTable.php
  7. 1
    1
      app/DataTables/ContrattoServizioDataTableEditor.php
  8. 2
    4
      app/DataTables/ContrattoServizioIndexDataTable.php
  9. 79
    0
      app/DataTables/FatturaDataTable.php
  10. 175
    0
      app/DataTables/SegnalazioneClienteDataTable.php
  11. 16
    15
      app/DataTables/SegnalazioneDataTable.php
  12. 11
    0
      app/DataTables/UserDataTable.php
  13. 12
    0
      app/DataTables/UserDataTableEditor.php
  14. 3
    0
      app/Http/Controllers/AziendaController.php
  15. 207
    0
      app/Http/Controllers/CasellaImapController.php
  16. 102
    18
      app/Http/Controllers/ConfigController.php
  17. 34
    0
      app/Http/Controllers/ContrattoServizioController.php
  18. 168
    0
      app/Http/Controllers/FatturazioneController.php
  19. 58
    2
      app/Http/Controllers/HomePageController.php
  20. 38
    7
      app/Http/Controllers/OffertaController.php
  21. 88
    4
      app/Http/Controllers/OrdineLavoroController.php
  22. 221
    0
      app/Http/Controllers/SegnalazioneClienteController.php
  23. 168
    4
      app/Http/Controllers/SegnalazioneController.php
  24. 14
    0
      app/Http/Controllers/UserController.php
  25. 5
    0
      app/Models/AbstractModels/AbstractAnnotazione.php
  26. 97
    0
      app/Models/AbstractModels/AbstractCasellaImap.php
  27. 5
    0
      app/Models/AbstractModels/AbstractComune.php
  28. 9
    1
      app/Models/AbstractModels/AbstractContrattoServizio.php
  29. 13
    0
      app/Models/AbstractModels/AbstractContrattoServizioRiga.php
  30. 112
    0
      app/Models/AbstractModels/AbstractFattura.php
  31. 130
    0
      app/Models/AbstractModels/AbstractFatturaRiga.php
  32. 5
    0
      app/Models/AbstractModels/AbstractOrdineLavoro.php
  33. 5
    0
      app/Models/AbstractModels/AbstractProdotto.php
  34. 5
    0
      app/Models/AbstractModels/AbstractSegnalazione.php
  35. 16
    1
      app/Models/AbstractModels/AbstractUser.php
  36. 57
    0
      app/Models/CasellaImap.php
  37. 149
    62
      app/Models/Config.php
  38. 7
    1
      app/Models/ConfigNotifiche.php
  39. 231
    3
      app/Models/ContrattoServizio.php
  40. 241
    2
      app/Models/ContrattoServizioRiga.php
  41. 9
    0
      app/Models/ContrattoTemplateRiga.php
  42. 96
    0
      app/Models/Fattura.php
  43. 31
    0
      app/Models/FatturaRiga.php
  44. 7
    0
      app/Models/Offerta.php
  45. 4
    0
      app/Models/OffertaRiga.php
  46. 66
    0
      app/Models/OrdineLavoro.php
  47. 94
    0
      app/Models/OrdineLavoroRiga.php
  48. 1
    0
      app/Models/Prodotto.php
  49. 164
    0
      app/Models/Segnalazione.php
  50. 54
    0
      app/Models/User.php
  51. 71
    0
      app/Notifications/AnnotazionePubblicaApertaDa.php
  52. 7
    2
      app/Notifications/NuovaSegnalazioneAdmin.php
  53. 8
    7
      app/Notifications/NuovaSegnalazioneUser.php
  54. 71
    0
      app/Notifications/RispostaEmailAssegnatario.php
  55. 79
    0
      app/Services/ChiudiSegnalazioniInattiveService.php
  56. 3
    0
      app/Services/ContrattoServizioPdfService.php
  57. 1042
    0
      app/Services/FatturazioneGeneraService.php
  58. 339
    0
      app/Services/Redmine/RedmineClient.php
  59. 1739
    0
      app/Services/Redmine/RedmineImportService.php
  60. 851
    0
      app/Services/SegnalazioneEmailInboxService.php
  61. 121
    0
      app/Support/IsolaHtml.php
  62. 1
    0
      composer.json
  63. 82
    1
      composer.lock
  64. 115
    0
      config/redmine.php
  65. 42
    0
      database/migrations/2026_08_10_235452_skipper_migrations_2026081023545207.php
  66. 76
    0
      database/migrations/2026_08_21_132551_skipper_migrations_2026082113255147.php
  67. 36
    0
      database/migrations/2026_08_21_223625_skipper_migrations_2026082122362547.php
  68. 36
    0
      database/migrations/2026_08_21_230149_skipper_migrations_2026082123014912.php
  69. 164
    0
      database/migrations/2026_08_21_234348_skipper_migrations_2026082123434842.php
  70. 49
    0
      database/migrations/2026_08_22_000100_create_ordine_lavoro_riga_table.php
  71. 2
    3
      database/seeders/ConfigSeeder.php
  72. 4
    4
      database/seeders/ConfiguraSegnalazioneSeeder.php
  73. 1
    0
      database/seeders/DatabaseSeeder.php
  74. 6
    0
      resources/assets/vendor/libs/quill/typography.scss
  75. 26
    3
      resources/assets/vendor/scss/_custom-styles.scss
  76. 39
    5
      resources/menu/verticalMenu.json
  77. 5
    0
      resources/views/azienda/index.blade.php
  78. 1
    0
      resources/views/azienda/modal-scripts.blade.php
  79. 6
    0
      resources/views/azienda/modal.blade.php
  80. 245
    0
      resources/views/casella_imap/form.blade.php
  81. 139
    0
      resources/views/casella_imap/index.blade.php
  82. 28
    0
      resources/views/components/annotazione-html.blade.php
  83. 26
    11
      resources/views/components/datatable-filter-field.blade.php
  84. 23
    0
      resources/views/components/datatable-filters-offcanvas.blade.php
  85. 160
    52
      resources/views/config/index.blade.php
  86. 9
    0
      resources/views/contratto_servizio/create.blade.php
  87. 360
    23
      resources/views/contratto_servizio/form.blade.php
  88. 0
    3
      resources/views/contratto_servizio/menu.blade.php
  89. 19
    0
      resources/views/contratto_servizio/partials/riga_validita_chip.blade.php
  90. 21
    0
      resources/views/contratto_servizio/partials/riga_validita_fields.blade.php
  91. 31
    0
      resources/views/contratto_servizio/partials/riga_validita_styles.blade.php
  92. 67
    58
      resources/views/contratto_servizio/pdf.blade.php
  93. 34
    7
      resources/views/contratto_servizio/show.blade.php
  94. 1
    1
      resources/views/contratto_template/form.blade.php
  95. 3
    3
      resources/views/contratto_template/show.blade.php
  96. 0
    165
      resources/views/dashboard/dashboard-reparto.blade.php
  97. 210
    1
      resources/views/dashboard/index.blade.php
  98. 149
    0
      resources/views/fatturazione/genera.blade.php
  99. 81
    0
      resources/views/fatturazione/index.blade.php
  100. 0
    0
      resources/views/fatturazione/menu.blade.php

+ 11
- 0
.env.example 查看文件

@@ -57,6 +57,8 @@ MAIL_PASSWORD=null
57 57
 MAIL_FROM_ADDRESS="hello@example.com"
58 58
 MAIL_FROM_NAME="${APP_NAME}"
59 59
 
60
+# Caselle IMAP: configurazione in Amministrazione → Caselle email (tabella casella_imap)
61
+
60 62
 AWS_ACCESS_KEY_ID=
61 63
 AWS_SECRET_ACCESS_KEY=
62 64
 AWS_DEFAULT_REGION=us-east-1
@@ -73,6 +75,15 @@ PAYPAL_CLIENT_ID=
73 75
 PAYPAL_SECRET=
74 76
 PAYPAL_MODE=sandbox
75 77
 
78
+# Redmine 6.x — import issue / tempi / costi
79
+REDMINE_URL=
80
+REDMINE_API_KEY=
81
+REDMINE_VERIFY_SSL=true
82
+REDMINE_TIMEOUT=60
83
+# REDMINE_DOWNLOAD_TIMEOUT=180
84
+# Tariffa €/ora se Redmine non espone il costo
85
+# REDMINE_HOURLY_RATE=
86
+
76 87
 ACQUISTO_DOMICILIO_EURO=15
77 88
 
78 89
 # Provider SMS per firma consegna: aimon | skebby

+ 2510
- 6
GestioneManutenzioni.skipper
文件差异内容过多而无法显示
查看文件


+ 39
- 0
app/Console/Commands/ChiudiSegnalazioniInattive.php 查看文件

@@ -0,0 +1,39 @@
1
+<?php
2
+
3
+namespace App\Console\Commands;
4
+
5
+use App\Services\ChiudiSegnalazioniInattiveService;
6
+use Illuminate\Console\Command;
7
+
8
+class ChiudiSegnalazioniInattive extends Command
9
+{
10
+    protected $signature = 'segnalazione:chiudi-inattive
11
+                            {--dry-run : Elenca le segnalazioni che verrebbero chiuse, senza scrivere}';
12
+
13
+    protected $description = 'Chiude in automatico le segnalazioni inattive negli stati configurati e notifica l\'autore';
14
+
15
+    public function handle(ChiudiSegnalazioniInattiveService $service): int
16
+    {
17
+        $dryRun = (bool) $this->option('dry-run');
18
+        $stats = $service->run($dryRun);
19
+
20
+        if ($stats['disabled']) {
21
+            $this->warn('Chiusura automatica disattivata: imposta gli stati e i giorni di inattività in Configurazione → Segnalazioni.');
22
+
23
+            return self::SUCCESS;
24
+        }
25
+
26
+        $this->info(sprintf(
27
+            'Stati: %s — inattività: %d giorni%s',
28
+            implode(', ', $stats['stati']),
29
+            $stats['giorni'],
30
+            $dryRun ? ' (dry-run)' : ''
31
+        ));
32
+        $this->info('Chiuse: '.$stats['closed']);
33
+        if ($stats['blocked'] > 0) {
34
+            $this->warn('Non chiuse per task obbligatori incompleti: '.$stats['blocked']);
35
+        }
36
+
37
+        return self::SUCCESS;
38
+    }
39
+}

+ 60
- 0
app/Console/Commands/ImportaEmailSegnalazioni.php 查看文件

@@ -0,0 +1,60 @@
1
+<?php
2
+
3
+namespace App\Console\Commands;
4
+
5
+use App\Models\CasellaImap;
6
+use App\Services\SegnalazioneEmailInboxService;
7
+use Illuminate\Console\Command;
8
+
9
+class ImportaEmailSegnalazioni extends Command
10
+{
11
+    protected $signature = 'segnalazione:importa-email
12
+                            {--dry-run : Elenca le caselle attive senza elaborare}';
13
+
14
+    protected $description = 'Legge le caselle IMAP configurate, aggancia le mail alle segnalazioni e sposta in cartella elaborati';
15
+
16
+    public function handle(SegnalazioneEmailInboxService $service): int
17
+    {
18
+        $caselle = CasellaImap::attive()->orderBy('nome')->get();
19
+        if ($caselle->isEmpty()) {
20
+            $this->warn('Nessuna casella IMAP attiva. Configurale da Amministrazione → Caselle email.');
21
+
22
+            return self::SUCCESS;
23
+        }
24
+
25
+        if ($this->option('dry-run')) {
26
+            foreach ($caselle as $casella) {
27
+                $this->info(sprintf(
28
+                    '%s — %s:%d cartella=%s → %s',
29
+                    $casella->label,
30
+                    $casella->host,
31
+                    $casella->porta,
32
+                    $casella->cartella ?: 'INBOX',
33
+                    $casella->sposta_processate && $casella->cartella_processate
34
+                        ? $casella->cartella_processate
35
+                        : '(non sposta)'
36
+                ));
37
+            }
38
+
39
+            return self::SUCCESS;
40
+        }
41
+
42
+        $this->info('Import email segnalazioni in corso ('.$caselle->count().' caselle)…');
43
+        $stats = $service->processInbox();
44
+
45
+        $this->info(sprintf(
46
+            'Caselle: %d | Elaborate: %d | Nuove: %d | Aggancio esistenti: %d | Saltate: %d',
47
+            $stats['mailboxes'],
48
+            $stats['processed'],
49
+            $stats['created'],
50
+            $stats['attached'],
51
+            $stats['skipped']
52
+        ));
53
+
54
+        foreach ($stats['errors'] as $error) {
55
+            $this->error($error);
56
+        }
57
+
58
+        return empty($stats['errors']) ? self::SUCCESS : self::FAILURE;
59
+    }
60
+}

+ 257
- 0
app/Console/Commands/ImportaRedmine.php 查看文件

@@ -0,0 +1,257 @@
1
+<?php
2
+
3
+namespace App\Console\Commands;
4
+
5
+use App\Models\Azienda;
6
+use App\Models\ContrattoServizio;
7
+use App\Services\Redmine\RedmineClient;
8
+use App\Services\Redmine\RedmineImportService;
9
+use Illuminate\Console\Command;
10
+use RuntimeException;
11
+use Throwable;
12
+
13
+class ImportaRedmine extends Command
14
+{
15
+    protected $signature = 'redmine:import
16
+                            {project? : ID o identifier del progetto Redmine}
17
+                            {azienda? : ID, ragione sociale o email dell\'azienda locale}
18
+                            {--list-projects : Elenca i progetti visibili in Redmine}
19
+                            {--url= : URL Redmine (override REDMINE_URL)}
20
+                            {--api-key= : API key (override REDMINE_API_KEY)}
21
+                            {--dry-run : Mostra cosa verrebbe importato, senza scrivere}
22
+                            {--update : Aggiorna segnalazioni già importate da questo progetto}
23
+                            {--con-sottoprogetti : Include anche i sotto-progetti}
24
+                            {--no-attachments : Non scaricare gli allegati}
25
+                            {--no-tempi : Non importare i time entry}
26
+                            {--crea-utenti : Crea anche gli autori di journal/tempi se non esistono (contatti e assegnatari vengono sempre creati)}
27
+                            {--user-fallback= : ID utente locale se non c\'è match}
28
+                            {--tariffa= : €/ora per calcolare il costo dai tempi}
29
+                            {--contratto= : ID del contratto servizio da assegnare alle segnalazioni}
30
+                            {--limit= : Importa al massimo N issue}';
31
+
32
+    protected $description = 'Importa issue, tempi, costi e allegati da un progetto Redmine 6.x verso un\'azienda locale';
33
+
34
+    public function handle(): int
35
+    {
36
+        if (! $this->option('list-projects') && (! $this->argument('project') || ! $this->argument('azienda'))) {
37
+            $this->error('Specifica progetto Redmine e azienda locale, oppure usa --list-projects.');
38
+            $this->line('Esempio: php artisan redmine:import NOME_PROGETTO 12');
39
+            $this->line('         php artisan redmine:import 42 "ElephanTech" --dry-run');
40
+
41
+            return self::INVALID;
42
+        }
43
+
44
+        try {
45
+            $client = RedmineClient::fromConfig(
46
+                $this->option('url') ?: null,
47
+                $this->option('api-key') ?: null,
48
+            );
49
+        } catch (RuntimeException $e) {
50
+            $this->error($e->getMessage());
51
+
52
+            return self::FAILURE;
53
+        }
54
+
55
+        if ($this->option('list-projects')) {
56
+            return $this->listProjects($client);
57
+        }
58
+
59
+        $projectArg = $this->argument('project');
60
+        $aziendaArg = $this->argument('azienda');
61
+
62
+        try {
63
+            $azienda = $this->resolveAzienda((string) $aziendaArg);
64
+            $contratto = $this->resolveContratto($this->option('contratto'), $azienda);
65
+            $project = $client->project($projectArg);
66
+        } catch (Throwable $e) {
67
+            $this->error($e->getMessage());
68
+
69
+            return self::FAILURE;
70
+        }
71
+
72
+        if ($project === [] || empty($project['id'])) {
73
+            $this->error('Progetto Redmine non trovato: '.$projectArg);
74
+
75
+            return self::FAILURE;
76
+        }
77
+
78
+        $this->info('Redmine:  '.$client->baseUrl());
79
+        $this->info('Progetto: #'.$project['id'].' '.$project['name'].' ('.$project['identifier'].')');
80
+        $this->info('Azienda:  #'.$azienda->id.' '.($azienda->ragione_sociale ?: $azienda->nome));
81
+        if ($contratto) {
82
+            $this->info('Contratto: #'.$contratto->id.' '.$contratto->label);
83
+        }
84
+        if ($this->option('dry-run')) {
85
+            $this->warn('Modalità dry-run: nessuna scrittura sul database.');
86
+        }
87
+
88
+        if (! $this->option('no-interaction') && ! $this->confirm('Procedo con l\'import?', true)) {
89
+            $this->comment('Annullato.');
90
+
91
+            return self::SUCCESS;
92
+        }
93
+
94
+        $tariffa = $this->option('tariffa');
95
+        $limit = $this->option('limit');
96
+
97
+        $service = new RedmineImportService(
98
+            client: $client,
99
+            azienda: $azienda,
100
+            dryRun: (bool) $this->option('dry-run'),
101
+            updateExisting: (bool) $this->option('update'),
102
+            importAttachments: ! $this->option('no-attachments'),
103
+            importTempi: ! $this->option('no-tempi'),
104
+            creaUtenti: (bool) $this->option('crea-utenti'),
105
+            hourlyRate: $tariffa !== null && $tariffa !== '' ? (float) $tariffa : null,
106
+            userFallbackId: $this->option('user-fallback') ? (int) $this->option('user-fallback') : null,
107
+            contrattoServizioId: $contratto?->id,
108
+            onProgress: function (int $current, int $total, int $issueId) {
109
+                if ($current === 1) {
110
+                    $this->output->progressStart($total);
111
+                }
112
+                $this->output->progressAdvance();
113
+                if ($current === $total) {
114
+                    $this->output->progressFinish();
115
+                }
116
+            },
117
+        );
118
+
119
+        try {
120
+            $stats = $service->import(
121
+                $project['id'],
122
+                (bool) $this->option('con-sottoprogetti'),
123
+                $limit !== null && $limit !== '' ? (int) $limit : null,
124
+            );
125
+        } catch (Throwable $e) {
126
+            if ($this->output->isVerbose()) {
127
+                $this->error($e->getTraceAsString());
128
+            }
129
+            $this->error($e->getMessage());
130
+
131
+            return self::FAILURE;
132
+        }
133
+
134
+        $this->newLine();
135
+        $this->table(
136
+            ['Voce', 'Valore'],
137
+            [
138
+                ['Issue Redmine', $stats['issues']],
139
+                ['Segnalazioni create', $stats['created']],
140
+                ['Segnalazioni aggiornate', $stats['updated']],
141
+                ['Segnalazioni saltate (già importate)', $stats['skipped']],
142
+                ['Annotazioni', $stats['annotazioni']],
143
+                ['Time entry', $stats['tempi']],
144
+                ['Costi', $stats['costi']],
145
+                ['Allegati', $stats['allegati']],
146
+                ['Todo / checklist', $stats['todos']],
147
+                ['Utenti creati', $stats['utenti_creati']],
148
+            ],
149
+        );
150
+
151
+        foreach ($stats['warnings'] as $warning) {
152
+            $this->warn($warning);
153
+        }
154
+        foreach ($stats['errors'] as $error) {
155
+            $this->error($error);
156
+        }
157
+
158
+        if ($stats['skipped'] > 0 && ! $this->option('update')) {
159
+            $this->comment('Le issue già importate sono state saltate. Usa --update per riallinearle.');
160
+        }
161
+
162
+        return $stats['errors'] === [] ? self::SUCCESS : self::FAILURE;
163
+    }
164
+
165
+    protected function listProjects(RedmineClient $client): int
166
+    {
167
+        $this->info('Progetti visibili su '.$client->baseUrl());
168
+
169
+        try {
170
+            $projects = $client->projects();
171
+        } catch (Throwable $e) {
172
+            $this->error($e->getMessage());
173
+
174
+            return self::FAILURE;
175
+        }
176
+
177
+        $rows = [];
178
+        foreach ($projects as $project) {
179
+            $rows[] = [
180
+                $project['id'] ?? '',
181
+                $project['identifier'] ?? '',
182
+                $project['name'] ?? '',
183
+                ! empty($project['status']) && (int) $project['status'] === 1 ? 'attivo' : ($project['status'] ?? ''),
184
+            ];
185
+        }
186
+
187
+        $this->table(['ID', 'Identifier', 'Nome', 'Stato'], $rows);
188
+        $this->comment('Usa l\'ID o l\'identifier come primo argomento di redmine:import.');
189
+
190
+        return self::SUCCESS;
191
+    }
192
+
193
+    protected function resolveAzienda(string $value): Azienda
194
+    {
195
+        $query = Azienda::query();
196
+
197
+        if (ctype_digit($value)) {
198
+            $azienda = $query->find((int) $value);
199
+            if ($azienda) {
200
+                return $azienda;
201
+            }
202
+        }
203
+
204
+        $azienda = Azienda::query()
205
+            ->where(function ($q) use ($value) {
206
+                $q->where('ragione_sociale', $value)
207
+                    ->orWhere('nome', $value)
208
+                    ->orWhere('email', $value);
209
+            })
210
+            ->first();
211
+
212
+        if ($azienda) {
213
+            return $azienda;
214
+        }
215
+
216
+        $matches = Azienda::query()
217
+            ->where(function ($q) use ($value) {
218
+                $q->where('ragione_sociale', 'like', '%'.$value.'%')
219
+                    ->orWhere('nome', 'like', '%'.$value.'%');
220
+            })
221
+            ->limit(10)
222
+            ->get();
223
+
224
+        if ($matches->count() === 1) {
225
+            return $matches->first();
226
+        }
227
+
228
+        if ($matches->isNotEmpty()) {
229
+            $elenco = $matches->map(fn (Azienda $a) => '#'.$a->id.' '.($a->ragione_sociale ?: $a->nome))->implode(', ');
230
+            throw new RuntimeException('Azienda ambigua "'.$value.'". Specifica l\'ID. Trovate: '.$elenco);
231
+        }
232
+
233
+        throw new RuntimeException('Azienda locale non trovata: '.$value);
234
+    }
235
+
236
+    protected function resolveContratto(mixed $value, Azienda $azienda): ?ContrattoServizio
237
+    {
238
+        if ($value === null || $value === '') {
239
+            return null;
240
+        }
241
+        if (! ctype_digit((string) $value)) {
242
+            throw new RuntimeException('L\'ID contratto deve essere numerico.');
243
+        }
244
+
245
+        $contratto = ContrattoServizio::query()->find((int) $value);
246
+        if (! $contratto) {
247
+            throw new RuntimeException('Contratto servizio #'.$value.' non trovato.');
248
+        }
249
+        if ((int) $contratto->user_id !== (int) $azienda->id) {
250
+            throw new RuntimeException(
251
+                'Il contratto #'.$contratto->id.' non appartiene all\'azienda #'.$azienda->id.'.'
252
+            );
253
+        }
254
+
255
+        return $contratto;
256
+    }
257
+}

+ 2
- 4
app/DataTables/ContrattoServizioDataTable.php 查看文件

@@ -27,9 +27,7 @@ class ContrattoServizioDataTable extends DataTable
27 27
     return (new EloquentDataTable($query))
28 28
       ->addColumn('action', fn ($e) => view('contratto_servizio.menu', ['entity' => $e, 'mode' => 'embed']))
29 29
       ->addColumn('template_label', fn ($e) => $e->contrattoTemplate ? $e->contrattoTemplate->nome : '—')
30
-      ->addColumn('costo_label', fn ($e) => $e->costo !== null
31
-        ? number_format((float) $e->costo, 2, ',', '.').' €'
32
-        : '—')
30
+      ->addColumn('costo_label', fn ($e) => number_format((float) $e->totale_righe, 2, ',', '.').' €')
33 31
       ->addColumn('righe_count', fn ($e) => $e->contratto_servizio_rigas_count ?? $e->contrattoServizioRigas()->count())
34 32
       ->addColumn('periodo_label', fn ($e) => Prodotto::PERIODI[$e->periodo]
35 33
         ?? (\App\Models\ContrattoTemplate::PERIODI[$e->periodo] ?? ($e->periodo ?: '—')))
@@ -51,7 +49,7 @@ class ContrattoServizioDataTable extends DataTable
51 49
 
52 50
   public function query(ContrattoServizio $model): QueryBuilder
53 51
   {
54
-    $q = $model->newQuery()->with('contrattoTemplate')->withCount('contrattoServizioRigas');
52
+    $q = $model->newQuery()->with(['contrattoTemplate', 'righe.prodotto'])->withCount('contrattoServizioRigas');
55 53
     if ($this->user) {
56 54
       $q->where('user_id', $this->user->id);
57 55
     }

+ 1
- 1
app/DataTables/ContrattoServizioDataTableEditor.php 查看文件

@@ -82,7 +82,7 @@ class ContrattoServizioDataTableEditor extends DataTablesEditor
82 82
         $data[$key] = null;
83 83
       }
84 84
     }
85
-    foreach (['attivo'] as $flag) {
85
+    foreach (['attivo', 'default_import_email'] as $flag) {
86 86
       if (array_key_exists($flag, $data)) {
87 87
         $data[$flag] = filter_var($data[$flag], FILTER_VALIDATE_BOOLEAN) || $data[$flag] === '1' || $data[$flag] === 1;
88 88
       }

+ 2
- 4
app/DataTables/ContrattoServizioIndexDataTable.php 查看文件

@@ -26,9 +26,7 @@ class ContrattoServizioIndexDataTable extends DataTable
26 26
         ? $e->user->full_name.($e->user->is_azienda ? ' (azienda)' : '')
27 27
         : '—')
28 28
       ->addColumn('template_label', fn ($e) => $e->contrattoTemplate ? $e->contrattoTemplate->nome : '—')
29
-      ->addColumn('costo_label', fn ($e) => $e->costo !== null
30
-        ? number_format((float) $e->costo, 2, ',', '.').' €'
31
-        : '—')
29
+      ->addColumn('costo_label', fn ($e) => number_format((float) $e->totale_righe, 2, ',', '.').' €')
32 30
       ->addColumn('periodo_label', fn ($e) => $e->periodo_label)
33 31
       ->addColumn('date_label', function ($e) {
34 32
         $i = $e->data_inizio ? \Carbon\Carbon::parse($e->data_inizio)->format('d/m/Y') : '—';
@@ -47,7 +45,7 @@ class ContrattoServizioIndexDataTable extends DataTable
47 45
 
48 46
   public function query(ContrattoServizio $model): QueryBuilder
49 47
   {
50
-    return $model->newQuery()->with(['user', 'contrattoTemplate']);
48
+    return $model->newQuery()->with(['user', 'contrattoTemplate', 'righe.prodotto']);
51 49
   }
52 50
 
53 51
   public function html(): HtmlBuilder

+ 79
- 0
app/DataTables/FatturaDataTable.php 查看文件

@@ -0,0 +1,79 @@
1
+<?php
2
+
3
+namespace App\DataTables;
4
+
5
+use App\Models\Config;
6
+use App\Models\Fattura;
7
+use Illuminate\Database\Eloquent\Builder as QueryBuilder;
8
+use Yajra\DataTables\EloquentDataTable;
9
+use Yajra\DataTables\Html\Builder as HtmlBuilder;
10
+use Yajra\DataTables\Html\Column;
11
+use Yajra\DataTables\Services\DataTable;
12
+
13
+class FatturaDataTable extends DataTable
14
+{
15
+  public function __construct()
16
+  {
17
+    $this->dataTableVariable = 'dataTable_fattura';
18
+  }
19
+
20
+  public function dataTable(QueryBuilder $query): EloquentDataTable
21
+  {
22
+    return (new EloquentDataTable($query))
23
+      ->addColumn('action', fn ($e) => view('fatturazione.menu', ['entity' => $e]))
24
+      ->addColumn('cliente_label', fn ($e) => $e->ragione_sociale ?: ($e->user?->full_name ?: '—'))
25
+      ->addColumn('stato_label', function ($e) {
26
+        $map = [
27
+          Fattura::STATO_BOZZA => 'secondary',
28
+          Fattura::STATO_EMESSA => 'info',
29
+          Fattura::STATO_PAGATA => 'success',
30
+          Fattura::STATO_ANNULLATA => 'danger',
31
+        ];
32
+        $color = $map[$e->stato] ?? 'secondary';
33
+
34
+        return '<span class="badge bg-label-'.$color.'">'.e($e->stato_label).'</span>';
35
+      })
36
+      ->addColumn('numero_label', fn ($e) => $e->numero_documento)
37
+      ->addColumn('periodo_label', function ($e) {
38
+        $da = $e->periodo_da ? \Carbon\Carbon::parse($e->periodo_da)->format('d/m/Y') : '—';
39
+        $a = $e->periodo_a ? \Carbon\Carbon::parse($e->periodo_a)->format('d/m/Y') : '—';
40
+
41
+        return $da.' – '.$a;
42
+      })
43
+      ->addColumn('totale_label', fn ($e) => Config::formatCosto($e->totale ?? 0))
44
+      ->addColumn('data_label', fn ($e) => $e->data ? \Carbon\Carbon::parse($e->data)->format('d/m/Y') : '—')
45
+      ->rawColumns(['stato_label', 'action'])
46
+      ->setRowId('id');
47
+  }
48
+
49
+  public function query(Fattura $model): QueryBuilder
50
+  {
51
+    return $model->newQuery()->with('user');
52
+  }
53
+
54
+  public function html(): HtmlBuilder
55
+  {
56
+    return $this->builder()
57
+      ->setTableId($this->dataTableVariable)
58
+      ->columns($this->getColumns())
59
+      ->language(asset('assets/Italian.json'))
60
+      ->dom('<"py-2"<"head-label text-center"><"dt-action-buttons"B>><"d-flex justify-content-between align-items-center row mb-2"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>t<"d-flex justify-content-between row"<"col-sm-12 col-md-6"i><"col-sm-12 col-md-6"p>>')
61
+      ->responsive()
62
+      ->minifiedAjax()
63
+      ->orderBy(0, 'desc')
64
+      ->initComplete('function(){ $("div.dt-buttons button").removeClass("btn-secondary"); }');
65
+  }
66
+
67
+  public function getColumns(): array
68
+  {
69
+    return [
70
+      Column::make('numero')->title('N°')->data('numero_label')->name('numero'),
71
+      Column::make('ragione_sociale')->title('Cliente')->data('cliente_label'),
72
+      Column::make('stato')->title('Stato')->data('stato_label'),
73
+      Column::make('data')->title('Data')->data('data_label'),
74
+      Column::make('periodo_da')->title('Periodo')->data('periodo_label')->orderable(false)->searchable(false),
75
+      Column::make('totale')->title('Totale')->data('totale_label')->searchable(false),
76
+      Column::computed('action')->exportable(false)->printable(false)->width(60)->title(' ')->addClass('text-center'),
77
+    ];
78
+  }
79
+}

+ 175
- 0
app/DataTables/SegnalazioneClienteDataTable.php 查看文件

@@ -0,0 +1,175 @@
1
+<?php
2
+
3
+namespace App\DataTables;
4
+
5
+use App\Models\Config;
6
+use App\Models\Segnalazione;
7
+use Carbon\Carbon;
8
+use Illuminate\Database\Eloquent\Builder as QueryBuilder;
9
+use Illuminate\Support\Facades\Auth;
10
+use Yajra\DataTables\EloquentDataTable;
11
+use Yajra\DataTables\Html\Builder as HtmlBuilder;
12
+use Yajra\DataTables\Html\Button;
13
+use Yajra\DataTables\Html\Column;
14
+use Yajra\DataTables\Services\DataTable;
15
+
16
+class SegnalazioneClienteDataTable extends DataTable
17
+{
18
+    public function __construct()
19
+    {
20
+        $this->dataTableVariable = 'dataTable_segnalazione_cliente';
21
+    }
22
+
23
+    /**
24
+     * @param  QueryBuilder<Segnalazione>  $query
25
+     */
26
+    public function dataTable(QueryBuilder $query): EloquentDataTable
27
+    {
28
+        return (new EloquentDataTable($query))
29
+            ->addColumn('numero_label', fn ($entity) => '#'.$entity->id)
30
+            ->addColumn('user_label', fn ($entity) => $entity->user ? $entity->user->full_name : '—')
31
+            ->addColumn('stato_display', function ($entity) {
32
+                $config = $entity->getConfiguraStato();
33
+                if (! $config) {
34
+                    return '<span class="badge bg-label-secondary">'.e($entity->stato ?? 'N/A').'</span>';
35
+                }
36
+                $colore = $config->colore ? 'bg-label-'.$config->colore : 'bg-label-secondary';
37
+
38
+                return '<span class="badge '.$colore.'">'.e($config->label).'</span>';
39
+            })
40
+            ->addColumn('priorita_display', function ($entity) {
41
+                $config = $entity->getConfiguraPriorita();
42
+                if (! $config) {
43
+                    return '<span class="badge bg-label-secondary">'.e($entity->priorita ?? 'N/A').'</span>';
44
+                }
45
+                $colore = $config->colore ? 'bg-label-'.$config->colore : 'bg-label-secondary';
46
+
47
+                return '<span class="badge '.$colore.'">'.e($config->label).'</span>';
48
+            })
49
+            ->addColumn('aggiornato_il', fn ($entity) => $entity->updated_at?->diffForHumans() ?: '—')
50
+            ->filterColumn('numero_label', function ($query, $keyword) {
51
+                $keyword = trim((string) $keyword);
52
+                if ($keyword === '') {
53
+                    return;
54
+                }
55
+                $keyword = ltrim($keyword, '#');
56
+                $query->where(function ($q) use ($keyword) {
57
+                    $q->where('segnalazione.id', 'like', '%'.$keyword.'%');
58
+                    if (ctype_digit($keyword)) {
59
+                        $q->orWhere('segnalazione.id', (int) $keyword);
60
+                    }
61
+                });
62
+            })
63
+            ->filterColumn('user_id', function ($query, $keyword) {
64
+                $keyword = trim((string) $keyword);
65
+                if ($keyword === '') {
66
+                    return;
67
+                }
68
+                $query->whereHas('user', function ($q) use ($keyword) {
69
+                    $q->where(function ($sub) use ($keyword) {
70
+                        $sub->where('nome', 'like', '%'.$keyword.'%')
71
+                            ->orWhere('cognome', 'like', '%'.$keyword.'%')
72
+                            ->orWhere('email', 'like', '%'.$keyword.'%');
73
+                    });
74
+                });
75
+            })
76
+            ->filterColumn('stato', function ($query, $keyword) {
77
+                $keyword = trim((string) $keyword);
78
+                if ($keyword !== '') {
79
+                    $query->where('segnalazione.stato', $keyword);
80
+                }
81
+            })
82
+            ->filterColumn('priorita', function ($query, $keyword) {
83
+                $keyword = trim((string) $keyword);
84
+                if ($keyword !== '') {
85
+                    $query->where('segnalazione.priorita', $keyword);
86
+                }
87
+            })
88
+            ->rawColumns(['stato_display', 'priorita_display'])
89
+            ->setRowId('id')
90
+            ->setRowClass('cursor-pointer');
91
+    }
92
+
93
+    /**
94
+     * @return QueryBuilder<Segnalazione>
95
+     */
96
+    public function query(Segnalazione $model): QueryBuilder
97
+    {
98
+        $user = Auth::user();
99
+        $query = $model->newQuery()
100
+            ->select('segnalazione.*')
101
+            ->visibiliAlCliente($user)
102
+            ->with(['user']);
103
+
104
+        $status = $this->status;
105
+        if ($status === 'aperte') {
106
+            $query->whereIn('segnalazione.stato', Config::getStatiSegnalazione('aperte') ?? []);
107
+        } elseif ($status === 'in_lavorazione') {
108
+            $query->whereIn('segnalazione.stato', Config::getStatiSegnalazione('in_lavorazione') ?? []);
109
+        } elseif ($status === 'chiuse') {
110
+            $query->whereIn('segnalazione.stato', Config::getStatiSegnalazione('chiuse') ?? []);
111
+        } elseif ($status === 'in_ritardo') {
112
+            $query->whereNotIn('segnalazione.stato', Config::getStatiSegnalazione('chiuse') ?? [])
113
+                ->where('segnalazione.created_at', '<', Carbon::now()->subDays(7));
114
+        }
115
+
116
+        return $query;
117
+    }
118
+
119
+    public function html(): HtmlBuilder
120
+    {
121
+        $buttons = [];
122
+        if (Auth::user()?->can('create-segnalazione')) {
123
+            $buttons[] = Button::raw()
124
+                ->className('btn btn-sm btn-primary')
125
+                ->text('<i class="fas fa-plus"></i> Nuova segnalazione')
126
+                ->action('location.href="'.route('segnalazione.create').'"');
127
+        }
128
+
129
+        $ajaxParams = [];
130
+        if ($this->status) {
131
+            $ajaxParams['status'] = $this->status;
132
+        }
133
+
134
+        return $this->builder()
135
+            ->setTableId($this->dataTableVariable)
136
+            ->initComplete('function(){ $("div.dt-buttons button").removeClass("btn-secondary"); }')
137
+            ->language(asset('assets/Italian.json'))
138
+            ->columns($this->getColumns())
139
+            ->dom('<"py-2"<"head-label text-center"><"dt-action-buttons"B>><"d-flex justify-content-between align-items-center row mb-2"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>t<"d-flex justify-content-between row"<"col-sm-12 col-md-6"i><"col-sm-12 col-md-6"p>>')
140
+            ->responsive()
141
+            ->minifiedAjax(route('segnalazione.index', $ajaxParams))
142
+            ->orderBy($this->mostraAutore() ? 5 : 4, 'desc')
143
+            ->buttons($buttons);
144
+    }
145
+
146
+    public function getColumns(): array
147
+    {
148
+        $columns = [
149
+            Column::make('id')->title('N.')->data('numero_label')->name('numero_label')->responsivePriority(0),
150
+        ];
151
+
152
+        if ($this->mostraAutore()) {
153
+            $columns[] = Column::make('user_id')->title('Autore')->data('user_label')->name('user_id');
154
+        }
155
+
156
+        $columns[] = Column::make('titolo')->title('Oggetto')->responsivePriority(1);
157
+        $columns[] = Column::make('stato')->title('Stato')->data('stato_display');
158
+        $columns[] = Column::make('priorita')->title('Priorità')->data('priorita_display');
159
+        $columns[] = Column::make('updated_at')->title('Aggiornata')->data('aggiornato_il')->searchable(false);
160
+
161
+        return $columns;
162
+    }
163
+
164
+    protected function filename(): string
165
+    {
166
+        return 'Segnalazioni_'.date('YmdHis');
167
+    }
168
+
169
+    private function mostraAutore(): bool
170
+    {
171
+        $user = Auth::user();
172
+
173
+        return (bool) $user?->responsabile_azienda && (bool) $user?->azienda_id;
174
+    }
175
+}

+ 16
- 15
app/DataTables/SegnalazioneDataTable.php 查看文件

@@ -99,9 +99,15 @@ class SegnalazioneDataTable extends DataTable
99 99
                 if ($keyword === '') {
100 100
                     return;
101 101
                 }
102
+                if (ctype_digit($keyword)) {
103
+                    $query->where('segnalazione.azienda_id', (int) $keyword);
104
+
105
+                    return;
106
+                }
102 107
                 $query->where(function ($q) use ($keyword) {
103 108
                     $q->where('azienda_user.nome', 'like', '%' . $keyword . '%')
104
-                        ->orWhere('azienda_user.cognome', 'like', '%' . $keyword . '%');
109
+                        ->orWhere('azienda_user.cognome', 'like', '%' . $keyword . '%')
110
+                        ->orWhere('azienda_user.ragione_sociale', 'like', '%' . $keyword . '%');
105 111
                 });
106 112
             })
107 113
             ->filterColumn('stato', function ($query, $keyword) {
@@ -164,8 +170,7 @@ class SegnalazioneDataTable extends DataTable
164 170
         $canViewAll = $authUser && $authUser->can('view-segnalazione');
165 171
         $canOwn = $authUser && $authUser->can('view-own-segnalazione');
166 172
         $canAssegnate = $authUser && $authUser->can('view-segnalazione-assegnate');
167
-        // Solo "proprie": mostra anche le chiuse (altrimenti l’unica segnalazione chiusa sparisce dall’elenco).
168
-        $soloProprie = $authUser && ! $canViewAll && $canOwn && ! $canAssegnate;
173
+        $showChiuse = $this->showAll || request()->boolean('show_chiuse');
169 174
 
170 175
         if ($this->status) {
171 176
             if ($this->status == 'urgente') {
@@ -191,7 +196,9 @@ class SegnalazioneDataTable extends DataTable
191 196
             if ($this->status == 'in_ritardo') {
192 197
                 $query->where('segnalazione.created_at', '<', Carbon::now()->subDays(7));
193 198
             }
194
-        } elseif (! $soloProprie) {
199
+        } elseif ($showChiuse) {
200
+            $query->whereIn('segnalazione.stato', Config::getStatiSegnalazione('chiuse'));
201
+        } else {
195 202
             $query->whereNotIn('segnalazione.stato', Config::getStatiSegnalazione('chiuse'));
196 203
         }
197 204
 
@@ -259,15 +266,7 @@ class SegnalazioneDataTable extends DataTable
259 266
         if ($keyword === '') {
260 267
             return;
261 268
         }
262
-        $query->where(function ($q) use ($keyword, $gruppo, $column) {
263
-            $q->where("segnalazione.{$column}", 'like', '%' . $keyword . '%')
264
-                ->orWhereIn("segnalazione.{$column}", function ($sub) use ($keyword, $gruppo) {
265
-                    $sub->select('value')
266
-                        ->from('configura_segnalazione')
267
-                        ->where('gruppo', $gruppo)
268
-                        ->where('label', 'like', '%' . $keyword . '%');
269
-                });
270
-        });
269
+        $query->where("segnalazione.{$column}", $keyword);
271 270
     }
272 271
 
273 272
     /**
@@ -295,7 +294,9 @@ class SegnalazioneDataTable extends DataTable
295 294
             ->columns($this->getColumns())
296 295
             ->dom('<"py-2"<"head-label text-center"><"dt-action-buttons"B>><"d-flex justify-content-between align-items-center row mb-2"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>t<"d-flex justify-content-between row"<"col-sm-12 col-md-6"i><"col-sm-12 col-md-6"p>>')
297 296
             ->responsive()
298
-            ->minifiedAjax()
297
+            ->minifiedAjax('', null, [
298
+                'show_chiuse' => '$("#filtro_segnalazioni_chiuse").is(":checked") ? 1 : 0',
299
+            ])
299 300
             ->orderBy(8, 'desc')
300 301
             ->buttons($buttons)
301 302
             ->editor(
@@ -309,7 +310,7 @@ class SegnalazioneDataTable extends DataTable
309 310
     public function getColumns(): array
310 311
     {
311 312
         return [
312
-            Column::computed('numero_label')->title('Numero')->responsivePriority(0),
313
+            Column::make('id')->title('Numero')->responsivePriority(0),
313 314
             Column::make('titolo')->title('Titolo')->responsivePriority(1),
314 315
             Column::make('azienda_id')->title('Azienda')->data('azienda_label'),
315 316
             Column::make('stato')->title('Stato')->data('stato_display'),

+ 11
- 0
app/DataTables/UserDataTable.php 查看文件

@@ -40,9 +40,18 @@ class UserDataTable extends DataTable
40 40
     ->addColumn('azienda_display', function($entity){
41 41
       return $entity->azienda ? $entity->azienda->nome : '';
42 42
     })
43
+    ->addColumn('responsabile_azienda_label', function($entity){
44
+      return $entity->responsabile_azienda
45
+        ? '<span class="badge bg-label-success">Sì</span>'
46
+        : '<span class="badge bg-label-secondary">No</span>';
47
+    })
43 48
     ->editColumn('azienda_id', function ($entity) {
44 49
       return $entity->azienda_id ? (string) $entity->azienda_id : '';
45 50
     })
51
+    ->editColumn('responsabile_azienda', function ($entity) {
52
+      return $entity->responsabile_azienda ? 1 : 0;
53
+    })
54
+    ->rawColumns(['responsabile_azienda_label', 'action'])
46 55
     ->setRowId('id');
47 56
   }
48 57
 
@@ -87,6 +96,7 @@ class UserDataTable extends DataTable
87 96
       Fields\Text::make('telefono')->label('Telefono'),
88 97
       Fields\Select::make('ruolo_id')->label('Ruolo')->options($roles)->default(Role::where('name', 'user')->first()->id),
89 98
       Fields\Select::make('azienda_id')->label('Azienda')->options($aziende)->placeholder('Nessuna'),
99
+      Fields\Radio::make('responsabile_azienda')->label('Responsabile azienda')->options([['label' => 'Si', 'value' => 1], ['label' => 'No', 'value' => 0]])->default(0),
90 100
       Fields\Password::make('password_new')->label('Password (lasciare vuoto per non modificare)'),
91 101
       Fields\Password::make('password_new_confirmation')->label('Conferma password'),
92 102
     ];
@@ -122,6 +132,7 @@ class UserDataTable extends DataTable
122 132
         Column::make('telefono')->title('Telefono'),
123 133
         Column::make('ruolo_id')->title('Ruolo')->searchable(false)->name('ruolo_id')->data('ruolo_display'),
124 134
         Column::make('azienda_id')->title('Azienda')->data('azienda_display')->name('azienda_id')->searchable(false),
135
+        Column::make('responsabile_azienda')->title('Resp. azienda')->data('responsabile_azienda_label')->name('responsabile_azienda')->searchable(false),
125 136
         Column::computed('action')
126 137
         ->exportable(false)
127 138
         ->printable(false)

+ 12
- 0
app/DataTables/UserDataTableEditor.php 查看文件

@@ -81,6 +81,12 @@ class UserDataTableEditor extends DataTablesEditor
81 81
       $data['azienda_id'] = null;
82 82
     }
83 83
 
84
+    if (array_key_exists('responsabile_azienda', $data)) {
85
+      $data['responsabile_azienda'] = filter_var($data['responsabile_azienda'], FILTER_VALIDATE_BOOLEAN)
86
+        || $data['responsabile_azienda'] === '1'
87
+        || $data['responsabile_azienda'] === 1;
88
+    }
89
+
84 90
     return $data;
85 91
   }
86 92
 
@@ -92,6 +98,12 @@ class UserDataTableEditor extends DataTablesEditor
92 98
             $data['azienda_id'] = null;
93 99
         }
94 100
 
101
+        if (array_key_exists('responsabile_azienda', $data)) {
102
+            $data['responsabile_azienda'] = filter_var($data['responsabile_azienda'], FILTER_VALIDATE_BOOLEAN)
103
+              || $data['responsabile_azienda'] === '1'
104
+              || $data['responsabile_azienda'] === 1;
105
+        }
106
+
95 107
         if (array_key_exists('password_new', $data)) {
96 108
             $password = $data['password_new'];
97 109
             unset($data['password_new']);

+ 3
- 0
app/Http/Controllers/AziendaController.php 查看文件

@@ -70,6 +70,7 @@ class AziendaController extends Controller implements HasMiddleware
70 70
             'telefono' => 'nullable|string|max:255',
71 71
             'indirizzo' => 'nullable|string|max:500',
72 72
             'note' => 'nullable|string',
73
+            'dominio_email' => 'nullable|string|max:255',
73 74
         ]);
74 75
 
75 76
         $azienda = $isUpdate
@@ -94,6 +95,7 @@ class AziendaController extends Controller implements HasMiddleware
94 95
         $azienda->telefono = $validated['telefono'] ?? null;
95 96
         $azienda->indirizzo = $validated['indirizzo'] ?? null;
96 97
         $azienda->note = $validated['note'] ?? null;
98
+        $azienda->dominio_email = \App\Models\User::normalizeDominioEmail($validated['dominio_email'] ?? null);
97 99
         $azienda->save();
98 100
 
99 101
         // if (! $isUpdate) {
@@ -133,6 +135,7 @@ class AziendaController extends Controller implements HasMiddleware
133 135
             'telefono' => $azienda->telefono,
134 136
             'indirizzo' => $azienda->indirizzo,
135 137
             'note' => $azienda->note,
138
+            'dominio_email' => $azienda->dominio_email,
136 139
             'text' => $azienda->full_name,
137 140
             'full_name' => $azienda->full_name,
138 141
         ];

+ 207
- 0
app/Http/Controllers/CasellaImapController.php 查看文件

@@ -0,0 +1,207 @@
1
+<?php
2
+
3
+namespace App\Http\Controllers;
4
+
5
+use App\Models\CasellaImap;
6
+use App\Services\SegnalazioneEmailInboxService;
7
+use Illuminate\Http\Request;
8
+use Illuminate\Routing\Controllers\HasMiddleware;
9
+use Illuminate\Routing\Controllers\Middleware;
10
+use Illuminate\Validation\Rule;
11
+
12
+class CasellaImapController extends Controller implements HasMiddleware
13
+{
14
+    public static $permission_group = 'Caselle email IMAP';
15
+
16
+    public static $permissions = [
17
+        'view-casella-imap' => 'Vedi',
18
+        'create-casella-imap' => 'Crea',
19
+        'edit-casella-imap' => 'Modifica',
20
+        'delete-casella-imap' => 'Elimina',
21
+    ];
22
+
23
+    public static function middleware(): array
24
+    {
25
+        return [
26
+            new Middleware('permission:view-casella-imap', only: ['admin_index', 'admin_importa']),
27
+            new Middleware('permission:view-casella-imap|create-casella-imap|edit-casella-imap', only: ['admin_test']),
28
+            new Middleware('permission:create-casella-imap', only: ['admin_create', 'admin_store']),
29
+            new Middleware('permission:edit-casella-imap', only: ['admin_edit', 'admin_update', 'admin_toggle']),
30
+            new Middleware('permission:delete-casella-imap', only: ['admin_destroy']),
31
+        ];
32
+    }
33
+
34
+    public function admin_index()
35
+    {
36
+        $caselle = CasellaImap::query()->orderBy('nome')->get();
37
+
38
+        return view('casella_imap.index', compact('caselle'));
39
+    }
40
+
41
+    public function admin_create()
42
+    {
43
+        return view('casella_imap.form', [
44
+            'casella' => new CasellaImap(),
45
+            'mode' => 'create',
46
+        ]);
47
+    }
48
+
49
+    public function admin_store(Request $request)
50
+    {
51
+        $validated = $this->validateCasella($request);
52
+        $casella = new CasellaImap();
53
+        $this->fillCasella($casella, $validated, true);
54
+        $casella->save();
55
+
56
+        return redirect()->route('admin.casella_imap.index')->with('success', 'Casella IMAP creata.');
57
+    }
58
+
59
+    public function admin_edit($id)
60
+    {
61
+        $casella = CasellaImap::findOrFail($id);
62
+
63
+        return view('casella_imap.form', [
64
+            'casella' => $casella,
65
+            'mode' => 'edit',
66
+        ]);
67
+    }
68
+
69
+    public function admin_update(Request $request, $id)
70
+    {
71
+        $casella = CasellaImap::findOrFail($id);
72
+        $validated = $this->validateCasella($request, $casella);
73
+        $this->fillCasella($casella, $validated, false);
74
+        $casella->save();
75
+
76
+        return redirect()->route('admin.casella_imap.index')->with('success', 'Casella IMAP aggiornata.');
77
+    }
78
+
79
+    public function admin_destroy($id)
80
+    {
81
+        CasellaImap::findOrFail($id)->delete();
82
+
83
+        if (request()->expectsJson()) {
84
+            return response()->json(['success' => true]);
85
+        }
86
+
87
+        return redirect()->route('admin.casella_imap.index')->with('success', 'Casella IMAP eliminata.');
88
+    }
89
+
90
+    public function admin_toggle(Request $request, $id)
91
+    {
92
+        $casella = CasellaImap::findOrFail($id);
93
+        $casella->attivo = $request->boolean('attivo');
94
+        $casella->save();
95
+
96
+        if ($request->expectsJson()) {
97
+            return response()->json([
98
+                'success' => true,
99
+                'attivo' => $casella->attivo,
100
+            ]);
101
+        }
102
+
103
+        return redirect()->route('admin.casella_imap.index')->with('success', 'Stato casella aggiornato.');
104
+    }
105
+
106
+    public function admin_test(Request $request, SegnalazioneEmailInboxService $service)
107
+    {
108
+        $casella = $this->casellaFromRequest($request);
109
+        $result = $service->testConnection($casella);
110
+
111
+        return response()->json($result, $result['ok'] ? 200 : 422);
112
+    }
113
+
114
+    public function admin_importa(SegnalazioneEmailInboxService $service)
115
+    {
116
+        $stats = $service->processInbox();
117
+        $message = sprintf(
118
+            'Caselle: %d — elaborate %d, nuove %d, aggancio %d, saltate %d.',
119
+            $stats['mailboxes'],
120
+            $stats['processed'],
121
+            $stats['created'],
122
+            $stats['attached'],
123
+            $stats['skipped']
124
+        );
125
+
126
+        if ($stats['mailboxes'] === 0) {
127
+            return redirect()->route('admin.casella_imap.index')
128
+                ->with('warning', 'Nessuna casella IMAP attiva da leggere.');
129
+        }
130
+
131
+        if (! empty($stats['errors'])) {
132
+            return redirect()->route('admin.casella_imap.index')
133
+                ->with('warning', $message)
134
+                ->with('import_errors', $stats['errors']);
135
+        }
136
+
137
+        return redirect()->route('admin.casella_imap.index')->with('success', $message);
138
+    }
139
+
140
+    private function validateCasella(Request $request, ?CasellaImap $existing = null): array
141
+    {
142
+        $passwordRule = $existing ? 'nullable|string|max:255' : 'required|string|max:255';
143
+
144
+        return $request->validate([
145
+            'nome' => 'required|string|max:120',
146
+            'host' => 'required|string|max:255',
147
+            'porta' => 'required|integer|min:1|max:65535',
148
+            'encryption' => ['required', Rule::in(['ssl', 'tls', 'none'])],
149
+            'valida_certificato' => 'nullable|boolean',
150
+            'username' => 'required|string|max:255',
151
+            'password' => $passwordRule,
152
+            'protocollo' => ['nullable', Rule::in(['imap'])],
153
+            'cartella' => 'required|string|max:255',
154
+            'cartella_processate' => 'nullable|string|max:255',
155
+            'solo_non_lette' => 'nullable|boolean',
156
+            'marca_come_letta' => 'nullable|boolean',
157
+            'sposta_processate' => 'nullable|boolean',
158
+            'attivo' => 'nullable|boolean',
159
+        ]);
160
+    }
161
+
162
+    private function fillCasella(CasellaImap $casella, array $validated, bool $isCreate): void
163
+    {
164
+        $casella->nome = trim($validated['nome']);
165
+        $casella->host = trim($validated['host']);
166
+        $casella->porta = (int) $validated['porta'];
167
+        $casella->encryption = $validated['encryption'];
168
+        $casella->valida_certificato = request()->boolean('valida_certificato');
169
+        $casella->username = trim($validated['username']);
170
+        if ($isCreate || filled($validated['password'] ?? null)) {
171
+            $casella->password = $validated['password'];
172
+        }
173
+        $casella->protocollo = $validated['protocollo'] ?? 'imap';
174
+        $casella->cartella = trim($validated['cartella']);
175
+        $casella->cartella_processate = filled($validated['cartella_processate'] ?? null)
176
+            ? trim($validated['cartella_processate'])
177
+            : null;
178
+        $casella->solo_non_lette = request()->boolean('solo_non_lette');
179
+        $casella->marca_come_letta = request()->boolean('marca_come_letta');
180
+        $casella->sposta_processate = request()->boolean('sposta_processate');
181
+        $casella->attivo = request()->boolean('attivo');
182
+    }
183
+
184
+    private function casellaFromRequest(Request $request): CasellaImap
185
+    {
186
+        $existing = null;
187
+        if ($request->filled('casella_id')) {
188
+            $existing = CasellaImap::find($request->integer('casella_id'));
189
+        }
190
+
191
+        $password = $request->input('password');
192
+        if (! filled($password) && $existing) {
193
+            $password = $existing->password;
194
+        }
195
+
196
+        $casella = $existing ? $existing->replicate() : new CasellaImap();
197
+        $casella->host = $request->input('host', $existing?->host);
198
+        $casella->porta = (int) $request->input('porta', $existing?->porta ?: 993);
199
+        $casella->encryption = $request->input('encryption', $existing?->encryption ?: 'ssl');
200
+        $casella->valida_certificato = $request->boolean('valida_certificato', $existing?->valida_certificato ?? true);
201
+        $casella->username = $request->input('username', $existing?->username);
202
+        $casella->password = $password;
203
+        $casella->protocollo = $request->input('protocollo', $existing?->protocollo ?: 'imap');
204
+
205
+        return $casella;
206
+    }
207
+}

+ 102
- 18
app/Http/Controllers/ConfigController.php 查看文件

@@ -5,18 +5,14 @@ namespace App\Http\Controllers;
5 5
 use Illuminate\Routing\Controllers\Middleware;
6 6
 use Illuminate\Routing\Controllers\HasMiddleware;
7 7
 use Illuminate\Http\Request;
8
+use App\Models\Azienda;
8 9
 use App\Models\Config;
9
-use App\Models\Evento;
10
+use App\Models\ConfiguraSegnalazione;
10 11
 use Illuminate\Support\Facades\Auth;
11
-use Notification;
12
-use Session;
13
-use PDF;
14
-use Illuminate\Support\Str;
15
-use Carbon\Carbon;
12
+use Illuminate\Support\Facades\Log;
16 13
 use Illuminate\Support\Facades\Storage;
17 14
 use Intervention\Image\ImageManager;
18 15
 use Intervention\Image\Drivers\Imagick\Driver;
19
-use Illuminate\Support\Facades\Log;
20 16
 
21 17
 class ConfigController extends Controller implements HasMiddleware
22 18
 {
@@ -29,14 +25,109 @@ class ConfigController extends Controller implements HasMiddleware
29 25
   public static function middleware(): array
30 26
   {
31 27
     return [
32
-      new Middleware('permission:view-config', only: ['index']),
33
-      new Middleware('permission:edit-config', only: ['store']),
28
+      new Middleware('permission:view-config', only: ['index', 'admin_index']),
29
+      new Middleware('permission:edit-config', only: ['store', 'admin_save']),
34 30
     ];
35 31
   }
36 32
 
37 33
   public function index()
38 34
   {
39
-    return view('config.index', ['user' => Auth::user()]);
35
+    return $this->admin_index();
36
+  }
37
+
38
+  public function admin_index()
39
+  {
40
+    Config::syncDefinitions();
41
+
42
+    $definitions = Config::getConfig();
43
+    $groups = [];
44
+    foreach (array_keys($definitions) as $group) {
45
+      $groups[$group] = Config::query()
46
+        ->where('group', $group)
47
+        ->where('type', '!=', 'hidden')
48
+        ->orderBy('order')
49
+        ->get();
50
+    }
51
+
52
+    return view('config.index', [
53
+      'groups' => $groups,
54
+      'aziende' => Azienda::query()->orderBy('ragione_sociale')->orderBy('nome')->get(),
55
+      'statiSegnalazione' => ConfiguraSegnalazione::query()
56
+        ->where('gruppo', 'stato')
57
+        ->orderBy('order')
58
+        ->get(),
59
+      'canEdit' => Auth::user()->can('edit-config'),
60
+    ]);
61
+  }
62
+
63
+  public function store(Request $request)
64
+  {
65
+    return $this->admin_save($request);
66
+  }
67
+
68
+  public function admin_save(Request $request)
69
+  {
70
+    Config::syncDefinitions();
71
+
72
+    $values = $request->input('value', []);
73
+    if (! is_array($values)) {
74
+      $values = [];
75
+    }
76
+
77
+    foreach (Config::getConfig() as $items) {
78
+      foreach ($items as $item) {
79
+        if (($item['type'] ?? '') === 'hidden') {
80
+          continue;
81
+        }
82
+
83
+        $key = $item['key'];
84
+        $config = Config::find($key);
85
+        if (! $config) {
86
+          continue;
87
+        }
88
+
89
+        $config->value = $this->normalizeValue($config->type, $values[$key] ?? null);
90
+        $config->save();
91
+      }
92
+    }
93
+
94
+    return redirect()->route('admin.config.index')->with('success', 'Configurazione aggiornata');
95
+  }
96
+
97
+  protected function normalizeValue(?string $type, mixed $value): ?string
98
+  {
99
+    return match ($type) {
100
+      'boolean' => ($value === true || $value === 1 || $value === '1') ? '1' : '0',
101
+      'number' => $value === null || $value === '' ? null : (string) $value,
102
+      'integer' => $value === null || $value === '' ? null : (string) max(0, (int) $value),
103
+      'azienda' => $value === null || $value === '' ? null : (string) (int) $value,
104
+      'stato' => $value === null || $value === '' ? null : (string) $value,
105
+      'stati' => json_encode($this->normalizeStati($value), JSON_UNESCAPED_UNICODE),
106
+      default => $value === null ? null : (string) $value,
107
+    };
108
+  }
109
+
110
+  protected function normalizeStati(mixed $value): array
111
+  {
112
+    if (! is_array($value)) {
113
+      return [];
114
+    }
115
+
116
+    $valid = ConfiguraSegnalazione::query()
117
+      ->where('gruppo', 'stato')
118
+      ->pluck('value')
119
+      ->all();
120
+    $validMap = array_flip($valid);
121
+
122
+    $stati = [];
123
+    foreach ($value as $stato) {
124
+      $stato = trim((string) $stato);
125
+      if ($stato !== '' && isset($validMap[$stato])) {
126
+        $stati[] = $stato;
127
+      }
128
+    }
129
+
130
+    return array_values(array_unique($stati));
40 131
   }
41 132
 
42 133
   public function createToken(Request $request){
@@ -65,23 +156,16 @@ class ConfigController extends Controller implements HasMiddleware
65 156
   }
66 157
 
67 158
   public function cropImage(Request $request){
68
-    // $evento = Evento::find($request->evento_id);
69 159
     $fileName = explode(".", $request->filename);
70 160
     $extension = array_pop($fileName);
71 161
     $filename = implode("_", $fileName);
72 162
 
73
-    // create new image instance
74 163
     $manager = new ImageManager(new Driver());
75 164
     $image = $manager->read(Storage::disk('temp')->path($request->filename));
76 165
     $image->crop($request->cropBoxData['width'], $request->cropBoxData['height'], $request->cropBoxData['x'], $request->cropBoxData['y']);
77 166
     $newFileName = $filename.'_'.time().'_cropped.'.$extension;
78 167
     Storage::disk('temp')->put($filename.'_'.time().'_cropped.'.$extension, $image->encodeByExtension($extension, quality: 100));
79
-    // $evento->path_img = $newFileName;
80
-    // $evento->save();
81 168
 
82 169
     return response()->json(['url' => Storage::disk('temp')->url($newFileName), 'filename' => $newFileName]);
83
-}
84
-
85
-
86
-
170
+  }
87 171
 }

+ 34
- 0
app/Http/Controllers/ContrattoServizioController.php 查看文件

@@ -77,6 +77,7 @@ class ContrattoServizioController extends Controller implements HasMiddleware
77 77
       'data_fine' => 'nullable|date|after_or_equal:data_inizio',
78 78
       'note' => 'nullable|string',
79 79
       'attivo' => 'nullable|boolean',
80
+      'default_import_email' => 'nullable|boolean',
80 81
     ]);
81 82
 
82 83
     if (($validated['origine'] ?? '') === 'vuoto') {
@@ -135,6 +136,7 @@ class ContrattoServizioController extends Controller implements HasMiddleware
135 136
       'data_fine' => 'nullable|date|after_or_equal:data_inizio',
136 137
       'note' => 'nullable|string',
137 138
       'attivo' => 'nullable|boolean',
139
+      'default_import_email' => 'nullable|boolean',
138 140
       'todos' => 'nullable|array',
139 141
       'todos.*.id' => 'nullable|integer',
140 142
       'todos.*.titolo' => 'nullable|string|max:255',
@@ -152,7 +154,11 @@ class ContrattoServizioController extends Controller implements HasMiddleware
152 154
       'righe.*.quantita' => 'nullable|numeric|min:0',
153 155
       'righe.*.unita_misura' => 'nullable|string|in:'.implode(',', array_keys(Prodotto::UNITA_MISURA)),
154 156
       'righe.*.prezzo_unitario' => 'nullable|numeric|min:0',
157
+      'righe.*.sconto_tipo' => 'nullable|in:percentuale,importo',
158
+      'righe.*.sconto_valore' => 'nullable|numeric|min:0',
155 159
       'righe.*.periodo' => 'nullable|string|max:50',
160
+      'righe.*.data_inizio' => 'nullable|date',
161
+      'righe.*.data_fine' => 'nullable|date',
156 162
       'righe.*.note' => 'nullable|string',
157 163
     ]);
158 164
 
@@ -161,6 +167,10 @@ class ContrattoServizioController extends Controller implements HasMiddleware
161 167
       $contratto->save();
162 168
       $this->syncTodos($contratto, $request->input('todos', []));
163 169
       $this->syncRighe($contratto, $request->input('righe', []));
170
+      $contratto->unsetRelation('righe');
171
+      $contratto->load('righe.prodotto');
172
+      $contratto->costo = $contratto->totale_righe;
173
+      $contratto->save();
164 174
     });
165 175
 
166 176
     return redirect()->route('admin.contratto_servizio.show', $contratto->id)
@@ -243,6 +253,7 @@ class ContrattoServizioController extends Controller implements HasMiddleware
243 253
     $contratto->data_fine = $validated['data_fine'] ?? null;
244 254
     $contratto->note = $validated['note'] ?? null;
245 255
     $contratto->attivo = request()->boolean('attivo');
256
+    $contratto->default_import_email = request()->boolean('default_import_email');
246 257
     // Ore residue e collegamento segnalazioni: gestiti sulle righe, non sull'header
247 258
     $contratto->collegabile_segnalazioni = false;
248 259
     $contratto->quantita_residua = null;
@@ -319,12 +330,35 @@ class ContrattoServizioController extends Controller implements HasMiddleware
319 330
         continue;
320 331
       }
321 332
       $prodotto = Prodotto::find($prodottoId);
333
+      $scontoTipo = $row['sconto_tipo'] ?? null;
334
+      $scontoValore = $row['sconto_valore'] ?? null;
335
+      if ($scontoValore === '' || $scontoValore === null) {
336
+        $scontoTipo = null;
337
+        $scontoValore = null;
338
+      } elseif (! in_array($scontoTipo, ['percentuale', 'importo'], true)) {
339
+        $scontoTipo = 'percentuale';
340
+      }
341
+      if ($scontoTipo === 'percentuale' && $scontoValore !== null) {
342
+        $scontoValore = min(100, (float) $scontoValore);
343
+      }
344
+      $dataInizio = $row['data_inizio'] ?? null;
345
+      $dataFine = $row['data_fine'] ?? null;
346
+      if ($dataInizio === '') {
347
+        $dataInizio = null;
348
+      }
349
+      if ($dataFine === '') {
350
+        $dataFine = null;
351
+      }
322 352
       $riga->fill([
323 353
         'prodotto_id' => $prodottoId,
324 354
         'quantita' => $row['quantita'] ?? 1,
325 355
         'unita_misura' => $row['unita_misura'] ?? ($prodotto->unita_misura ?? null),
326 356
         'prezzo_unitario' => $row['prezzo_unitario'] ?? ($prodotto->prezzo ?? null),
357
+        'sconto_tipo' => $scontoTipo,
358
+        'sconto_valore' => $scontoValore,
327 359
         'periodo' => $row['periodo'] ?? ($prodotto->periodo ?? null),
360
+        'data_inizio' => $dataInizio,
361
+        'data_fine' => $dataFine,
328 362
         'note' => $row['note'] ?? null,
329 363
         'ordine' => $ordine++,
330 364
       ]);

+ 168
- 0
app/Http/Controllers/FatturazioneController.php 查看文件

@@ -0,0 +1,168 @@
1
+<?php
2
+
3
+namespace App\Http\Controllers;
4
+
5
+use App\DataTables\FatturaDataTable;
6
+use App\Models\Config;
7
+use App\Models\Fattura;
8
+use App\Services\FatturazioneGeneraService;
9
+use Carbon\Carbon;
10
+use Illuminate\Http\Request;
11
+use Illuminate\Routing\Controllers\HasMiddleware;
12
+use Illuminate\Routing\Controllers\Middleware;
13
+use Illuminate\Support\Facades\Auth;
14
+use RuntimeException;
15
+
16
+class FatturazioneController extends Controller implements HasMiddleware
17
+{
18
+  public static $permission_group = 'Fatturazione';
19
+
20
+  public static $permissions = [
21
+    'view-fatturazione' => 'Vedi',
22
+    'genera-fatturazione' => 'Genera',
23
+    'create-fattura' => 'Crea fattura',
24
+    'edit-fattura' => 'Modifica / emetti / annulla',
25
+    'delete-fattura' => 'Elimina',
26
+  ];
27
+
28
+  public static function middleware(): array
29
+  {
30
+    return [
31
+      new Middleware('permission:view-fatturazione', only: ['admin_index', 'admin_genera', 'admin_show']),
32
+      new Middleware('permission:genera-fatturazione', only: ['admin_genera']),
33
+      new Middleware('permission:create-fattura', only: ['admin_crea']),
34
+      new Middleware('permission:edit-fattura', only: ['admin_emetti', 'admin_annulla']),
35
+      new Middleware('permission:delete-fattura', only: ['admin_destroy']),
36
+    ];
37
+  }
38
+
39
+  public function admin_index(FatturaDataTable $dataTable)
40
+  {
41
+    Config::syncDefinitions();
42
+
43
+    return $dataTable->render('fatturazione.index', [
44
+      'mese' => now()->subMonthNoOverflow()->format('Y-m'),
45
+      'tariffa_default' => Config::tariffaOrariaDefault(),
46
+    ]);
47
+  }
48
+
49
+  public function admin_genera(Request $request, FatturazioneGeneraService $service)
50
+  {
51
+    [$from, $to] = $this->periodoDaRequest($request);
52
+    $esito = $service->genera($from, $to);
53
+
54
+    return view('fatturazione.genera', [
55
+      'esito' => $esito,
56
+      'mese' => $from->format('Y-m'),
57
+      'data_da' => $from->toDateString(),
58
+      'data_a' => $to->toDateString(),
59
+    ]);
60
+  }
61
+
62
+  public function admin_crea(Request $request, FatturazioneGeneraService $service)
63
+  {
64
+    $request->validate([
65
+      'cliente_key' => 'required|string',
66
+      'mese' => 'nullable|date_format:Y-m',
67
+      'data_da' => 'nullable|date',
68
+      'data_a' => 'nullable|date|after_or_equal:data_da',
69
+    ]);
70
+
71
+    [$from, $to] = $this->periodoDaRequest($request);
72
+
73
+    try {
74
+      $fattura = $service->creaFattura(
75
+        $request->input('cliente_key'),
76
+        $from,
77
+        $to,
78
+        Auth::user()
79
+      );
80
+    } catch (RuntimeException $e) {
81
+      return redirect()
82
+        ->route('admin.fatturazione.genera', $this->periodoQuery($from, $to))
83
+        ->with('error', $e->getMessage());
84
+    }
85
+
86
+    return redirect()
87
+      ->route('admin.fatturazione.show', $fattura->id)
88
+      ->with('success', 'Fattura '.$fattura->numero_documento.' creata in bozza.');
89
+  }
90
+
91
+  public function admin_show($id)
92
+  {
93
+    $fattura = Fattura::with(['user', 'createdBy', 'comune', 'righe'])->findOrFail($id);
94
+
95
+    return view('fatturazione.show', compact('fattura'));
96
+  }
97
+
98
+  public function admin_emetti($id)
99
+  {
100
+    $fattura = Fattura::findOrFail($id);
101
+    if (! $fattura->isBozza()) {
102
+      return redirect()->back()->with('error', 'Si può emettere solo una fattura in bozza.');
103
+    }
104
+    $fattura->stato = Fattura::STATO_EMESSA;
105
+    if (! $fattura->data) {
106
+      $fattura->data = now()->toDateString();
107
+    }
108
+    $fattura->save();
109
+
110
+    return redirect()->back()->with('success', 'Fattura emessa.');
111
+  }
112
+
113
+  public function admin_annulla($id)
114
+  {
115
+    $fattura = Fattura::findOrFail($id);
116
+    if ($fattura->isAnnullata()) {
117
+      return redirect()->back()->with('error', 'La fattura è già annullata.');
118
+    }
119
+    $fattura->stato = Fattura::STATO_ANNULLATA;
120
+    $fattura->save();
121
+
122
+    return redirect()->back()->with('success', 'Fattura annullata. Le voci tornano disponibili al genera.');
123
+  }
124
+
125
+  public function admin_destroy($id)
126
+  {
127
+    $fattura = Fattura::findOrFail($id);
128
+    if (! $fattura->isBozza()) {
129
+      return redirect()->back()->with('error', 'Si può eliminare solo una fattura in bozza. Annulla quelle già emesse.');
130
+    }
131
+    $fattura->delete();
132
+
133
+    return redirect()->route('admin.fatturazione.index')->with('success', 'Fattura eliminata.');
134
+  }
135
+
136
+  /**
137
+   * @return array{0: Carbon, 1: Carbon}
138
+   */
139
+  protected function periodoDaRequest(Request $request): array
140
+  {
141
+    $validated = $request->validate([
142
+      'mese' => 'nullable|date_format:Y-m',
143
+      'data_da' => 'nullable|date',
144
+      'data_a' => 'nullable|date|after_or_equal:data_da',
145
+    ]);
146
+
147
+    if (! empty($validated['data_da']) && ! empty($validated['data_a'])) {
148
+      return [
149
+        Carbon::parse($validated['data_da'])->startOfDay(),
150
+        Carbon::parse($validated['data_a'])->endOfDay(),
151
+      ];
152
+    }
153
+
154
+    $mese = $validated['mese'] ?? now()->subMonthNoOverflow()->format('Y-m');
155
+    $from = Carbon::createFromFormat('Y-m-d', $mese.'-01')->startOfMonth()->startOfDay();
156
+
157
+    return [$from, $from->copy()->endOfMonth()->endOfDay()];
158
+  }
159
+
160
+  protected function periodoQuery(Carbon $from, Carbon $to): array
161
+  {
162
+    return [
163
+      'mese' => $from->format('Y-m'),
164
+      'data_da' => $from->toDateString(),
165
+      'data_a' => $to->toDateString(),
166
+    ];
167
+  }
168
+}

+ 58
- 2
app/Http/Controllers/HomePageController.php 查看文件

@@ -277,7 +277,63 @@ class HomePageController extends Controller
277 277
     return $query;
278 278
   }
279 279
 
280
-  public function dashboard(){
281
-    return view('dashboard.index');
280
+  public function dashboard()
281
+  {
282
+    $user = Auth::user();
283
+    $data = [];
284
+    if ($user && $user->can('view-own-segnalazione')) {
285
+      $data = $this->getClienteDashboardData($user);
286
+    }
287
+
288
+    return view('dashboard.index', $data);
289
+  }
290
+
291
+  /**
292
+   * KPI area cliente: proprie, oppure tutte quelle dell'azienda se responsabile.
293
+   *
294
+   * @return array<string, mixed>
295
+   */
296
+  private function getClienteDashboardData(\App\Models\User $user): array
297
+  {
298
+    $statiAperte = Config::getStatiSegnalazione('aperte') ?? [];
299
+    $statiLavorazione = Config::getStatiSegnalazione('in_lavorazione') ?? [];
300
+    $statiChiuse = Config::getStatiSegnalazione('chiuse') ?? [];
301
+
302
+    $base = fn () => Segnalazione::query()->visibiliAlCliente($user);
303
+
304
+    $totaleSegnalazioni = $base()->count();
305
+    $segnalazioniAperte = $base()->whereIn('stato', $statiAperte)->count();
306
+    $segnalazioniInLavorazione = $base()->whereIn('stato', $statiLavorazione)->count();
307
+    $segnalazioniChiuse = $base()->whereIn('stato', $statiChiuse)->count();
308
+
309
+    $meseCorrente = Carbon::now()->startOfMonth();
310
+    $fineMeseCorrente = Carbon::now()->endOfMonth();
311
+    $chiuseQuestoMese = $base()
312
+      ->whereIn('stato', $statiChiuse)
313
+      ->whereBetween('updated_at', [$meseCorrente, $fineMeseCorrente])
314
+      ->count();
315
+
316
+    $segnalazioniInRitardo = $base()
317
+      ->whereNotIn('stato', $statiChiuse)
318
+      ->where('created_at', '<', Carbon::now()->subDays(7))
319
+      ->count();
320
+
321
+    $avgDays = $base()
322
+      ->whereIn('stato', $statiChiuse)
323
+      ->whereNotNull('created_at')
324
+      ->whereNotNull('updated_at')
325
+      ->avg(DB::raw('TIMESTAMPDIFF(DAY, created_at, updated_at)'));
326
+    $tempoMedioRisoluzione = $avgDays !== null ? round((float) $avgDays, 1) : 0;
327
+
328
+    return [
329
+      'totaleSegnalazioni' => $totaleSegnalazioni,
330
+      'segnalazioniAperte' => $segnalazioniAperte,
331
+      'segnalazioniInLavorazione' => $segnalazioniInLavorazione,
332
+      'segnalazioniChiuse' => $segnalazioniChiuse,
333
+      'chiuseQuestoMese' => $chiuseQuestoMese,
334
+      'segnalazioniInRitardo' => $segnalazioniInRitardo,
335
+      'tempoMedioRisoluzione' => $tempoMedioRisoluzione,
336
+      'isResponsabileAzienda' => (bool) $user->responsabile_azienda && (bool) $user->azienda_id,
337
+    ];
282 338
   }
283 339
 }

+ 38
- 7
app/Http/Controllers/OffertaController.php 查看文件

@@ -34,7 +34,7 @@ class OffertaController extends Controller implements HasMiddleware
34 34
   public static function middleware(): array
35 35
   {
36 36
     return [
37
-      new Middleware('permission:view-offerta', only: ['admin_index', 'admin_show', 'admin_list', 'admin_pdf']),
37
+      new Middleware('permission:view-offerta', only: ['admin_index', 'admin_show', 'admin_list', 'admin_righe', 'admin_pdf']),
38 38
       new Middleware('permission:create-offerta', only: ['admin_create', 'admin_store']),
39 39
       new Middleware('permission:edit-offerta', only: ['admin_edit', 'admin_update', 'admin_conferma', 'downloadAllegato', 'deleteAllegato']),
40 40
       new Middleware('permission:delete-offerta', only: ['admin_destroy']),
@@ -157,6 +157,7 @@ class OffertaController extends Controller implements HasMiddleware
157 157
       ]);
158 158
       $ordine->save();
159 159
       $ordine->offerte()->sync([$offerta->id]);
160
+      $ordine->clonaRigheDaOfferta($offerta);
160 161
 
161 162
       if ($rigaIdsDaCreare->isEmpty()) {
162 163
         return $ordine;
@@ -182,7 +183,7 @@ class OffertaController extends Controller implements HasMiddleware
182 183
         $segnalazione->assegnata_a_id = $assegnataAId;
183 184
         $segnalazione->azienda_id = $aziendaId;
184 185
         $segnalazione->user_id = Auth::id();
185
-        $segnalazione->tempo_stimato = $riga->tempo_stimato;
186
+        $segnalazione->tempo_stimato = $riga->ore > 0 ? $riga->ore : null;
186 187
         $segnalazione->meta = ['offerta_riga_id' => $riga->id];
187 188
         $segnalazione->notificaApertaDa = false;
188 189
         $segnalazione->save();
@@ -220,8 +221,8 @@ class OffertaController extends Controller implements HasMiddleware
220 221
     if ($riga->quantita !== null || $um) {
221 222
       $parts[] = 'Quantità: '.($riga->quantita ?? '—').($um ? ' '.$um : '');
222 223
     }
223
-    if ($riga->unita_misura === 'forfait' && $riga->tempo_stimato !== null) {
224
-      $parts[] = 'Tempo stimato: '.rtrim(rtrim(number_format((float) $riga->tempo_stimato, 2, ',', ''), '0'), ',').' ore';
224
+    if ($riga->ore > 0) {
225
+      $parts[] = 'Tempo stimato: '.rtrim(rtrim(number_format($riga->ore, 2, ',', ''), '0'), ',').' ore';
225 226
     }
226 227
     if ($riga->note_private) {
227 228
       $parts[] = 'Note: '.$riga->note_private;
@@ -302,6 +303,26 @@ class OffertaController extends Controller implements HasMiddleware
302 303
     ]);
303 304
   }
304 305
 
306
+  public function admin_righe($id)
307
+  {
308
+    $offerta = Offerta::with('righe')->findOrFail($id);
309
+
310
+    return response()->json([
311
+      'id' => $offerta->id,
312
+      'text' => $offerta->label,
313
+      'righe' => $offerta->righe->map(fn (OffertaRiga $r) => [
314
+        'prodotto_id' => $r->prodotto_id,
315
+        'descrizione' => $r->descrizione,
316
+        'quantita' => $r->quantita,
317
+        'unita_misura' => $r->unita_misura,
318
+        'prezzo_unitario' => $r->prezzo_unitario,
319
+        'sconto' => $r->sconto,
320
+        'note_private' => $r->note_private,
321
+        'tempo_stimato' => $r->tempo_stimato,
322
+      ])->values(),
323
+    ]);
324
+  }
325
+
305 326
   public function admin_pdf($id, OffertaPdfService $pdfService)
306 327
   {
307 328
     $offerta = Offerta::findOrFail($id);
@@ -393,14 +414,24 @@ class OffertaController extends Controller implements HasMiddleware
393 414
         'note_private' => isset($riga['note_private']) && trim((string) $riga['note_private']) !== ''
394 415
           ? trim((string) $riga['note_private'])
395 416
           : null,
396
-        'tempo_stimato' => (($riga['unita_misura'] ?? null) === 'forfait' && isset($riga['tempo_stimato']) && $riga['tempo_stimato'] !== '')
397
-          ? $riga['tempo_stimato']
398
-          : null,
417
+        'tempo_stimato' => $this->tempoStimatoDaRiga($riga),
399 418
         'ordine' => $ordine++,
400 419
       ]);
401 420
     }
402 421
   }
403 422
 
423
+  private function tempoStimatoDaRiga(array $riga): ?float
424
+  {
425
+    if (isset($riga['tempo_stimato']) && $riga['tempo_stimato'] !== '') {
426
+      return (float) $riga['tempo_stimato'];
427
+    }
428
+    if (($riga['unita_misura'] ?? null) === 'ora' && isset($riga['quantita']) && $riga['quantita'] !== '') {
429
+      return (float) $riga['quantita'];
430
+    }
431
+
432
+    return null;
433
+  }
434
+
404 435
   private function storeAllegato(Request $request, Offerta $offerta): void
405 436
   {
406 437
     if (! $request->hasFile('path_file')) {

+ 88
- 4
app/Http/Controllers/OrdineLavoroController.php 查看文件

@@ -5,6 +5,8 @@ namespace App\Http\Controllers;
5 5
 use App\DataTables\OrdineLavoroDataTable;
6 6
 use App\Models\Offerta;
7 7
 use App\Models\OrdineLavoro;
8
+use App\Models\OrdineLavoroRiga;
9
+use App\Models\Prodotto;
8 10
 use App\Models\User;
9 11
 use Illuminate\Http\Request;
10 12
 use Illuminate\Routing\Controllers\HasMiddleware;
@@ -54,20 +56,24 @@ class OrdineLavoroController extends Controller implements HasMiddleware
54 56
       'user_id' => $request->user_id,
55 57
     ]);
56 58
 
59
+    $righeIniziali = null;
57 60
     if ($offertaIds->isNotEmpty()) {
58
-      $offerte = Offerta::whereIn('id', $offertaIds)->get();
61
+      $offerte = Offerta::with('righe')->whereIn('id', $offertaIds)->get();
59 62
       if ($offerte->isNotEmpty()) {
60 63
         $ordine->user_id = $ordine->user_id ?: $offerte->first()->user_id;
61 64
         if (! $ordine->titolo) {
62 65
           $ordine->titolo = $offerte->first()->titolo ?: ('ODL da '.$offerte->first()->label);
63 66
         }
67
+        $righeIniziali = $offerte->flatMap->righe->map(fn ($r) => OrdineLavoroRiga::fromOffertaRiga($r))->values()->all();
64 68
       }
65 69
     }
66 70
 
67 71
     return view('ordine_lavoro.form', [
68 72
       'ordine' => $ordine,
69 73
       'clienti' => $clienti,
74
+      'prodotti' => Prodotto::where('attivo', true)->orderBy('nome')->get(),
70 75
       'offerteSelezionate' => $offertaIds->all(),
76
+      'righeIniziali' => $righeIniziali,
71 77
       'mode' => 'create',
72 78
     ]);
73 79
   }
@@ -75,12 +81,13 @@ class OrdineLavoroController extends Controller implements HasMiddleware
75 81
   public function admin_store(Request $request)
76 82
   {
77 83
     $validated = $this->validateOrdine($request);
78
-    $ordine = DB::transaction(function () use ($validated) {
84
+    $ordine = DB::transaction(function () use ($validated, $request) {
79 85
       $ordine = new OrdineLavoro();
80 86
       $this->fillOrdine($ordine, $validated);
81 87
       $ordine->numero = $validated['numero'] ?? OrdineLavoro::nextNumero();
82 88
       $ordine->save();
83 89
       $ordine->offerte()->sync($validated['offerta_ids'] ?? []);
90
+      $this->syncRigheOCopiaOfferte($ordine, $request->input('righe', []), $validated['offerta_ids'] ?? []);
84 91
 
85 92
       return $ordine;
86 93
     });
@@ -93,6 +100,7 @@ class OrdineLavoroController extends Controller implements HasMiddleware
93 100
   {
94 101
     $ordine = OrdineLavoro::with([
95 102
       'user',
103
+      'righe.prodotto',
96 104
       'offerte.user',
97 105
       'segnalazioni.annotazioni',
98 106
     ])->findOrFail($id);
@@ -102,13 +110,15 @@ class OrdineLavoroController extends Controller implements HasMiddleware
102 110
 
103 111
   public function admin_edit($id)
104 112
   {
105
-    $ordine = OrdineLavoro::with('offerte')->findOrFail($id);
113
+    $ordine = OrdineLavoro::with(['offerte', 'righe'])->findOrFail($id);
106 114
     $clienti = User::where('is_gruppo', false)->orderBy('cognome')->orderBy('nome')->get();
107 115
 
108 116
     return view('ordine_lavoro.form', [
109 117
       'ordine' => $ordine,
110 118
       'clienti' => $clienti,
119
+      'prodotti' => Prodotto::where('attivo', true)->orderBy('nome')->get(),
111 120
       'offerteSelezionate' => $ordine->offerte->pluck('id')->all(),
121
+      'righeIniziali' => null,
112 122
       'mode' => 'edit',
113 123
     ]);
114 124
   }
@@ -118,10 +128,11 @@ class OrdineLavoroController extends Controller implements HasMiddleware
118 128
     $ordine = OrdineLavoro::findOrFail($id);
119 129
     $validated = $this->validateOrdine($request, $ordine);
120 130
 
121
-    DB::transaction(function () use ($ordine, $validated) {
131
+    DB::transaction(function () use ($ordine, $validated, $request) {
122 132
       $this->fillOrdine($ordine, $validated);
123 133
       $ordine->save();
124 134
       $ordine->offerte()->sync($validated['offerta_ids'] ?? []);
135
+      $this->syncRighe($ordine, $request->input('righe', []));
125 136
     });
126 137
 
127 138
     return redirect()->route('admin.ordine_lavoro.show', $ordine->id)
@@ -183,6 +194,15 @@ class OrdineLavoroController extends Controller implements HasMiddleware
183 194
         'integer',
184 195
         Rule::exists('offerta', 'id')->where(fn ($q) => $q->where('user_id', $request->user_id)),
185 196
       ],
197
+      'righe' => 'nullable|array',
198
+      'righe.*.prodotto_id' => 'nullable|exists:prodotto,id',
199
+      'righe.*.descrizione' => 'nullable|string|max:255',
200
+      'righe.*.quantita' => 'nullable|numeric|min:0',
201
+      'righe.*.unita_misura' => 'nullable|string|in:'.implode(',', array_keys(Prodotto::UNITA_MISURA)),
202
+      'righe.*.prezzo_unitario' => 'nullable|numeric|min:0',
203
+      'righe.*.sconto' => 'nullable|numeric|min:0|max:100',
204
+      'righe.*.note_private' => 'nullable|string',
205
+      'righe.*.tempo_stimato' => 'nullable|numeric|min:0',
186 206
     ]);
187 207
 
188 208
     $offertaIds = collect($validated['offerta_ids'] ?? [])->filter()->unique()->values();
@@ -219,4 +239,68 @@ class OrdineLavoroController extends Controller implements HasMiddleware
219 239
     $ordine->data_chiusura = $validated['data_chiusura'] ?? null;
220 240
     $ordine->note = $validated['note'] ?? null;
221 241
   }
242
+
243
+  private function syncRigheOCopiaOfferte(OrdineLavoro $ordine, array $righe, array $offertaIds): void
244
+  {
245
+    if (! $this->righeVuote($righe)) {
246
+      $this->syncRighe($ordine, $righe);
247
+
248
+      return;
249
+    }
250
+
251
+    foreach (Offerta::with('righe')->whereIn('id', $offertaIds)->get() as $offerta) {
252
+      $ordine->clonaRigheDaOfferta($offerta);
253
+    }
254
+  }
255
+
256
+  private function righeVuote(array $righe): bool
257
+  {
258
+    foreach ($righe as $riga) {
259
+      $descrizione = trim((string) ($riga['descrizione'] ?? ''));
260
+      $prodottoId = $riga['prodotto_id'] ?? null;
261
+      if ($descrizione !== '' || $prodottoId) {
262
+        return false;
263
+      }
264
+    }
265
+
266
+    return true;
267
+  }
268
+
269
+  private function syncRighe(OrdineLavoro $ordine, array $righe): void
270
+  {
271
+    $ordine->righe()->delete();
272
+    $ordineIdx = 0;
273
+    foreach ($righe as $riga) {
274
+      $descrizione = trim((string) ($riga['descrizione'] ?? ''));
275
+      $prodottoId = $riga['prodotto_id'] ?? null;
276
+      if ($descrizione === '' && ! $prodottoId) {
277
+        continue;
278
+      }
279
+      if ($descrizione === '' && $prodottoId) {
280
+        $descrizione = optional(Prodotto::find($prodottoId))->nome ?? 'Riga';
281
+      }
282
+      $um = $riga['unita_misura'] ?? null;
283
+      $tempo = null;
284
+      if (isset($riga['tempo_stimato']) && $riga['tempo_stimato'] !== '') {
285
+        $tempo = $riga['tempo_stimato'];
286
+      } elseif ($um === 'ora') {
287
+        $tempo = $riga['quantita'] ?? null;
288
+      }
289
+
290
+      OrdineLavoroRiga::create([
291
+        'ordine_lavoro_id' => $ordine->id,
292
+        'prodotto_id' => $prodottoId ?: null,
293
+        'descrizione' => $descrizione,
294
+        'quantita' => $riga['quantita'] ?? 1,
295
+        'unita_misura' => $um,
296
+        'prezzo_unitario' => $riga['prezzo_unitario'] ?? 0,
297
+        'sconto' => $riga['sconto'] ?? null,
298
+        'note_private' => isset($riga['note_private']) && trim((string) $riga['note_private']) !== ''
299
+          ? trim((string) $riga['note_private'])
300
+          : null,
301
+        'tempo_stimato' => $tempo,
302
+        'ordine' => $ordineIdx++,
303
+      ]);
304
+    }
305
+  }
222 306
 }

+ 221
- 0
app/Http/Controllers/SegnalazioneClienteController.php 查看文件

@@ -0,0 +1,221 @@
1
+<?php
2
+
3
+namespace App\Http\Controllers;
4
+
5
+use App\DataTables\SegnalazioneClienteDataTable;
6
+use App\Models\AllegatoAnnotazione;
7
+use App\Models\Annotazione;
8
+use App\Models\Config;
9
+use App\Models\ConfiguraSegnalazione;
10
+use App\Models\ContrattoServizio;
11
+use App\Models\Segnalazione;
12
+use App\Models\User;
13
+use App\Notifications\RispostaEmailAssegnatario;
14
+use Illuminate\Http\Request;
15
+use Illuminate\Routing\Controllers\HasMiddleware;
16
+use Illuminate\Routing\Controllers\Middleware;
17
+use Illuminate\Support\Facades\Auth;
18
+use Illuminate\Support\Facades\File;
19
+use Illuminate\Support\Facades\Notification;
20
+use Illuminate\Validation\Rule;
21
+
22
+class SegnalazioneClienteController extends Controller implements HasMiddleware
23
+{
24
+    public static $permission_group = 'Segnalazioni';
25
+
26
+    public static $permissions = [];
27
+
28
+    public static function middleware(): array
29
+    {
30
+        return [
31
+            new Middleware('permission:view-own-segnalazione'),
32
+            new Middleware('permission:create-segnalazione', only: ['create', 'store']),
33
+        ];
34
+    }
35
+
36
+    public function index(SegnalazioneClienteDataTable $dataTable, Request $request)
37
+    {
38
+        $user = Auth::user();
39
+        $status = $request->get('status');
40
+        if ($request->boolean('chiuse')) {
41
+            $status = 'chiuse';
42
+        }
43
+        if (! in_array($status, ['aperte', 'in_lavorazione', 'chiuse', 'in_ritardo'], true)) {
44
+            $status = null;
45
+        }
46
+
47
+        return $dataTable->with(['status' => $status])->render('segnalazione.cliente.index', [
48
+            'status' => $status,
49
+            'canCreate' => $user->can('create-segnalazione'),
50
+        ]);
51
+    }
52
+
53
+    public function create()
54
+    {
55
+        return view('segnalazione.cliente.nuova', [
56
+            'priorita' => ConfiguraSegnalazione::where('gruppo', 'Priorità')->orderBy('order')->get(),
57
+            'tipi' => ConfiguraSegnalazione::where('gruppo', 'Tipo')->orderBy('order')->get(),
58
+        ]);
59
+    }
60
+
61
+    public function store(Request $request)
62
+    {
63
+        $user = Auth::user();
64
+        $prioritaValide = ConfiguraSegnalazione::where('gruppo', 'Priorità')->pluck('value')->all();
65
+        $tipiValidi = ConfiguraSegnalazione::where('gruppo', 'Tipo')->pluck('value')->all();
66
+
67
+        $validated = $request->validate([
68
+            'titolo' => 'required|string|max:255',
69
+            'descrizione' => 'required|string',
70
+            'priorita' => ['nullable', Rule::in($prioritaValide)],
71
+            'tipo' => ['nullable', Rule::in($tipiValidi)],
72
+            'allegato' => 'nullable|array',
73
+            'allegato.*' => 'file|max:10240',
74
+        ]);
75
+
76
+        if (trim(strip_tags(html_entity_decode($validated['descrizione']))) === '') {
77
+            return redirect()->back()->withInput()->withErrors(['descrizione' => 'Inserisci una descrizione.']);
78
+        }
79
+
80
+        $aziendaId = $user->is_azienda ? (int) $user->id : ($user->azienda_id ? (int) $user->azienda_id : null);
81
+
82
+        $segnalazione = new Segnalazione();
83
+        $segnalazione->titolo = $validated['titolo'];
84
+        $segnalazione->user_id = $user->id;
85
+        $segnalazione->azienda_id = $aziendaId;
86
+        $segnalazione->contratto_servizio_id = ContrattoServizio::defaultImportEmailForAzienda($aziendaId)?->id;
87
+        $segnalazione->assegnata_a_id = null;
88
+        $segnalazione->stato = Config::getStatoSegnalazioneDefault();
89
+        $segnalazione->priorita = $validated['priorita'] ?? Config::getPrioritaSegnalazioneDefault();
90
+        $segnalazione->tipo = $validated['tipo'] ?? 'ordinaria';
91
+        $segnalazione->origine = 'manuale';
92
+        $segnalazione->notificaApertaDa = false;
93
+        $segnalazione->save();
94
+
95
+        $annotazione = new Annotazione();
96
+        $annotazione->text = $validated['descrizione'];
97
+        $annotazione->segnalazione_id = $segnalazione->id;
98
+        $annotazione->user_id = $user->id;
99
+        $annotazione->riservata = false;
100
+        $annotazione->save();
101
+
102
+        $this->storeAllegati($request, $segnalazione, $annotazione);
103
+
104
+        return redirect()
105
+            ->route('segnalazione.dettaglio', $segnalazione->id)
106
+            ->with('success', 'Segnalazione creata con successo');
107
+    }
108
+
109
+    public function show($id)
110
+    {
111
+        $segnalazione = $this->findOwnOrAbort($id);
112
+
113
+        $segnalazione->load([
114
+            'azienda',
115
+            'user',
116
+            'assegnataA',
117
+            'ordineLavoro.offerte.righe',
118
+            'ordineLavoro.righe',
119
+            'contrattoServizio.contrattoTemplate',
120
+            'contrattoServizio.contrattoServizioRigas.prodotto',
121
+            'contrattoServizioTodo',
122
+            'prodotto',
123
+            'acquistoServizio.prodotto',
124
+            'acquistoServizios.prodotto',
125
+            'todos.completatoDa',
126
+            'annotazioni.allegatiAnnotazione',
127
+            'annotazioni.user',
128
+        ]);
129
+
130
+        return view('segnalazione.cliente.show', [
131
+            'segnalazione' => $segnalazione,
132
+            'statiDisponibili' => [],
133
+            'areaCliente' => true,
134
+        ]);
135
+    }
136
+
137
+    public function rispondi(Request $request, $id)
138
+    {
139
+        $user = Auth::user();
140
+        $segnalazione = $this->findOwnOrAbort($id);
141
+
142
+        $validated = $request->validate([
143
+            'text' => 'required|string',
144
+            'allegato' => 'nullable|array',
145
+            'allegato.*' => 'file|max:10240',
146
+        ]);
147
+
148
+        if (trim(strip_tags(html_entity_decode($validated['text']))) === '') {
149
+            return redirect()->back()->withInput()->withErrors(['text' => 'Inserisci un testo per la risposta.']);
150
+        }
151
+
152
+        $annotazione = new Annotazione();
153
+        $annotazione->text = $validated['text'];
154
+        $annotazione->segnalazione_id = $segnalazione->id;
155
+        $annotazione->user_id = $user->id;
156
+        $annotazione->riservata = false;
157
+        $annotazione->save();
158
+
159
+        $this->storeAllegati($request, $segnalazione, $annotazione);
160
+
161
+        if ($segnalazione->stato !== 'in_attesa_operatore') {
162
+            $segnalazione->stato = 'in_attesa_operatore';
163
+            $segnalazione->save();
164
+        }
165
+
166
+        if ($segnalazione->assegnata_a_id) {
167
+            $targets = User::notifiableMembersForAssigneeId((int) $segnalazione->assegnata_a_id)
168
+                ->reject(fn (User $u) => (int) $u->id === (int) $user->id)
169
+                ->values();
170
+            if ($targets->isNotEmpty()) {
171
+                Notification::send(
172
+                    $targets,
173
+                    new RispostaEmailAssegnatario(
174
+                        $segnalazione->fresh(['azienda']),
175
+                        $annotazione->fresh(['user']),
176
+                    )
177
+                );
178
+            }
179
+        }
180
+
181
+        return redirect()
182
+            ->route('segnalazione.dettaglio', $segnalazione->id)
183
+            ->with('success', 'Risposta inviata');
184
+    }
185
+
186
+    protected function findOwnOrAbort($id): Segnalazione
187
+    {
188
+        $segnalazione = Segnalazione::find($id);
189
+        if (! $segnalazione || ! $segnalazione->isAccessibleByCliente(Auth::user())) {
190
+            abort(403);
191
+        }
192
+
193
+        return $segnalazione;
194
+    }
195
+
196
+    protected function storeAllegati(Request $request, Segnalazione $segnalazione, Annotazione $annotazione): void
197
+    {
198
+        if (! $request->hasFile('allegato')) {
199
+            return;
200
+        }
201
+
202
+        $directory = storage_path('app/public/allegatiAnnotazioni/'.$segnalazione->id);
203
+        if (! File::exists($directory)) {
204
+            File::makeDirectory($directory, 0755, true);
205
+        }
206
+
207
+        foreach ($request->file('allegato') as $file) {
208
+            if (! $file) {
209
+                continue;
210
+            }
211
+            $filename = time().'_'.$file->getClientOriginalName();
212
+            $path = $file->storeAs((string) $segnalazione->id, $filename, 'allegatiAnnotazioni');
213
+
214
+            $allegato = new AllegatoAnnotazione();
215
+            $allegato->annotazione()->associate($annotazione);
216
+            $allegato->nome = $file->getClientOriginalName();
217
+            $allegato->path_file = $path;
218
+            $allegato->save();
219
+        }
220
+    }
221
+}

+ 168
- 4
app/Http/Controllers/SegnalazioneController.php 查看文件

@@ -16,12 +16,13 @@ use App\Models\TempoImpiegato;
16 16
 use App\Models\Config;
17 17
 use App\Models\ConfiguraSegnalazione;
18 18
 use App\Models\SegnalazioneTodo;
19
+use App\Models\ContrattoServizio;
20
+use App\Models\OrdineLavoro;
19 21
 use App\DataTables\SegnalazioneDataTable;
20 22
 use Illuminate\Support\Facades\Auth;
21 23
 use App\DataTables\SegnalazioneDataTableEditor;
22 24
 use App\Services\SegnalazioneTicketPdfService;
23 25
 use Illuminate\Support\Str;
24
-use Illuminate\Support\Facades\Log;
25 26
 use Illuminate\Support\Facades\File;
26 27
 use Illuminate\Validation\Rule;
27 28
 
@@ -50,6 +51,7 @@ class SegnalazioneController extends Controller implements HasMiddleware
50 51
         'admin_store_todo',
51 52
         'admin_update_todo',
52 53
         'admin_destroy_todo',
54
+        'admin_tempo_timer',
53 55
       ]),
54 56
       new Middleware('permission:delete-segnalazione', only: ['destroy', 'admin_delete']),
55 57
       // new Middleware('permission:view-segnalazione', only: ['show']),
@@ -58,16 +60,91 @@ class SegnalazioneController extends Controller implements HasMiddleware
58 60
   }
59 61
   public function admin_index(SegnalazioneDataTable $dataTable, Request $request, ?string $status = null)
60 62
   {
61
-    // Imposta showAll in base al parametro della richiesta
62
-    $dataTable->showAll = $request->get('show_all', 'false') === 'true';
63
+    if (Segnalazione::isClienteVista(Auth::user())) {
64
+      return redirect()->route('segnalazione.index');
65
+    }
66
+    $dataTable->showAll = $request->boolean('show_chiuse') || $request->get('show_all') === 'true';
63 67
     return $dataTable->with(['status' => $status])->render('segnalazione.admin.index', ['status' => $status]);
64 68
   }
65 69
 
66 70
   // ADMIN
67 71
   public function admin_nuova(Request $request){
72
+    if (Segnalazione::isClienteVista(Auth::user())) {
73
+      return redirect()->route('segnalazione.create');
74
+    }
68 75
     return view('segnalazione.admin.nuova');
69 76
   }
70 77
 
78
+  public function admin_options_by_azienda(Request $request)
79
+  {
80
+    $aziendaId = $request->query('azienda_id');
81
+    if ($aziendaId === null || $aziendaId === '') {
82
+      return response()->json([
83
+        'contratti' => [],
84
+        'ordini' => [],
85
+      ]);
86
+    }
87
+
88
+    $request->validate([
89
+      'azienda_id' => ['required', Rule::exists('users', 'id')->where('is_azienda', 1)],
90
+    ]);
91
+
92
+    $contratti = ContrattoServizio::where('user_id', $aziendaId)
93
+      ->attivi()
94
+      ->with(['contrattoServizioRigas.prodotto'])
95
+      ->orderBy('nome')
96
+      ->get()
97
+      ->map(function (ContrattoServizio $cs) {
98
+        $totali = $cs->ore_totali;
99
+        $usate = $cs->ore_usate;
100
+        $rimanenti = round($totali - $usate, 2);
101
+
102
+        return [
103
+          'id' => $cs->id,
104
+          'label' => $cs->label,
105
+          'ore_totali' => $totali,
106
+          'ore_usate' => $usate,
107
+          'ore_rimanenti' => $rimanenti,
108
+          'ore_totali_label' => Config::formatTempo((int) round($totali * 60)),
109
+          'ore_usate_label' => Config::formatTempo((int) round($usate * 60)),
110
+          'ore_rimanenti_label' => Config::formatTempo((int) round(max(0, $rimanenti) * 60)),
111
+          'periodo' => $cs->periodo_ore,
112
+          'periodo_label' => $cs->periodo_riferimento_label,
113
+        ];
114
+      })
115
+      ->values();
116
+
117
+    $ordini = OrdineLavoro::where('user_id', $aziendaId)
118
+      ->with(['offerte.righe', 'righe'])
119
+      ->orderByDesc('numero')
120
+      ->limit(100)
121
+      ->get()
122
+      ->map(function (OrdineLavoro $odl) {
123
+        $totali = $odl->ore_totali;
124
+        $usate = $odl->ore_usate;
125
+        $rimanenti = round($totali - $usate, 2);
126
+
127
+        return [
128
+          'id' => $odl->id,
129
+          'label' => $odl->label.' ('.$odl->stato_label.')',
130
+          'ore_totali' => $totali,
131
+          'ore_usate' => $usate,
132
+          'ore_rimanenti' => $rimanenti,
133
+          'ore_totali_label' => Config::formatTempo((int) round($totali * 60)),
134
+          'ore_usate_label' => Config::formatTempo((int) round($usate * 60)),
135
+          'ore_rimanenti_label' => Config::formatTempo((int) round(max(0, $rimanenti) * 60)),
136
+          'periodo' => 'ordine',
137
+          'periodo_label' => 'ordine',
138
+        ];
139
+      })
140
+      ->values();
141
+
142
+    return response()->json([
143
+      'contratti' => $contratti,
144
+      'ordini' => $ordini,
145
+    ]);
146
+  }
147
+
71 148
   public function admin_aggiorna(Request $request, $id)
72 149
   {
73 150
     $request->validate([
@@ -83,12 +160,19 @@ class SegnalazioneController extends Controller implements HasMiddleware
83 160
       'categoria_sicurezza' => 'nullable|string|max:255',
84 161
       'annotazione_user_id' => 'required|exists:users,id',
85 162
       'annotazione' => 'nullable|string',
163
+      'tempo_stimato' => 'nullable|numeric|min:0',
86 164
       'tempo_ore' => 'nullable|string',
87 165
       'tempo_user_id' => 'nullable|exists:users,id',
88 166
       'allegato' => 'nullable|array',
89 167
       'allegato.*' => 'file|max:10240', // max 10MB per file
90 168
     ]);
91 169
 
170
+    if ($request->filled('ordine_lavoro_id') && $request->filled('contratto_servizio_id')) {
171
+      return redirect()->back()
172
+        ->withInput()
173
+        ->withErrors(['contratto_servizio_id' => 'Ordine di lavoro e contratto sono alternativi: scegline solo uno.']);
174
+    }
175
+
92 176
     $segnalazione = Segnalazione::find($id);
93 177
     if (!$segnalazione) {
94 178
       return redirect()->back()->with('error', 'Segnalazione non trovata');
@@ -126,6 +210,9 @@ class SegnalazioneController extends Controller implements HasMiddleware
126 210
     $segnalazione->stato = $request->stato;
127 211
     $segnalazione->priorita = $request->priorita;
128 212
     $segnalazione->tipo = $request->tipo;
213
+    $segnalazione->tempo_stimato = $request->filled('tempo_stimato')
214
+      ? round((float) $request->input('tempo_stimato'), 2)
215
+      : null;
129 216
     $segnalazione->save();
130 217
 
131 218
     $annotazione = new Annotazione();
@@ -168,12 +255,20 @@ class SegnalazioneController extends Controller implements HasMiddleware
168 255
       }
169 256
     }
170 257
 
258
+    // Annotazione pubblica: notifica anche chi ha aperto la segnalazione
259
+    if (! $annotazione->riservata && $segnalazione->user_id) {
260
+      $segnalazione->notifyApertaDaAnnotazionePubblica($annotazione);
261
+    }
262
+
171 263
     return redirect()->route('admin.segnalazione.show', ['id' => $segnalazione->id])
172 264
     ->with('success', 'Segnalazione aggiornata con successo');
173 265
   }
174 266
 
175 267
   public function admin_crea(Request $request)
176 268
   {
269
+    if (Segnalazione::isClienteVista(Auth::user())) {
270
+      return app(SegnalazioneClienteController::class)->store($request);
271
+    }
177 272
     // Validazione base
178 273
     $validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
179 274
       'azienda_id' => ['nullable', Rule::exists('users', 'id')->where('is_azienda', 1)],
@@ -187,6 +282,12 @@ class SegnalazioneController extends Controller implements HasMiddleware
187 282
       'allegato.*' => 'file|max:10240', // max 10MB per file
188 283
     ]);
189 284
 
285
+    $validator->after(function ($validator) use ($request) {
286
+      if ($request->filled('ordine_lavoro_id') && $request->filled('contratto_servizio_id')) {
287
+        $validator->errors()->add('contratto_servizio_id', 'Ordine di lavoro e contratto sono alternativi: scegline solo uno.');
288
+      }
289
+    });
290
+
190 291
     if ($validator->fails()) {
191 292
       return redirect()->back()
192 293
       ->withErrors($validator)
@@ -260,8 +361,10 @@ class SegnalazioneController extends Controller implements HasMiddleware
260 361
       'azienda',
261 362
       'user',
262 363
       'assegnataA',
263
-      'ordineLavoro',
364
+      'ordineLavoro.offerte.righe',
365
+      'ordineLavoro.righe',
264 366
       'contrattoServizio.contrattoTemplate',
367
+      'contrattoServizio.contrattoServizioRigas.prodotto',
265 368
       'contrattoServizioTodo',
266 369
       'prodotto',
267 370
       'acquistoServizio.prodotto',
@@ -274,6 +377,10 @@ class SegnalazioneController extends Controller implements HasMiddleware
274 377
 
275 378
       abort_unless(Segnalazione::authUserMayViewSegnalazione(Auth::user(), $segnalazione->assegnata_a_id, $segnalazione->user_id), 403);
276 379
 
380
+      if (Segnalazione::isClienteVista(Auth::user())) {
381
+        return redirect()->route('segnalazione.dettaglio', $segnalazione->id);
382
+      }
383
+
277 384
       // Recupera gli stati disponibili per il dropdown (solo se l'utente ha i permessi)
278 385
       $statiDisponibili = [];
279 386
       if(Auth::user()->can('edit-segnalazione')){
@@ -324,6 +431,63 @@ class SegnalazioneController extends Controller implements HasMiddleware
324 431
         ->with('success', 'Checklist aggiornata.');
325 432
     }
326 433
 
434
+    public function admin_tempo_timer(Request $request, $id)
435
+    {
436
+      $segnalazione = Segnalazione::findOrFail($id);
437
+      abort_unless(Segnalazione::authUserMayViewSegnalazione(Auth::user(), $segnalazione->assegnata_a_id, $segnalazione->user_id), 403);
438
+
439
+      $validated = $request->validate([
440
+        'action' => 'required|in:start,stop',
441
+      ]);
442
+
443
+      $userId = (int) Auth::id();
444
+
445
+      if ($validated['action'] === 'start') {
446
+        $existing = $segnalazione->getTempoTimerStartedAt($userId);
447
+        $startedAt = $existing ?: $segnalazione->startTempoTimer($userId);
448
+
449
+        return response()->json([
450
+          'success' => true,
451
+          'action' => 'start',
452
+          'started_at' => $startedAt,
453
+        ]);
454
+      }
455
+
456
+      $startedAt = $segnalazione->stopTempoTimer($userId);
457
+      $elapsedMinutes = 0;
458
+      $roundedMinutes = 0;
459
+      $tempoOre = '';
460
+
461
+      if ($startedAt) {
462
+        $elapsedMinutes = max(0, (int) floor((time() - $startedAt) / 60));
463
+        // Frazioni di 15 minuti (ceil); se < 1 minuto comunque 15m se c'è stato avvio
464
+        $elapsedFloat = max(0, (time() - $startedAt) / 60);
465
+        if ($elapsedFloat > 0) {
466
+          $roundedMinutes = (int) (ceil($elapsedFloat / 15) * 15);
467
+        }
468
+        if ($roundedMinutes > 0) {
469
+          $hours = intdiv($roundedMinutes, 60);
470
+          $mins = $roundedMinutes % 60;
471
+          if ($hours > 0 && $mins > 0) {
472
+            $tempoOre = $hours . 'h' . $mins . 'm';
473
+          } elseif ($hours > 0) {
474
+            $tempoOre = $hours . 'h';
475
+          } else {
476
+            $tempoOre = $mins . 'm';
477
+          }
478
+        }
479
+      }
480
+
481
+      return response()->json([
482
+        'success' => true,
483
+        'action' => 'stop',
484
+        'started_at' => $startedAt,
485
+        'elapsed_minutes' => $elapsedMinutes,
486
+        'rounded_minutes' => $roundedMinutes,
487
+        'tempo_ore' => $tempoOre,
488
+      ]);
489
+    }
490
+
327 491
     public function admin_update_todo_note(Request $request, $id, $todoId)
328 492
     {
329 493
       $segnalazione = Segnalazione::findOrFail($id);

+ 14
- 0
app/Http/Controllers/UserController.php 查看文件

@@ -85,6 +85,8 @@ class UserController extends Controller implements HasMiddleware
85 85
       'comune_id' => 'nullable|exists:comune,id',
86 86
       'note' => 'nullable|string',
87 87
       'azienda_id' => 'nullable|exists:users,id',
88
+      'dominio_email' => 'nullable|string|max:255',
89
+      'responsabile_azienda' => 'nullable|boolean',
88 90
     ]);
89 91
 
90 92
     foreach ($validated as $key => $value) {
@@ -93,6 +95,18 @@ class UserController extends Controller implements HasMiddleware
93 95
       }
94 96
     }
95 97
 
98
+    if (array_key_exists('dominio_email', $validated)) {
99
+      $validated['dominio_email'] = $user->is_azienda
100
+        ? User::normalizeDominioEmail($validated['dominio_email'])
101
+        : null;
102
+    }
103
+
104
+    if (! $user->is_azienda) {
105
+      $validated['responsabile_azienda'] = $request->boolean('responsabile_azienda');
106
+    } else {
107
+      unset($validated['responsabile_azienda']);
108
+    }
109
+
96 110
     $user->fill($validated);
97 111
     $user->save();
98 112
 

+ 5
- 0
app/Models/AbstractModels/AbstractAnnotazione.php 查看文件

@@ -92,4 +92,9 @@ abstract class AbstractAnnotazione extends Model
92 92
     {
93 93
         return $this->hasMany('\App\Models\AllegatoAnnotazione', 'annotazione_id', 'id');
94 94
     }
95
+    
96
+    public function fatturaRighe()
97
+    {
98
+        return $this->hasMany('\App\Models\FatturaRiga', 'annotazione_id', 'id');
99
+    }
95 100
 }

+ 97
- 0
app/Models/AbstractModels/AbstractCasellaImap.php 查看文件

@@ -0,0 +1,97 @@
1
+<?php
2
+/**
3
+ * Model object generated by: Skipper (http://www.skipper18.com)
4
+ * Do not modify this file manually.
5
+ */
6
+
7
+namespace App\Models\AbstractModels;
8
+
9
+use Illuminate\Database\Eloquent\Model;
10
+
11
+abstract class AbstractCasellaImap extends Model
12
+{
13
+    /**  
14
+     * The table associated with the model.
15
+     * 
16
+     * @var string
17
+     */
18
+    protected $table = 'casella_imap';
19
+    
20
+    /**  
21
+     * Primary key type.
22
+     * 
23
+     * @var string
24
+     */
25
+    protected $keyType = 'bigInteger';
26
+    
27
+    /**  
28
+     * The model's default values for attributes.
29
+     * 
30
+     * @var array
31
+     */
32
+    protected $attributes = [
33
+        'porta' => 993,
34
+        'encryption' => 'ssl',
35
+        'valida_certificato' => 1,
36
+        'protocollo' => 'imap',
37
+        'cartella' => 'INBOX',
38
+        'solo_non_lette' => 1,
39
+        'marca_come_letta' => 1,
40
+        'sposta_processate' => 1,
41
+        'attivo' => 1
42
+    ];
43
+    
44
+    /**  
45
+     * The attributes that should be cast to native types.
46
+     * 
47
+     * @var array
48
+     */
49
+    protected $casts = [
50
+        'id' => 'integer',
51
+        'nome' => 'string',
52
+        'host' => 'string',
53
+        'porta' => 'integer',
54
+        'encryption' => 'string',
55
+        'valida_certificato' => 'boolean',
56
+        'username' => 'string',
57
+        'password' => 'string',
58
+        'protocollo' => 'string',
59
+        'cartella' => 'string',
60
+        'cartella_processate' => 'string',
61
+        'solo_non_lette' => 'boolean',
62
+        'marca_come_letta' => 'boolean',
63
+        'sposta_processate' => 'boolean',
64
+        'attivo' => 'boolean',
65
+        'ultimo_controllo_at' => 'datetime',
66
+        'ultimo_errore' => 'string',
67
+        'created_at' => 'datetime',
68
+        'updated_at' => 'datetime'
69
+    ];
70
+    
71
+    /**  
72
+     * The attributes that are mass assignable.
73
+     * 
74
+     * @var array
75
+     */
76
+    protected $fillable = [
77
+        'id',
78
+        'nome',
79
+        'host',
80
+        'porta',
81
+        'encryption',
82
+        'valida_certificato',
83
+        'username',
84
+        'password',
85
+        'protocollo',
86
+        'cartella',
87
+        'cartella_processate',
88
+        'solo_non_lette',
89
+        'marca_come_letta',
90
+        'sposta_processate',
91
+        'attivo',
92
+        'ultimo_controllo_at',
93
+        'ultimo_errore',
94
+        'created_at',
95
+        'updated_at'
96
+    ];
97
+}

+ 5
- 0
app/Models/AbstractModels/AbstractComune.php 查看文件

@@ -68,4 +68,9 @@ abstract class AbstractComune extends Model
68 68
     {
69 69
         return $this->hasMany('\App\Models\User', 'comune_id', 'id');
70 70
     }
71
+    
72
+    public function fatture()
73
+    {
74
+        return $this->hasMany('\App\Models\Fattura', 'comune_id', 'id');
75
+    }
71 76
 }

+ 9
- 1
app/Models/AbstractModels/AbstractContrattoServizio.php 查看文件

@@ -31,7 +31,8 @@ abstract class AbstractContrattoServizio extends Model
31 31
      */
32 32
     protected $attributes = [
33 33
         'collegabile_segnalazioni' => 0,
34
-        'attivo' => 1
34
+        'attivo' => 1,
35
+        'default_import_email' => 0
35 36
     ];
36 37
     
37 38
     /**  
@@ -52,6 +53,7 @@ abstract class AbstractContrattoServizio extends Model
52 53
         'quantita_residua' => 'decimal:2',
53 54
         'note' => 'string',
54 55
         'attivo' => 'boolean',
56
+        'default_import_email' => 'boolean',
55 57
         'created_at' => 'datetime',
56 58
         'updated_at' => 'datetime'
57 59
     ];
@@ -74,6 +76,7 @@ abstract class AbstractContrattoServizio extends Model
74 76
         'quantita_residua',
75 77
         'note',
76 78
         'attivo',
79
+        'default_import_email',
77 80
         'created_at',
78 81
         'updated_at'
79 82
     ];
@@ -102,4 +105,9 @@ abstract class AbstractContrattoServizio extends Model
102 105
     {
103 106
         return $this->hasMany('\App\Models\ContrattoServizioRiga', 'contratto_servizio_id', 'id');
104 107
     }
108
+    
109
+    public function fatturaRighe()
110
+    {
111
+        return $this->hasMany('\App\Models\FatturaRiga', 'contratto_servizio_id', 'id');
112
+    }
105 113
 }

+ 13
- 0
app/Models/AbstractModels/AbstractContrattoServizioRiga.php 查看文件

@@ -43,7 +43,11 @@ abstract class AbstractContrattoServizioRiga extends Model
43 43
         'quantita' => 'decimal:2',
44 44
         'unita_misura' => 'string',
45 45
         'prezzo_unitario' => 'decimal:2',
46
+        'sconto_tipo' => 'string',
47
+        'sconto_valore' => 'decimal:2',
46 48
         'periodo' => 'string',
49
+        'data_inizio' => 'datetime:Y-m-d',
50
+        'data_fine' => 'datetime:Y-m-d',
47 51
         'note' => 'string',
48 52
         'ordine' => 'integer',
49 53
         'created_at' => 'datetime',
@@ -62,7 +66,11 @@ abstract class AbstractContrattoServizioRiga extends Model
62 66
         'quantita',
63 67
         'unita_misura',
64 68
         'prezzo_unitario',
69
+        'sconto_tipo',
70
+        'sconto_valore',
65 71
         'periodo',
72
+        'data_inizio',
73
+        'data_fine',
66 74
         'note',
67 75
         'ordine',
68 76
         'created_at',
@@ -78,4 +86,9 @@ abstract class AbstractContrattoServizioRiga extends Model
78 86
     {
79 87
         return $this->belongsTo('\App\Models\ContrattoServizio', 'contratto_servizio_id', 'id');
80 88
     }
89
+    
90
+    public function fatturaRighe()
91
+    {
92
+        return $this->hasMany('\App\Models\FatturaRiga', 'contratto_servizio_riga_id', 'id');
93
+    }
81 94
 }

+ 112
- 0
app/Models/AbstractModels/AbstractFattura.php 查看文件

@@ -0,0 +1,112 @@
1
+<?php
2
+/**
3
+ * Model object generated by: Skipper (http://www.skipper18.com)
4
+ * Do not modify this file manually.
5
+ */
6
+
7
+namespace App\Models\AbstractModels;
8
+
9
+use Illuminate\Database\Eloquent\Model;
10
+
11
+abstract class AbstractFattura extends Model
12
+{
13
+    /**  
14
+     * The table associated with the model.
15
+     * 
16
+     * @var string
17
+     */
18
+    protected $table = 'fattura';
19
+    
20
+    /**  
21
+     * Primary key type.
22
+     * 
23
+     * @var string
24
+     */
25
+    protected $keyType = 'bigInteger';
26
+    
27
+    /**  
28
+     * The attributes that should be cast to native types.
29
+     * 
30
+     * @var array
31
+     */
32
+    protected $casts = [
33
+        'id' => 'integer',
34
+        'user_id' => 'integer',
35
+        'created_by_id' => 'integer',
36
+        'numero' => 'integer',
37
+        'stato' => 'string',
38
+        'data' => 'datetime:Y-m-d',
39
+        'data_scadenza' => 'datetime:Y-m-d',
40
+        'periodo_da' => 'datetime:Y-m-d',
41
+        'periodo_a' => 'datetime:Y-m-d',
42
+        'imponibile' => 'decimal:2',
43
+        'iva_percentuale' => 'decimal:2',
44
+        'iva' => 'decimal:2',
45
+        'totale' => 'decimal:2',
46
+        'note' => 'string',
47
+        'path_file' => 'string',
48
+        'ragione_sociale' => 'string',
49
+        'piva' => 'string',
50
+        'cod_fiscale' => 'string',
51
+        'indirizzo' => 'string',
52
+        'email' => 'string',
53
+        'telefono' => 'string',
54
+        'meta' => 'array',
55
+        'comune_id' => 'integer',
56
+        'created_at' => 'datetime',
57
+        'updated_at' => 'datetime'
58
+    ];
59
+    
60
+    /**  
61
+     * The attributes that are mass assignable.
62
+     * 
63
+     * @var array
64
+     */
65
+    protected $fillable = [
66
+        'id',
67
+        'user_id',
68
+        'created_by_id',
69
+        'numero',
70
+        'stato',
71
+        'data',
72
+        'data_scadenza',
73
+        'periodo_da',
74
+        'periodo_a',
75
+        'imponibile',
76
+        'iva_percentuale',
77
+        'iva',
78
+        'totale',
79
+        'note',
80
+        'path_file',
81
+        'ragione_sociale',
82
+        'piva',
83
+        'cod_fiscale',
84
+        'indirizzo',
85
+        'email',
86
+        'telefono',
87
+        'meta',
88
+        'comune_id',
89
+        'created_at',
90
+        'updated_at'
91
+    ];
92
+    
93
+    public function user()
94
+    {
95
+        return $this->belongsTo('\App\Models\User', 'user_id', 'id');
96
+    }
97
+    
98
+    public function createdBy()
99
+    {
100
+        return $this->belongsTo('\App\Models\User', 'created_by_id', 'id');
101
+    }
102
+    
103
+    public function comune()
104
+    {
105
+        return $this->belongsTo('\App\Models\Comune', 'comune_id', 'id');
106
+    }
107
+    
108
+    public function fatturaRighe()
109
+    {
110
+        return $this->hasMany('\App\Models\FatturaRiga', 'fattura_id', 'id');
111
+    }
112
+}

+ 130
- 0
app/Models/AbstractModels/AbstractFatturaRiga.php 查看文件

@@ -0,0 +1,130 @@
1
+<?php
2
+/**
3
+ * Model object generated by: Skipper (http://www.skipper18.com)
4
+ * Do not modify this file manually.
5
+ */
6
+
7
+namespace App\Models\AbstractModels;
8
+
9
+use Illuminate\Database\Eloquent\Model;
10
+
11
+abstract class AbstractFatturaRiga extends Model
12
+{
13
+    /**  
14
+     * The table associated with the model.
15
+     * 
16
+     * @var string
17
+     */
18
+    protected $table = 'fattura_riga';
19
+    
20
+    /**  
21
+     * Primary key type.
22
+     * 
23
+     * @var string
24
+     */
25
+    protected $keyType = 'bigInteger';
26
+    
27
+    /**  
28
+     * The model's default values for attributes.
29
+     * 
30
+     * @var array
31
+     */
32
+    protected $attributes = ['ordine' => 0];
33
+    
34
+    /**  
35
+     * The attributes that should be cast to native types.
36
+     * 
37
+     * @var array
38
+     */
39
+    protected $casts = [
40
+        'id' => 'integer',
41
+        'fattura_id' => 'integer',
42
+        'tipo' => 'string',
43
+        'descrizione' => 'string',
44
+        'quantita' => 'decimal:2',
45
+        'unita_misura' => 'string',
46
+        'prezzo_unitario' => 'decimal:2',
47
+        'sconto' => 'decimal:2',
48
+        'iva_percentuale' => 'decimal:2',
49
+        'importo' => 'decimal:2',
50
+        'minuti' => 'integer',
51
+        'ordine' => 'integer',
52
+        'nota' => 'string',
53
+        'annotazione_id' => 'integer',
54
+        'segnalazione_id' => 'integer',
55
+        'contratto_servizio_id' => 'integer',
56
+        'contratto_servizio_riga_id' => 'integer',
57
+        'ordine_lavoro_id' => 'integer',
58
+        'prodotto_id' => 'integer',
59
+        'periodo_da' => 'datetime:Y-m-d',
60
+        'periodo_a' => 'datetime:Y-m-d',
61
+        'created_at' => 'datetime',
62
+        'updated_at' => 'datetime'
63
+    ];
64
+    
65
+    /**  
66
+     * The attributes that are mass assignable.
67
+     * 
68
+     * @var array
69
+     */
70
+    protected $fillable = [
71
+        'id',
72
+        'fattura_id',
73
+        'tipo',
74
+        'descrizione',
75
+        'quantita',
76
+        'unita_misura',
77
+        'prezzo_unitario',
78
+        'sconto',
79
+        'iva_percentuale',
80
+        'importo',
81
+        'minuti',
82
+        'ordine',
83
+        'nota',
84
+        'annotazione_id',
85
+        'segnalazione_id',
86
+        'contratto_servizio_id',
87
+        'contratto_servizio_riga_id',
88
+        'ordine_lavoro_id',
89
+        'prodotto_id',
90
+        'periodo_da',
91
+        'periodo_a',
92
+        'created_at',
93
+        'updated_at'
94
+    ];
95
+    
96
+    public function fattura()
97
+    {
98
+        return $this->belongsTo('\App\Models\Fattura', 'fattura_id', 'id');
99
+    }
100
+    
101
+    public function annotazione()
102
+    {
103
+        return $this->belongsTo('\App\Models\Annotazione', 'annotazione_id', 'id');
104
+    }
105
+    
106
+    public function segnalazione()
107
+    {
108
+        return $this->belongsTo('\App\Models\Segnalazione', 'segnalazione_id', 'id');
109
+    }
110
+    
111
+    public function contrattoServizio()
112
+    {
113
+        return $this->belongsTo('\App\Models\ContrattoServizio', 'contratto_servizio_id', 'id');
114
+    }
115
+    
116
+    public function contrattoServizioRiga()
117
+    {
118
+        return $this->belongsTo('\App\Models\ContrattoServizioRiga', 'contratto_servizio_riga_id', 'id');
119
+    }
120
+    
121
+    public function ordineLavoro()
122
+    {
123
+        return $this->belongsTo('\App\Models\OrdineLavoro', 'ordine_lavoro_id', 'id');
124
+    }
125
+    
126
+    public function prodotto()
127
+    {
128
+        return $this->belongsTo('\App\Models\Prodotto', 'prodotto_id', 'id');
129
+    }
130
+}

+ 5
- 0
app/Models/AbstractModels/AbstractOrdineLavoro.php 查看文件

@@ -81,6 +81,11 @@ abstract class AbstractOrdineLavoro extends Model
81 81
         return $this->hasMany('\App\Models\Segnalazione', 'ordine_lavoro_id', 'id');
82 82
     }
83 83
     
84
+    public function fatturaRighe()
85
+    {
86
+        return $this->hasMany('\App\Models\FatturaRiga', 'ordine_lavoro_id', 'id');
87
+    }
88
+    
84 89
     public function offerte()
85 90
     {
86 91
         return $this->belongsToMany('\App\Models\Offerta', 'ordine_lavoro_has_offerta', 'ordine_lavoro_id', 'offerta_id');

+ 5
- 0
app/Models/AbstractModels/AbstractProdotto.php 查看文件

@@ -106,6 +106,11 @@ abstract class AbstractProdotto extends Model
106 106
         return $this->hasMany('\App\Models\ProdottoTodo', 'prodotto_id', 'id');
107 107
     }
108 108
     
109
+    public function fatturaRighe()
110
+    {
111
+        return $this->hasMany('\App\Models\FatturaRiga', 'prodotto_id', 'id');
112
+    }
113
+    
109 114
     public function promoCodes()
110 115
     {
111 116
         return $this->belongsToMany('\App\Models\PromoCode', 'promo_code_has_prodotto', 'prodotto_id', 'promo_code_id');

+ 5
- 0
app/Models/AbstractModels/AbstractSegnalazione.php 查看文件

@@ -134,4 +134,9 @@ abstract class AbstractSegnalazione extends Model
134 134
     {
135 135
         return $this->hasMany('\App\Models\SegnalazioneTodo', 'segnalazione_id', 'id');
136 136
     }
137
+    
138
+    public function fatturaRighe()
139
+    {
140
+        return $this->hasMany('\App\Models\FatturaRiga', 'segnalazione_id', 'id');
141
+    }
137 142
 }

+ 16
- 1
app/Models/AbstractModels/AbstractUser.php 查看文件

@@ -29,7 +29,8 @@ abstract class AbstractUser extends \Illuminate\Foundation\Auth\User
29 29
      */
30 30
     protected $attributes = [
31 31
         'is_gruppo' => 0,
32
-        'is_azienda' => 0
32
+        'is_azienda' => 0,
33
+        'responsabile_azienda' => 0
33 34
     ];
34 35
     
35 36
     /**  
@@ -60,7 +61,9 @@ abstract class AbstractUser extends \Illuminate\Foundation\Auth\User
60 61
         'cod_fiscale' => 'string',
61 62
         'indirizzo' => 'string',
62 63
         'note' => 'string',
64
+        'dominio_email' => 'string',
63 65
         'comune_id' => 'integer',
66
+        'responsabile_azienda' => 'boolean',
64 67
         'created_at' => 'datetime',
65 68
         'updated_at' => 'datetime'
66 69
     ];
@@ -93,7 +96,9 @@ abstract class AbstractUser extends \Illuminate\Foundation\Auth\User
93 96
         'cod_fiscale',
94 97
         'indirizzo',
95 98
         'note',
99
+        'dominio_email',
96 100
         'comune_id',
101
+        'responsabile_azienda',
97 102
         'created_at',
98 103
         'updated_at'
99 104
     ];
@@ -152,4 +157,14 @@ abstract class AbstractUser extends \Illuminate\Foundation\Auth\User
152 157
     {
153 158
         return $this->hasMany('\App\Models\SegnalazioneTodo', 'completato_da_id', 'id');
154 159
     }
160
+    
161
+    public function fatture()
162
+    {
163
+        return $this->hasMany('\App\Models\Fattura', 'user_id', 'id');
164
+    }
165
+    
166
+    public function fattureCreate()
167
+    {
168
+        return $this->hasMany('\App\Models\Fattura', 'created_by_id', 'id');
169
+    }
155 170
 }

+ 57
- 0
app/Models/CasellaImap.php 查看文件

@@ -0,0 +1,57 @@
1
+<?php
2
+
3
+namespace App\Models;
4
+
5
+use Illuminate\Database\Eloquent\Builder;
6
+use Illuminate\Database\Eloquent\Model;
7
+use Illuminate\Support\Str;
8
+
9
+class CasellaImap extends \App\Models\AbstractModels\AbstractCasellaImap
10
+{
11
+    public function scopeAttive(Builder $query): Builder
12
+    {
13
+        return $query->where('attivo', true);
14
+    }
15
+
16
+    public function getLabelAttribute(): string
17
+    {
18
+        $nome = trim((string) $this->nome);
19
+
20
+        return $nome !== '' ? $nome : (string) $this->username;
21
+    }
22
+
23
+    /**
24
+     * @return array<string, mixed>
25
+     */
26
+    public function imapClientConfig(): array
27
+    {
28
+        $encryption = strtolower(trim((string) $this->encryption));
29
+        if (in_array($encryption, ['', 'none', 'false', '0'], true)) {
30
+            $encryption = false;
31
+        }
32
+
33
+        return [
34
+            'host' => $this->host,
35
+            'port' => (int) $this->porta,
36
+            'encryption' => $encryption,
37
+            'validate_cert' => (bool) $this->valida_certificato,
38
+            'username' => $this->username,
39
+            'password' => $this->password,
40
+            'protocol' => $this->protocollo ?: 'imap',
41
+        ];
42
+    }
43
+
44
+    public function registraSuccesso(): void
45
+    {
46
+        $this->ultimo_controllo_at = now();
47
+        $this->ultimo_errore = null;
48
+        $this->save();
49
+    }
50
+
51
+    public function registraErrore(string $errore): void
52
+    {
53
+        $this->ultimo_controllo_at = now();
54
+        $this->ultimo_errore = Str::limit($errore, 2000);
55
+        $this->save();
56
+    }
57
+}

+ 149
- 62
app/Models/Config.php 查看文件

@@ -4,23 +4,148 @@ namespace App\Models;
4 4
 class Config extends \App\Models\AbstractModels\AbstractConfig
5 5
 {
6 6
 
7
-  const COLORE_RUOLO = 'colore_ruolo';
7
+  const PDF_AZIENDA_NOME = 'PDF_AZIENDA_NOME';
8
+  const PDF_AZIENDA_INDIRIZZO = 'PDF_AZIENDA_INDIRIZZO';
9
+  const PDF_AZIENDA_TELEFONO = 'PDF_AZIENDA_TELEFONO';
10
+  const PDF_AZIENDA_EMAIL = 'PDF_AZIENDA_EMAIL';
11
+  const PDF_AZIENDA_PIVA = 'PDF_AZIENDA_PIVA';
12
+  const PDF_AZIENDA_CF = 'PDF_AZIENDA_CF';
13
+  const PDF_AZIENDA_SITO = 'PDF_AZIENDA_SITO';
14
+
15
+  const ACQUISTO_AZIENDA_PRIVATI_ID = 'ACQUISTO_AZIENDA_PRIVATI_ID';
16
+  const ACQUISTO_DOMICILIO_EURO = 'ACQUISTO_DOMICILIO_EURO';
17
+  const ACQUISTO_CONDIZIONI_TESTO = 'ACQUISTO_CONDIZIONI_TESTO';
18
+  const ACQUISTO_NEGOZIO_INDIRIZZO = 'ACQUISTO_NEGOZIO_INDIRIZZO';
19
+  const ACQUISTO_NEGOZIO_ORARI = 'ACQUISTO_NEGOZIO_ORARI';
20
+
21
+  const SEGNALAZIONE_CHIUSURA_AUTO_STATI = 'SEGNALAZIONE_CHIUSURA_AUTO_STATI';
22
+  const SEGNALAZIONE_CHIUSURA_AUTO_GIORNI = 'SEGNALAZIONE_CHIUSURA_AUTO_GIORNI';
23
+  const SEGNALAZIONE_CHIUSURA_AUTO_STATO = 'SEGNALAZIONE_CHIUSURA_AUTO_STATO';
24
+
25
+  const FATTURAZIONE_TARIFFA_ORARIA = 'FATTURAZIONE_TARIFFA_ORARIA';
26
+  const FATTURAZIONE_IVA_PERCENTUALE = 'FATTURAZIONE_IVA_PERCENTUALE';
8 27
 
9 28
   public static function getConfig(){
10 29
     $config = [];
11
-    $config['Generale'] = [
12
-      ['key' => self::COLORE_RUOLO, 'value' => null, 'display_name' => 'Colore ruolo', 'type' => 'color'],
30
+
31
+    $config['Segnalazioni'] = [
32
+      ['key' => self::SEGNALAZIONE_CHIUSURA_AUTO_STATI, 'value' => json_encode(['in_attesa_cliente']), 'display_name' => 'Stati da chiudere dopo inattività', 'type' => 'stati'],
33
+      ['key' => self::SEGNALAZIONE_CHIUSURA_AUTO_GIORNI, 'value' => null, 'display_name' => 'Giorni di inattività prima della chiusura automatica', 'type' => 'integer'],
34
+      ['key' => self::SEGNALAZIONE_CHIUSURA_AUTO_STATO, 'value' => 'chiusa', 'display_name' => 'Stato assegnato dalla chiusura automatica', 'type' => 'stato'],
35
+    ];
36
+
37
+    $config['Documenti PDF'] = [
38
+      ['key' => self::PDF_AZIENDA_NOME, 'value' => null, 'display_name' => 'Nome azienda', 'type' => 'text'],
39
+      ['key' => self::PDF_AZIENDA_INDIRIZZO, 'value' => null, 'display_name' => 'Indirizzo', 'type' => 'text'],
40
+      ['key' => self::PDF_AZIENDA_TELEFONO, 'value' => null, 'display_name' => 'Telefono', 'type' => 'text'],
41
+      ['key' => self::PDF_AZIENDA_EMAIL, 'value' => null, 'display_name' => 'Email', 'type' => 'text'],
42
+      ['key' => self::PDF_AZIENDA_PIVA, 'value' => null, 'display_name' => 'Partita IVA', 'type' => 'text'],
43
+      ['key' => self::PDF_AZIENDA_CF, 'value' => null, 'display_name' => 'Codice fiscale', 'type' => 'text'],
44
+      ['key' => self::PDF_AZIENDA_SITO, 'value' => null, 'display_name' => 'Sito web', 'type' => 'text'],
45
+    ];
46
+
47
+    $config['Acquisto servizio'] = [
48
+      ['key' => self::ACQUISTO_AZIENDA_PRIVATI_ID, 'value' => null, 'display_name' => 'Azienda Privati', 'type' => 'azienda'],
49
+      ['key' => self::ACQUISTO_DOMICILIO_EURO, 'value' => '15', 'display_name' => 'Costo domicilio (€)', 'type' => 'number'],
50
+      ['key' => self::ACQUISTO_CONDIZIONI_TESTO, 'value' => null, 'display_name' => 'Condizioni di vendita', 'type' => 'textarea'],
51
+      ['key' => self::ACQUISTO_NEGOZIO_INDIRIZZO, 'value' => null, 'display_name' => 'Indirizzo negozio', 'type' => 'text'],
52
+      ['key' => self::ACQUISTO_NEGOZIO_ORARI, 'value' => null, 'display_name' => 'Orari negozio', 'type' => 'text'],
53
+    ];
54
+
55
+    $config['Fatturazione'] = [
56
+      ['key' => self::FATTURAZIONE_TARIFFA_ORARIA, 'value' => '40', 'display_name' => 'Tariffa oraria default (€/ora)', 'type' => 'number'],
57
+      ['key' => self::FATTURAZIONE_IVA_PERCENTUALE, 'value' => '22', 'display_name' => 'Aliquota IVA default (%)', 'type' => 'number'],
13 58
     ];
14 59
 
15 60
     return $config;
16 61
   }
17 62
 
63
+  /**
64
+   * Allinea le righe in tabella alle definizioni di getConfig(), senza sovrascrivere i valori già salvati.
65
+   */
66
+  public static function syncDefinitions(): void
67
+  {
68
+    self::where('key', 'colore_ruolo')->delete();
69
+
70
+    foreach (self::getConfig() as $group => $items) {
71
+      $order = 1;
72
+      foreach ($items as $item) {
73
+        $config = self::find($item['key']);
74
+        if ($config === null) {
75
+          $config = new self;
76
+          $config->fill($item);
77
+        } else {
78
+          $config->display_name = $item['display_name'];
79
+          $config->type = $item['type'];
80
+        }
81
+        $config->group = $group;
82
+        $config->order = $order;
83
+        $config->save();
84
+        $order++;
85
+      }
86
+    }
87
+  }
88
+
89
+  public static function valueOf(string $key, mixed $default = null): mixed
90
+  {
91
+    $row = self::find($key);
92
+    if (! $row || $row->value === null || trim((string) $row->value) === '') {
93
+      return $default;
94
+    }
95
+
96
+    return $row->value;
97
+  }
98
+
99
+  public static function decodeList(?string $value): array
100
+  {
101
+    if ($value === null || trim($value) === '') {
102
+      return [];
103
+    }
104
+
105
+    $json = json_decode($value, true);
106
+    if (! is_array($json)) {
107
+      return [];
108
+    }
109
+
110
+    return array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $json), static fn ($item) => $item !== ''));
111
+  }
112
+
113
+  public static function chiusuraAutoStati(): array
114
+  {
115
+    return self::decodeList((string) self::valueOf(self::SEGNALAZIONE_CHIUSURA_AUTO_STATI, '[]'));
116
+  }
117
+
118
+  public static function chiusuraAutoGiorni(): ?int
119
+  {
120
+    $value = self::valueOf(self::SEGNALAZIONE_CHIUSURA_AUTO_GIORNI);
121
+    if ($value === null || ! is_numeric($value)) {
122
+      return null;
123
+    }
124
+    $giorni = (int) $value;
125
+
126
+    return $giorni > 0 ? $giorni : null;
127
+  }
128
+
129
+  public static function chiusuraAutoStatoDestinazione(): string
130
+  {
131
+    $stato = trim((string) self::valueOf(self::SEGNALAZIONE_CHIUSURA_AUTO_STATO, 'chiusa'));
132
+
133
+    return $stato !== '' ? $stato : 'chiusa';
134
+  }
135
+
136
+  public static function messaggioChiusuraInattivita(?int $giorni = null): string
137
+  {
138
+    $giorni ??= self::chiusuraAutoGiorni() ?? 0;
139
+
140
+    return 'Gentile utente, la sua segnalazione è stata chiusa dopo '.$giorni.' giorni di inattività o da un operatore.';
141
+  }
142
+
18 143
   public static function getStatoSegnalazioneDefault(){
19 144
     return 'nuova';
20 145
   }
21 146
 
22 147
   public static function getPrioritaSegnalazioneDefault(){
23
-    return 'bassa';
148
+    return 'normale';
24 149
   }
25 150
 
26 151
   public static function getStatiSegnalazione($type){
@@ -31,6 +156,26 @@ class Config extends \App\Models\AbstractModels\AbstractConfig
31 156
     }
32 157
   }
33 158
 
159
+  public static function tariffaOrariaDefault(): float
160
+  {
161
+    $value = self::valueOf(self::FATTURAZIONE_TARIFFA_ORARIA, '40');
162
+    if ($value === null || ! is_numeric($value)) {
163
+      return 40.0;
164
+    }
165
+
166
+    return max(0, (float) $value);
167
+  }
168
+
169
+  public static function ivaPercentualeDefault(): float
170
+  {
171
+    $value = self::valueOf(self::FATTURAZIONE_IVA_PERCENTUALE, '22');
172
+    if ($value === null || ! is_numeric($value)) {
173
+      return 22.0;
174
+    }
175
+
176
+    return max(0, (float) $value);
177
+  }
178
+
34 179
   public static function formatCosto($value, $decimals = 2){
35 180
     return number_format($value, $decimals, ",",".",)." €";
36 181
   }
@@ -81,62 +226,4 @@ class Config extends \App\Models\AbstractModels\AbstractConfig
81 226
       return $minutes;
82 227
     }
83 228
 
84
-  public static function getIconRole($role){
85
-    try {
86
-      $config = self::where('key', 'icona_ruolo')->orWhere('display_name', 'icona_ruolo')->first();
87
-
88
-      if (!$config || !$config->value) {
89
-        return null;
90
-      }
91
-
92
-      $jsonData = json_decode($config->value, true);
93
-
94
-      if (!is_array($jsonData)) {
95
-        return null;
96
-      }
97
-
98
-      return $jsonData[$role] ?? null;
99
-
100
-    } catch (\Exception $e) {
101
-      return null;
102
-    }
103
-  }
104
-
105
-  public static function getColoreRole($role) {
106
-    try {
107
-      // Cerca per 'display_name' invece di 'key' se non funziona, oppure controlla che il valore sia esattamente quello nel DB
108
-      $config = self::where('key', 'colore_ruolo')->orWhere('display_name', 'colore_ruolo')->first();
109
-
110
-      if (!$config || !$config->value) {
111
-        return null;
112
-      }
113
-
114
-      // Se il valore non è un JSON valido, potrebbe essere serializzato diversamente.
115
-      // Si prova un fallback extra per stringa semplice (mappa a default), o array senza JSON.
116
-      $jsonData = json_decode($config->value, true);
117
-
118
-      // Se decode fallisce, fai fallback su stringa pura
119
-      if (json_last_error() !== JSON_ERROR_NONE || !is_array($jsonData)) {
120
-        // Prova a vedere se è una stringa tipo "admin:#ffcc00;user:#00ffcc"
121
-        $pairs = explode(';', $config->value);
122
-        $map = [];
123
-        foreach ($pairs as $pair) {
124
-          $pair = trim($pair);
125
-          if (empty($pair)) continue;
126
-          [$k, $v] = array_map('trim', explode(':', $pair, 2) + [null, null]);
127
-          if ($k !== null && $v !== null) $map[$k] = $v;
128
-        }
129
-        if (isset($map[$role])) {
130
-          return $map[$role];
131
-        }
132
-        return null;
133
-      }
134
-
135
-      return $jsonData[$role] ?? null;
136
-    } catch (\Exception $e) {
137
-      // In caso di errore, torna null per coerenza con altre funzioni (e per evitare leak di errori come return $e)
138
-      return null;
139
-    }
140
-  }
141
-
142 229
 }

+ 7
- 1
app/Models/ConfigNotifiche.php 查看文件

@@ -6,6 +6,7 @@ class ConfigNotifiche extends \App\Models\AbstractModels\AbstractConfigNotifiche
6 6
 
7 7
   const NUOVA_SEGNALAZIONE_ADMIN = 'nuova_segnalazione_admin';
8 8
   const NUOVA_SEGNALAZIONE_ASSEGNATA = 'nuova_segnalazione_assegnata';
9
+  const SEGNALAZIONE_EMAIL_CONFERMA_APERTA_DA = 'segnalazione_email_conferma_aperta_da';
9 10
 
10 11
   // protected $casts = [
11 12
   //   'id' => 'integer',
@@ -28,7 +29,12 @@ class ConfigNotifiche extends \App\Models\AbstractModels\AbstractConfigNotifiche
28 29
         'label' => 'Segnalazione assegnata o riassegnata',
29 30
         'descrizione' => 'Invia notifica ai membri dell\'assegnatario (utente o componenti del gruppo) quando viene impostata o modificata l\'assegnazione.',
30 31
         'has_users' => false,
31
-      ]
32
+      ],
33
+      self::SEGNALAZIONE_EMAIL_CONFERMA_APERTA_DA => [
34
+        'label' => 'Conferma apertura ticket da email',
35
+        'descrizione' => 'Quando una segnalazione viene creata da una mail in ingresso, invia conferma di apertura a chi ha scritto (Aperta da), come per l\'apertura manuale con notifica attiva.',
36
+        'has_users' => false,
37
+      ],
32 38
     ];
33 39
   }
34 40
 

+ 231
- 3
app/Models/ContrattoServizio.php 查看文件

@@ -5,11 +5,45 @@ use Carbon\Carbon;
5 5
 
6 6
 class ContrattoServizio extends \App\Models\AbstractModels\AbstractContrattoServizio
7 7
 {
8
+    protected static function booted(): void
9
+    {
10
+        static::saving(function (self $contratto) {
11
+            if (! $contratto->default_import_email || ! $contratto->user_id) {
12
+                return;
13
+            }
14
+
15
+            // Un solo contratto default per azienda/cliente
16
+            static::query()
17
+                ->where('user_id', $contratto->user_id)
18
+                ->when($contratto->exists, fn ($q) => $q->where('id', '!=', $contratto->id))
19
+                ->where('default_import_email', true)
20
+                ->update(['default_import_email' => false]);
21
+        });
22
+    }
23
+
8 24
     public function getLabelAttribute(): string
9 25
     {
10 26
         return $this->nome ?: optional($this->contrattoTemplate)->nome ?: ('Contratto #'.$this->id);
11 27
     }
12 28
 
29
+    public function scopeDefaultImportEmail($query)
30
+    {
31
+        return $query->where('default_import_email', true);
32
+    }
33
+
34
+    public static function defaultImportEmailForAzienda(?int $aziendaId): ?self
35
+    {
36
+        if (! $aziendaId) {
37
+            return null;
38
+        }
39
+
40
+        return static::query()
41
+            ->where('user_id', $aziendaId)
42
+            ->defaultImportEmail()
43
+            ->orderByDesc('id')
44
+            ->first();
45
+    }
46
+
13 47
     public function scopeAttivi($query)
14 48
     {
15 49
         $oggi = Carbon::today()->toDateString();
@@ -33,15 +67,20 @@ class ContrattoServizio extends \App\Models\AbstractModels\AbstractContrattoServ
33 67
     }
34 68
 
35 69
     public function isAttivoOggi(): bool
70
+    {
71
+        return $this->isAttivoAl(Carbon::today());
72
+    }
73
+
74
+    public function isAttivoAl($date = null): bool
36 75
     {
37 76
         if (! $this->attivo) {
38 77
             return false;
39 78
         }
40
-        $oggi = Carbon::today();
41
-        if ($this->data_inizio && Carbon::parse($this->data_inizio)->gt($oggi)) {
79
+        $giorno = $date ? Carbon::parse($date)->startOfDay() : Carbon::today();
80
+        if ($this->data_inizio && Carbon::parse($this->data_inizio)->startOfDay()->gt($giorno)) {
42 81
             return false;
43 82
         }
44
-        if ($this->data_fine && Carbon::parse($this->data_fine)->lt($oggi)) {
83
+        if ($this->data_fine && Carbon::parse($this->data_fine)->startOfDay()->lt($giorno)) {
45 84
             return false;
46 85
         }
47 86
 
@@ -63,6 +102,195 @@ class ContrattoServizio extends \App\Models\AbstractModels\AbstractContrattoServ
63 102
         return (float) $this->righe->sum(fn (ContrattoServizioRiga $r) => $r->importo ?? 0);
64 103
     }
65 104
 
105
+    /**
106
+     * Durata in mesi usata per annualizzare le voci ricorrenti.
107
+     */
108
+    public function getMesiDurataAttribute(): int
109
+    {
110
+        if ($this->data_inizio && $this->data_fine) {
111
+            $from = Carbon::parse($this->data_inizio)->startOfDay();
112
+            $to = Carbon::parse($this->data_fine)->startOfDay();
113
+            if ($to->gte($from)) {
114
+                return max(1, (int) round($from->floatDiffInMonths($to)));
115
+            }
116
+        }
117
+
118
+        return match ($this->periodo) {
119
+            'mensile' => 1,
120
+            'trimestrale' => 3,
121
+            'semestrale' => 6,
122
+            'annuale' => 12,
123
+            default => 1,
124
+        };
125
+    }
126
+
127
+    /**
128
+     * Periodo di riferimento per il conteggio ore (priorità: riga ora, poi header).
129
+     */
130
+    public function getPeriodoOreAttribute(): string
131
+    {
132
+        return $this->periodoOreAllaData(null);
133
+    }
134
+
135
+    public function periodoOreAllaData($date = null): string
136
+    {
137
+        $giorno = $date ? Carbon::parse($date) : Carbon::today();
138
+        foreach ($this->righeOraCaricate() as $riga) {
139
+            if ($riga->isUnitaOra() && filled($riga->periodo) && $riga->periodo !== 'a_consumo' && $riga->isValidaAl($giorno)) {
140
+                return (string) $riga->periodo;
141
+            }
142
+        }
143
+
144
+        return (string) ($this->periodo ?: 'una_tantum');
145
+    }
146
+
147
+    /**
148
+     * Etichetta periodo corrente (es. "ago 2026", "2026", "Q3 2026").
149
+     */
150
+    public function getPeriodoRiferimentoLabelAttribute(): string
151
+    {
152
+        $oggi = Carbon::today();
153
+        $mesi = [1=>'gen',2=>'feb',3=>'mar',4=>'apr',5=>'mag',6=>'giu',7=>'lug',8=>'ago',9=>'set',10=>'ott',11=>'nov',12=>'dic'];
154
+
155
+        return match ($this->periodo_ore) {
156
+            'mensile' => ($mesi[(int) $oggi->month] ?? $oggi->format('m')).' '.$oggi->format('Y'),
157
+            'trimestrale' => 'Q'.$oggi->quarter.' '.$oggi->format('Y'),
158
+            'annuale' => $oggi->format('Y'),
159
+            default => 'una tantum',
160
+        };
161
+    }
162
+
163
+    /**
164
+     * Ore totali da righe con unità "ora".
165
+     */
166
+    public function getOreTotaliAttribute(): float
167
+    {
168
+        return $this->oreTotaliAllaData(null);
169
+    }
170
+
171
+    public function oreTotaliAllaData($date = null): float
172
+    {
173
+        $giorno = $date ? Carbon::parse($date) : Carbon::today();
174
+
175
+        return round((float) $this->righeOraCaricate()
176
+            ->filter(function (ContrattoServizioRiga $riga) use ($giorno) {
177
+                return $riga->isUnitaOra() && $riga->periodo !== 'a_consumo' && $riga->isValidaAl($giorno);
178
+            })
179
+            ->sum(fn (ContrattoServizioRiga $riga) => (float) ($riga->quantita ?? 0)), 2);
180
+    }
181
+
182
+    /**
183
+     * Riga a ore valida in una data (priorità: validità più recente).
184
+     */
185
+    public function rigaTariffaOrariaAllaData($date = null, ?int $prodottoId = null): ?ContrattoServizioRiga
186
+    {
187
+        $giorno = $date ? Carbon::parse($date) : Carbon::today();
188
+
189
+        return $this->righeOraCaricate()
190
+            ->filter(function (ContrattoServizioRiga $riga) use ($giorno, $prodottoId) {
191
+                if ($prodottoId && (int) $riga->prodotto_id !== (int) $prodottoId) {
192
+                    return false;
193
+                }
194
+                if (! $riga->isUnitaOra()) {
195
+                    return false;
196
+                }
197
+
198
+                return $riga->isValidaAl($giorno);
199
+            })
200
+            ->sortByDesc(fn (ContrattoServizioRiga $riga) => optional($riga->data_inizio)?->toDateString() ?? '0000-00-00')
201
+            ->first();
202
+    }
203
+
204
+    /**
205
+     * Tariffa extra (€/ora) in una data: priorità riga a consumo, poi qualsiasi riga ora.
206
+     */
207
+    public function tariffaExtraAllaData($date = null): ?float
208
+    {
209
+        $giorno = $date ? Carbon::parse($date) : Carbon::today();
210
+
211
+        $consumo = $this->righeOraCaricate()
212
+            ->filter(fn (ContrattoServizioRiga $riga) => $riga->isUnitaOra() && $riga->isAConsumo() && $riga->isValidaAl($giorno))
213
+            ->sortByDesc(fn (ContrattoServizioRiga $riga) => optional($riga->data_inizio)?->toDateString() ?? '0000-00-00')
214
+            ->first();
215
+
216
+        if ($consumo && $consumo->prezzo_netto !== null) {
217
+            return (float) $consumo->prezzo_netto;
218
+        }
219
+
220
+        $riga = $this->rigaTariffaOrariaAllaData($giorno);
221
+
222
+        return $riga && $riga->prezzo_netto !== null ? (float) $riga->prezzo_netto : null;
223
+    }
224
+
225
+    /**
226
+     * Ore già registrate nel periodo di riferimento corrente.
227
+     */
228
+    public function getOreUsateAttribute(): float
229
+    {
230
+        [$from, $to] = $this->intervalloPeriodoOreCorrente();
231
+
232
+        $query = Annotazione::query()
233
+            ->whereHas('segnalazione', fn ($q) => $q->where('contratto_servizio_id', $this->id));
234
+
235
+        if ($from && $to) {
236
+            $query->where(function ($q) use ($from, $to) {
237
+                $q->whereBetween('tempo_data', [$from, $to])
238
+                    ->orWhere(function ($q2) use ($from, $to) {
239
+                        $q2->whereNull('tempo_data')->whereBetween('created_at', [$from, $to]);
240
+                    });
241
+            });
242
+        }
243
+
244
+        return round(((float) $query->sum('tempo_minuti')) / 60, 2);
245
+    }
246
+
247
+    public function getOreRimanentiAttribute(): float
248
+    {
249
+        return round($this->ore_totali - $this->ore_usate, 2);
250
+    }
251
+
252
+    /**
253
+     * @return array{0: ?\Carbon\Carbon, 1: ?\Carbon\Carbon}
254
+     */
255
+    public function intervalloPeriodoOreCorrente(): array
256
+    {
257
+        return $this->intervalloPeriodoOrePerData(Carbon::today());
258
+    }
259
+
260
+    /**
261
+     * @return array{0: ?\Carbon\Carbon, 1: ?\Carbon\Carbon}
262
+     */
263
+    public function intervalloPeriodoOrePerData($date = null): array
264
+    {
265
+        $giorno = $date ? Carbon::parse($date) : Carbon::today();
266
+
267
+        return match ($this->periodoOreAllaData($giorno)) {
268
+            'mensile' => [$giorno->copy()->startOfMonth()->startOfDay(), $giorno->copy()->endOfMonth()->endOfDay()],
269
+            'trimestrale' => [$giorno->copy()->firstOfQuarter()->startOfDay(), $giorno->copy()->lastOfQuarter()->endOfDay()],
270
+            'semestrale' => $giorno->month <= 6
271
+                ? [$giorno->copy()->startOfYear()->startOfDay(), $giorno->copy()->month(6)->endOfMonth()->endOfDay()]
272
+                : [$giorno->copy()->month(7)->startOfMonth()->startOfDay(), $giorno->copy()->endOfYear()->endOfDay()],
273
+            'annuale' => [$giorno->copy()->startOfYear()->startOfDay(), $giorno->copy()->endOfYear()->endOfDay()],
274
+            default => [null, null],
275
+        };
276
+    }
277
+
278
+    /**
279
+     * @return \Illuminate\Support\Collection<int, ContrattoServizioRiga>
280
+     */
281
+    protected function righeOraCaricate()
282
+    {
283
+        if ($this->relationLoaded('righe')) {
284
+            $this->righe->loadMissing('prodotto');
285
+
286
+            return $this->righe;
287
+        }
288
+
289
+        $this->loadMissing('contrattoServizioRigas.prodotto');
290
+
291
+        return $this->contrattoServizioRigas;
292
+    }
293
+
66 294
     public function getPeriodoLabelAttribute(): string
67 295
     {
68 296
         return Prodotto::PERIODI[$this->periodo]

+ 241
- 2
app/Models/ContrattoServizioRiga.php 查看文件

@@ -1,8 +1,117 @@
1 1
 <?php
2 2
 namespace App\Models;
3 3
 
4
+use Carbon\Carbon;
5
+
4 6
 class ContrattoServizioRiga extends \App\Models\AbstractModels\AbstractContrattoServizioRiga
5 7
 {
8
+    public const SCONTO_TIPI = [
9
+        'percentuale' => '%',
10
+        'importo' => '€',
11
+    ];
12
+
13
+    protected $fillable = [
14
+        'id',
15
+        'contratto_servizio_id',
16
+        'prodotto_id',
17
+        'quantita',
18
+        'unita_misura',
19
+        'prezzo_unitario',
20
+        'sconto_tipo',
21
+        'sconto_valore',
22
+        'periodo',
23
+        'data_inizio',
24
+        'data_fine',
25
+        'note',
26
+        'ordine',
27
+        'created_at',
28
+        'updated_at',
29
+    ];
30
+
31
+    protected function casts(): array
32
+    {
33
+        return [
34
+            'sconto_valore' => 'decimal:2',
35
+            'data_inizio' => 'date',
36
+            'data_fine' => 'date',
37
+        ];
38
+    }
39
+
40
+    public function haValidita(): bool
41
+    {
42
+        return $this->data_inizio !== null || $this->data_fine !== null;
43
+    }
44
+
45
+    public function isValidaAl($date = null): bool
46
+    {
47
+        $giorno = $date ? Carbon::parse($date)->startOfDay() : Carbon::today();
48
+        if ($this->data_inizio && $this->data_inizio->copy()->startOfDay()->gt($giorno)) {
49
+            return false;
50
+        }
51
+        if ($this->data_fine && $this->data_fine->copy()->startOfDay()->lt($giorno)) {
52
+            return false;
53
+        }
54
+
55
+        return true;
56
+    }
57
+
58
+    public function isValidaOggi(): bool
59
+    {
60
+        return $this->isValidaAl(null);
61
+    }
62
+
63
+    /**
64
+     * sempre | attiva | futura | scaduta
65
+     */
66
+    public function getValiditaStatoAttribute(): string
67
+    {
68
+        if (! $this->haValidita()) {
69
+            return 'sempre';
70
+        }
71
+
72
+        $oggi = Carbon::today();
73
+        if ($this->data_inizio && $this->data_inizio->copy()->startOfDay()->gt($oggi)) {
74
+            return 'futura';
75
+        }
76
+        if ($this->data_fine && $this->data_fine->copy()->startOfDay()->lt($oggi)) {
77
+            return 'scaduta';
78
+        }
79
+
80
+        return 'attiva';
81
+    }
82
+
83
+    public function getValiditaLabelAttribute(): ?string
84
+    {
85
+        if (! $this->haValidita()) {
86
+            return null;
87
+        }
88
+
89
+        $dal = $this->data_inizio ? $this->data_inizio->format('d/m/Y') : 'inizio contratto';
90
+        $al = $this->data_fine ? $this->data_fine->format('d/m/Y') : 'in corso';
91
+
92
+        return $dal.' → '.$al;
93
+    }
94
+
95
+    /**
96
+     * Mesi coperti da questa riga (validità propria, altrimenti durata contratto).
97
+     */
98
+    public function getMesiValiditaAttribute(): int
99
+    {
100
+        $contratto = $this->contrattoServizio;
101
+        $from = $this->data_inizio ?: optional($contratto)->data_inizio;
102
+        $to = $this->data_fine ?: optional($contratto)->data_fine;
103
+
104
+        if ($from && $to) {
105
+            $fromC = Carbon::parse($from)->startOfDay();
106
+            $toC = Carbon::parse($to)->startOfDay();
107
+            if ($toC->gte($fromC)) {
108
+                return max(1, (int) round($fromC->floatDiffInMonths($toC)));
109
+            }
110
+        }
111
+
112
+        return (int) (optional($contratto)->mesi_durata ?: 1);
113
+    }
114
+
6 115
     public function getLabelAttribute(): string
7 116
     {
8 117
         $nome = optional($this->prodotto)->nome ?: 'Servizio';
@@ -14,13 +123,143 @@ class ContrattoServizioRiga extends \App\Models\AbstractModels\AbstractContratto
14 123
         return trim($nome.($qty !== null ? ' ('.$qty.($um ? ' '.$um : '').')' : ''));
15 124
     }
16 125
 
17
-    public function getImportoAttribute(): ?float
126
+    public function getScontoSuPrezzoAttribute(): float
127
+    {
128
+        if ($this->prezzo_unitario === null || $this->sconto_valore === null) {
129
+            return 0.0;
130
+        }
131
+
132
+        $prezzo = (float) $this->prezzo_unitario;
133
+        $valore = max(0, (float) $this->sconto_valore);
134
+
135
+        return match ($this->sconto_tipo) {
136
+            'percentuale' => round($prezzo * min(100, $valore) / 100, 2),
137
+            'importo' => round(min($prezzo, $valore), 2),
138
+            default => 0.0,
139
+        };
140
+    }
141
+
142
+    public function getPrezzoNettoAttribute(): ?float
18 143
     {
19 144
         if ($this->prezzo_unitario === null) {
20 145
             return null;
21 146
         }
22 147
 
23
-        return round((float) ($this->quantita ?? 1) * (float) $this->prezzo_unitario, 2);
148
+        return round(max(0, (float) $this->prezzo_unitario - $this->sconto_su_prezzo), 2);
149
+    }
150
+
151
+    public function isAConsumo(): bool
152
+    {
153
+        return $this->periodo === 'a_consumo';
154
+    }
155
+
156
+    public function getMoltiplicatorePeriodoAttribute(): int
157
+    {
158
+        $um = $this->unita_misura ?: optional($this->prodotto)->unita_misura;
159
+        // Qty già espressa in mesi/anni/forfait: non moltiplicare di nuovo
160
+        if (in_array($um, ['mese', 'anno', 'forfait'], true)) {
161
+            return 1;
162
+        }
163
+
164
+        $periodoRiga = (string) $this->periodo;
165
+        if ($periodoRiga === '' || $periodoRiga === 'una_tantum' || $periodoRiga === 'a_consumo') {
166
+            return 1;
167
+        }
168
+
169
+        $mesi = $this->mesi_validita;
170
+
171
+        return match ($periodoRiga) {
172
+            'mensile' => max(1, $mesi),
173
+            'trimestrale' => max(1, intdiv($mesi, 3)),
174
+            'annuale' => max(1, intdiv($mesi, 12)),
175
+            default => 1,
176
+        };
177
+    }
178
+
179
+    public function getMoltiplicatoreLabelAttribute(): ?string
180
+    {
181
+        $n = $this->moltiplicatore_periodo;
182
+        if ($n <= 1) {
183
+            return null;
184
+        }
185
+
186
+        return match ($this->periodo) {
187
+            'mensile' => '× '.$n.' mesi',
188
+            'trimestrale' => '× '.$n.' trimestri',
189
+            'annuale' => '× '.$n.' anni',
190
+            default => '× '.$n,
191
+        };
192
+    }
193
+
194
+    public function getImportoLordoAttribute(): ?float
195
+    {
196
+        if ($this->isAConsumo() || $this->prezzo_unitario === null) {
197
+            return $this->isAConsumo() ? 0.0 : null;
198
+        }
199
+
200
+        $qty = (float) ($this->quantita ?? 1) * $this->moltiplicatore_periodo;
201
+
202
+        return round($qty * (float) $this->prezzo_unitario, 2);
203
+    }
204
+
205
+    public function getScontoApplicatoAttribute(): float
206
+    {
207
+        if ($this->isAConsumo()) {
208
+            return 0.0;
209
+        }
210
+
211
+        $qty = (float) ($this->quantita ?? 1) * $this->moltiplicatore_periodo;
212
+
213
+        return round($qty * $this->sconto_su_prezzo, 2);
214
+    }
215
+
216
+    public function getImportoAttribute(): ?float
217
+    {
218
+        if ($this->isAConsumo()) {
219
+            return 0.0;
220
+        }
221
+
222
+        if ($this->prezzo_netto === null) {
223
+            return null;
224
+        }
225
+
226
+        $qty = (float) ($this->quantita ?? 1) * $this->moltiplicatore_periodo;
227
+
228
+        return round($qty * $this->prezzo_netto, 2);
229
+    }
230
+
231
+    /**
232
+     * Importo di un singolo ciclo (mese/trimestre/anno), senza moltiplicare per tutta la durata.
233
+     */
234
+    public function getImportoCicloAttribute(): float
235
+    {
236
+        if ($this->isAConsumo() || $this->prezzo_netto === null) {
237
+            return 0.0;
238
+        }
239
+
240
+        return round((float) ($this->quantita ?? 1) * (float) $this->prezzo_netto, 2);
241
+    }
242
+
243
+    public function isUnitaOra(): bool
244
+    {
245
+        $um = $this->unita_misura ?: optional($this->prodotto)->unita_misura;
246
+
247
+        return $um === 'ora';
248
+    }
249
+
250
+    public function getScontoLabelAttribute(): string
251
+    {
252
+        if ($this->sconto_valore === null || $this->sconto_tipo === null || (float) $this->sconto_valore == 0.0) {
253
+            return '—';
254
+        }
255
+
256
+        $valore = rtrim(rtrim(number_format((float) $this->sconto_valore, 2, ',', ''), '0'), ',');
257
+
258
+        return match ($this->sconto_tipo) {
259
+            'percentuale' => $valore.' %',
260
+            'importo' => number_format((float) $this->sconto_valore, 2, ',', '.').' €',
261
+            default => '—',
262
+        };
24 263
     }
25 264
 
26 265
     public function getPeriodoLabelAttribute(): string

+ 9
- 0
app/Models/ContrattoTemplateRiga.php 查看文件

@@ -14,8 +14,17 @@ class ContrattoTemplateRiga extends \App\Models\AbstractModels\AbstractContratto
14 14
         return trim($nome.($qty !== null ? ' ('.$qty.($um ? ' '.$um : '').')' : ''));
15 15
     }
16 16
 
17
+    public function isAConsumo(): bool
18
+    {
19
+        return $this->periodo === 'a_consumo';
20
+    }
21
+
17 22
     public function getImportoAttribute(): ?float
18 23
     {
24
+        if ($this->isAConsumo()) {
25
+            return 0.0;
26
+        }
27
+
19 28
         if ($this->prezzo_unitario === null) {
20 29
             return null;
21 30
         }

+ 96
- 0
app/Models/Fattura.php 查看文件

@@ -0,0 +1,96 @@
1
+<?php
2
+namespace App\Models;
3
+
4
+use Carbon\Carbon;
5
+
6
+class Fattura extends \App\Models\AbstractModels\AbstractFattura
7
+{
8
+    public const STATO_BOZZA = 'bozza';
9
+    public const STATO_EMESSA = 'emessa';
10
+    public const STATO_PAGATA = 'pagata';
11
+    public const STATO_ANNULLATA = 'annullata';
12
+
13
+    public const STATI = [
14
+        self::STATO_BOZZA => 'Bozza',
15
+        self::STATO_EMESSA => 'Emessa',
16
+        self::STATO_PAGATA => 'Pagata',
17
+        self::STATO_ANNULLATA => 'Annullata',
18
+    ];
19
+
20
+    public function righe()
21
+    {
22
+        return $this->hasMany(FatturaRiga::class, 'fattura_id', 'id')->orderBy('ordine')->orderBy('id');
23
+    }
24
+
25
+    public function getLabelAttribute(): string
26
+    {
27
+        return 'Fattura '.$this->numero_documento;
28
+    }
29
+
30
+    public function getNumeroDocumentoAttribute(): string
31
+    {
32
+        $n = $this->numero ?: $this->id;
33
+        $year = $this->data
34
+            ? Carbon::parse($this->data)->format('y')
35
+            : now()->format('y');
36
+
37
+        return $n.'/'.$year;
38
+    }
39
+
40
+    public function getStatoLabelAttribute(): string
41
+    {
42
+        return self::STATI[$this->stato] ?? ($this->stato ?: '—');
43
+    }
44
+
45
+    public function isBozza(): bool
46
+    {
47
+        return $this->stato === self::STATO_BOZZA;
48
+    }
49
+
50
+    public function isAnnullata(): bool
51
+    {
52
+        return $this->stato === self::STATO_ANNULLATA;
53
+    }
54
+
55
+    public function isAttiva(): bool
56
+    {
57
+        return $this->stato !== self::STATO_ANNULLATA;
58
+    }
59
+
60
+    public function scopeNonAnnullate($query)
61
+    {
62
+        return $query->where('stato', '!=', self::STATO_ANNULLATA);
63
+    }
64
+
65
+    public static function nextNumero(): int
66
+    {
67
+        return (int) (static::query()->max('numero') ?? 0) + 1;
68
+    }
69
+
70
+    public function ricalcolaTotale(): void
71
+    {
72
+        $this->loadMissing('righe');
73
+        $imponibile = round((float) $this->righe->sum(fn (FatturaRiga $r) => (float) ($r->importo ?? 0)), 2);
74
+        $pct = $this->iva_percentuale !== null ? (float) $this->iva_percentuale : Config::ivaPercentualeDefault();
75
+        $iva = round($imponibile * $pct / 100, 2);
76
+
77
+        $this->imponibile = $imponibile;
78
+        $this->iva_percentuale = $pct;
79
+        $this->iva = $iva;
80
+        $this->totale = round($imponibile + $iva, 2);
81
+        $this->save();
82
+    }
83
+
84
+    public function snapshotDaUser(User $user): void
85
+    {
86
+        $this->ragione_sociale = $user->is_azienda
87
+            ? ($user->ragione_sociale ?: $user->nome)
88
+            : ($user->full_name ?: $user->email);
89
+        $this->piva = $user->piva;
90
+        $this->cod_fiscale = $user->cod_fiscale;
91
+        $this->indirizzo = $user->indirizzo;
92
+        $this->email = $user->email;
93
+        $this->telefono = $user->telefono;
94
+        $this->comune_id = $user->comune_id;
95
+    }
96
+}

+ 31
- 0
app/Models/FatturaRiga.php 查看文件

@@ -0,0 +1,31 @@
1
+<?php
2
+namespace App\Models;
3
+
4
+class FatturaRiga extends \App\Models\AbstractModels\AbstractFatturaRiga
5
+{
6
+    public const TIPO_TEMPO = 'tempo';
7
+    public const TIPO_EXTRA_CONTRATTO = 'extra_contratto';
8
+    public const TIPO_EXTRA_ORDINE = 'extra_ordine';
9
+    public const TIPO_SPESA = 'spesa';
10
+    public const TIPO_CANONE = 'canone';
11
+    public const TIPO_MANUALE = 'manuale';
12
+
13
+    public const TIPI = [
14
+        self::TIPO_TEMPO => 'Ore',
15
+        self::TIPO_EXTRA_CONTRATTO => 'Ore extra contratto',
16
+        self::TIPO_EXTRA_ORDINE => 'Ore extra ordine',
17
+        self::TIPO_SPESA => 'Spesa',
18
+        self::TIPO_CANONE => 'Canone / rinnovo',
19
+        self::TIPO_MANUALE => 'Manuale',
20
+    ];
21
+
22
+    public function getTipoLabelAttribute(): string
23
+    {
24
+        return self::TIPI[$this->tipo] ?? ($this->tipo ?: '—');
25
+    }
26
+
27
+    public function scopeSuFattureAttive($query)
28
+    {
29
+        return $query->whereHas('fattura', fn ($q) => $q->nonAnnullate());
30
+    }
31
+}

+ 7
- 0
app/Models/Offerta.php 查看文件

@@ -39,6 +39,13 @@ class Offerta extends \App\Models\AbstractModels\AbstractOfferta
39 39
         return self::STATI[$this->stato] ?? ($this->stato ?: '—');
40 40
     }
41 41
 
42
+    public function getOreTotaliAttribute(): float
43
+    {
44
+        $this->loadMissing('righe');
45
+
46
+        return round($this->righe->sum(fn (OffertaRiga $r) => $r->ore), 2);
47
+    }
48
+
42 49
     public function ricalcolaTotale(): void
43 50
     {
44 51
         $totale = 0;

+ 4
- 0
app/Models/OffertaRiga.php 查看文件

@@ -3,4 +3,8 @@ namespace App\Models;
3 3
 
4 4
 class OffertaRiga extends \App\Models\AbstractModels\AbstractOffertaRiga
5 5
 {
6
+    public function getOreAttribute(): float
7
+    {
8
+        return OrdineLavoro::oreDaRiga($this);
9
+    }
6 10
 }

+ 66
- 0
app/Models/OrdineLavoro.php 查看文件

@@ -32,11 +32,77 @@ class OrdineLavoro extends \App\Models\AbstractModels\AbstractOrdineLavoro
32 32
         return (int) (static::query()->max('numero') ?? 0) + 1;
33 33
     }
34 34
 
35
+    public function righe()
36
+    {
37
+        return $this->hasMany(OrdineLavoroRiga::class, 'ordine_lavoro_id', 'id')->orderBy('ordine')->orderBy('id');
38
+    }
39
+
35 40
     public function getTempoTotaleMinutiAttribute(): int
36 41
     {
37 42
         return (int) $this->segnalazioni->sum(fn (Segnalazione $s) => $s->getTempoImpiegato(false));
38 43
     }
39 44
 
45
+    /**
46
+     * Ore stimate di una riga: tempo_stimato se valorizzato, altrimenti quantità se U.M. ora.
47
+     */
48
+    public static function oreDaRiga($riga): float
49
+    {
50
+        if ($riga->tempo_stimato !== null && $riga->tempo_stimato !== '') {
51
+            return round((float) $riga->tempo_stimato, 2);
52
+        }
53
+        if (($riga->unita_misura ?? null) === 'ora') {
54
+            return round((float) ($riga->quantita ?? 0), 2);
55
+        }
56
+
57
+        return 0.0;
58
+    }
59
+
60
+    /**
61
+     * Ore totali stimate dalle righe dell'ordine; se assenti, dalle offerte collegate.
62
+     */
63
+    public function getOreTotaliAttribute(): float
64
+    {
65
+        $this->loadMissing(['righe', 'offerte.righe']);
66
+
67
+        $sorgente = $this->righe->isNotEmpty() ? $this->righe : $this->offerte->flatMap->righe;
68
+
69
+        return round($sorgente->sum(fn ($riga) => self::oreDaRiga($riga)), 2);
70
+    }
71
+
72
+    public function getImportoTotaleAttribute(): float
73
+    {
74
+        $this->loadMissing('righe');
75
+
76
+        return round($this->righe->sum(fn (OrdineLavoroRiga $r) => $r->importo), 2);
77
+    }
78
+
79
+    public function clonaRigheDaOfferta(Offerta $offerta): int
80
+    {
81
+        $offerta->loadMissing('righe');
82
+        $ordine = (int) ($this->righe()->max('ordine') ?? -1) + 1;
83
+        $count = 0;
84
+        foreach ($offerta->righe as $riga) {
85
+            $this->righe()->create(OrdineLavoroRiga::fromOffertaRiga($riga, $ordine++));
86
+            $count++;
87
+        }
88
+
89
+        return $count;
90
+    }
91
+
92
+    public function getOreUsateAttribute(): float
93
+    {
94
+        $minuti = (float) Annotazione::query()
95
+            ->whereHas('segnalazione', fn ($q) => $q->where('ordine_lavoro_id', $this->id))
96
+            ->sum('tempo_minuti');
97
+
98
+        return round($minuti / 60, 2);
99
+    }
100
+
101
+    public function getOreRimanentiAttribute(): float
102
+    {
103
+        return round($this->ore_totali - $this->ore_usate, 2);
104
+    }
105
+
40 106
     public function getCostoTotaleAttribute(): float
41 107
     {
42 108
         return (float) $this->segnalazioni->sum(fn (Segnalazione $s) => $s->getCostoSegnalazione(false));

+ 94
- 0
app/Models/OrdineLavoroRiga.php 查看文件

@@ -0,0 +1,94 @@
1
+<?php
2
+
3
+namespace App\Models;
4
+
5
+use Illuminate\Database\Eloquent\Model;
6
+
7
+class OrdineLavoroRiga extends Model
8
+{
9
+    protected $table = 'ordine_lavoro_riga';
10
+
11
+    protected $keyType = 'bigInteger';
12
+
13
+    protected $attributes = ['ordine' => 0];
14
+
15
+    protected $casts = [
16
+        'id' => 'integer',
17
+        'ordine_lavoro_id' => 'integer',
18
+        'prodotto_id' => 'integer',
19
+        'descrizione' => 'string',
20
+        'quantita' => 'decimal:2',
21
+        'unita_misura' => 'string',
22
+        'prezzo_unitario' => 'decimal:2',
23
+        'sconto' => 'decimal:2',
24
+        'ordine' => 'integer',
25
+        'note_private' => 'string',
26
+        'tempo_stimato' => 'decimal:2',
27
+        'created_at' => 'datetime',
28
+        'updated_at' => 'datetime',
29
+    ];
30
+
31
+    protected $fillable = [
32
+        'ordine_lavoro_id',
33
+        'prodotto_id',
34
+        'descrizione',
35
+        'quantita',
36
+        'unita_misura',
37
+        'prezzo_unitario',
38
+        'sconto',
39
+        'ordine',
40
+        'note_private',
41
+        'tempo_stimato',
42
+    ];
43
+
44
+    public function ordineLavoro()
45
+    {
46
+        return $this->belongsTo(OrdineLavoro::class, 'ordine_lavoro_id', 'id');
47
+    }
48
+
49
+    public function prodotto()
50
+    {
51
+        return $this->belongsTo(Prodotto::class, 'prodotto_id', 'id');
52
+    }
53
+
54
+    public function getUnitaMisuraLabelAttribute(): string
55
+    {
56
+        return Prodotto::UNITA_MISURA[$this->unita_misura] ?? ($this->unita_misura ?: '—');
57
+    }
58
+
59
+    public function getImportoAttribute(): float
60
+    {
61
+        $qty = (float) ($this->quantita ?? 0);
62
+        $prezzo = (float) ($this->prezzo_unitario ?? 0);
63
+        $sconto = (float) ($this->sconto ?? 0);
64
+        $totale = $qty * $prezzo;
65
+        if ($sconto > 0) {
66
+            $totale -= ($totale * $sconto / 100);
67
+        }
68
+
69
+        return round($totale, 2);
70
+    }
71
+
72
+    /**
73
+     * Ore stimate per l'operatore: tempo_stimato se valorizzato, altrimenti quantità se U.M. ora.
74
+     */
75
+    public function getOreAttribute(): float
76
+    {
77
+        return OrdineLavoro::oreDaRiga($this);
78
+    }
79
+
80
+    public static function fromOffertaRiga(OffertaRiga $riga, int $ordine = 0): array
81
+    {
82
+        return [
83
+            'prodotto_id' => $riga->prodotto_id,
84
+            'descrizione' => $riga->descrizione,
85
+            'quantita' => $riga->quantita,
86
+            'unita_misura' => $riga->unita_misura,
87
+            'prezzo_unitario' => $riga->prezzo_unitario,
88
+            'sconto' => $riga->sconto,
89
+            'note_private' => $riga->note_private,
90
+            'tempo_stimato' => $riga->tempo_stimato,
91
+            'ordine' => $ordine,
92
+        ];
93
+    }
94
+}

+ 1
- 0
app/Models/Prodotto.php 查看文件

@@ -14,6 +14,7 @@ class Prodotto extends \App\Models\AbstractModels\AbstractProdotto
14 14
         'mensile' => 'Mensile',
15 15
         'trimestrale' => 'Trimestrale',
16 16
         'annuale' => 'Annuale',
17
+        'a_consumo' => 'A consumo',
17 18
     ];
18 19
 
19 20
     /** Unità di misura condivise in tutta la piattaforma. */

+ 164
- 0
app/Models/Segnalazione.php 查看文件

@@ -3,6 +3,8 @@ namespace App\Models;
3 3
 
4 4
 use OwenIt\Auditing\Contracts\Auditable;
5 5
 use Carbon\Carbon;
6
+use App\Notifications\AnnotazionePubblicaApertaDa;
7
+use Illuminate\Support\Facades\Notification;
6 8
 
7 9
 class Segnalazione extends \App\Models\AbstractModels\AbstractSegnalazione implements Auditable
8 10
 {
@@ -115,6 +117,18 @@ class Segnalazione extends \App\Models\AbstractModels\AbstractSegnalazione imple
115 117
         return "#".$this->id." - ".$this->titolo;
116 118
     }
117 119
 
120
+    /**
121
+     * Subject email stabile per threading client ([#ID] Oggetto).
122
+     * Per aggiornamenti/risposte antepone "Re: " (Gmail e simili raggruppano meglio).
123
+     */
124
+    public function mailSubject(bool $isReply = false): string
125
+    {
126
+        $titolo = trim((string) ($this->titolo ?: 'Segnalazione'));
127
+        $base = '[#'.$this->id.'] '.$titolo;
128
+
129
+        return $isReply ? 'Re: '.$base : $base;
130
+    }
131
+
118 132
     public function getLink(){
119 133
       return "<a href='".route('admin.segnalazione.show', ['id' => $this->id])."'>".$this->getLabel()."</a>";
120 134
     }
@@ -180,6 +194,58 @@ class Segnalazione extends \App\Models\AbstractModels\AbstractSegnalazione imple
180 194
       return "#".$this->numero." / ".Carbon::parse($this->created_at)->format('Y');
181 195
     }
182 196
 
197
+    public static function isClienteVista(?User $user): bool
198
+    {
199
+        if (! $user) {
200
+            return false;
201
+        }
202
+
203
+        return $user->can('view-own-segnalazione')
204
+            && ! $user->can('view-segnalazione')
205
+            && ! $user->can('view-segnalazione-assegnate');
206
+    }
207
+
208
+    public function isOwnedBy(?User $user): bool
209
+    {
210
+        return $user !== null && $this->user_id !== null && (int) $this->user_id === (int) $user->id;
211
+    }
212
+
213
+    /**
214
+     * Vista cliente: proprie, oppure (se responsabile azienda) quelle aperte dai colleghi della stessa azienda.
215
+     */
216
+    public function isAccessibleByCliente(?User $user): bool
217
+    {
218
+        if ($this->isOwnedBy($user)) {
219
+            return true;
220
+        }
221
+
222
+        if (! $user?->responsabile_azienda || ! $user->azienda_id || ! $this->user_id) {
223
+            return false;
224
+        }
225
+
226
+        $openerAziendaId = $this->relationLoaded('user')
227
+            ? $this->user?->azienda_id
228
+            : User::whereKey($this->user_id)->value('azienda_id');
229
+
230
+        return $openerAziendaId && (int) $openerAziendaId === (int) $user->azienda_id;
231
+    }
232
+
233
+    /**
234
+     * Elenco area cliente: proprie + (responsabile azienda) colleghi della stessa azienda.
235
+     */
236
+    public function scopeVisibiliAlCliente($query, User $user)
237
+    {
238
+        return $query->where(function ($q) use ($user) {
239
+            $q->where('user_id', $user->id);
240
+
241
+            if ($user->responsabile_azienda && $user->azienda_id) {
242
+                $q->orWhereHas('user', function ($uq) use ($user) {
243
+                    $uq->where('azienda_id', $user->azienda_id);
244
+                });
245
+            }
246
+        });
247
+    }
248
+
183 249
     public static function isStatoChiuso(?string $stato): bool
184 250
     {
185 251
         if ($stato === null || $stato === '') {
@@ -220,4 +286,102 @@ class Segnalazione extends \App\Models\AbstractModels\AbstractSegnalazione imple
220 286
             .($elenco !== '' ? ' ('.$elenco.')' : '').'.';
221 287
     }
222 288
 
289
+    /**
290
+     * Timestamp unix (secondi) di avvio timer per l'utente, se attivo.
291
+     */
292
+    public function getTempoTimerStartedAt(?int $userId): ?int
293
+    {
294
+        if (!$userId) {
295
+            return null;
296
+        }
297
+
298
+        $startedAt = data_get($this->meta ?? [], 'tempo_timer.' . $userId . '.started_at');
299
+        if ($startedAt === null || $startedAt === '') {
300
+            return null;
301
+        }
302
+
303
+        $ts = (int) $startedAt;
304
+
305
+        return $ts > 0 ? $ts : null;
306
+    }
307
+
308
+    public function startTempoTimer(int $userId, ?int $startedAt = null): int
309
+    {
310
+        $meta = $this->meta ?? [];
311
+        $timers = $meta['tempo_timer'] ?? [];
312
+        if (! is_array($timers)) {
313
+            $timers = [];
314
+        }
315
+
316
+        $ts = $startedAt ?? time();
317
+        $timers[(string) $userId] = [
318
+            'started_at' => $ts,
319
+            'user_id' => $userId,
320
+        ];
321
+        $meta['tempo_timer'] = $timers;
322
+        $this->meta = $meta;
323
+        $this->save();
324
+
325
+        return $ts;
326
+    }
327
+
328
+    /**
329
+     * Ferma il timer dell'utente e restituisce started_at (unix) se c'era.
330
+     */
331
+    public function stopTempoTimer(int $userId): ?int
332
+    {
333
+        $meta = $this->meta ?? [];
334
+        $timers = $meta['tempo_timer'] ?? [];
335
+        if (! is_array($timers)) {
336
+            $timers = [];
337
+        }
338
+
339
+        $key = (string) $userId;
340
+        $startedAt = isset($timers[$key]['started_at']) ? (int) $timers[$key]['started_at'] : null;
341
+        unset($timers[$key]);
342
+
343
+        if ($timers === []) {
344
+            unset($meta['tempo_timer']);
345
+        } else {
346
+            $meta['tempo_timer'] = $timers;
347
+        }
348
+
349
+        $this->meta = $meta === [] ? null : $meta;
350
+        $this->save();
351
+
352
+        return ($startedAt && $startedAt > 0) ? $startedAt : null;
353
+    }
354
+
355
+    public function getNumeroAttribute(){
356
+      return $this->id;
357
+    }
358
+
359
+    /**
360
+     * Notifica pubblica all'utente "Aperta da", come per un'annotazione standard.
361
+     */
362
+    public function notifyApertaDaAnnotazionePubblica(Annotazione $annotazione): void
363
+    {
364
+        if ($annotazione->riservata || ! $this->user_id) {
365
+            return;
366
+        }
367
+
368
+        $apertaDa = User::soloUtenti()
369
+            ->where('id', $this->user_id)
370
+            ->whereNotNull('email')
371
+            ->where('email', '!=', '')
372
+            ->first();
373
+
374
+        if (! $apertaDa) {
375
+            return;
376
+        }
377
+
378
+        Notification::send(
379
+            collect([$apertaDa]),
380
+            new AnnotazionePubblicaApertaDa(
381
+                $this->fresh(['azienda']),
382
+                $annotazione->fresh(['user']),
383
+            )
384
+        );
385
+    }
386
+
223 387
 }

+ 54
- 0
app/Models/User.php 查看文件

@@ -51,6 +51,17 @@ class User extends \App\Models\AbstractModels\AbstractUser
51 51
     return $query->where('is_gruppo', false)->where('is_azienda', false);
52 52
   }
53 53
 
54
+  /**
55
+   * Possibili assegnatari di una segnalazione (permesso edit-segnalazione).
56
+   */
57
+  public function scopeAssegnatariSegnalazione($query)
58
+  {
59
+    return $query->permission('edit-segnalazione')
60
+      ->where('is_azienda', false)
61
+      ->orderBy('cognome')
62
+      ->orderBy('nome');
63
+  }
64
+
54 65
   /**
55 66
    * Record azienda (User con is_azienda=1).
56 67
    */
@@ -59,6 +70,49 @@ class User extends \App\Models\AbstractModels\AbstractUser
59 70
     return $query->where('is_azienda', true);
60 71
   }
61 72
 
73
+  /**
74
+   * Normalizza un dominio email (es. *@arratti.it → arratti.it).
75
+   */
76
+  public static function normalizeDominioEmail(?string $value): ?string
77
+  {
78
+    if ($value === null) {
79
+      return null;
80
+    }
81
+
82
+    $value = strtolower(trim($value));
83
+    if ($value === '') {
84
+      return null;
85
+    }
86
+
87
+    $value = preg_replace('/^https?:\/\//', '', $value) ?? $value;
88
+    $value = preg_replace('/^www\./', '', $value) ?? $value;
89
+    $value = ltrim($value, '@');
90
+    if (str_starts_with($value, '*@')) {
91
+      $value = substr($value, 2);
92
+    }
93
+    if (str_contains($value, '@')) {
94
+      $parts = explode('@', $value);
95
+      $value = end($parts) ?: $value;
96
+    }
97
+    $value = rtrim($value, '.');
98
+
99
+    return $value !== '' ? $value : null;
100
+  }
101
+
102
+  public static function findAziendaByDominioEmail(?string $emailOrDomain): ?self
103
+  {
104
+    $dominio = self::normalizeDominioEmail($emailOrDomain);
105
+    if (! $dominio) {
106
+      return null;
107
+    }
108
+
109
+    return self::soloAziende()
110
+      ->whereNotNull('dominio_email')
111
+      ->where('dominio_email', '!=', '')
112
+      ->get()
113
+      ->first(fn (self $azienda) => self::normalizeDominioEmail($azienda->dominio_email) === $dominio);
114
+  }
115
+
62 116
   /**
63 117
    * Evita conflitti se una colonna/attributo omonimo maschera la relazione.
64 118
    */

+ 71
- 0
app/Notifications/AnnotazionePubblicaApertaDa.php 查看文件

@@ -0,0 +1,71 @@
1
+<?php
2
+
3
+namespace App\Notifications;
4
+
5
+use App\Models\Annotazione;
6
+use App\Models\Segnalazione;
7
+use Illuminate\Bus\Queueable;
8
+use Illuminate\Contracts\Queue\ShouldQueue;
9
+use Illuminate\Notifications\Messages\MailMessage;
10
+use Illuminate\Notifications\Notification;
11
+use Illuminate\Queue\InteractsWithQueue;
12
+
13
+class AnnotazionePubblicaApertaDa extends Notification implements ShouldQueue
14
+{
15
+    use Queueable, InteractsWithQueue;
16
+
17
+    public function __construct(
18
+        protected Segnalazione $segnalazione,
19
+        protected Annotazione $annotazione,
20
+    ) {
21
+    }
22
+
23
+    /**
24
+     * @return array<int, string>
25
+     */
26
+    public function via(object $notifiable): array
27
+    {
28
+        return ['mail'];
29
+    }
30
+
31
+    public function toMail(object $notifiable): MailMessage
32
+    {
33
+        $this->segnalazione->loadMissing(['azienda']);
34
+        $this->annotazione->loadMissing(['user']);
35
+
36
+        $stato = $this->segnalazione->getConfiguraStato();
37
+        $priorita = $this->segnalazione->getConfiguraPriorita();
38
+
39
+        $statoLabel = $stato ? $stato->label : ($this->segnalazione->stato ?? 'N/D');
40
+        $prioritaLabel = $priorita ? $priorita->label : ($this->segnalazione->priorita ?? 'N/D');
41
+        $aziendaLabel = $this->segnalazione->azienda
42
+            ? $this->segnalazione->azienda->full_name
43
+            : 'N/D';
44
+        $autoreLabel = $this->annotazione->user
45
+            ? $this->annotazione->user->full_name
46
+            : trim((string) ($this->annotazione->riferimento ?: 'Operatore'));
47
+
48
+        return (new MailMessage)
49
+            ->subject($this->segnalazione->mailSubject(true))
50
+            ->markdown('mail.annotazione-pubblica-aperta-da', [
51
+                'segnalazione' => $this->segnalazione,
52
+                'annotazione' => $this->annotazione,
53
+                'annotazioneHtml' => (string) ($this->annotazione->text ?? ''),
54
+                'aziendaLabel' => $aziendaLabel,
55
+                'statoLabel' => $statoLabel,
56
+                'prioritaLabel' => $prioritaLabel,
57
+                'autoreLabel' => $autoreLabel,
58
+            ]);
59
+    }
60
+
61
+    /**
62
+     * @return array<string, mixed>
63
+     */
64
+    public function toArray(object $notifiable): array
65
+    {
66
+        return [
67
+            'segnalazione_id' => $this->segnalazione->id,
68
+            'annotazione_id' => $this->annotazione->id,
69
+        ];
70
+    }
71
+}

+ 7
- 2
app/Notifications/NuovaSegnalazioneAdmin.php 查看文件

@@ -8,6 +8,7 @@ use Illuminate\Notifications\Notification;
8 8
 use Illuminate\Contracts\Queue\ShouldQueue;
9 9
 use Illuminate\Queue\InteractsWithQueue;
10 10
 use App\Models\Segnalazione;
11
+use App\Support\IsolaHtml;
11 12
 
12 13
 class NuovaSegnalazioneAdmin extends Notification implements ShouldQueue
13 14
 {
@@ -40,14 +41,18 @@ class NuovaSegnalazioneAdmin extends Notification implements ShouldQueue
40 41
         $priorita = $this->segnalazione->getConfiguraPriorita();
41 42
         $aziendaLabel = $this->segnalazione->azienda ? $this->segnalazione->azienda->full_name : 'N/D';
42 43
         $prioritaLabel = $priorita ? $priorita->label : ($this->segnalazione->priorita ?? 'N/D');
43
-        $subject = 'Nuova segnalazione: '.$this->segnalazione->titolo;
44
+        $subject = $this->segnalazione->mailSubject();
45
+        $this->segnalazione->loadMissing(['annotazioni']);
46
+        $primaAnnotazione = $this->segnalazione->annotazioni->sortBy('id')->first();
47
+        $annotazioneHtml = IsolaHtml::fragment((string) ($primaAnnotazione->text ?? ''));
44 48
 
45 49
         return (new MailMessage)
46 50
             ->subject($subject)
47 51
             ->markdown('mail.nuova-segnalazione-admin', [
48 52
                 'segnalazione' => $this->segnalazione,
49 53
                 'aziendaLabel' => $aziendaLabel,
50
-                'prioritaLabel' => $prioritaLabel
54
+                'prioritaLabel' => $prioritaLabel,
55
+                'annotazioneHtml' => $annotazioneHtml,
51 56
             ]);
52 57
     }
53 58
 

+ 8
- 7
app/Notifications/NuovaSegnalazioneUser.php 查看文件

@@ -9,6 +9,7 @@ use Illuminate\Notifications\Notification;
9 9
 use Illuminate\Queue\InteractsWithQueue;
10 10
 use App\Models\Segnalazione;
11 11
 use App\Services\SegnalazioneTicketPdfService;
12
+use App\Support\IsolaHtml;
12 13
 
13 14
 class NuovaSegnalazioneUser extends Notification implements ShouldQueue
14 15
 {
@@ -47,13 +48,12 @@ class NuovaSegnalazioneUser extends Notification implements ShouldQueue
47 48
         $aziendaLabel = $this->segnalazione->azienda ? $this->segnalazione->azienda->full_name : 'N/D';
48 49
         $prioritaLabel = $priorita ? $priorita->label : ($this->segnalazione->priorita ?? 'N/D');
49 50
 
50
-        if ($this->isApertaDa) {
51
-            $subject = 'Segnalazione aperta a tuo nome: '.$this->segnalazione->titolo;
52
-        } elseif ($this->isReassignment) {
53
-            $subject = 'Segnalazione riassegnata a te: '.$this->segnalazione->titolo;
54
-        } else {
55
-            $subject = 'Ti è stata assegnata una nuova richiesta di manutenzione: '.$this->segnalazione->titolo;
56
-        }
51
+        // Subject stabile [#ID] titolo (+ Re: su riassegnazione)
52
+        $subject = $this->segnalazione->mailSubject($this->isReassignment);
53
+
54
+        $this->segnalazione->loadMissing(['annotazioni']);
55
+        $primaAnnotazione = $this->segnalazione->annotazioni->sortBy('id')->first();
56
+        $annotazioneHtml = IsolaHtml::fragment((string) ($primaAnnotazione->text ?? ''));
57 57
 
58 58
         $mail = (new MailMessage)
59 59
             ->subject($subject)
@@ -63,6 +63,7 @@ class NuovaSegnalazioneUser extends Notification implements ShouldQueue
63 63
                 'prioritaLabel' => $prioritaLabel,
64 64
                 'isReassignment' => $this->isReassignment,
65 65
                 'isApertaDa' => $this->isApertaDa,
66
+                'annotazioneHtml' => $annotazioneHtml,
66 67
             ]);
67 68
 
68 69
         try {

+ 71
- 0
app/Notifications/RispostaEmailAssegnatario.php 查看文件

@@ -0,0 +1,71 @@
1
+<?php
2
+
3
+namespace App\Notifications;
4
+
5
+use App\Models\Annotazione;
6
+use App\Models\Segnalazione;
7
+use Illuminate\Bus\Queueable;
8
+use Illuminate\Contracts\Queue\ShouldQueue;
9
+use Illuminate\Notifications\Messages\MailMessage;
10
+use Illuminate\Notifications\Notification;
11
+use Illuminate\Queue\InteractsWithQueue;
12
+
13
+class RispostaEmailAssegnatario extends Notification implements ShouldQueue
14
+{
15
+    use Queueable, InteractsWithQueue;
16
+
17
+    public function __construct(
18
+        protected Segnalazione $segnalazione,
19
+        protected Annotazione $annotazione,
20
+    ) {
21
+    }
22
+
23
+    /**
24
+     * @return array<int, string>
25
+     */
26
+    public function via(object $notifiable): array
27
+    {
28
+        return ['mail'];
29
+    }
30
+
31
+    public function toMail(object $notifiable): MailMessage
32
+    {
33
+        $this->segnalazione->loadMissing(['azienda']);
34
+        $this->annotazione->loadMissing(['user']);
35
+
36
+        $stato = $this->segnalazione->getConfiguraStato();
37
+        $priorita = $this->segnalazione->getConfiguraPriorita();
38
+
39
+        $statoLabel = $stato ? $stato->label : ($this->segnalazione->stato ?? 'N/D');
40
+        $prioritaLabel = $priorita ? $priorita->label : ($this->segnalazione->priorita ?? 'N/D');
41
+        $aziendaLabel = $this->segnalazione->azienda
42
+            ? $this->segnalazione->azienda->full_name
43
+            : 'N/D';
44
+        $autoreLabel = $this->annotazione->user
45
+            ? $this->annotazione->user->full_name
46
+            : trim((string) ($this->annotazione->riferimento ?: $this->annotazione->email ?: 'Mittente email'));
47
+
48
+        return (new MailMessage)
49
+            ->subject($this->segnalazione->mailSubject(true))
50
+            ->markdown('mail.risposta-email-assegnatario', [
51
+                'segnalazione' => $this->segnalazione,
52
+                'annotazione' => $this->annotazione,
53
+                'annotazioneHtml' => (string) ($this->annotazione->text ?? ''),
54
+                'aziendaLabel' => $aziendaLabel,
55
+                'statoLabel' => $statoLabel,
56
+                'prioritaLabel' => $prioritaLabel,
57
+                'autoreLabel' => $autoreLabel,
58
+            ]);
59
+    }
60
+
61
+    /**
62
+     * @return array<string, mixed>
63
+     */
64
+    public function toArray(object $notifiable): array
65
+    {
66
+        return [
67
+            'segnalazione_id' => $this->segnalazione->id,
68
+            'annotazione_id' => $this->annotazione->id,
69
+        ];
70
+    }
71
+}

+ 79
- 0
app/Services/ChiudiSegnalazioniInattiveService.php 查看文件

@@ -0,0 +1,79 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Models\Annotazione;
6
+use App\Models\Config;
7
+use App\Models\Segnalazione;
8
+use Carbon\Carbon;
9
+use Illuminate\Support\Facades\DB;
10
+
11
+class ChiudiSegnalazioniInattiveService
12
+{
13
+    /**
14
+     * @return array{closed: int, blocked: int, disabled: bool, giorni: ?int, stati: array<int, string>}
15
+     */
16
+    public function run(bool $dryRun = false): array
17
+    {
18
+        $giorni = Config::chiusuraAutoGiorni();
19
+        $stati = Config::chiusuraAutoStati();
20
+        $dest = Config::chiusuraAutoStatoDestinazione();
21
+
22
+        $stats = [
23
+            'closed' => 0,
24
+            'blocked' => 0,
25
+            'disabled' => false,
26
+            'giorni' => $giorni,
27
+            'stati' => $stati,
28
+        ];
29
+
30
+        if ($giorni === null || $stati === []) {
31
+            $stats['disabled'] = true;
32
+
33
+            return $stats;
34
+        }
35
+
36
+        $cutoff = Carbon::now()->subDays($giorni);
37
+        $testo = Config::messaggioChiusuraInattivita($giorni);
38
+
39
+        Segnalazione::query()
40
+            ->whereIn('stato', $stati)
41
+            ->where('stato', '!=', $dest)
42
+            ->where('updated_at', '<=', $cutoff)
43
+            ->whereDoesntHave('annotazioni', function ($query) use ($cutoff) {
44
+                $query->where('created_at', '>', $cutoff);
45
+            })
46
+            ->orderBy('id')
47
+            ->chunkById(100, function ($segnalazioni) use ($dryRun, $dest, $testo, &$stats) {
48
+                foreach ($segnalazioni as $segnalazione) {
49
+                    if ($segnalazione->closingBlockedMessage($dest)) {
50
+                        $stats['blocked']++;
51
+                        continue;
52
+                    }
53
+
54
+                    if ($dryRun) {
55
+                        $stats['closed']++;
56
+                        continue;
57
+                    }
58
+
59
+                    DB::transaction(function () use ($segnalazione, $dest, $testo, &$stats) {
60
+                        $segnalazione->stato = $dest;
61
+                        $segnalazione->save();
62
+
63
+                        $annotazione = new Annotazione();
64
+                        $annotazione->segnalazione_id = $segnalazione->id;
65
+                        $annotazione->user_id = null;
66
+                        $annotazione->riservata = false;
67
+                        $annotazione->riferimento = 'Sistema';
68
+                        $annotazione->text = '<p>'.e($testo).'</p>';
69
+                        $annotazione->save();
70
+
71
+                        $segnalazione->notifyApertaDaAnnotazionePubblica($annotazione);
72
+                        $stats['closed']++;
73
+                    });
74
+                }
75
+            });
76
+
77
+        return $stats;
78
+    }
79
+}

+ 3
- 0
app/Services/ContrattoServizioPdfService.php 查看文件

@@ -3,6 +3,7 @@
3 3
 namespace App\Services;
4 4
 
5 5
 use App\Models\ContrattoServizio;
6
+use App\Support\PdfDocument;
6 7
 use Barryvdh\DomPDF\Facade\Pdf;
7 8
 use Illuminate\Support\Str;
8 9
 
@@ -29,6 +30,8 @@ class ContrattoServizioPdfService
29 30
 
30 31
         $pdf = Pdf::loadView('contratto_servizio.pdf', [
31 32
             'contratto' => $contratto,
33
+            'logoBase64' => PdfDocument::logoBase64(),
34
+            'azienda' => PdfDocument::azienda(),
32 35
         ]);
33 36
         $pdf->setPaper('a4', 'portrait');
34 37
         $pdf->setOption('defaultFont', 'DejaVu Sans');

+ 1042
- 0
app/Services/FatturazioneGeneraService.php
文件差异内容过多而无法显示
查看文件


+ 339
- 0
app/Services/Redmine/RedmineClient.php 查看文件

@@ -0,0 +1,339 @@
1
+<?php
2
+
3
+namespace App\Services\Redmine;
4
+
5
+use Illuminate\Http\Client\PendingRequest;
6
+use Illuminate\Http\Client\RequestException;
7
+use Illuminate\Support\Facades\Http;
8
+use RuntimeException;
9
+
10
+class RedmineClient
11
+{
12
+    public function __construct(
13
+        protected string $baseUrl,
14
+        protected string $apiKey,
15
+        protected bool $verifySsl = true,
16
+        protected int $timeout = 60,
17
+        protected int $downloadTimeout = 180,
18
+    ) {
19
+        $this->baseUrl = rtrim($baseUrl, '/');
20
+    }
21
+
22
+    public static function fromConfig(?string $url = null, ?string $apiKey = null): self
23
+    {
24
+        $url = rtrim((string) ($url ?: config('redmine.url')), '/');
25
+        $apiKey = (string) ($apiKey ?: config('redmine.api_key'));
26
+
27
+        if ($url !== '' && ! preg_match('#^https?://#i', $url)) {
28
+            $url = 'https://'.$url;
29
+        }
30
+
31
+        if ($url === '' || $apiKey === '') {
32
+            throw new RuntimeException('Configura REDMINE_URL e REDMINE_API_KEY nel .env (oppure --url e --api-key).');
33
+        }
34
+
35
+        return new self(
36
+            $url,
37
+            $apiKey,
38
+            (bool) config('redmine.verify_ssl', true),
39
+            (int) config('redmine.timeout', 60),
40
+            (int) config('redmine.download_timeout', 180),
41
+        );
42
+    }
43
+
44
+    public function baseUrl(): string
45
+    {
46
+        return $this->baseUrl;
47
+    }
48
+
49
+    public function issueUrl(int $issueId): string
50
+    {
51
+        return $this->baseUrl.'/issues/'.$issueId;
52
+    }
53
+
54
+    /**
55
+     * @return array<string, mixed>
56
+     */
57
+    public function get(string $path, array $query = []): array
58
+    {
59
+        try {
60
+            $response = $this->jsonRequest()
61
+                ->get($this->url($path), $query)
62
+                ->throw();
63
+        } catch (RequestException $e) {
64
+            throw $this->wrapHttpException($e, 'GET '.$path);
65
+        }
66
+
67
+        $json = $response->json();
68
+
69
+        return is_array($json) ? $json : [];
70
+    }
71
+
72
+    public function download(string $url): string
73
+    {
74
+        $absolute = str_starts_with($url, 'http://') || str_starts_with($url, 'https://')
75
+            ? $url
76
+            : $this->url($url);
77
+
78
+        try {
79
+            $response = $this->request($this->downloadTimeout)
80
+                ->get($absolute)
81
+                ->throw();
82
+        } catch (RequestException $e) {
83
+            throw $this->wrapHttpException($e, 'GET '.$absolute);
84
+        }
85
+
86
+        return $response->body();
87
+    }
88
+
89
+    /**
90
+     * @return list<array<string, mixed>>
91
+     */
92
+    public function paginate(string $path, string $collectionKey, array $query = [], int $limit = 100): array
93
+    {
94
+        $all = [];
95
+        $offset = 0;
96
+
97
+        do {
98
+            $data = $this->get($path, array_merge($query, [
99
+                'offset' => $offset,
100
+                'limit' => $limit,
101
+            ]));
102
+            $chunk = $data[$collectionKey] ?? [];
103
+            if (! is_array($chunk) || $chunk === []) {
104
+                break;
105
+            }
106
+            foreach ($chunk as $item) {
107
+                $all[] = $item;
108
+            }
109
+            $total = (int) ($data['total_count'] ?? count($all));
110
+            $offset += $limit;
111
+        } while ($offset < $total);
112
+
113
+        return $all;
114
+    }
115
+
116
+    /**
117
+     * @return list<array<string, mixed>>
118
+     */
119
+    public function projects(): array
120
+    {
121
+        return $this->paginate('projects.json', 'projects', ['include' => 'enabled_modules']);
122
+    }
123
+
124
+    /**
125
+     * @return array<string, mixed>
126
+     */
127
+    public function project(string|int $idOrIdentifier): array
128
+    {
129
+        $data = $this->get('projects/'.$idOrIdentifier.'.json', [
130
+            'include' => 'trackers,issue_categories,enabled_modules',
131
+        ]);
132
+
133
+        return is_array($data['project'] ?? null) ? $data['project'] : [];
134
+    }
135
+
136
+    /**
137
+     * @return list<array<string, mixed>>
138
+     */
139
+    public function issues(string|int $projectId, bool $includeSubprojects = false, ?int $limit = null): array
140
+    {
141
+        $query = [
142
+            'project_id' => $projectId,
143
+            'status_id' => '*',
144
+            'sort' => 'id',
145
+        ];
146
+        if (! $includeSubprojects) {
147
+            $query['subproject_id'] = '!*';
148
+        }
149
+
150
+        $issues = $this->paginate('issues.json', 'issues', $query);
151
+
152
+        if ($limit !== null) {
153
+            return array_slice($issues, 0, max(0, $limit));
154
+        }
155
+
156
+        return $issues;
157
+    }
158
+
159
+    /**
160
+     * @return array<string, mixed>
161
+     */
162
+    public function issue(int $id): array
163
+    {
164
+        $data = $this->get('issues/'.$id.'.json', [
165
+            'include' => 'journals,attachments,relations,children,watchers,changesets',
166
+        ]);
167
+
168
+        return is_array($data['issue'] ?? null) ? $data['issue'] : [];
169
+    }
170
+
171
+    /**
172
+     * @return list<array<string, mixed>>
173
+     */
174
+    public function timeEntries(string|int $projectId, bool $includeSubprojects = false): array
175
+    {
176
+        $query = [
177
+            'project_id' => $projectId,
178
+            'spent_on' => '*',
179
+        ];
180
+        if (! $includeSubprojects) {
181
+            $query['subproject_id'] = '!*';
182
+        }
183
+
184
+        return $this->paginate('time_entries.json', 'time_entries', $query);
185
+    }
186
+
187
+    /**
188
+     * @return list<array<string, mixed>>
189
+     */
190
+    public function users(): array
191
+    {
192
+        $all = [];
193
+        foreach ([1, 2, 3] as $status) {
194
+            try {
195
+                $chunk = $this->paginate('users.json', 'users', ['status' => $status]);
196
+            } catch (RuntimeException) {
197
+                continue;
198
+            }
199
+            foreach ($chunk as $user) {
200
+                $id = (int) ($user['id'] ?? 0);
201
+                if ($id > 0) {
202
+                    $all[$id] = $user;
203
+                }
204
+            }
205
+        }
206
+
207
+        return array_values($all);
208
+    }
209
+
210
+    /**
211
+     * @return array<string, mixed>|null
212
+     */
213
+    public function user(int $id): ?array
214
+    {
215
+        try {
216
+            $data = $this->get('users/'.$id.'.json');
217
+        } catch (RuntimeException) {
218
+            return null;
219
+        }
220
+
221
+        return is_array($data['user'] ?? null) ? $data['user'] : null;
222
+    }
223
+
224
+    /**
225
+     * Ticket Helpdesk (plugin RedmineUP): l'id coincide con l'issue.
226
+     *
227
+     * @return array<string, mixed>|null
228
+     */
229
+    public function helpdeskTicket(int $issueId): ?array
230
+    {
231
+        try {
232
+            $data = $this->get('helpdesk_tickets/'.$issueId.'.json');
233
+        } catch (RuntimeException $e) {
234
+            if (in_array($e->getCode(), [404, 500], true)) {
235
+                return null;
236
+            }
237
+
238
+            throw $e;
239
+        }
240
+
241
+        return is_array($data['helpdesk_ticket'] ?? null) ? $data['helpdesk_ticket'] : null;
242
+    }
243
+
244
+    /**
245
+     * @return array<string, mixed>|null
246
+     */
247
+    public function contact(int $id): ?array
248
+    {
249
+        try {
250
+            $data = $this->get('contacts/'.$id.'.json');
251
+        } catch (RuntimeException) {
252
+            return null;
253
+        }
254
+
255
+        return is_array($data['contact'] ?? null) ? $data['contact'] : null;
256
+    }
257
+
258
+    /**
259
+     * @return list<array<string, mixed>>
260
+     */
261
+    public function issueStatuses(): array
262
+    {
263
+        try {
264
+            return $this->get('issue_statuses.json')['issue_statuses'] ?? [];
265
+        } catch (RuntimeException) {
266
+            return [];
267
+        }
268
+    }
269
+
270
+    /**
271
+     * @return list<array<string, mixed>>
272
+     */
273
+    public function trackers(): array
274
+    {
275
+        try {
276
+            return $this->get('trackers.json')['trackers'] ?? [];
277
+        } catch (RuntimeException) {
278
+            return [];
279
+        }
280
+    }
281
+
282
+    /**
283
+     * @return list<array<string, mixed>>
284
+     */
285
+    public function priorities(): array
286
+    {
287
+        try {
288
+            return $this->get('enumerations/issue_priorities.json')['issue_priorities'] ?? [];
289
+        } catch (RuntimeException) {
290
+            return [];
291
+        }
292
+    }
293
+
294
+    protected function jsonRequest(): PendingRequest
295
+    {
296
+        return $this->request($this->timeout)->acceptJson();
297
+    }
298
+
299
+    protected function request(int $timeout): PendingRequest
300
+    {
301
+        return Http::withHeaders([
302
+            'X-Redmine-API-Key' => $this->apiKey,
303
+        ])
304
+            ->timeout($timeout)
305
+            ->connectTimeout(15)
306
+            ->withOptions([
307
+                'verify' => $this->verifySsl,
308
+                'allow_redirects' => true,
309
+            ]);
310
+    }
311
+
312
+    protected function url(string $path): string
313
+    {
314
+        if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
315
+            return $path;
316
+        }
317
+
318
+        return $this->baseUrl.'/'.ltrim($path, '/');
319
+    }
320
+
321
+    protected function wrapHttpException(RequestException $e, string $context): RuntimeException
322
+    {
323
+        $status = $e->response?->status();
324
+        $body = $e->response?->body();
325
+        $hint = match ($status) {
326
+            401, 403 => ' API key non valida o senza permessi (serve un utente con accesso al progetto e, per gli utenti, spesso ruolo Administrator).',
327
+            404 => ' Risorsa non trovata.',
328
+            default => '',
329
+        };
330
+
331
+        $snippet = $body ? ' '.mb_substr(trim(strip_tags($body)), 0, 200) : '';
332
+
333
+        return new RuntimeException(
334
+            sprintf('Redmine %s fallita (HTTP %s).%s%s', $context, $status ?: 'n/d', $hint, $snippet),
335
+            (int) $status,
336
+            $e,
337
+        );
338
+    }
339
+}

+ 1739
- 0
app/Services/Redmine/RedmineImportService.php
文件差异内容过多而无法显示
查看文件


+ 851
- 0
app/Services/SegnalazioneEmailInboxService.php 查看文件

@@ -0,0 +1,851 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Models\AllegatoAnnotazione;
6
+use App\Models\Annotazione;
7
+use App\Models\CasellaImap;
8
+use App\Models\Config;
9
+use App\Models\ConfigNotifiche;
10
+use App\Models\ContrattoServizio;
11
+use App\Models\Segnalazione;
12
+use App\Models\User;
13
+use App\Notifications\NuovaSegnalazioneUser;
14
+use App\Notifications\RispostaEmailAssegnatario;
15
+use Illuminate\Support\Facades\Log;
16
+use Illuminate\Support\Facades\Notification;
17
+use Illuminate\Support\Facades\Storage;
18
+use Illuminate\Support\Str;
19
+use Webklex\PHPIMAP\Attachment;
20
+use Webklex\PHPIMAP\Client;
21
+use Webklex\PHPIMAP\ClientManager;
22
+use Webklex\PHPIMAP\Folder;
23
+use Webklex\PHPIMAP\Message;
24
+
25
+class SegnalazioneEmailInboxService
26
+{
27
+    /**
28
+     * @return array{processed:int, created:int, attached:int, skipped:int, mailboxes:int, errors:array<int,string>}
29
+     */
30
+    public function processInbox(): array
31
+    {
32
+        $stats = [
33
+            'processed' => 0,
34
+            'created' => 0,
35
+            'attached' => 0,
36
+            'skipped' => 0,
37
+            'mailboxes' => 0,
38
+            'errors' => [],
39
+        ];
40
+
41
+        $caselle = CasellaImap::attive()->orderBy('nome')->get();
42
+        if ($caselle->isEmpty()) {
43
+            return $stats;
44
+        }
45
+
46
+        foreach ($caselle as $casella) {
47
+            $mailboxStats = $this->processCasella($casella);
48
+            $stats['mailboxes']++;
49
+            foreach (['processed', 'created', 'attached', 'skipped'] as $key) {
50
+                $stats[$key] += $mailboxStats[$key];
51
+            }
52
+            foreach ($mailboxStats['errors'] as $error) {
53
+                $stats['errors'][] = '['.$casella->label.'] '.$error;
54
+            }
55
+        }
56
+
57
+        return $stats;
58
+    }
59
+
60
+    /**
61
+     * @return array{processed:int, created:int, attached:int, skipped:int, errors:array<int,string>}
62
+     */
63
+    public function processCasella(CasellaImap $casella): array
64
+    {
65
+        $stats = [
66
+            'processed' => 0,
67
+            'created' => 0,
68
+            'attached' => 0,
69
+            'skipped' => 0,
70
+            'errors' => [],
71
+        ];
72
+
73
+        $client = null;
74
+        try {
75
+            $client = $this->connect($casella);
76
+            $inboxName = (string) ($casella->cartella ?: 'INBOX');
77
+            $inbox = $client->getFolder($inboxName);
78
+            if (! $inbox) {
79
+                throw new \RuntimeException('Cartella da leggere non trovata: '.$inboxName);
80
+            }
81
+
82
+            $processedFolderName = (string) ($casella->cartella_processate ?: '');
83
+            if ($casella->sposta_processate && $processedFolderName !== '') {
84
+                $this->ensureFolder($client, $processedFolderName);
85
+            }
86
+
87
+            $query = $inbox->messages()->all();
88
+            if ($casella->solo_non_lette) {
89
+                $query->unseen();
90
+            }
91
+
92
+            /** @var \Webklex\PHPIMAP\Support\MessageCollection $messages */
93
+            $messages = $query->leaveUnread()->get();
94
+
95
+            foreach ($messages as $message) {
96
+                try {
97
+                    $result = $this->processMessage($message, $casella, $processedFolderName);
98
+                    $stats['processed']++;
99
+                    if ($result === 'created') {
100
+                        $stats['created']++;
101
+                    } elseif ($result === 'attached') {
102
+                        $stats['attached']++;
103
+                    } else {
104
+                        $stats['skipped']++;
105
+                    }
106
+                } catch (\Throwable $e) {
107
+                    report($e);
108
+                    $stats['errors'][] = 'UID '.($message->getUid() ?? '?').': '.$e->getMessage();
109
+                    Log::error('Import email segnalazione fallito', [
110
+                        'casella_id' => $casella->id,
111
+                        'uid' => $message->getUid(),
112
+                        'subject' => (string) $message->getSubject(),
113
+                        'error' => $e->getMessage(),
114
+                    ]);
115
+                }
116
+            }
117
+
118
+            if (empty($stats['errors'])) {
119
+                $casella->registraSuccesso();
120
+            } else {
121
+                $casella->registraErrore(implode(' | ', $stats['errors']));
122
+            }
123
+        } catch (\Throwable $e) {
124
+            report($e);
125
+            $stats['errors'][] = $e->getMessage();
126
+            $casella->registraErrore($e->getMessage());
127
+            Log::error('Connessione IMAP fallita', [
128
+                'casella_id' => $casella->id,
129
+                'error' => $e->getMessage(),
130
+            ]);
131
+        } finally {
132
+            if ($client) {
133
+                try {
134
+                    $client->disconnect();
135
+                } catch (\Throwable $e) {
136
+                    // ignore
137
+                }
138
+            }
139
+        }
140
+
141
+        return $stats;
142
+    }
143
+
144
+    /**
145
+     * @return array{ok:bool, message:string, folders:array<int, array{path:string, name:string}>}
146
+     */
147
+    public function testConnection(CasellaImap $casella): array
148
+    {
149
+        $client = null;
150
+        try {
151
+            $client = $this->connect($casella);
152
+            $folders = [];
153
+            $collection = $client->getFolders(false);
154
+            foreach ($collection as $folder) {
155
+                $folders[] = [
156
+                    'path' => (string) $folder->path,
157
+                    'name' => (string) ($folder->full_name ?: $folder->name),
158
+                ];
159
+            }
160
+            usort($folders, fn ($a, $b) => strcasecmp($a['name'], $b['name']));
161
+
162
+            return [
163
+                'ok' => true,
164
+                'message' => 'Connessione riuscita. Trovate '.count($folders).' cartelle.',
165
+                'folders' => $folders,
166
+            ];
167
+        } catch (\Throwable $e) {
168
+            return [
169
+                'ok' => false,
170
+                'message' => $e->getMessage(),
171
+                'folders' => [],
172
+            ];
173
+        } finally {
174
+            if ($client) {
175
+                try {
176
+                    $client->disconnect();
177
+                } catch (\Throwable $e) {
178
+                    // ignore
179
+                }
180
+            }
181
+        }
182
+    }
183
+
184
+    protected function connect(CasellaImap $casella): Client
185
+    {
186
+        $cm = new ClientManager([]);
187
+
188
+        /** @var Client $client */
189
+        $client = $cm->make($casella->imapClientConfig());
190
+        $client->connect();
191
+
192
+        return $client;
193
+    }
194
+
195
+    protected function ensureFolder(Client $client, string $folderPath): Folder
196
+    {
197
+        try {
198
+            $folder = $client->getFolder($folderPath);
199
+            if ($folder) {
200
+                return $folder;
201
+            }
202
+        } catch (\Throwable $e) {
203
+            // create below
204
+        }
205
+
206
+        $client->createFolder($folderPath, false);
207
+
208
+        $folder = $client->getFolder($folderPath);
209
+        if (! $folder) {
210
+            throw new \RuntimeException('Impossibile creare/aprire cartella IMAP: '.$folderPath);
211
+        }
212
+
213
+        return $folder;
214
+    }
215
+
216
+    /**
217
+     * @return 'created'|'attached'|'skipped'
218
+     */
219
+    protected function processMessage(Message $message, CasellaImap $casella, string $processedFolderName): string
220
+    {
221
+        $fromAddress = $this->extractFromEmail($message);
222
+        $ownAddresses = array_filter([
223
+            strtolower((string) config('mail.from.address')),
224
+            strtolower((string) $casella->username),
225
+        ]);
226
+
227
+        // Evita loop su copie/notifiche uscenti
228
+        if ($fromAddress && in_array(strtolower($fromAddress), $ownAddresses, true)) {
229
+            $this->finalizeMessage($message, $casella, $processedFolderName);
230
+
231
+            return 'skipped';
232
+        }
233
+
234
+        $messageId = trim((string) ($message->getMessageId() ?: ''));
235
+        $subject = trim((string) $message->getSubject());
236
+        $segnalazioneId = $this->extractSegnalazioneIdFromSubject($subject);
237
+
238
+        $segnalazione = null;
239
+        $created = false;
240
+
241
+        if ($segnalazioneId) {
242
+            $segnalazione = Segnalazione::find($segnalazioneId);
243
+            if ($segnalazione && $messageId && $this->alreadyImported($segnalazione, $messageId)) {
244
+                $this->finalizeMessage($message, $casella, $processedFolderName);
245
+
246
+                return 'skipped';
247
+            }
248
+        }
249
+
250
+        if (! $segnalazione) {
251
+            $segnalazione = $this->createSegnalazioneFromMessage($message, $subject, $fromAddress);
252
+            $created = true;
253
+        }
254
+
255
+        $annotazione = $this->createAnnotazioneFromMessage($segnalazione, $message, $fromAddress, ! $created);
256
+        $this->storeAttachments($segnalazione, $annotazione, $message);
257
+
258
+        if (! $created) {
259
+            // Risposta a ticket esistente (anche chiuso o notifica di chiusura): torna in attesa operatore
260
+            if ($segnalazione->stato !== 'in_attesa_operatore') {
261
+                $segnalazione->stato = 'in_attesa_operatore';
262
+                $segnalazione->save();
263
+            }
264
+            $this->notifyAssegnatarioRispostaEmail($segnalazione, $annotazione, $fromAddress);
265
+        }
266
+
267
+        if ($messageId) {
268
+            $this->rememberImportedMessageId($segnalazione, $messageId);
269
+        }
270
+
271
+        $this->finalizeMessage($message, $casella, $processedFolderName);
272
+
273
+        return $created ? 'created' : 'attached';
274
+    }
275
+
276
+    protected function notifyAssegnatarioRispostaEmail(
277
+        Segnalazione $segnalazione,
278
+        Annotazione $annotazione,
279
+        ?string $fromAddress,
280
+    ): void {
281
+        if (! $segnalazione->assegnata_a_id) {
282
+            return;
283
+        }
284
+
285
+        $targets = User::notifiableMembersForAssigneeId((int) $segnalazione->assegnata_a_id);
286
+        if ($fromAddress) {
287
+            $fromLower = strtolower($fromAddress);
288
+            $targets = $targets->reject(
289
+                fn (User $user) => strtolower((string) $user->email) === $fromLower
290
+            )->values();
291
+        }
292
+
293
+        if ($targets->isEmpty()) {
294
+            return;
295
+        }
296
+
297
+        Notification::send(
298
+            $targets,
299
+            new RispostaEmailAssegnatario(
300
+                $segnalazione->fresh(['azienda']),
301
+                $annotazione->fresh(['user']),
302
+            )
303
+        );
304
+    }
305
+
306
+    protected function createSegnalazioneFromMessage(Message $message, string $subject, ?string $fromAddress): Segnalazione
307
+    {
308
+        $titolo = $this->cleanSubjectAsTitle($subject);
309
+        $user = $fromAddress
310
+            ? User::soloUtenti()->whereRaw('LOWER(email) = ?', [strtolower($fromAddress)])->first()
311
+            : null;
312
+
313
+        $azienda = $fromAddress ? User::findAziendaByDominioEmail($fromAddress) : null;
314
+        if ($azienda && $user && (int) $user->azienda_id !== (int) $azienda->id) {
315
+            $user->azienda_id = $azienda->id;
316
+            $user->save();
317
+        }
318
+
319
+        $contrattoDefault = $azienda
320
+            ? ContrattoServizio::defaultImportEmailForAzienda((int) $azienda->id)
321
+            : null;
322
+
323
+        $configConferma = ConfigNotifiche::where('event', ConfigNotifiche::SEGNALAZIONE_EMAIL_CONFERMA_APERTA_DA)->first();
324
+        $confermaApertaDa = $configConferma && $configConferma->is_attivo;
325
+
326
+        $segnalazione = new Segnalazione();
327
+        $segnalazione->titolo = Str::limit($titolo, 250);
328
+        $segnalazione->stato = Config::getStatoSegnalazioneDefault();
329
+        $segnalazione->priorita = Config::getPrioritaSegnalazioneDefault();
330
+        $segnalazione->tipo = 'ordinaria';
331
+        $segnalazione->origine = 'email';
332
+        $segnalazione->user_id = $user?->id;
333
+        $segnalazione->azienda_id = $azienda?->id ?? $user?->azienda_id;
334
+        $segnalazione->contratto_servizio_id = $contrattoDefault?->id;
335
+        // Se mittente è utente noto: la notifica parte dal listener NuovaSegnalazione
336
+        $segnalazione->notificaApertaDa = $confermaApertaDa && $user !== null;
337
+        $segnalazione->save();
338
+
339
+        $fresh = $segnalazione->fresh(['user', 'azienda']);
340
+
341
+        // Mittente non registrato: invia comunque conferma all'indirizzo email
342
+        if ($confermaApertaDa && ! $user && $fromAddress) {
343
+            $fromName = $this->extractFromName($message) ?: $fromAddress;
344
+            Notification::route('mail', [$fromAddress => $fromName])
345
+                ->notify(new NuovaSegnalazioneUser($fresh, false, true));
346
+        }
347
+
348
+        return $fresh;
349
+    }
350
+
351
+    protected function createAnnotazioneFromMessage(
352
+        Segnalazione $segnalazione,
353
+        Message $message,
354
+        ?string $fromAddress,
355
+        bool $stripQuotedReply = false,
356
+    ): Annotazione {
357
+        $html = (string) ($message->getHTMLBody() ?: '');
358
+        $text = (string) ($message->getTextBody() ?: '');
359
+
360
+        if ($stripQuotedReply) {
361
+            $html = $this->stripQuotedHtml($html);
362
+            $text = $this->stripQuotedPlainText($text);
363
+        }
364
+
365
+        if (trim(strip_tags($html)) !== '') {
366
+            $body = $html;
367
+        } elseif (trim($text) !== '') {
368
+            $body = '<p>'.nl2br(e($text)).'</p>';
369
+        } else {
370
+            $body = '<p><em>(Messaggio senza testo)</em></p>';
371
+        }
372
+
373
+        $user = $fromAddress
374
+            ? User::soloUtenti()->whereRaw('LOWER(email) = ?', [strtolower($fromAddress)])->first()
375
+            : null;
376
+
377
+        $annotazione = new Annotazione();
378
+        $annotazione->segnalazione_id = $segnalazione->id;
379
+        $annotazione->user_id = $user?->id;
380
+        $annotazione->riferimento = $user ? null : ($this->extractFromName($message) ?: $fromAddress);
381
+        $annotazione->email = $fromAddress;
382
+        $annotazione->text = $body;
383
+        $annotazione->riservata = false;
384
+        $annotazione->save();
385
+
386
+        return $annotazione;
387
+    }
388
+
389
+    /**
390
+     * Estrae solo la risposta nuova da HTML (Gmail/Outlook/Thunderbird/blockquote).
391
+     */
392
+    protected function stripQuotedHtml(string $html): string
393
+    {
394
+        $html = trim($html);
395
+        if ($html === '') {
396
+            return '';
397
+        }
398
+
399
+        $previous = libxml_use_internal_errors(true);
400
+        $dom = new \DOMDocument('1.0', 'UTF-8');
401
+        $loaded = $dom->loadHTML(
402
+            '<?xml encoding="UTF-8">'.$html,
403
+            LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET
404
+        );
405
+        libxml_clear_errors();
406
+        libxml_use_internal_errors($previous);
407
+
408
+        if (! $loaded) {
409
+            return $this->stripQuotedHtmlRegexFallback($html);
410
+        }
411
+
412
+        $xpath = new \DOMXPath($dom);
413
+
414
+        // Contenitori tipici delle citazioni
415
+        $quoteQueries = [
416
+            '//blockquote',
417
+            '//div[contains(concat(" ", normalize-space(@class), " "), " gmail_quote ")]',
418
+            '//div[contains(concat(" ", normalize-space(@class), " "), " gmail_extra ")]',
419
+            '//div[contains(concat(" ", normalize-space(@class), " "), " yahoo_quoted ")]',
420
+            '//div[contains(concat(" ", normalize-space(@class), " "), " moz-cite-prefix ")]',
421
+            '//div[contains(@id, "divRplyFwdMsg")]',
422
+            '//div[@id="appendonsend"]',
423
+            '//hr[@id="stopSpelling"]',
424
+        ];
425
+
426
+        foreach ($quoteQueries as $query) {
427
+            $nodes = $xpath->query($query);
428
+            if (! $nodes) {
429
+                continue;
430
+            }
431
+            for ($i = $nodes->length - 1; $i >= 0; $i--) {
432
+                $node = $nodes->item($i);
433
+                if (! $node || ! $node->parentNode) {
434
+                    continue;
435
+                }
436
+                // Outlook: rimuovi anche i sibling successivi all'header reply
437
+                if ($node instanceof \DOMElement && (
438
+                    str_contains((string) $node->getAttribute('id'), 'divRplyFwdMsg')
439
+                    || $node->getAttribute('id') === 'appendonsend'
440
+                    || $node->getAttribute('id') === 'stopSpelling'
441
+                )) {
442
+                    while ($node->nextSibling) {
443
+                        $node->parentNode->removeChild($node->nextSibling);
444
+                    }
445
+                }
446
+                $node->parentNode->removeChild($node);
447
+            }
448
+        }
449
+
450
+        // Prefissi tipo "Il ... ha scritto:" / "On ... wrote:"
451
+        $prefixNodes = $xpath->query('//p|//div|//span');
452
+        if ($prefixNodes) {
453
+            for ($i = $prefixNodes->length - 1; $i >= 0; $i--) {
454
+                $node = $prefixNodes->item($i);
455
+                if (! $node instanceof \DOMElement) {
456
+                    continue;
457
+                }
458
+                $text = trim(preg_replace('/\s+/u', ' ', $node->textContent ?? '') ?? '');
459
+                if ($text === '' || mb_strlen($text) >= 220 || ! $this->looksLikeQuoteHeader($text)) {
460
+                    continue;
461
+                }
462
+                $nestedQuotes = $xpath->query('.//blockquote|.//div[contains(@class,"gmail_quote")]', $node);
463
+                if ($nestedQuotes && $nestedQuotes->length > 0) {
464
+                    continue;
465
+                }
466
+                $node->parentNode?->removeChild($node);
467
+            }
468
+        }
469
+
470
+        $body = $dom->getElementsByTagName('body')->item(0);
471
+        $root = $body ?: $dom->documentElement;
472
+        if (! $root) {
473
+            return $this->stripQuotedHtmlRegexFallback($html);
474
+        }
475
+
476
+        $out = '';
477
+        foreach ($root->childNodes as $child) {
478
+            $out .= $dom->saveHTML($child);
479
+        }
480
+
481
+        $out = trim($out);
482
+        if (trim(strip_tags($out)) === '') {
483
+            return $this->stripQuotedHtmlRegexFallback($html);
484
+        }
485
+
486
+        return $out;
487
+    }
488
+
489
+    protected function stripQuotedHtmlRegexFallback(string $html): string
490
+    {
491
+        $html = preg_replace('/<blockquote\b[^>]*>.*?<\/blockquote>/is', '', $html) ?? $html;
492
+        $html = preg_replace('/<div[^>]*class="[^"]*gmail_quote[^"]*"[^>]*>.*?<\/div>/is', '', $html) ?? $html;
493
+        $html = preg_replace('/<div[^>]*class="[^"]*gmail_extra[^"]*"[^>]*>.*?<\/div>/is', '', $html) ?? $html;
494
+
495
+        return trim($html);
496
+    }
497
+
498
+    /**
499
+     * Estrae solo la risposta da testo piano (>, On wrote, Il ha scritto, Original Message).
500
+     */
501
+    protected function stripQuotedPlainText(string $text): string
502
+    {
503
+        $text = str_replace(["\r\n", "\r"], "\n", $text);
504
+        $lines = explode("\n", $text);
505
+        $kept = [];
506
+
507
+        $lineCount = count($lines);
508
+        for ($idx = 0; $idx < $lineCount; $idx++) {
509
+            $line = $lines[$idx];
510
+            $trimmed = rtrim($line);
511
+
512
+            // Separatore messaggio originale
513
+            if (preg_match('/^-{2,}\s*(Original Message|Messaggio originale)\s*-{2,}/i', $trimmed)) {
514
+                break;
515
+            }
516
+
517
+            // Outlook: riga di underscore seguita da header From/Sent
518
+            if (preg_match('/^_{5,}\s*$/', $trimmed)) {
519
+                $next = '';
520
+                for ($j = $idx + 1; $j < $lineCount; $j++) {
521
+                    if (trim($lines[$j]) !== '') {
522
+                        $next = trim($lines[$j]);
523
+                        break;
524
+                    }
525
+                }
526
+                if ($next !== '' && preg_match('/^(From|Da|Sent|Inviato|Date|Data|To|A|Subject|Oggetto)\s*:/i', $next)) {
527
+                    break;
528
+                }
529
+            }
530
+
531
+            // Header citazione su una riga
532
+            if ($this->looksLikeQuoteHeader($trimmed)) {
533
+                break;
534
+            }
535
+
536
+            // Linee quotate stile mail >
537
+            if (preg_match('/^>+\s?/', $trimmed)) {
538
+                if (count(array_filter($kept, fn ($l) => trim($l) !== '')) > 0) {
539
+                    break;
540
+                }
541
+                continue;
542
+            }
543
+
544
+            $kept[] = $line;
545
+        }
546
+
547
+        return trim(implode("\n", $kept));
548
+    }
549
+
550
+    protected function looksLikeQuoteHeader(string $text): bool
551
+    {
552
+        $text = trim($text);
553
+        if ($text === '') {
554
+            return false;
555
+        }
556
+
557
+        // EN / IT / FR comuni
558
+        if (preg_match('/^(On|Il|Le|Am|El)\s+.+\s+(wrote|ha scritto|a écrit|schrieb)\s*:?\s*$/iu', $text)) {
559
+            return true;
560
+        }
561
+        if (preg_match('/^From:\s.+/i', $text) && preg_match('/(Sent|Date|To|Subject):/i', $text)) {
562
+            return true;
563
+        }
564
+        if (preg_match('/^-{2,}\s*(Original Message|Messaggio originale)/i', $text)) {
565
+            return true;
566
+        }
567
+
568
+        return false;
569
+    }
570
+
571
+    protected function storeAttachments(Segnalazione $segnalazione, Annotazione $annotazione, Message $message): void
572
+    {
573
+        $attachments = $message->getAttachments();
574
+        if ($attachments->isEmpty()) {
575
+            return;
576
+        }
577
+
578
+        $html = (string) ($message->getHTMLBody() ?: '');
579
+        $mainHtml = $this->stripSignatureHtml($this->stripQuotedHtml($html));
580
+        $mainCids = $this->extractCidMap($mainHtml);
581
+
582
+        foreach ($attachments as $attachment) {
583
+            /** @var Attachment $attachment */
584
+            if ($this->shouldSkipSignatureImage($attachment, $html, $mainCids)) {
585
+                continue;
586
+            }
587
+
588
+            $originalName = $attachment->getName() ?: ('allegato_'.Str::random(8));
589
+            $safeName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $originalName) ?: 'allegato.bin';
590
+            $filename = time().'_'.Str::random(6).'_'.$safeName;
591
+            $path = $segnalazione->id.'/'.$filename;
592
+
593
+            Storage::disk('allegatiAnnotazioni')->put($path, $attachment->getContent());
594
+
595
+            $allegato = new AllegatoAnnotazione();
596
+            $allegato->annotazione()->associate($annotazione);
597
+            $allegato->nome = $originalName;
598
+            $allegato->path_file = $path;
599
+            $allegato->save();
600
+        }
601
+    }
602
+
603
+    /**
604
+     * Salta loghi/icone di firma (e immagini solo nella citazione), non gli allegati veri.
605
+     *
606
+     * @param  array<string, true>  $mainCids
607
+     */
608
+    protected function shouldSkipSignatureImage(Attachment $attachment, string $html, array $mainCids): bool
609
+    {
610
+        if (! $this->isImageAttachment($attachment)) {
611
+            return false;
612
+        }
613
+
614
+        $disposition = strtolower(trim((string) $attachment->getDisposition()));
615
+        $id = $this->normalizeCid((string) $attachment->getId());
616
+        $referencedInHtml = $id !== '' && $this->htmlHasCid($html, $id);
617
+        $referencedInMain = $id !== '' && $this->cidMapHas($mainCids, $id);
618
+
619
+        // CID usato solo in firma / messaggio citato
620
+        if ($referencedInHtml && ! $referencedInMain) {
621
+            return true;
622
+        }
623
+
624
+        // Immagine embedded (inline) che non fa parte del corpo utile
625
+        if ($disposition === 'inline' && ! $referencedInMain) {
626
+            return true;
627
+        }
628
+
629
+        return false;
630
+    }
631
+
632
+    protected function isImageAttachment(Attachment $attachment): bool
633
+    {
634
+        $type = strtolower((string) $attachment->getType());
635
+        $contentType = strtolower((string) $attachment->getContentType());
636
+        $name = (string) ($attachment->getName() ?: $attachment->filename ?: '');
637
+
638
+        if ($type === 'image' || str_starts_with($contentType, 'image/')) {
639
+            return true;
640
+        }
641
+
642
+        return (bool) preg_match('/\.(png|jpe?g|gif|bmp|webp|svg|ico|tiff?)$/i', $name);
643
+    }
644
+
645
+    /**
646
+     * @return array<string, true>
647
+     */
648
+    protected function extractCidMap(string $html): array
649
+    {
650
+        $map = [];
651
+        if ($html === '') {
652
+            return $map;
653
+        }
654
+        if (preg_match_all('/cid:([^"\'\s>]+)/i', $html, $matches)) {
655
+            foreach ($matches[1] as $raw) {
656
+                foreach ($this->cidKeys($raw) as $key) {
657
+                    $map[$key] = true;
658
+                }
659
+            }
660
+        }
661
+
662
+        return $map;
663
+    }
664
+
665
+    protected function htmlHasCid(string $html, string $id): bool
666
+    {
667
+        return $this->cidMapHas($this->extractCidMap($html), $id);
668
+    }
669
+
670
+    /**
671
+     * @param  array<string, true>  $map
672
+     */
673
+    protected function cidMapHas(array $map, string $id): bool
674
+    {
675
+        foreach ($this->cidKeys($id) as $key) {
676
+            if (isset($map[$key])) {
677
+                return true;
678
+            }
679
+        }
680
+
681
+        return false;
682
+    }
683
+
684
+    /**
685
+     * @return array<int, string>
686
+     */
687
+    protected function cidKeys(string $id): array
688
+    {
689
+        $id = $this->normalizeCid($id);
690
+        if ($id === '') {
691
+            return [];
692
+        }
693
+        $keys = [$id];
694
+        if (str_contains($id, '@')) {
695
+            $keys[] = strstr($id, '@', true) ?: $id;
696
+        }
697
+
698
+        return array_values(array_unique(array_filter($keys)));
699
+    }
700
+
701
+    protected function normalizeCid(string $id): string
702
+    {
703
+        return strtolower(trim($id, " \t\n\r\0\x0B<>\"'"));
704
+    }
705
+
706
+    /**
707
+     * Rimuove blocchi firma (Gmail/Outlook/Apple/Thunderbird) dal HTML.
708
+     */
709
+    protected function stripSignatureHtml(string $html): string
710
+    {
711
+        $html = trim($html);
712
+        if ($html === '') {
713
+            return '';
714
+        }
715
+
716
+        $previous = libxml_use_internal_errors(true);
717
+        $dom = new \DOMDocument('1.0', 'UTF-8');
718
+        $loaded = $dom->loadHTML(
719
+            '<?xml encoding="UTF-8">'.$html,
720
+            LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET
721
+        );
722
+        libxml_clear_errors();
723
+        libxml_use_internal_errors($previous);
724
+
725
+        if (! $loaded) {
726
+            return $this->stripSignatureHtmlRegexFallback($html);
727
+        }
728
+
729
+        $xpath = new \DOMXPath($dom);
730
+        $queries = [
731
+            '//div[contains(concat(" ", normalize-space(@class), " "), " gmail_signature ")]',
732
+            '//div[@data-smartmail="gmail_signature"]',
733
+            '//div[contains(@class, "gmail_signature")]',
734
+            '//div[contains(concat(" ", normalize-space(@class), " "), " moz-signature ")]',
735
+            '//*[@id="Signature" or @id="signature" or @id="AppleMailSignature" or @id="apple-mail-signature"]',
736
+            '//div[contains(@id, "Signature")]',
737
+            '//div[contains(@id, "signature")]',
738
+        ];
739
+
740
+        foreach ($queries as $query) {
741
+            $nodes = $xpath->query($query);
742
+            if (! $nodes) {
743
+                continue;
744
+            }
745
+            for ($i = $nodes->length - 1; $i >= 0; $i--) {
746
+                $node = $nodes->item($i);
747
+                $node?->parentNode?->removeChild($node);
748
+            }
749
+        }
750
+
751
+        $body = $dom->getElementsByTagName('body')->item(0);
752
+        $root = $body ?: $dom->documentElement;
753
+        if (! $root) {
754
+            return $this->stripSignatureHtmlRegexFallback($html);
755
+        }
756
+
757
+        $out = '';
758
+        foreach ($root->childNodes as $child) {
759
+            $out .= $dom->saveHTML($child);
760
+        }
761
+
762
+        $out = trim($out);
763
+
764
+        return $out !== '' ? $out : $this->stripSignatureHtmlRegexFallback($html);
765
+    }
766
+
767
+    protected function stripSignatureHtmlRegexFallback(string $html): string
768
+    {
769
+        $html = preg_replace('/<div[^>]*class="[^"]*gmail_signature[^"]*"[^>]*>.*?<\/div>/is', '', $html) ?? $html;
770
+        $html = preg_replace('/<div[^>]*id="[^"]*[Ss]ignature[^"]*"[^>]*>.*?<\/div>/is', '', $html) ?? $html;
771
+
772
+        return trim($html);
773
+    }
774
+
775
+    protected function finalizeMessage(Message $message, CasellaImap $casella, string $processedFolderName): void
776
+    {
777
+        if ($casella->marca_come_letta) {
778
+            $message->setFlag('Seen');
779
+        }
780
+
781
+        if ($casella->sposta_processate && $processedFolderName !== '') {
782
+            $message->move($processedFolderName);
783
+        }
784
+    }
785
+
786
+    protected function extractSegnalazioneIdFromSubject(string $subject): ?int
787
+    {
788
+        if (preg_match('/\[#\s*(\d+)\s*\]/', $subject, $m)) {
789
+            return (int) $m[1];
790
+        }
791
+
792
+        return null;
793
+    }
794
+
795
+    protected function cleanSubjectAsTitle(string $subject): string
796
+    {
797
+        $title = preg_replace('/^(?:(?:Re|RE|R|Rif|RIF|Fw|FW|Fwd|FWD)\s*:\s*)+/u', '', $subject) ?? $subject;
798
+        $title = preg_replace('/\[#\s*\d+\s*\]\s*/', '', $title) ?? $title;
799
+        $title = trim($title);
800
+
801
+        return $title !== '' ? $title : 'Email ricevuta';
802
+    }
803
+
804
+    protected function extractFromEmail(Message $message): ?string
805
+    {
806
+        $from = $message->getFrom();
807
+        if (! $from || $from->count() === 0) {
808
+            return null;
809
+        }
810
+        $first = $from->first();
811
+
812
+        return $first && ! empty($first->mail) ? (string) $first->mail : null;
813
+    }
814
+
815
+    protected function extractFromName(Message $message): ?string
816
+    {
817
+        $from = $message->getFrom();
818
+        if (! $from || $from->count() === 0) {
819
+            return null;
820
+        }
821
+        $first = $from->first();
822
+        if (! $first) {
823
+            return null;
824
+        }
825
+
826
+        $personal = trim((string) ($first->personal ?? ''));
827
+
828
+        return $personal !== '' ? $personal : ($first->mail ? (string) $first->mail : null);
829
+    }
830
+
831
+    protected function alreadyImported(Segnalazione $segnalazione, string $messageId): bool
832
+    {
833
+        $meta = is_array($segnalazione->meta) ? $segnalazione->meta : [];
834
+        $ids = $meta['imported_email_message_ids'] ?? [];
835
+
836
+        return is_array($ids) && in_array($messageId, $ids, true);
837
+    }
838
+
839
+    protected function rememberImportedMessageId(Segnalazione $segnalazione, string $messageId): void
840
+    {
841
+        $meta = is_array($segnalazione->meta) ? $segnalazione->meta : [];
842
+        $ids = $meta['imported_email_message_ids'] ?? [];
843
+        if (! is_array($ids)) {
844
+            $ids = [];
845
+        }
846
+        $ids[] = $messageId;
847
+        $meta['imported_email_message_ids'] = array_values(array_unique($ids));
848
+        $segnalazione->meta = $meta;
849
+        $segnalazione->save();
850
+    }
851
+}

+ 121
- 0
app/Support/IsolaHtml.php 查看文件

@@ -0,0 +1,121 @@
1
+<?php
2
+
3
+namespace App\Support;
4
+
5
+/**
6
+ * Sanifica HTML di annotazioni/email per l'inserimento in pagina:
7
+ * chiude i tag, toglie script/style/document wrappers e limita gli attributi pericolosi.
8
+ */
9
+class IsolaHtml
10
+{
11
+    /** @var list<string> */
12
+    private const REMOVE_TAGS = [
13
+        'script', 'style', 'link', 'meta', 'noscript', 'iframe', 'object', 'embed',
14
+        'form', 'input', 'button', 'textarea', 'select', 'base', 'frame', 'frameset',
15
+        'applet', 'svg', 'canvas', 'video', 'audio', 'source', 'track', 'template',
16
+        'xml',
17
+    ];
18
+
19
+    public static function fragment(?string $html): string
20
+    {
21
+        $html = trim((string) $html);
22
+        if ($html === '') {
23
+            return '';
24
+        }
25
+
26
+        $html = preg_replace('/<!--\[if[\s\S]*?<!\[endif\]-->/i', '', $html) ?? $html;
27
+        $html = preg_replace('/<!--[\s\S]*?-->/', '', $html) ?? $html;
28
+        $html = preg_replace('/<\/?[ovw]:[^>]*>/i', '', $html) ?? $html;
29
+
30
+        $previous = libxml_use_internal_errors(true);
31
+        $dom = new \DOMDocument('1.0', 'UTF-8');
32
+        $loaded = $dom->loadHTML(
33
+            '<?xml encoding="UTF-8"><!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body>'.$html.'</body></html>',
34
+            LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET | LIBXML_HTML_NODEFDTD | LIBXML_PARSEHUGE
35
+        );
36
+        libxml_clear_errors();
37
+        libxml_use_internal_errors($previous);
38
+
39
+        if (! $loaded) {
40
+            return nl2br(e(strip_tags($html)));
41
+        }
42
+
43
+        foreach (self::REMOVE_TAGS as $tag) {
44
+            $nodes = $dom->getElementsByTagName($tag);
45
+            for ($i = $nodes->length - 1; $i >= 0; $i--) {
46
+                $node = $nodes->item($i);
47
+                if ($node && $node->parentNode) {
48
+                    $node->parentNode->removeChild($node);
49
+                }
50
+            }
51
+        }
52
+
53
+        $xpath = new \DOMXPath($dom);
54
+        $attributes = $xpath->query('//@*');
55
+        if ($attributes) {
56
+            for ($i = $attributes->length - 1; $i >= 0; $i--) {
57
+                /** @var \DOMAttr $attr */
58
+                $attr = $attributes->item($i);
59
+                $el = $attr->ownerElement;
60
+                if (! $el) {
61
+                    continue;
62
+                }
63
+                $name = strtolower($attr->nodeName);
64
+                $value = trim((string) $attr->nodeValue);
65
+
66
+                if (str_starts_with($name, 'on') || in_array($name, ['srcset', 'srcdoc', 'formaction', 'xlink:href'], true)) {
67
+                    $el->removeAttributeNode($attr);
68
+                    continue;
69
+                }
70
+
71
+                if (in_array($name, ['href', 'src', 'action', 'cite', 'poster'], true)) {
72
+                    if (self::isDangerousUrl($value, $name, $el->tagName)) {
73
+                        $el->removeAttributeNode($attr);
74
+                        continue;
75
+                    }
76
+                }
77
+
78
+                if ($name === 'style') {
79
+                    $clean = preg_replace('/(?:^|;)\s*(?:position|z-index|top|left|right|bottom)\s*:[^;]*/i', '', $value) ?? '';
80
+                    $clean = preg_replace('/expression\s*\([^)]*\)/i', '', $clean) ?? $clean;
81
+                    $clean = trim($clean, " \t\n\r\0\x0B;");
82
+                    if ($clean === '') {
83
+                        $el->removeAttribute('style');
84
+                    } else {
85
+                        $el->setAttribute('style', $clean);
86
+                    }
87
+                }
88
+            }
89
+        }
90
+
91
+        $body = $dom->getElementsByTagName('body')->item(0);
92
+        if (! $body) {
93
+            return '';
94
+        }
95
+
96
+        $out = '';
97
+        foreach ($body->childNodes as $child) {
98
+            $out .= $dom->saveHTML($child);
99
+        }
100
+
101
+        return trim($out);
102
+    }
103
+
104
+    private static function isDangerousUrl(string $value, string $attrName, string $tagName): bool
105
+    {
106
+        if (preg_match('/^\s*(javascript|vbscript):/i', $value)) {
107
+            return true;
108
+        }
109
+
110
+        if (! preg_match('/^\s*data:/i', $value)) {
111
+            return false;
112
+        }
113
+
114
+        // img inline da email (data:image/png;base64,...), non SVG (può contenere script)
115
+        if ($attrName === 'src' && strtolower($tagName) === 'img') {
116
+            return ! preg_match('/^\s*data:image\/(png|jpe?g|gif|webp|bmp|x-icon)(;|,)/i', $value);
117
+        }
118
+
119
+        return true;
120
+    }
121
+}

+ 1
- 0
composer.json 查看文件

@@ -25,6 +25,7 @@
25 25
     "propaganistas/laravel-phone": "^6.0",
26 26
     "spatie/laravel-permission": "^6.21",
27 27
     "stripe/stripe-php": "^21.1",
28
+    "webklex/php-imap": "^6.2",
28 29
     "yajra/laravel-datatables": "^12.0",
29 30
     "yajra/laravel-datatables-editor": "^12.0"
30 31
   },

+ 82
- 1
composer.lock 查看文件

@@ -4,7 +4,7 @@
4 4
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
5 5
         "This file is @generated automatically"
6 6
     ],
7
-    "content-hash": "b77ad04f1b11bf7b4f85c6a08cd4001d",
7
+    "content-hash": "3532f68b204cd86982b0b4fd6c4252a7",
8 8
     "packages": [
9 9
         {
10 10
             "name": "bacon/bacon-qr-code",
@@ -8678,6 +8678,87 @@
8678 8678
             ],
8679 8679
             "time": "2024-11-21T01:49:47+00:00"
8680 8680
         },
8681
+        {
8682
+            "name": "webklex/php-imap",
8683
+            "version": "6.2.0",
8684
+            "source": {
8685
+                "type": "git",
8686
+                "url": "https://github.com/Webklex/php-imap.git",
8687
+                "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05"
8688
+            },
8689
+            "dist": {
8690
+                "type": "zip",
8691
+                "url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
8692
+                "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05",
8693
+                "shasum": ""
8694
+            },
8695
+            "require": {
8696
+                "ext-fileinfo": "*",
8697
+                "ext-iconv": "*",
8698
+                "ext-json": "*",
8699
+                "ext-libxml": "*",
8700
+                "ext-mbstring": "*",
8701
+                "ext-openssl": "*",
8702
+                "ext-zip": "*",
8703
+                "illuminate/pagination": ">=5.0.0",
8704
+                "nesbot/carbon": "^2.62.1|^3.2.4",
8705
+                "php": "^8.0.2",
8706
+                "symfony/http-foundation": ">=2.8.0"
8707
+            },
8708
+            "require-dev": {
8709
+                "phpunit/phpunit": "^9.5.10"
8710
+            },
8711
+            "suggest": {
8712
+                "symfony/mime": "Recomended for better extension support",
8713
+                "symfony/var-dumper": "Usefull tool for debugging"
8714
+            },
8715
+            "type": "library",
8716
+            "extra": {
8717
+                "branch-alias": {
8718
+                    "dev-master": "6.0-dev"
8719
+                }
8720
+            },
8721
+            "autoload": {
8722
+                "psr-4": {
8723
+                    "Webklex\\PHPIMAP\\": "src"
8724
+                }
8725
+            },
8726
+            "notification-url": "https://packagist.org/downloads/",
8727
+            "license": [
8728
+                "MIT"
8729
+            ],
8730
+            "authors": [
8731
+                {
8732
+                    "name": "Malte Goldenbaum",
8733
+                    "email": "github@webklex.com",
8734
+                    "role": "Developer"
8735
+                }
8736
+            ],
8737
+            "description": "PHP IMAP client",
8738
+            "homepage": "https://github.com/webklex/php-imap",
8739
+            "keywords": [
8740
+                "imap",
8741
+                "mail",
8742
+                "php-imap",
8743
+                "pop3",
8744
+                "webklex"
8745
+            ],
8746
+            "support": {
8747
+                "issues": "https://github.com/Webklex/php-imap/issues",
8748
+                "source": "https://github.com/Webklex/php-imap/tree/6.2.0"
8749
+            },
8750
+            "funding": [
8751
+                {
8752
+                    "url": "https://www.buymeacoffee.com/webklex",
8753
+                    "type": "custom"
8754
+                },
8755
+                {
8756
+                    "url": "https://ko-fi.com/webklex",
8757
+                    "type": "ko_fi"
8758
+                }
8759
+            ],
8760
+            "time": "2025-04-25T06:02:37+00:00"
8761
+        },
8681 8762
         {
8682 8763
             "name": "yajra/laravel-datatables",
8683 8764
             "version": "v12.0.0",

+ 115
- 0
config/redmine.php 查看文件

@@ -0,0 +1,115 @@
1
+<?php
2
+
3
+return [
4
+
5
+    'url' => rtrim((string) env('REDMINE_URL', ''), '/'),
6
+
7
+    'api_key' => env('REDMINE_API_KEY'),
8
+
9
+    'verify_ssl' => env('REDMINE_VERIFY_SSL', true),
10
+
11
+    'timeout' => (int) env('REDMINE_TIMEOUT', 60),
12
+
13
+    'download_timeout' => (int) env('REDMINE_DOWNLOAD_TIMEOUT', 180),
14
+
15
+    /*
16
+    | Tariffa oraria (€) usata per calcolare il costo dai tempi
17
+    | quando Redmine non espone un costo esplicito (plugin / custom field).
18
+    */
19
+    'hourly_rate' => env('REDMINE_HOURLY_RATE'),
20
+
21
+    /*
22
+    | Nomi custom field Redmine interpretati come costo (case-insensitive).
23
+    */
24
+    'cost_custom_fields' => [
25
+        'costo',
26
+        'cost',
27
+        'costs',
28
+        'spesa',
29
+        'spese',
30
+        'expense',
31
+        'expenses',
32
+        'importo',
33
+    ],
34
+
35
+    /*
36
+    | Mapping nome stato Redmine (lowercase) → value ConfiguraSegnalazione (gruppo stato).
37
+    | I chiusi non mappati cadono su "chiusa"; gli aperti su "nuova".
38
+    */
39
+    'status_map' => [
40
+        'new' => 'nuova',
41
+        'nuovo' => 'nuova',
42
+        'nuova' => 'nuova',
43
+        'in progress' => 'presa_in_carico',
44
+        'in lavorazione' => 'presa_in_carico',
45
+        'assigned' => 'presa_in_carico',
46
+        'presa in carico' => 'presa_in_carico',
47
+        'feedback' => 'in_attesa_cliente',
48
+        'in attesa' => 'in_attesa_cliente',
49
+        'in attesa cliente' => 'in_attesa_cliente',
50
+        'in attesa del cliente' => 'in_attesa_cliente',
51
+        'in attesa di risposta' => 'in_attesa_cliente',
52
+        'waiting for customer' => 'in_attesa_cliente',
53
+        'resolved' => 'risolta',
54
+        'risolta' => 'risolta',
55
+        'closed' => 'chiusa',
56
+        'chiuso' => 'chiusa',
57
+        'chiusa' => 'chiusa',
58
+        'rejected' => 'chiusa',
59
+        'rifiutato' => 'chiusa',
60
+        'planned' => 'pianificata',
61
+        'pianificata' => 'pianificata',
62
+        'to plan' => 'da_pianificare',
63
+        'da pianificare' => 'da_pianificare',
64
+    ],
65
+
66
+    /*
67
+    | Mapping nome priorità Redmine → value ConfiguraSegnalazione (gruppo Priorità).
68
+    */
69
+    'priority_map' => [
70
+        'low' => 'bassa',
71
+        'bassa' => 'bassa',
72
+        'lowest' => 'bassa',
73
+        'normal' => 'normale',
74
+        'normale' => 'normale',
75
+        'high' => 'urgente',
76
+        'alta' => 'urgente',
77
+        'urgent' => 'urgente',
78
+        'urgente' => 'urgente',
79
+        'immediate' => 'emergenza_immediata',
80
+        'immediatezza' => 'emergenza_immediata',
81
+        'emergenza' => 'emergenza_immediata',
82
+        'emergenza immediata' => 'emergenza_immediata',
83
+    ],
84
+
85
+    /*
86
+    | Mapping nome tracker Redmine → value ConfiguraSegnalazione (gruppo Tipo).
87
+    */
88
+    'tracker_map' => [
89
+        'bug' => 'manutenzione',
90
+        'defect' => 'manutenzione',
91
+        'difetto' => 'manutenzione',
92
+        'support' => 'avviso',
93
+        'supporto' => 'avviso',
94
+        'feature' => 'progetto',
95
+        'funzionalità' => 'progetto',
96
+        'funzionalita' => 'progetto',
97
+        'task' => 'progetto',
98
+        'attività' => 'progetto',
99
+        'attivita' => 'progetto',
100
+        'maintenance' => 'manutenzione',
101
+        'manutenzione' => 'manutenzione',
102
+        'project' => 'progetto',
103
+        'progetto' => 'progetto',
104
+        'security' => 'sicurezza',
105
+        'sicurezza' => 'sicurezza',
106
+    ],
107
+
108
+    /*
109
+    | Override puntuali Redmine user id → user id locale.
110
+    */
111
+    'user_map' => [
112
+        // 12 => 3,
113
+    ],
114
+
115
+];

+ 42
- 0
database/migrations/2026_08_10_235452_skipper_migrations_2026081023545207.php 查看文件

@@ -0,0 +1,42 @@
1
+<?php
2
+/* 
3
+ * Migrations generated by: Skipper (http://www.skipper18.com)
4
+ * Migration id: 01bba22b-e663-4279-bf3f-f2d3ff5b54a3
5
+ * Migration datetime: 2026-08-10 23:54:52.079861
6
+ */ 
7
+
8
+use Illuminate\Support\Facades\Schema;
9
+use Illuminate\Database\Schema\Blueprint;
10
+use Illuminate\Database\Migrations\Migration;
11
+
12
+class SkipperMigrations2026081023545207 extends Migration
13
+{
14
+    /**
15
+     * Run the migrations.
16
+     *
17
+     * @return void
18
+     */
19
+    public function up()
20
+    {
21
+        Schema::table('users', function (Blueprint $table) {
22
+            $table->string('dominio_email')->nullable(true)->after('note');
23
+        });
24
+        Schema::table('contratto_servizio', function (Blueprint $table) {
25
+            $table->boolean('default_import_email')->nullable(true)->default(0)->after('attivo');
26
+        });
27
+    }
28
+    /**
29
+     * Reverse the migrations.
30
+     *
31
+     * @return void
32
+     */
33
+    public function down()
34
+    {
35
+        Schema::table('contratto_servizio', function (Blueprint $table) {
36
+            $table->dropColumn('default_import_email');
37
+        });
38
+        Schema::table('users', function (Blueprint $table) {
39
+            $table->dropColumn('dominio_email');
40
+        });
41
+    }
42
+}

+ 76
- 0
database/migrations/2026_08_21_132551_skipper_migrations_2026082113255147.php 查看文件

@@ -0,0 +1,76 @@
1
+<?php
2
+/* 
3
+ * Migrations generated by: Skipper (http://www.skipper18.com)
4
+ * Migration id: 42581b1c-7407-4cb6-acd9-9bb4cb918b0a
5
+ * Migration datetime: 2026-08-21 13:25:51.472661
6
+ */ 
7
+
8
+use Illuminate\Support\Facades\Schema;
9
+use Illuminate\Database\Schema\Blueprint;
10
+use Illuminate\Database\Migrations\Migration;
11
+
12
+class SkipperMigrations2026082113255147 extends Migration
13
+{
14
+    /**
15
+     * Run the migrations.
16
+     *
17
+     * @return void
18
+     */
19
+    public function up()
20
+    {
21
+        Schema::create('casella_imap', function (Blueprint $table) {
22
+            $table->bigInteger('id')->autoIncrement()->unsigned();
23
+            $table->string('nome')->nullable(true);
24
+            $table->string('host')->nullable(true);
25
+            $table->smallInteger('porta')->nullable(true)->default(993)->unsigned();
26
+            $table->string('encryption')->nullable(true)->default('ssl');
27
+            $table->boolean('valida_certificato')->nullable(true)->default(1);
28
+            $table->string('username')->nullable(true);
29
+            $table->string('password')->nullable(true);
30
+            $table->string('protocollo')->nullable(true)->default('imap');
31
+            $table->string('cartella')->nullable(true)->default('INBOX');
32
+            $table->string('cartella_processate')->nullable(true);
33
+            $table->boolean('solo_non_lette')->nullable(true)->default(1);
34
+            $table->boolean('marca_come_letta')->nullable(true)->default(1);
35
+            $table->boolean('sposta_processate')->nullable(true)->default(1);
36
+            $table->boolean('attivo')->nullable(true)->default(1);
37
+            $table->timestamp('ultimo_controllo_at')->nullable(true);
38
+            $table->text('ultimo_errore')->nullable(true);
39
+            $table->timestamp('created_at')->nullable(true);
40
+            $table->timestamp('updated_at')->nullable(true);
41
+        });
42
+        Schema::table('contratto_servizio_riga', function (Blueprint $table) {
43
+            $table->string('sconto_tipo')->nullable(true)->after('prezzo_unitario');
44
+        });
45
+        Schema::table('contratto_servizio_riga', function (Blueprint $table) {
46
+            $table->decimal('sconto_valore', 10, 2)->nullable(true)->after('sconto_tipo');
47
+        });
48
+        Schema::table('contratto_servizio_riga', function (Blueprint $table) {
49
+            $table->date('data_inizio')->nullable(true)->after('periodo');
50
+        });
51
+        Schema::table('contratto_servizio_riga', function (Blueprint $table) {
52
+            $table->date('data_fine')->nullable(true)->after('data_inizio');
53
+        });
54
+    }
55
+    /**
56
+     * Reverse the migrations.
57
+     *
58
+     * @return void
59
+     */
60
+    public function down()
61
+    {
62
+        Schema::table('contratto_servizio_riga', function (Blueprint $table) {
63
+            $table->dropColumn('data_fine');
64
+        });
65
+        Schema::table('contratto_servizio_riga', function (Blueprint $table) {
66
+            $table->dropColumn('data_inizio');
67
+        });
68
+        Schema::table('contratto_servizio_riga', function (Blueprint $table) {
69
+            $table->dropColumn('sconto_valore');
70
+        });
71
+        Schema::table('contratto_servizio_riga', function (Blueprint $table) {
72
+            $table->dropColumn('sconto_tipo');
73
+        });
74
+        Schema::dropIfExists('casella_imap');
75
+    }
76
+}

+ 36
- 0
database/migrations/2026_08_21_223625_skipper_migrations_2026082122362547.php 查看文件

@@ -0,0 +1,36 @@
1
+<?php
2
+/* 
3
+ * Migrations generated by: Skipper (http://www.skipper18.com)
4
+ * Migration id: 8e0d5a3a-8eda-4844-b920-94f9725ffb7d
5
+ * Migration datetime: 2026-08-21 22:36:25.479501
6
+ */ 
7
+
8
+use Illuminate\Support\Facades\Schema;
9
+use Illuminate\Database\Schema\Blueprint;
10
+use Illuminate\Database\Migrations\Migration;
11
+
12
+class SkipperMigrations2026082122362547 extends Migration
13
+{
14
+    /**
15
+     * Run the migrations.
16
+     *
17
+     * @return void
18
+     */
19
+    public function up()
20
+    {
21
+        Schema::table('annotazione', function (Blueprint $table) {
22
+            $table->longText('text')->nullable(true)->after('user_id')->change();
23
+        });
24
+    }
25
+    /**
26
+     * Reverse the migrations.
27
+     *
28
+     * @return void
29
+     */
30
+    public function down()
31
+    {
32
+        Schema::table('annotazione', function (Blueprint $table) {
33
+            $table->text('text')->nullable(true)->after('user_id')->change();
34
+        });
35
+    }
36
+}

+ 36
- 0
database/migrations/2026_08_21_230149_skipper_migrations_2026082123014912.php 查看文件

@@ -0,0 +1,36 @@
1
+<?php
2
+/* 
3
+ * Migrations generated by: Skipper (http://www.skipper18.com)
4
+ * Migration id: 7f3f4ead-c952-4a36-adc9-28e57f9d6798
5
+ * Migration datetime: 2026-08-21 23:01:49.125093
6
+ */ 
7
+
8
+use Illuminate\Support\Facades\Schema;
9
+use Illuminate\Database\Schema\Blueprint;
10
+use Illuminate\Database\Migrations\Migration;
11
+
12
+class SkipperMigrations2026082123014912 extends Migration
13
+{
14
+    /**
15
+     * Run the migrations.
16
+     *
17
+     * @return void
18
+     */
19
+    public function up()
20
+    {
21
+        Schema::table('users', function (Blueprint $table) {
22
+            $table->boolean('responsabile_azienda')->nullable(true)->default(0)->after('comune_id');
23
+        });
24
+    }
25
+    /**
26
+     * Reverse the migrations.
27
+     *
28
+     * @return void
29
+     */
30
+    public function down()
31
+    {
32
+        Schema::table('users', function (Blueprint $table) {
33
+            $table->dropColumn('responsabile_azienda');
34
+        });
35
+    }
36
+}

+ 164
- 0
database/migrations/2026_08_21_234348_skipper_migrations_2026082123434842.php 查看文件

@@ -0,0 +1,164 @@
1
+<?php
2
+/* 
3
+ * Migrations generated by: Skipper (http://www.skipper18.com)
4
+ * Migration id: 524213bb-470e-481b-a960-f6096b7d4436
5
+ * Migration datetime: 2026-08-21 23:43:48.425051
6
+ */ 
7
+
8
+use Illuminate\Support\Facades\Schema;
9
+use Illuminate\Database\Schema\Blueprint;
10
+use Illuminate\Database\Migrations\Migration;
11
+
12
+class SkipperMigrations2026082123434842 extends Migration
13
+{
14
+    /**
15
+     * Run the migrations.
16
+     *
17
+     * @return void
18
+     */
19
+    public function up()
20
+    {
21
+        Schema::create('fattura', function (Blueprint $table) {
22
+            $table->bigInteger('id')->autoIncrement()->unsigned();
23
+            $table->bigInteger('user_id')->nullable(true)->unsigned();
24
+            $table->bigInteger('created_by_id')->nullable(true)->unsigned();
25
+            $table->integer('numero')->nullable(true);
26
+            $table->string('stato')->nullable(true);
27
+            $table->date('data')->nullable(true);
28
+            $table->date('data_scadenza')->nullable(true);
29
+            $table->date('periodo_da')->nullable(true);
30
+            $table->date('periodo_a')->nullable(true);
31
+            $table->decimal('imponibile', 10, 2)->nullable(true);
32
+            $table->decimal('iva_percentuale', 10, 2)->nullable(true);
33
+            $table->decimal('iva', 10, 2)->nullable(true);
34
+            $table->decimal('totale', 10, 2)->nullable(true);
35
+            $table->text('note')->nullable(true);
36
+            $table->string('path_file')->nullable(true);
37
+            $table->string('ragione_sociale')->nullable(true);
38
+            $table->string('piva')->nullable(true);
39
+            $table->string('cod_fiscale')->nullable(true);
40
+            $table->string('indirizzo')->nullable(true);
41
+            $table->string('email')->nullable(true);
42
+            $table->string('telefono')->nullable(true);
43
+            $table->json('meta')->nullable(true);
44
+            $table->bigInteger('comune_id')->nullable(true)->unsigned();
45
+            $table->timestamp('created_at')->nullable(true);
46
+            $table->timestamp('updated_at')->nullable(true);
47
+        });
48
+        Schema::create('fattura_riga', function (Blueprint $table) {
49
+            $table->bigInteger('id')->autoIncrement()->unsigned();
50
+            $table->bigInteger('fattura_id')->nullable(true)->unsigned();
51
+            $table->string('tipo')->nullable(true);
52
+            $table->string('descrizione')->nullable(true);
53
+            $table->decimal('quantita', 10, 2)->nullable(true);
54
+            $table->string('unita_misura')->nullable(true);
55
+            $table->decimal('prezzo_unitario', 10, 2)->nullable(true);
56
+            $table->decimal('sconto', 10, 2)->nullable(true);
57
+            $table->decimal('iva_percentuale', 10, 2)->nullable(true);
58
+            $table->decimal('importo', 10, 2)->nullable(true);
59
+            $table->integer('minuti')->nullable(true);
60
+            $table->integer('ordine')->nullable(true)->default(0);
61
+            $table->text('nota')->nullable(true);
62
+            $table->bigInteger('annotazione_id')->nullable(true)->unsigned();
63
+            $table->bigInteger('segnalazione_id')->nullable(true)->unsigned();
64
+            $table->bigInteger('contratto_servizio_id')->nullable(true)->unsigned();
65
+            $table->bigInteger('contratto_servizio_riga_id')->nullable(true)->unsigned();
66
+            $table->bigInteger('ordine_lavoro_id')->nullable(true)->unsigned();
67
+            $table->bigInteger('prodotto_id')->nullable(true)->unsigned();
68
+            $table->date('periodo_da')->nullable(true);
69
+            $table->date('periodo_a')->nullable(true);
70
+            $table->timestamp('created_at')->nullable(true);
71
+            $table->timestamp('updated_at')->nullable(true);
72
+        });
73
+        Schema::table('fattura', function (Blueprint $table) {
74
+            $table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
75
+        });
76
+        Schema::table('fattura', function (Blueprint $table) {
77
+            $table->foreign('created_by_id')->references('id')->on('users')->onDelete('set null');
78
+        });
79
+        Schema::table('fattura', function (Blueprint $table) {
80
+            $table->foreign('comune_id')->references('id')->on('comune');
81
+        });
82
+        Schema::table('fattura_riga', function (Blueprint $table) {
83
+            $table->foreign('fattura_id')->references('id')->on('fattura')->onDelete('cascade');
84
+        });
85
+        Schema::table('fattura_riga', function (Blueprint $table) {
86
+            $table->foreign('annotazione_id')->references('id')->on('annotazione')->onDelete('set null');
87
+        });
88
+        Schema::table('fattura_riga', function (Blueprint $table) {
89
+            $table->foreign('segnalazione_id')->references('id')->on('segnalazione')->onDelete('set null');
90
+        });
91
+        Schema::table('fattura_riga', function (Blueprint $table) {
92
+            $table->foreign('contratto_servizio_id')->references('id')->on('contratto_servizio')->onDelete('set null');
93
+        });
94
+        Schema::table('fattura_riga', function (Blueprint $table) {
95
+            $table->foreign('contratto_servizio_riga_id')->references('id')->on('contratto_servizio_riga')->onDelete('set null');
96
+        });
97
+        Schema::table('fattura_riga', function (Blueprint $table) {
98
+            $table->foreign('ordine_lavoro_id')->references('id')->on('ordine_lavoro')->onDelete('set null');
99
+        });
100
+        Schema::table('fattura_riga', function (Blueprint $table) {
101
+            $table->foreign('prodotto_id')->references('id')->on('prodotto')->onDelete('set null');
102
+        });
103
+    }
104
+    /**
105
+     * Reverse the migrations.
106
+     *
107
+     * @return void
108
+     */
109
+    public function down()
110
+    {
111
+        if (Schema::hasTable('fattura_riga')) {
112
+            Schema::table('fattura_riga', function (Blueprint $table) {
113
+                $table->dropForeign(['prodotto_id']);
114
+            });
115
+        }
116
+        if (Schema::hasTable('fattura_riga')) {
117
+            Schema::table('fattura_riga', function (Blueprint $table) {
118
+                $table->dropForeign(['ordine_lavoro_id']);
119
+            });
120
+        }
121
+        if (Schema::hasTable('fattura_riga')) {
122
+            Schema::table('fattura_riga', function (Blueprint $table) {
123
+                $table->dropForeign(['contratto_servizio_riga_id']);
124
+            });
125
+        }
126
+        if (Schema::hasTable('fattura_riga')) {
127
+            Schema::table('fattura_riga', function (Blueprint $table) {
128
+                $table->dropForeign(['contratto_servizio_id']);
129
+            });
130
+        }
131
+        if (Schema::hasTable('fattura_riga')) {
132
+            Schema::table('fattura_riga', function (Blueprint $table) {
133
+                $table->dropForeign(['segnalazione_id']);
134
+            });
135
+        }
136
+        if (Schema::hasTable('fattura_riga')) {
137
+            Schema::table('fattura_riga', function (Blueprint $table) {
138
+                $table->dropForeign(['annotazione_id']);
139
+            });
140
+        }
141
+        if (Schema::hasTable('fattura_riga')) {
142
+            Schema::table('fattura_riga', function (Blueprint $table) {
143
+                $table->dropForeign(['fattura_id']);
144
+            });
145
+        }
146
+        if (Schema::hasTable('fattura')) {
147
+            Schema::table('fattura', function (Blueprint $table) {
148
+                $table->dropForeign(['comune_id']);
149
+            });
150
+        }
151
+        if (Schema::hasTable('fattura')) {
152
+            Schema::table('fattura', function (Blueprint $table) {
153
+                $table->dropForeign(['created_by_id']);
154
+            });
155
+        }
156
+        if (Schema::hasTable('fattura')) {
157
+            Schema::table('fattura', function (Blueprint $table) {
158
+                $table->dropForeign(['user_id']);
159
+            });
160
+        }
161
+        Schema::dropIfExists('fattura_riga');
162
+        Schema::dropIfExists('fattura');
163
+    }
164
+}

+ 49
- 0
database/migrations/2026_08_22_000100_create_ordine_lavoro_riga_table.php 查看文件

@@ -0,0 +1,49 @@
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('ordine_lavoro_riga', function (Blueprint $table) {
12
+            $table->bigInteger('id')->autoIncrement()->unsigned();
13
+            $table->bigInteger('ordine_lavoro_id')->nullable(true)->unsigned();
14
+            $table->bigInteger('prodotto_id')->nullable(true)->unsigned();
15
+            $table->string('descrizione')->nullable(true);
16
+            $table->decimal('quantita', 10, 2)->nullable(true);
17
+            $table->string('unita_misura')->nullable(true);
18
+            $table->decimal('prezzo_unitario', 10, 2)->nullable(true);
19
+            $table->decimal('sconto', 10, 2)->nullable(true);
20
+            $table->integer('ordine')->nullable(true)->default(0);
21
+            $table->text('note_private')->nullable(true);
22
+            $table->decimal('tempo_stimato', 10, 2)->nullable(true);
23
+            $table->timestamp('created_at')->nullable(true);
24
+            $table->timestamp('updated_at')->nullable(true);
25
+        });
26
+
27
+        Schema::table('ordine_lavoro_riga', function (Blueprint $table) {
28
+            $table->foreign('ordine_lavoro_id')->references('id')->on('ordine_lavoro')->onDelete('cascade');
29
+        });
30
+        Schema::table('ordine_lavoro_riga', function (Blueprint $table) {
31
+            $table->foreign('prodotto_id')->references('id')->on('prodotto')->onDelete('set null');
32
+        });
33
+    }
34
+
35
+    public function down(): void
36
+    {
37
+        if (Schema::hasTable('ordine_lavoro_riga')) {
38
+            Schema::table('ordine_lavoro_riga', function (Blueprint $table) {
39
+                $table->dropForeign(['prodotto_id']);
40
+            });
41
+        }
42
+        if (Schema::hasTable('ordine_lavoro_riga')) {
43
+            Schema::table('ordine_lavoro_riga', function (Blueprint $table) {
44
+                $table->dropForeign(['ordine_lavoro_id']);
45
+            });
46
+        }
47
+        Schema::dropIfExists('ordine_lavoro_riga');
48
+    }
49
+};

+ 2
- 3
database/seeders/ConfigSeeder.php 查看文件

@@ -3,8 +3,7 @@
3 3
 namespace Database\Seeders;
4 4
 
5 5
 use Illuminate\Database\Seeder;
6
-use App\Models\Role;
7
-use Illuminate\Support\Facades\DB;
6
+use App\Models\Config;
8 7
 
9 8
 class ConfigSeeder extends Seeder
10 9
 {
@@ -15,6 +14,6 @@ class ConfigSeeder extends Seeder
15 14
   */
16 15
   public function run()
17 16
   {
18
-
17
+    Config::syncDefinitions();
19 18
   }
20 19
 }

+ 4
- 4
database/seeders/ConfiguraSegnalazioneSeeder.php 查看文件

@@ -49,11 +49,11 @@ class ConfiguraSegnalazioneSeeder extends Seeder
49 49
         'colore' => 'warning',
50 50
       ],
51 51
       [
52
-        'label' => 'In attesa di risposta',
53
-        'descrizione' => 'Segnalazione in attesa di risposta da parte del reparto',
52
+        'label' => 'In attesa cliente',
53
+        'descrizione' => 'Segnalazione in attesa di risposta da parte del cliente',
54 54
         'gruppo' => 'stato',
55
-        'key' => 'stato_in_attesa_di_risposta',
56
-        'value' => 'in_attesa_di_risposta',
55
+        'key' => 'stato_in_attesa_cliente',
56
+        'value' => 'in_attesa_cliente',
57 57
         'type' => 'option',
58 58
         'order' => 3,
59 59
         'icona' => 'bx-cog',

+ 1
- 0
database/seeders/DatabaseSeeder.php 查看文件

@@ -18,6 +18,7 @@ class DatabaseSeeder extends Seeder
18 18
     $this->call(PermissionSeed::class);
19 19
     $this->call(ComuniSeeder::class);
20 20
     $this->call(ConfiguraSegnalazioneSeeder::class);
21
+    $this->call(ConfigSeeder::class);
21 22
     $this->call(AcquistoServizioSeeder::class);
22 23
   }
23 24
 }

+ 6
- 0
resources/assets/vendor/libs/quill/typography.scss 查看文件

@@ -40,6 +40,12 @@
40 40
     font-size: $blockquote-font-size;
41 41
   }
42 42
 
43
+  // Interlinea più compatta tra paragrafi
44
+  p {
45
+    margin-top: 0;
46
+    margin-bottom: 0.25rem;
47
+  }
48
+
43 49
   .ql-font-serif {
44 50
     font-family: $font-family-serif;
45 51
   }

+ 26
- 3
resources/assets/vendor/scss/_custom-styles.scss 查看文件

@@ -1,5 +1,28 @@
1 1
 // This file is used for writing your custom styles
2 2
 
3
-// .card {
4
-//    background-color: #fd0000;
5
-//  }
3
+// Logo sidebar: riempie la larghezza disponibile e non fuoriesce da collassata
4
+.layout-menu.menu .app-brand.demo {
5
+  height: auto;
6
+  min-height: 64px;
7
+  padding-block: .5rem;
8
+}
9
+
10
+.layout-menu .app-brand-link,
11
+.layout-menu .app-brand-logo {
12
+  display: flex;
13
+  min-width: 0;
14
+  width: 100%;
15
+}
16
+
17
+.layout-menu .app-brand-logo img {
18
+  width: 100%;
19
+  max-width: 100%;
20
+  height: auto;
21
+}
22
+
23
+@include media-breakpoint-up($menu-collapsed-layout-breakpoint) {
24
+  .layout-menu-collapsed:not(.layout-menu-hover):not(.layout-menu-offcanvas):not(.layout-menu-fixed-offcanvas) .layout-menu .app-brand {
25
+    padding-inline: .5rem;
26
+    overflow: hidden;
27
+  }
28
+}

+ 39
- 5
resources/menu/verticalMenu.json 查看文件

@@ -1,5 +1,21 @@
1 1
 {
2 2
   "menu": [
3
+    {
4
+      "name": "Dashboard",
5
+      "icon": "menu-icon icon-base bx bx-home-smile",
6
+      "slug": "dashboard",
7
+      "url": "dashboard"
8
+    },
9
+    {
10
+      "name": "Le mie segnalazioni",
11
+      "icon": "menu-icon icon-base bx bx-flag",
12
+      "slug": "segnalazione",
13
+      "url": "segnalazione",
14
+      "can": "view-own-segnalazione"
15
+    },
16
+    {
17
+      "menuHeader": "Gestione"
18
+    },
3 19
     {
4 20
       "name": "Dashboard",
5 21
       "icon": "menu-icon icon-base bx bx-home-smile",
@@ -12,7 +28,7 @@
12 28
       "icon": "menu-icon icon-base bx bx-flag",
13 29
       "slug": "admin.segnalazione.index",
14 30
       "url": "admin/segnalazione",
15
-      "can": "view-segnalazione|view-segnalazione-assegnate|view-own-segnalazione"
31
+      "can": "view-segnalazione|view-segnalazione-assegnate"
16 32
     },
17 33
     {
18 34
       "name": "Tempo Impiegato",
@@ -28,9 +44,6 @@
28 44
       "url": "admin/costo_segnalazione",
29 45
       "can": "view-costo-segnalazione"
30 46
     },
31
-    {
32
-      "menuHeader": "Gestione"
33
-    },
34 47
     {
35 48
       "name": "Aziende",
36 49
       "icon": "menu-icon icon-base bx bx-buildings",
@@ -73,6 +86,13 @@
73 86
       "url": "admin/contratto-servizio",
74 87
       "can": "view-contratto-servizio"
75 88
     },
89
+    {
90
+      "name": "Fatturazione",
91
+      "icon": "menu-icon icon-base bx bx-receipt",
92
+      "slug": "admin.fatturazione.index",
93
+      "url": "admin/fatturazione",
94
+      "can": "view-fatturazione"
95
+    },
76 96
     {
77 97
       "name": "Ordini di lavoro",
78 98
       "icon": "menu-icon icon-base bx bx-briefcase",
@@ -87,6 +107,16 @@
87 107
       "url": "admin/contratto-template",
88 108
       "can": "view-contratto-template"
89 109
     },
110
+    {
111
+      "menuHeader": "Amministrazione"
112
+    },
113
+    {
114
+      "name": "Configurazione",
115
+      "icon": "menu-icon icon-base bx bx-slider-alt",
116
+      "slug": "admin.config.index",
117
+      "url": "admin/config",
118
+      "can": "view-config"
119
+    },
90 120
     {
91 121
       "name": "Badge",
92 122
       "icon": "menu-icon icon-base bx bx-cog",
@@ -102,7 +132,11 @@
102 132
       "can": "view-config-notifiche"
103 133
     },
104 134
     {
105
-      "menuHeader": "Amministrazione"
135
+      "name": "Caselle email",
136
+      "icon": "menu-icon icon-base bx bx-envelope",
137
+      "slug": "admin.casella_imap.index",
138
+      "url": "admin/casella-imap",
139
+      "can": "view-casella-imap"
106 140
     },
107 141
     {
108 142
       "name": "Utenti",

+ 5
- 0
resources/views/azienda/index.blade.php 查看文件

@@ -61,6 +61,11 @@ $configData = Helper::appClasses();
61 61
   $(document).ready(function () {
62 62
     const $table = $('#dataTable_azienda');
63 63
 
64
+    $table.on('dblclick', 'tbody td', function () {
65
+      var id = $(this).closest('tr').attr('id');
66
+      window.location.href = "{{ route('admin.user.scheda', ['id' => '__ID__']) }}".replace('__ID__', id);
67
+    });
68
+
64 69
     $table.on('click', '.js-edit-azienda', function (e) {
65 70
       e.preventDefault();
66 71
       const id = $(this).data('id');

+ 1
- 0
resources/views/azienda/modal-scripts.blade.php 查看文件

@@ -75,6 +75,7 @@
75 75
       $('#azienda_cod_fiscale').val(data.cod_fiscale || '');
76 76
       $('#azienda_telefono').val(data.telefono || '');
77 77
       $('#azienda_email').val(data.email || '');
78
+      $('#azienda_dominio_email').val(data.dominio_email || '');
78 79
       $('#azienda_indirizzo').val(data.indirizzo || '');
79 80
       $('#azienda_note').val(data.note || '');
80 81
       modal.show();

+ 6
- 0
resources/views/azienda/modal.blade.php 查看文件

@@ -44,6 +44,12 @@
44 44
               <label class="form-label" for="azienda_email">Email</label>
45 45
               <input type="email" class="form-control" name="email" id="azienda_email" maxlength="255">
46 46
             </div>
47
+            <div class="col-md-6">
48
+              <label class="form-label" for="azienda_dominio_email">Dominio email</label>
49
+              <input type="text" class="form-control" name="dominio_email" id="azienda_dominio_email" maxlength="255"
50
+                placeholder="es. arratti.it">
51
+              <div class="form-text">Usato per assegnare automaticamente azienda (e contratto default) alle segnalazioni da email.</div>
52
+            </div>
47 53
             <div class="col-md-6">
48 54
               <label class="form-label" for="azienda_indirizzo">Indirizzo</label>
49 55
               <input type="text" class="form-control" name="indirizzo" id="azienda_indirizzo" maxlength="500"

+ 245
- 0
resources/views/casella_imap/form.blade.php 查看文件

@@ -0,0 +1,245 @@
1
+@extends('layouts/layoutMaster')
2
+
3
+@section('title', $mode === 'create' ? 'Nuova casella IMAP' : 'Modifica casella IMAP')
4
+
5
+@section('pageTitle')
6
+<h4 class="mb-0">{{ $mode === 'create' ? 'Nuova casella IMAP' : 'Modifica '.$casella->nome }}</h4>
7
+@endsection
8
+
9
+@section('content')
10
+<div class="d-flex flex-wrap align-items-center gap-2 mb-4 pb-3 border-bottom">
11
+  <button type="submit" form="formCasellaImap" class="btn btn-primary btn-sm">
12
+    <i class="bx bx-save me-1"></i>Salva
13
+  </button>
14
+  <button type="button" class="btn btn-label-primary btn-sm" id="btnTestImap">
15
+    <i class="bx bx-plug me-1"></i>Testa connessione
16
+  </button>
17
+  <a href="{{ route('admin.casella_imap.index') }}" class="btn btn-label-secondary btn-sm">Elenco</a>
18
+</div>
19
+
20
+@if($errors->any())
21
+<div class="alert alert-danger">
22
+  <ul class="mb-0">@foreach($errors->all() as $e)<li>{{ $e }}</li>@endforeach</ul>
23
+</div>
24
+@endif
25
+
26
+<div id="testImapAlert" class="alert d-none" role="alert"></div>
27
+
28
+<form id="formCasellaImap" method="POST"
29
+  action="{{ $mode === 'create' ? route('admin.casella_imap.store') : route('admin.casella_imap.update', $casella->id) }}">
30
+  @csrf
31
+  @if($mode === 'edit') @method('PUT') @endif
32
+  @if($mode === 'edit')
33
+  <input type="hidden" name="casella_id" value="{{ $casella->id }}">
34
+  @endif
35
+
36
+  <div class="card mb-4">
37
+    <div class="card-header">
38
+      <h5 class="mb-0">Connessione</h5>
39
+    </div>
40
+    <div class="card-body row g-3">
41
+      <div class="col-md-6">
42
+        <label class="form-label" for="nome">Nome <span class="text-danger">*</span></label>
43
+        <input type="text" name="nome" id="nome" class="form-control" required
44
+          value="{{ old('nome', $casella->nome) }}" placeholder="Es. Assistenza, Info, Supporto IT">
45
+      </div>
46
+      <div class="col-md-3">
47
+        <label class="form-label d-block">Stato</label>
48
+        <div class="form-check form-switch mt-2">
49
+          <input type="hidden" name="attivo" value="0">
50
+          <input class="form-check-input" type="checkbox" name="attivo" value="1" id="attivo"
51
+            @checked(old('attivo', $casella->attivo))>
52
+          <label class="form-check-label" for="attivo">Attiva (monitorata)</label>
53
+        </div>
54
+      </div>
55
+      <div class="col-md-6">
56
+        <label class="form-label" for="host">Host IMAP <span class="text-danger">*</span></label>
57
+        <input type="text" name="host" id="host" class="form-control" required
58
+          value="{{ old('host', $casella->host) }}" placeholder="mail.example.com">
59
+      </div>
60
+      <div class="col-md-2">
61
+        <label class="form-label" for="porta">Porta <span class="text-danger">*</span></label>
62
+        <input type="number" name="porta" id="porta" class="form-control" required min="1" max="65535"
63
+          value="{{ old('porta', $casella->porta ?: 993) }}">
64
+      </div>
65
+      <div class="col-md-2">
66
+        <label class="form-label" for="encryption">Cifratura</label>
67
+        @php $enc = old('encryption', $casella->encryption ?: 'ssl'); @endphp
68
+        <select name="encryption" id="encryption" class="form-select">
69
+          <option value="ssl" @selected($enc === 'ssl')>SSL</option>
70
+          <option value="tls" @selected($enc === 'tls')>TLS</option>
71
+          <option value="none" @selected($enc === 'none')>Nessuna</option>
72
+        </select>
73
+      </div>
74
+      <div class="col-md-2">
75
+        <label class="form-label d-block">Certificato</label>
76
+        <div class="form-check form-switch mt-2">
77
+          <input type="hidden" name="valida_certificato" value="0">
78
+          <input class="form-check-input" type="checkbox" name="valida_certificato" value="1" id="valida_certificato"
79
+            @checked(old('valida_certificato', $casella->valida_certificato))>
80
+          <label class="form-check-label" for="valida_certificato">Valida</label>
81
+        </div>
82
+      </div>
83
+      <div class="col-md-6">
84
+        <label class="form-label" for="username">Username / email <span class="text-danger">*</span></label>
85
+        <input type="text" name="username" id="username" class="form-control" required autocomplete="off"
86
+          value="{{ old('username', $casella->username) }}">
87
+      </div>
88
+      <div class="col-md-6">
89
+        <label class="form-label" for="password">Password @if($mode === 'create')<span class="text-danger">*</span>@endif</label>
90
+        <input type="password" name="password" id="password" class="form-control" autocomplete="new-password"
91
+          @if($mode === 'create') required @endif
92
+          placeholder="{{ $mode === 'edit' ? 'Lascia vuoto per non modificare' : '' }}">
93
+      </div>
94
+    </div>
95
+  </div>
96
+
97
+  <div class="card mb-4">
98
+    <div class="card-header d-flex justify-content-between align-items-center">
99
+      <h5 class="mb-0">Cartelle e comportamento</h5>
100
+    </div>
101
+    <div class="card-body row g-3">
102
+      <div class="col-md-6">
103
+        <label class="form-label" for="cartella">Cartella da leggere <span class="text-danger">*</span></label>
104
+        <input type="text" name="cartella" id="cartella" class="form-control" required list="listaCartelleImap"
105
+          value="{{ old('cartella', $casella->cartella ?: 'INBOX') }}" placeholder="INBOX">
106
+        <div class="form-text">Path IMAP della cartella (es. <code>INBOX</code>, <code>INBOX.Supporto</code>).</div>
107
+      </div>
108
+      <div class="col-md-6">
109
+        <label class="form-label" for="cartella_processate">Cartella mail elaborate</label>
110
+        <input type="text" name="cartella_processate" id="cartella_processate" class="form-control" list="listaCartelleImap"
111
+          value="{{ old('cartella_processate', $casella->cartella_processate) }}" placeholder="Es. Archiviate">
112
+        <div class="form-text">Se attiva l'opzione di spostamento, la cartella viene creata se non esiste.</div>
113
+      </div>
114
+      <datalist id="listaCartelleImap"></datalist>
115
+      <div id="folderPickerWrap" class="col-12 d-none">
116
+        <label class="form-label">Cartelle trovate sul server</label>
117
+        <div id="folderPicker" class="border rounded p-2" style="max-height: 220px; overflow-y: auto;"></div>
118
+        <div class="form-text">Usa i pulsanti per copiare il path nella cartella da leggere o in quella delle elaborate.</div>
119
+      </div>
120
+      <div class="col-md-4">
121
+        <div class="form-check form-switch">
122
+          <input type="hidden" name="solo_non_lette" value="0">
123
+          <input class="form-check-input" type="checkbox" name="solo_non_lette" value="1" id="solo_non_lette"
124
+            @checked(old('solo_non_lette', $casella->solo_non_lette))>
125
+          <label class="form-check-label" for="solo_non_lette">Solo messaggi non letti</label>
126
+        </div>
127
+      </div>
128
+      <div class="col-md-4">
129
+        <div class="form-check form-switch">
130
+          <input type="hidden" name="marca_come_letta" value="0">
131
+          <input class="form-check-input" type="checkbox" name="marca_come_letta" value="1" id="marca_come_letta"
132
+            @checked(old('marca_come_letta', $casella->marca_come_letta))>
133
+          <label class="form-check-label" for="marca_come_letta">Marca come letta dopo l'import</label>
134
+        </div>
135
+      </div>
136
+      <div class="col-md-4">
137
+        <div class="form-check form-switch">
138
+          <input type="hidden" name="sposta_processate" value="0">
139
+          <input class="form-check-input" type="checkbox" name="sposta_processate" value="1" id="sposta_processate"
140
+            @checked(old('sposta_processate', $casella->sposta_processate))>
141
+          <label class="form-check-label" for="sposta_processate">Sposta nelle elaborate</label>
142
+        </div>
143
+      </div>
144
+    </div>
145
+  </div>
146
+</form>
147
+@endsection
148
+
149
+@section('page-script')
150
+<script type="module">
151
+  const form = document.getElementById('formCasellaImap');
152
+  const btnTest = document.getElementById('btnTestImap');
153
+  const alertBox = document.getElementById('testImapAlert');
154
+  const folderPicker = document.getElementById('folderPicker');
155
+  const folderPickerWrap = document.getElementById('folderPickerWrap');
156
+  const lista = document.getElementById('listaCartelleImap');
157
+  const encryption = document.getElementById('encryption');
158
+  const porta = document.getElementById('porta');
159
+
160
+  encryption.addEventListener('change', () => {
161
+    if (porta.value === '993' || porta.value === '143') {
162
+      porta.value = encryption.value === 'ssl' ? '993' : '143';
163
+    }
164
+  });
165
+
166
+  function showAlert(ok, message) {
167
+    alertBox.classList.remove('d-none', 'alert-success', 'alert-danger');
168
+    alertBox.classList.add(ok ? 'alert-success' : 'alert-danger');
169
+    alertBox.textContent = message;
170
+  }
171
+
172
+  function renderFolders(folders) {
173
+    lista.innerHTML = '';
174
+    folderPicker.innerHTML = '';
175
+    if (!folders || !folders.length) {
176
+      folderPickerWrap.classList.add('d-none');
177
+      return;
178
+    }
179
+    folderPickerWrap.classList.remove('d-none');
180
+    folders.forEach((folder) => {
181
+      const opt = document.createElement('option');
182
+      opt.value = folder.path;
183
+      lista.appendChild(opt);
184
+
185
+      const item = document.createElement('div');
186
+      item.className = 'd-flex align-items-center justify-content-between gap-2 py-1 px-2 border-bottom';
187
+      const label = document.createElement('code');
188
+      label.textContent = folder.path;
189
+      const actions = document.createElement('div');
190
+      actions.className = 'btn-group btn-group-sm';
191
+      const btnRead = document.createElement('button');
192
+      btnRead.type = 'button';
193
+      btnRead.className = 'btn btn-label-primary';
194
+      btnRead.textContent = 'Da leggere';
195
+      btnRead.addEventListener('click', () => {
196
+        document.getElementById('cartella').value = folder.path;
197
+      });
198
+      const btnProcessed = document.createElement('button');
199
+      btnProcessed.type = 'button';
200
+      btnProcessed.className = 'btn btn-label-secondary';
201
+      btnProcessed.textContent = 'Elaborate';
202
+      btnProcessed.addEventListener('click', () => {
203
+        document.getElementById('cartella_processate').value = folder.path;
204
+      });
205
+      actions.append(btnRead, btnProcessed);
206
+      item.append(label, actions);
207
+      folderPicker.appendChild(item);
208
+    });
209
+  }
210
+
211
+  btnTest.addEventListener('click', async () => {
212
+    const pwd = document.getElementById('password');
213
+    if (!pwd.value && !form.querySelector('[name="casella_id"]')) {
214
+      showAlert(false, 'Inserisci la password per testare la connessione.');
215
+      return;
216
+    }
217
+    btnTest.disabled = true;
218
+    const original = btnTest.innerHTML;
219
+    btnTest.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Connessione…';
220
+    try {
221
+      const data = new FormData(form);
222
+      data.delete('_method');
223
+      const res = await fetch('{{ route('admin.casella_imap.test') }}', {
224
+        method: 'POST',
225
+        headers: {
226
+          'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
227
+          'Accept': 'application/json',
228
+          'X-Requested-With': 'XMLHttpRequest',
229
+        },
230
+        body: data,
231
+      });
232
+      const json = await res.json();
233
+      showAlert(!!json.ok, json.message || (json.ok ? 'OK' : 'Connessione fallita'));
234
+      if (json.ok) {
235
+        renderFolders(json.folders || []);
236
+      }
237
+    } catch (e) {
238
+      showAlert(false, 'Errore durante il test di connessione.');
239
+    } finally {
240
+      btnTest.disabled = false;
241
+      btnTest.innerHTML = original;
242
+    }
243
+  });
244
+</script>
245
+@endsection

+ 139
- 0
resources/views/casella_imap/index.blade.php 查看文件

@@ -0,0 +1,139 @@
1
+@extends('layouts/layoutMaster')
2
+
3
+@section('title', 'Caselle email IMAP')
4
+
5
+@section('pageTitle')
6
+<h4 class="mb-0">Caselle email IMAP</h4>
7
+@endsection
8
+
9
+@section('content')
10
+@if(session('success'))
11
+<div class="alert alert-success">{{ session('success') }}</div>
12
+@endif
13
+@if(session('warning'))
14
+<div class="alert alert-warning">
15
+  {{ session('warning') }}
16
+  @if(session('import_errors'))
17
+  <ul class="mb-0 mt-2">
18
+    @foreach(session('import_errors') as $err)
19
+    <li>{{ $err }}</li>
20
+    @endforeach
21
+  </ul>
22
+  @endif
23
+</div>
24
+@endif
25
+
26
+<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">
27
+  <p class="text-muted mb-0">Caselle monitorate per importare le segnalazioni da email. Puoi indicare più account e, per ciascuno, la cartella IMAP da leggere.</p>
28
+  <div class="d-flex flex-wrap gap-2">
29
+    @can('view-casella-imap')
30
+    <form method="POST" action="{{ route('admin.casella_imap.importa') }}" class="d-inline">
31
+      @csrf
32
+      <button type="submit" class="btn btn-label-primary btn-sm" onclick="return confirm('Avviare ora la lettura delle caselle attive?');">
33
+        <i class="bx bx-refresh me-1"></i>Importa ora
34
+      </button>
35
+    </form>
36
+    @endcan
37
+    @can('create-casella-imap')
38
+    <a href="{{ route('admin.casella_imap.create') }}" class="btn btn-primary btn-sm">
39
+      <i class="bx bx-plus me-1"></i>Nuova casella
40
+    </a>
41
+    @endcan
42
+  </div>
43
+</div>
44
+
45
+@if($caselle->isEmpty())
46
+<div class="card">
47
+  <div class="card-body text-center py-5">
48
+    <i class="bx bx-envelope bx-lg text-muted mb-2"></i>
49
+    <h5 class="mb-1">Nessuna casella configurata</h5>
50
+    <p class="text-muted mb-3">Aggiungi una o più caselle IMAP da monitorare. Per ciascuna puoi indicare la cartella da leggere (es. INBOX o INBOX.Supporto).</p>
51
+    @can('create-casella-imap')
52
+    <a href="{{ route('admin.casella_imap.create') }}" class="btn btn-primary">Aggiungi casella</a>
53
+    @endcan
54
+  </div>
55
+</div>
56
+@else
57
+<div class="row">
58
+  @foreach($caselle as $casella)
59
+  <div class="col-xl-6 mb-4">
60
+    <div class="card h-100">
61
+      <div class="card-body">
62
+        <div class="d-flex justify-content-between align-items-start gap-2 mb-3">
63
+          <div>
64
+            <h5 class="mb-1">{{ $casella->nome }}</h5>
65
+            <div class="text-muted">{{ $casella->username }}</div>
66
+          </div>
67
+          @can('edit-casella-imap')
68
+          <form method="POST" action="{{ route('admin.casella_imap.toggle', $casella->id) }}" class="js-toggle-attivo">
69
+            @csrf
70
+            <input type="hidden" name="attivo" value="0">
71
+            <div class="form-check form-switch mb-0">
72
+              <input class="form-check-input" type="checkbox" name="attivo" value="1" role="switch"
73
+                id="attivo_{{ $casella->id }}" @checked($casella->attivo)
74
+                onchange="this.form.requestSubmit()">
75
+              <label class="form-check-label" for="attivo_{{ $casella->id }}">Attiva</label>
76
+            </div>
77
+          </form>
78
+          @else
79
+          @if($casella->attivo)
80
+          <span class="badge bg-label-success">Attiva</span>
81
+          @else
82
+          <span class="badge bg-label-secondary">Disattiva</span>
83
+          @endif
84
+          @endcan
85
+        </div>
86
+
87
+        <dl class="row mb-0 small">
88
+          <dt class="col-sm-4 text-muted">Server</dt>
89
+          <dd class="col-sm-8">{{ $casella->host }}:{{ $casella->porta }}
90
+            <span class="text-uppercase">({{ $casella->encryption }})</span>
91
+          </dd>
92
+          <dt class="col-sm-4 text-muted">Cartella da leggere</dt>
93
+          <dd class="col-sm-8"><code>{{ $casella->cartella }}</code></dd>
94
+          <dt class="col-sm-4 text-muted">Dopo l'elaborazione</dt>
95
+          <dd class="col-sm-8">
96
+            @if($casella->sposta_processate && $casella->cartella_processate)
97
+              Sposta in <code>{{ $casella->cartella_processate }}</code>
98
+            @else
99
+              <span class="text-muted">Non sposta</span>
100
+            @endif
101
+            @if($casella->marca_come_letta) · marca come letta @endif
102
+            @if($casella->solo_non_lette) · solo non lette @endif
103
+          </dd>
104
+          <dt class="col-sm-4 text-muted">Ultimo controllo</dt>
105
+          <dd class="col-sm-8">
106
+            @if($casella->ultimo_controllo_at)
107
+              {{ $casella->ultimo_controllo_at->format('d/m/Y H:i') }}
108
+            @else
109
+              <span class="text-muted">Mai</span>
110
+            @endif
111
+            @if($casella->ultimo_errore)
112
+              <div class="text-danger mt-1">{{ $casella->ultimo_errore }}</div>
113
+            @endif
114
+          </dd>
115
+        </dl>
116
+      </div>
117
+      <div class="card-footer d-flex justify-content-end gap-2">
118
+        @can('edit-casella-imap')
119
+        <a href="{{ route('admin.casella_imap.edit', $casella->id) }}" class="btn btn-sm btn-label-secondary">
120
+          <i class="bx bx-edit-alt me-1"></i>Modifica
121
+        </a>
122
+        @endcan
123
+        @can('delete-casella-imap')
124
+        <form method="POST" action="{{ route('admin.casella_imap.destroy', $casella->id) }}"
125
+          onsubmit="return confirm(@json('Eliminare la casella '.$casella->nome.'?'));">
126
+          @csrf
127
+          @method('DELETE')
128
+          <button type="submit" class="btn btn-sm btn-label-danger">
129
+            <i class="bx bx-trash me-1"></i>Elimina
130
+          </button>
131
+        </form>
132
+        @endcan
133
+      </div>
134
+    </div>
135
+  </div>
136
+  @endforeach
137
+</div>
138
+@endif
139
+@endsection

+ 28
- 0
resources/views/components/annotazione-html.blade.php 查看文件

@@ -0,0 +1,28 @@
1
+@props(['html' => ''])
2
+
3
+@once
4
+<style>
5
+  .annotazione-html {
6
+    overflow-x: auto;
7
+    max-width: 100%;
8
+    word-break: break-word;
9
+    overflow-wrap: anywhere;
10
+  }
11
+  .annotazione-html img {
12
+    max-width: 100%;
13
+    height: auto;
14
+  }
15
+  .annotazione-html table {
16
+    border-collapse: collapse;
17
+    max-width: 100%;
18
+  }
19
+  .annotazione-html table td,
20
+  .annotazione-html table th {
21
+    word-break: break-word;
22
+  }
23
+</style>
24
+@endonce
25
+
26
+<div {{ $attributes->merge(['class' => 'annotazione-html mb-2']) }}>
27
+  {!! \App\Support\IsolaHtml::fragment($html) !!}
28
+</div>

+ 26
- 11
resources/views/components/datatable-filter-field.blade.php 查看文件

@@ -7,19 +7,34 @@
7 7
     'datePicker' => false,
8 8
     'inputmode' => null,
9 9
     'step' => null,
10
+    'options' => [],
11
+    'emptyLabel' => 'Tutti',
10 12
 ])
11 13
 
12 14
 <div class="mb-3">
13 15
   <label class="form-label small text-body-secondary mb-1">{{ $label }}</label>
14
-  <input
15
-    type="{{ $type }}"
16
-    class="form-control form-control-sm"
17
-    placeholder="{{ $placeholder }}"
18
-    autocomplete="off"
19
-    data-dt-table="{{ $tableId }}"
20
-    data-dt-column="{{ $column }}"
21
-    @if ($datePicker) data-dt-flatpickr data-dt-date-filter="1" @endif
22
-    @if ($inputmode) inputmode="{{ $inputmode }}" @endif
23
-    @if ($step !== null && $step !== '') step="{{ $step }}" @endif
24
-  />
16
+  @if ($type === 'select')
17
+    <select
18
+      class="form-select form-select-sm"
19
+      data-dt-table="{{ $tableId }}"
20
+      data-dt-column="{{ $column }}"
21
+    >
22
+      <option value="">{{ $emptyLabel }}</option>
23
+      @foreach ($options as $optionValue => $optionLabel)
24
+        <option value="{{ $optionValue }}">{{ $optionLabel }}</option>
25
+      @endforeach
26
+    </select>
27
+  @else
28
+    <input
29
+      type="{{ $type }}"
30
+      class="form-control form-control-sm"
31
+      placeholder="{{ $placeholder }}"
32
+      autocomplete="off"
33
+      data-dt-table="{{ $tableId }}"
34
+      data-dt-column="{{ $column }}"
35
+      @if ($datePicker) data-dt-flatpickr data-dt-date-filter="1" @endif
36
+      @if ($inputmode) inputmode="{{ $inputmode }}" @endif
37
+      @if ($step !== null && $step !== '') step="{{ $step }}" @endif
38
+    />
39
+  @endif
25 40
 </div>

+ 23
- 0
resources/views/components/datatable-filters-offcanvas.blade.php 查看文件

@@ -154,6 +154,18 @@
154 154
             return;
155 155
           }
156 156
           bindDateRangeContainers(root);
157
+          root.querySelectorAll('[data-dt-reload][data-dt-table]').forEach(function (el) {
158
+            if (el.getAttribute('data-dt-offcanvas-bound') === '1') {
159
+              return;
160
+            }
161
+            el.setAttribute('data-dt-offcanvas-bound', '1');
162
+            el.addEventListener('change', function () {
163
+              var dt = getDataTable(el.getAttribute('data-dt-table'));
164
+              if (dt) {
165
+                dt.draw();
166
+              }
167
+            });
168
+          });
157 169
           root.querySelectorAll('[data-dt-table][data-dt-column]').forEach(function (el) {
158 170
             if (el.closest('[data-dt-date-range]')) {
159 171
               return;
@@ -211,6 +223,17 @@
211 223
             return;
212 224
           }
213 225
           var tableIds = {};
226
+          root.querySelectorAll('[data-dt-reload][data-dt-table]').forEach(function (el) {
227
+            var tid = el.getAttribute('data-dt-table');
228
+            if (tid) {
229
+              tableIds[tid] = true;
230
+            }
231
+            if (el.type === 'checkbox' || el.type === 'radio') {
232
+              el.checked = false;
233
+            } else {
234
+              el.value = '';
235
+            }
236
+          });
214 237
           root.querySelectorAll('[data-dt-date-range]').forEach(function (container) {
215 238
             var tid = container.getAttribute('data-dt-table');
216 239
             if (tid) {

+ 160
- 52
resources/views/config/index.blade.php 查看文件

@@ -1,69 +1,177 @@
1 1
 @php
2
+use App\Models\Config;
2 3
 $configData = Helper::appClasses();
4
+$tabIcons = [
5
+  'Segnalazioni' => 'bx-flag',
6
+  'Documenti PDF' => 'bx-file',
7
+  'Acquisto servizio' => 'bx-cart',
8
+];
9
+$groupKeys = array_keys($groups);
3 10
 @endphp
4 11
 
5 12
 @extends('layouts/layoutMaster')
6 13
 
7
-@section('title', 'Config')
14
+@section('title', 'Configurazione')
8 15
 
9
-@section('vendor-style')
10
-@vite([
11
-'resources/assets/vendor/libs/datatables-bs5/datatables.bootstrap5.scss',
12
-'resources/assets/vendor/libs/datatables-buttons-bs5/buttons.bootstrap5.scss',
13
-'resources/assets/vendor/libs/flatpickr/flatpickr.scss',
14
-'resources/assets/vendor/libs/@form-validation/form-validation.scss'
15
-])
16
-@endsection
17
-
18
-<!-- Vendor Scripts -->
19
-@section('vendor-script')
20
-@vite([
21
-'resources/assets/vendor/libs/moment/moment.js',
22
-'resources/assets/vendor/libs/flatpickr/flatpickr.js',
23
-'resources/assets/vendor/libs/@form-validation/popular.js',
24
-'resources/assets/vendor/libs/@form-validation/bootstrap5.js',
25
-'resources/assets/vendor/libs/@form-validation/auto-focus.js'
26
-])
16
+@section('pageTitle')
17
+<div class="d-flex flex-column">
18
+  <h4 class="mb-1">Configurazione</h4>
19
+</div>
27 20
 @endsection
28 21
 
29 22
 @section('content')
30
-<div class="row justify-content-center">
31
-  <div class="col-xxl-10 col-xl-10 col-md-6 mb-4">
32
-    <div class="card">
33
-      <div class="card-header pb-0"><h5 class="card-title mb-0">Token</h5></div>
34
-      <div class="card-body">
35
-        <button class="btn btn-primary" onclick="generaToken()">Genera Token</button>
23
+<style>
24
+  .config-field + .config-field {
25
+    margin-top: 1.25rem;
26
+    padding-top: 1.25rem;
27
+    border-top: 1px solid var(--bs-border-color);
28
+  }
29
+</style>
30
+
31
+@if(session('success'))
32
+<div class="alert alert-success alert-dismissible fade show" role="alert">
33
+  <i class="bx bx-check-circle me-2"></i>
34
+  {{ session('success') }}
35
+  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
36
+</div>
37
+@endif
38
+
39
+@if ($errors->any())
40
+<div class="alert alert-danger">
41
+  <ul class="mb-0">
42
+    @foreach ($errors->all() as $error)
43
+    <li>{{ $error }}</li>
44
+    @endforeach
45
+  </ul>
46
+</div>
47
+@endif
48
+
49
+<form method="POST" action="{{ route('admin.config.save') }}" autocomplete="off">
50
+  @csrf
51
+
52
+  <div class="card mb-4">
53
+    <div class="card-header border-bottom-0 pb-0">
54
+      <ul class="nav nav-tabs card-header-tabs flex-wrap gap-1" role="tablist">
55
+        @foreach($groupKeys as $ti => $group)
56
+        <li class="nav-item" role="presentation">
57
+          <button type="button"
58
+            class="nav-link d-inline-flex align-items-center gap-1 @if($ti === 0) active @endif"
59
+            id="config-tab-{{ $ti }}-tab"
60
+            data-bs-toggle="tab"
61
+            data-bs-target="#config-tab-{{ $ti }}"
62
+            role="tab"
63
+            aria-controls="config-tab-{{ $ti }}"
64
+            @if($ti === 0) aria-selected="true" @else aria-selected="false" @endif>
65
+            <i class="icon-base bx {{ $tabIcons[$group] ?? 'bx-slider-alt' }}" aria-hidden="true"></i>
66
+            <span>{{ $group }}</span>
67
+          </button>
68
+        </li>
69
+        @endforeach
70
+      </ul>
71
+    </div>
72
+    <div class="card-body pt-3">
73
+      <div class="tab-content">
74
+        @foreach($groupKeys as $ti => $group)
75
+        @php $items = $groups[$group]; @endphp
76
+        <div class="tab-pane fade @if($ti === 0) show active @endif" id="config-tab-{{ $ti }}" role="tabpanel" aria-labelledby="config-tab-{{ $ti }}-tab">
77
+          @forelse($items as $config)
78
+          @php
79
+            $fid = 'cfg-'.preg_replace('/[^a-zA-Z0-9_-]/', '-', $config->key);
80
+            $disabled = $canEdit ? '' : 'disabled';
81
+          @endphp
82
+          <div class="config-field">
83
+            <label class="form-label fw-medium mb-2" for="{{ $fid }}-control">{{ $config->display_name }}</label>
84
+            @switch($config->type)
85
+              @case('boolean')
86
+              <div class="d-flex flex-wrap gap-3 align-items-center" id="{{ $fid }}-control">
87
+                <div class="form-check">
88
+                  <input class="form-check-input" type="radio" name="value[{{ $config->key }}]" id="{{ $fid }}-si" value="1" @checked($config->value == '1' || $config->value === true || $config->value === 'true') {{ $disabled }}>
89
+                  <label class="form-check-label" for="{{ $fid }}-si">Sì</label>
90
+                </div>
91
+                <div class="form-check">
92
+                  <input class="form-check-input" type="radio" name="value[{{ $config->key }}]" id="{{ $fid }}-no" value="0" @checked(!($config->value == '1' || $config->value === true || $config->value === 'true')) {{ $disabled }}>
93
+                  <label class="form-check-label" for="{{ $fid }}-no">No</label>
94
+                </div>
95
+              </div>
96
+              @break
36 97
 
37
-        <span id="tokenEventi"></span>
98
+              @case('textarea')
99
+              <textarea class="form-control" name="value[{{ $config->key }}]" id="{{ $fid }}-control" rows="6" {{ $disabled }}>{{ $config->value }}</textarea>
100
+              @break
38 101
 
102
+              @case('integer')
103
+              <input type="number" step="1" min="0" class="form-control" name="value[{{ $config->key }}]" id="{{ $fid }}-control" value="{{ $config->value }}" {{ $disabled }}>
104
+              @if($config->key === Config::SEGNALAZIONE_CHIUSURA_AUTO_GIORNI)
105
+              <div class="form-text">Lascia vuoto o 0 per disattivare la chiusura automatica. L'inattività si calcola dall'ultimo aggiornamento o annotazione.</div>
106
+              @endif
107
+              @break
108
+
109
+              @case('number')
110
+              <input type="number" step="0.01" class="form-control" name="value[{{ $config->key }}]" id="{{ $fid }}-control" value="{{ $config->value }}" {{ $disabled }}>
111
+              @break
112
+
113
+              @case('azienda')
114
+              <select class="form-select" name="value[{{ $config->key }}]" id="{{ $fid }}-control" {{ $disabled }}>
115
+                <option value="">— Seleziona —</option>
116
+                @foreach($aziende as $azienda)
117
+                <option value="{{ $azienda->id }}" @selected((string) $config->value === (string) $azienda->id)>
118
+                  {{ $azienda->ragione_sociale ?: $azienda->full_name }} (#{{ $azienda->id }})
119
+                </option>
120
+                @endforeach
121
+              </select>
122
+              @break
123
+
124
+              @case('stato')
125
+              <select class="form-select" name="value[{{ $config->key }}]" id="{{ $fid }}-control" {{ $disabled }}>
126
+                <option value="">— Seleziona —</option>
127
+                @foreach($statiSegnalazione as $stato)
128
+                <option value="{{ $stato->value }}" @selected((string) $config->value === (string) $stato->value)>
129
+                  {{ $stato->label }}
130
+                </option>
131
+                @endforeach
132
+              </select>
133
+              @break
134
+
135
+              @case('stati')
136
+              @php $selectedStati = Config::decodeList($config->value); @endphp
137
+              <div class="d-flex flex-column gap-2" id="{{ $fid }}-control">
138
+                @forelse($statiSegnalazione as $stato)
139
+                <div class="form-check">
140
+                  <input class="form-check-input" type="checkbox"
141
+                    name="value[{{ $config->key }}][]"
142
+                    id="{{ $fid }}-{{ $stato->value }}"
143
+                    value="{{ $stato->value }}"
144
+                    @checked(in_array($stato->value, $selectedStati, true))
145
+                    {{ $disabled }}>
146
+                  <label class="form-check-label" for="{{ $fid }}-{{ $stato->value }}">{{ $stato->label }}</label>
147
+                </div>
148
+                @empty
149
+                <p class="text-muted mb-0">Nessuno stato configurato.</p>
150
+                @endforelse
151
+              </div>
152
+              @break
153
+
154
+              @default
155
+              <input type="text" class="form-control" name="value[{{ $config->key }}]" id="{{ $fid }}-control" value="{{ $config->value }}" {{ $disabled }}>
156
+            @endswitch
157
+          </div>
158
+          @empty
159
+          <p class="text-muted mb-0">Nessuna impostazione in questo gruppo.</p>
160
+          @endforelse
161
+        </div>
162
+        @endforeach
39 163
       </div>
40 164
     </div>
41 165
   </div>
42
-</div>
43
-
44
-@endsection
45 166
 
46
-@section('page-script')
47
-
48
-
49
-<script type="module">
50
-</script>
51
-
52
-<script>
53
-function generaToken(){
54
-  $.ajax({
55
-    type: "POST",
56
-    async: false,
57
-    dataType: 'json',
58
-    url: "{{ route('config.createToken') }}",
59
-    data: {
60
-      _token : "{{csrf_token()}}",
61
-    },
62
-    success: function(data){
63
-      $('#tokenEventi').text(data['token']);
64
-    },
65
-    error: function(XMLHttpRequest, textStatus, exception) { alert("Ajax failure\n" + XMLHttpRequest.responseText + "\n" + exception); },
66
-  });
67
-}
68
-</script>
167
+  @if($canEdit)
168
+  <div class="card">
169
+    <div class="card-body d-flex flex-wrap gap-2 align-items-center">
170
+      <button type="submit" class="btn btn-primary">
171
+        <i class="bx bx-save me-1"></i>Salva configurazione
172
+      </button>
173
+    </div>
174
+  </div>
175
+  @endif
176
+</form>
69 177
 @endsection

+ 9
- 0
resources/views/contratto_servizio/create.blade.php 查看文件

@@ -97,6 +97,15 @@
97 97
             <input class="form-check-input" type="checkbox" name="attivo" value="1" @checked(old('attivo', true))>
98 98
           </div>
99 99
         </div>
100
+        <div class="col-md-4">
101
+          <label class="form-label d-block">Default import email</label>
102
+          <div class="form-check form-switch mt-2">
103
+            <input type="hidden" name="default_import_email" value="0">
104
+            <input class="form-check-input" type="checkbox" name="default_import_email" value="1" id="default_import_email"
105
+              @checked(old('default_import_email'))>
106
+            <label class="form-check-label" for="default_import_email">Contratto di default per segnalazioni da email</label>
107
+          </div>
108
+        </div>
100 109
         <div class="col-md-3">
101 110
           <label class="form-label">Data inizio</label>
102 111
           <input type="date" name="data_inizio" class="form-control" value="{{ old('data_inizio', now()->toDateString()) }}">

+ 360
- 23
resources/views/contratto_servizio/form.blade.php 查看文件

@@ -11,6 +11,91 @@
11 11
 </div>
12 12
 @endsection
13 13
 
14
+@section('vendor-script')
15
+@vite([
16
+  'resources/assets/vendor/libs/sortablejs/sortable.js'
17
+])
18
+@endsection
19
+
20
+@section('page-style')
21
+<style>
22
+  #righe-table .riga-drag-cell {
23
+    width: 2.25rem;
24
+    vertical-align: middle;
25
+    padding-right: 0;
26
+  }
27
+  #righe-table .riga-drag-handle {
28
+    cursor: grab;
29
+    color: #8592a3;
30
+    border: 0;
31
+    background: transparent;
32
+    padding: .15rem .1rem;
33
+    line-height: 1;
34
+  }
35
+  #righe-table .riga-drag-handle i {
36
+    font-size: 1.25rem;
37
+  }
38
+  #righe-table .riga-drag-handle:hover {
39
+    color: #566a7f;
40
+  }
41
+  #righe-table .riga-drag-handle:active {
42
+    cursor: grabbing;
43
+  }
44
+  #righe-table tr.riga-row-ghost {
45
+    opacity: .45;
46
+  }
47
+  #righe-table tr.riga-row-chosen {
48
+    background: #fff8e1;
49
+  }
50
+  #righe-table .riga-validita {
51
+    margin-top: .45rem;
52
+  }
53
+  #righe-table .riga-validita-inputs {
54
+    display: flex;
55
+    align-items: flex-end;
56
+    gap: .35rem;
57
+  }
58
+  #righe-table .riga-validita-field {
59
+    flex: 1;
60
+    min-width: 0;
61
+    margin: 0;
62
+  }
63
+  #righe-table .riga-validita-field span {
64
+    display: block;
65
+    font-size: .65rem;
66
+    font-weight: 600;
67
+    letter-spacing: .04em;
68
+    text-transform: uppercase;
69
+    color: #a1acb8;
70
+    margin-bottom: .15rem;
71
+  }
72
+  #righe-table .riga-validita-sep {
73
+    color: #a1acb8;
74
+    font-size: .8rem;
75
+    padding-bottom: .4rem;
76
+    flex-shrink: 0;
77
+  }
78
+  #righe-table .riga-dal,
79
+  #righe-table .riga-al {
80
+    min-width: 0;
81
+    padding-left: .4rem;
82
+    padding-right: .3rem;
83
+  }
84
+  #righe-table tr.riga-row.is-scaduta {
85
+    background: #f6f7f8;
86
+  }
87
+  #righe-table tr.riga-row.is-scaduta td {
88
+    opacity: .78;
89
+  }
90
+  #righe-table tr.riga-row.is-futura {
91
+    background: #f5f8ff;
92
+  }
93
+  #righe-table tr.riga-row.is-attiva .riga-validita-field span {
94
+    color: #0d9488;
95
+  }
96
+</style>
97
+@endsection
98
+
14 99
 @section('content')
15 100
 <div class="d-flex flex-wrap align-items-center gap-2 mb-4 pb-3 border-bottom">
16 101
   <button type="submit" form="formContrattoServizio" class="btn btn-primary btn-sm">
@@ -98,12 +183,12 @@
98 183
         <div class="col-md-2">
99 184
           <label class="form-label">Costo (€)</label>
100 185
           <input type="number" step="0.01" min="0" name="costo" id="contratto_costo" class="form-control"
101
-            value="{{ old('costo', $contratto->costo) }}">
102
-          <div class="form-text">Aggiornato dai servizi inclusi</div>
186
+            value="{{ old('costo', $contratto->totale_righe ?: $contratto->costo) }}">
187
+          <div class="form-text">Calcolato dai servizi (qty × prezzo netto × durata)</div>
103 188
         </div>
104 189
         <div class="col-md-3">
105 190
           <label class="form-label">Periodo</label>
106
-          <select name="periodo" class="form-select">
191
+          <select name="periodo" id="contratto_periodo" class="form-select">
107 192
             <option value="">—</option>
108 193
             @foreach($periodiContratto as $val => $label)
109 194
             <option value="{{ $val }}" @selected(old('periodo', $contratto->periodo) === $val)>{{ $label }}</option>
@@ -112,12 +197,12 @@
112 197
         </div>
113 198
         <div class="col-md-2">
114 199
           <label class="form-label">Data inizio</label>
115
-          <input type="date" name="data_inizio" class="form-control"
200
+          <input type="date" name="data_inizio" id="contratto_data_inizio" class="form-control"
116 201
             value="{{ old('data_inizio', optional($contratto->data_inizio)->format('Y-m-d') ?? $contratto->data_inizio) }}">
117 202
         </div>
118 203
         <div class="col-md-2">
119 204
           <label class="form-label">Data fine</label>
120
-          <input type="date" name="data_fine" class="form-control"
205
+          <input type="date" name="data_fine" id="contratto_data_fine" class="form-control"
121 206
             value="{{ old('data_fine', optional($contratto->data_fine)->format('Y-m-d') ?? $contratto->data_fine) }}">
122 207
         </div>
123 208
         <div class="col-md-2">
@@ -127,6 +212,16 @@
127 212
             <input class="form-check-input" type="checkbox" name="attivo" value="1" @checked(old('attivo', $contratto->attivo))>
128 213
           </div>
129 214
         </div>
215
+        <div class="col-md-4">
216
+          <label class="form-label d-block">Default import email</label>
217
+          <div class="form-check form-switch mt-2">
218
+            <input type="hidden" name="default_import_email" value="0">
219
+            <input class="form-check-input" type="checkbox" name="default_import_email" value="1" id="default_import_email"
220
+              @checked(old('default_import_email', $contratto->default_import_email))>
221
+            <label class="form-check-label" for="default_import_email">Contratto di default per segnalazioni da email</label>
222
+          </div>
223
+          <div class="form-text">Se attivo, le mail nuove di questa azienda si agganciano a questo contratto (uno solo per azienda).</div>
224
+        </div>
130 225
         <div class="col-12">
131 226
           <label class="form-label">Note</label>
132 227
           <textarea name="note" class="form-control" rows="2">{{ old('note', $contratto->note) }}</textarea>
@@ -136,22 +231,33 @@
136 231
   </div>
137 232
 
138 233
   <div class="card mb-4">
139
-    <div class="card-header d-flex justify-content-between align-items-center">
234
+    <div class="card-header d-flex justify-content-between align-items-start gap-3">
140 235
       <div>
141
-        <h5 class="mb-0">Servizi / prodotti inclusi</h5>
142
-        <small class="text-muted">Come nel template, adattabili per questo contratto</small>
236
+        <h5 class="mb-1">Servizi / prodotti inclusi</h5>
237
+        <p class="text-muted small mb-0">
238
+          <strong>Unità di misura</strong> = cosa stai contando insieme alla quantità (es. 20 <em>ore</em>, 5 <em>device</em>).<br>
239
+          <strong>Periodo</strong> = ogni quanto vale quella quantità/prezzo (<em>mensile</em>, <em>annuale</em>, <em>una tantum</em>).
240
+          Esempio: 20 ore <em>mensili</em> non è lo stesso di 20 ore <em>annuali</em>.
241
+          Se l’unità è già un tempo (mese, anno) o un forfait, il periodo non compare.<br>
242
+          <strong>A consumo</strong> = solo tariffa extra (es. 40 €/ora): compare nel contratto, non è un monte ore e non entra nel canone. Le ore sulle segnalazioni si fatturano a parte.<br>
243
+          <strong>Validità</strong> (Dal → Al, sotto il prodotto) = da quando vale questa riga. Vuoto = tutta la durata del contratto.
244
+          Per un aumento tariffe: chiudi la riga vecchia con <em>Al</em>, aggiungine una nuova con <em>Dal</em> e il prezzo aggiornato.
245
+        </p>
143 246
       </div>
144
-      <button type="button" class="btn btn-sm btn-outline-primary" id="add-riga-row"><i class="bx bx-plus"></i> Aggiungi</button>
247
+      <button type="button" class="btn btn-sm btn-outline-primary flex-shrink-0" id="add-riga-row"><i class="bx bx-plus"></i> Aggiungi</button>
145 248
     </div>
146 249
     <div class="table-responsive">
147 250
       <table class="table mb-0 align-middle" id="righe-table">
148 251
         <thead>
149 252
           <tr>
150
-            <th style="min-width:200px">Prodotto</th>
253
+            <th class="riga-drag-cell"></th>
254
+            <th style="min-width:240px">Prodotto</th>
151 255
             <th style="min-width:80px">Qty</th>
152 256
             <th style="min-width:80px">U.M.</th>
153 257
             <th style="min-width:100px">Prezzo</th>
154
-            <th style="min-width:120px">Periodo</th>
258
+            <th style="min-width:150px">Sconto</th>
259
+            <th style="min-width:90px" class="text-end">Prezzo netto</th>
260
+            <th class="col-periodo" style="min-width:120px">Periodo</th>
155 261
             <th style="min-width:140px">Note</th>
156 262
             <th></th>
157 263
           </tr>
@@ -159,6 +265,11 @@
159 265
         <tbody>
160 266
           @foreach($contratto->righe as $i => $riga)
161 267
           <tr class="riga-row">
268
+            <td class="riga-drag-cell">
269
+              <button type="button" class="riga-drag-handle" title="Trascina per riordinare" aria-label="Trascina per riordinare">
270
+                <i class="bx bx-menu"></i>
271
+              </button>
272
+            </td>
162 273
             <td>
163 274
               <input type="hidden" name="righe[{{ $i }}][id]" value="{{ $riga->id }}">
164 275
               <select name="righe[{{ $i }}][prodotto_id]" class="form-select form-select-sm riga-prodotto">
@@ -171,6 +282,7 @@
171 282
                   @selected((int)$riga->prodotto_id === (int)$p->id)>{{ $p->nome }}</option>
172 283
                 @endforeach
173 284
               </select>
285
+              @include('contratto_servizio.partials.riga_validita_fields', ['i' => $i, 'riga' => $riga])
174 286
             </td>
175 287
             <td><input type="number" step="0.01" min="0" name="righe[{{ $i }}][quantita]" class="form-control form-control-sm riga-qty" value="{{ $riga->quantita }}"></td>
176 288
             <td>
@@ -183,6 +295,21 @@
183 295
             </td>
184 296
             <td><input type="number" step="0.01" min="0" name="righe[{{ $i }}][prezzo_unitario]" class="form-control form-control-sm riga-prezzo" value="{{ $riga->prezzo_unitario }}"></td>
185 297
             <td>
298
+              <div class="input-group input-group-sm">
299
+                <select name="righe[{{ $i }}][sconto_tipo]" class="form-select riga-sconto-tipo" style="max-width:4.2rem">
300
+                  <option value="percentuale" @selected(($riga->sconto_tipo ?: 'percentuale') === 'percentuale')>%</option>
301
+                  <option value="importo" @selected($riga->sconto_tipo === 'importo')>€</option>
302
+                </select>
303
+                <input type="number" step="0.01" min="0" @if(($riga->sconto_tipo ?: 'percentuale') === 'percentuale') max="100" @endif
304
+                  name="righe[{{ $i }}][sconto_valore]"
305
+                  class="form-control riga-sconto-valore"
306
+                  value="{{ $riga->sconto_valore }}" placeholder="0">
307
+              </div>
308
+            </td>
309
+            <td class="text-end text-nowrap riga-netto">
310
+              {{ $riga->prezzo_netto !== null ? number_format($riga->prezzo_netto, 2, ',', '.').' €' : '—' }}
311
+            </td>
312
+            <td class="field-periodo">
186 313
               <select name="righe[{{ $i }}][periodo]" class="form-select form-select-sm riga-periodo">
187 314
                 <option value="">—</option>
188 315
                 @foreach($periodi as $val => $label)
@@ -308,6 +435,28 @@
308 435
     return html;
309 436
   }
310 437
 
438
+  function rigaNeedsPeriodo(um) {
439
+    return !!um && ['mese', 'anno', 'forfait'].indexOf(um) === -1;
440
+  }
441
+
442
+  function refreshRighePeriodo() {
443
+    let needPeriodo = false;
444
+    righeBody.querySelectorAll('tr.riga-row').forEach(function (tr) {
445
+      const um = ((tr.querySelector('.riga-um') || {}).value) || '';
446
+      const show = rigaNeedsPeriodo(um);
447
+      if (show) needPeriodo = true;
448
+      tr.querySelectorAll('.field-periodo').forEach(function (el) {
449
+        el.classList.toggle('d-none', !show);
450
+      });
451
+      const periodo = tr.querySelector('.riga-periodo');
452
+      if (periodo && !show) periodo.value = '';
453
+    });
454
+    const hasRows = righeBody.querySelectorAll('tr.riga-row').length > 0;
455
+    document.querySelectorAll('#righe-table thead .col-periodo').forEach(function (el) {
456
+      el.classList.toggle('d-none', hasRows && !needPeriodo);
457
+    });
458
+  }
459
+
311 460
   function fillFromProdotto(select) {
312 461
     const opt = select.options[select.selectedIndex];
313 462
     if (!opt || !opt.value) return;
@@ -317,45 +466,183 @@
317 466
     const periodo = tr.querySelector('.riga-periodo');
318 467
     if (um && !um.value) um.value = opt.dataset.um || '';
319 468
     if (prezzo && (prezzo.value === '' || prezzo.value === null)) prezzo.value = opt.dataset.prezzo || '';
320
-    if (periodo && !periodo.value) periodo.value = opt.dataset.periodo || '';
469
+    if (periodo) {
470
+      const umVal = (um && um.value) || opt.dataset.um || '';
471
+      if (rigaNeedsPeriodo(umVal)) {
472
+        if (!periodo.value) periodo.value = opt.dataset.periodo || '';
473
+      } else {
474
+        periodo.value = '';
475
+      }
476
+    }
321 477
     aggiornaCostoContratto();
478
+    refreshRighePeriodo();
479
+  }
480
+
481
+  function rigaPrezzoNetto(tr) {
482
+    const prezzo = parseFloat((tr.querySelector('.riga-prezzo') || {}).value);
483
+    if (!Number.isFinite(prezzo)) return null;
484
+    const tipo = ((tr.querySelector('.riga-sconto-tipo') || {}).value) || 'percentuale';
485
+    let scontoVal = parseFloat((tr.querySelector('.riga-sconto-valore') || {}).value);
486
+    if (!Number.isFinite(scontoVal) || scontoVal < 0) scontoVal = 0;
487
+    let sconto = 0;
488
+    if (tipo === 'percentuale') {
489
+      sconto = prezzo * Math.min(100, scontoVal) / 100;
490
+    } else if (tipo === 'importo') {
491
+      sconto = Math.min(prezzo, scontoVal);
492
+    }
493
+    return Math.round(Math.max(0, prezzo - sconto) * 100) / 100;
494
+  }
495
+
496
+  function rigaValiditaHtml(idx, dal, al) {
497
+    return '<div class="riga-validita">' +
498
+      '<div class="riga-validita-inputs">' +
499
+        '<label class="riga-validita-field"><span>Dal</span>' +
500
+          '<input type="date" name="righe[' + idx + '][data_inizio]" class="form-control form-control-sm riga-dal" value="' + (dal || '') + '">' +
501
+        '</label>' +
502
+        '<span class="riga-validita-sep">→</span>' +
503
+        '<label class="riga-validita-field"><span>Al</span>' +
504
+          '<input type="date" name="righe[' + idx + '][data_fine]" class="form-control form-control-sm riga-al" value="' + (al || '') + '">' +
505
+        '</label>' +
506
+      '</div></div>';
507
+  }
508
+
509
+  function mesiTraDate(dal, al) {
510
+    if (!dal || !al) return null;
511
+    const from = new Date(dal + 'T00:00:00');
512
+    const to = new Date(al + 'T00:00:00');
513
+    if (to < from) return null;
514
+    const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth());
515
+    const dayFrac = (to.getDate() - from.getDate()) / 30;
516
+    return Math.max(1, Math.round(months + dayFrac));
517
+  }
518
+
519
+  function mesiDurataContratto() {
520
+    const dal = (document.getElementById('contratto_data_inizio') || {}).value;
521
+    const al = (document.getElementById('contratto_data_fine') || {}).value;
522
+    const mesi = mesiTraDate(dal, al);
523
+    if (mesi) return mesi;
524
+    switch ((document.getElementById('contratto_periodo') || {}).value) {
525
+      case 'mensile': return 1;
526
+      case 'trimestrale': return 3;
527
+      case 'semestrale': return 6;
528
+      case 'annuale': return 12;
529
+      default: return 1;
530
+    }
531
+  }
532
+
533
+  function rigaMesiValidita(tr) {
534
+    const dalRiga = ((tr.querySelector('.riga-dal') || {}).value) || '';
535
+    const alRiga = ((tr.querySelector('.riga-al') || {}).value) || '';
536
+    const dalContratto = (document.getElementById('contratto_data_inizio') || {}).value || '';
537
+    const alContratto = (document.getElementById('contratto_data_fine') || {}).value || '';
538
+    const mesi = mesiTraDate(dalRiga || dalContratto, alRiga || alContratto);
539
+    return mesi || mesiDurataContratto();
540
+  }
541
+
542
+  function refreshRigheValidita() {
543
+    const oggi = new Date();
544
+    oggi.setHours(0, 0, 0, 0);
545
+    righeBody.querySelectorAll('tr.riga-row').forEach(function (tr) {
546
+      const dal = ((tr.querySelector('.riga-dal') || {}).value) || '';
547
+      const al = ((tr.querySelector('.riga-al') || {}).value) || '';
548
+      tr.classList.remove('is-scaduta', 'is-futura', 'is-attiva');
549
+      if (!dal && !al) return;
550
+      if (dal) {
551
+        const from = new Date(dal + 'T00:00:00');
552
+        if (from > oggi) {
553
+          tr.classList.add('is-futura');
554
+          return;
555
+        }
556
+      }
557
+      if (al) {
558
+        const to = new Date(al + 'T00:00:00');
559
+        if (to < oggi) {
560
+          tr.classList.add('is-scaduta');
561
+          return;
562
+        }
563
+      }
564
+      tr.classList.add('is-attiva');
565
+    });
566
+  }
567
+
568
+  function rigaMoltiplicatore(tr) {
569
+    const um = ((tr.querySelector('.riga-um') || {}).value) || '';
570
+    if (['mese', 'anno', 'forfait'].indexOf(um) !== -1) return 1;
571
+    const periodo = ((tr.querySelector('.riga-periodo') || {}).value) || '';
572
+    if (!periodo || periodo === 'una_tantum' || periodo === 'a_consumo') return 1;
573
+    const mesi = rigaMesiValidita(tr);
574
+    if (periodo === 'mensile') return Math.max(1, mesi);
575
+    if (periodo === 'trimestrale') return Math.max(1, Math.floor(mesi / 3));
576
+    if (periodo === 'annuale') return Math.max(1, Math.floor(mesi / 12));
577
+    return 1;
578
+  }
579
+
580
+  function rigaImporto(tr) {
581
+    const periodo = ((tr.querySelector('.riga-periodo') || {}).value) || '';
582
+    if (periodo === 'a_consumo') return 0;
583
+    const prezzoNetto = rigaPrezzoNetto(tr);
584
+    if (prezzoNetto === null) return null;
585
+    const qty = parseFloat((tr.querySelector('.riga-qty') || {}).value);
586
+    const mol = rigaMoltiplicatore(tr);
587
+    return Math.round((Number.isFinite(qty) ? qty : 1) * mol * prezzoNetto * 100) / 100;
588
+  }
589
+
590
+  function formatEuro(n) {
591
+    return n.toLocaleString('it-IT', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
322 592
   }
323 593
 
324 594
   function aggiornaCostoContratto() {
325 595
     const costoInput = document.getElementById('contratto_costo');
326
-    if (!costoInput) return;
327 596
     let totale = 0;
328 597
     righeBody.querySelectorAll('tr.riga-row').forEach(function (tr) {
329 598
       const prodotto = tr.querySelector('.riga-prodotto');
330
-      if (!prodotto || !prodotto.value) return;
331
-      const qty = parseFloat((tr.querySelector('.riga-qty') || {}).value);
332
-      const prezzo = parseFloat((tr.querySelector('.riga-prezzo') || {}).value);
333
-      if (Number.isFinite(prezzo)) {
334
-        totale += (Number.isFinite(qty) ? qty : 1) * prezzo;
599
+      const nettoCell = tr.querySelector('.riga-netto');
600
+      if (!prodotto || !prodotto.value) {
601
+        if (nettoCell) nettoCell.textContent = '—';
602
+        return;
335 603
       }
604
+      const prezzoNetto = rigaPrezzoNetto(tr);
605
+      const netto = rigaImporto(tr);
606
+      if (nettoCell) nettoCell.textContent = prezzoNetto === null ? '—' : formatEuro(prezzoNetto);
607
+      if (netto !== null) totale += netto;
336 608
     });
337
-    costoInput.value = Math.round(totale * 100) / 100;
609
+    if (costoInput) {
610
+      costoInput.value = Math.round(totale * 100) / 100;
611
+    }
338 612
   }
339 613
 
340 614
   document.getElementById('add-riga-row').addEventListener('click', function () {
341 615
     const tr = document.createElement('tr');
342 616
     tr.className = 'riga-row';
343 617
     tr.innerHTML =
344
-      '<td><select name="righe[' + rigaIdx + '][prodotto_id]" class="form-select form-select-sm riga-prodotto">' + prodottiOptionsHtml() + '</select></td>' +
618
+      '<td class="riga-drag-cell"><button type="button" class="riga-drag-handle" title="Trascina per riordinare" aria-label="Trascina per riordinare"><i class="bx bx-menu"></i></button></td>' +
619
+      '<td><select name="righe[' + rigaIdx + '][prodotto_id]" class="form-select form-select-sm riga-prodotto">' + prodottiOptionsHtml() + '</select>' + rigaValiditaHtml(rigaIdx) + '</td>' +
345 620
       '<td><input type="number" step="0.01" min="0" name="righe[' + rigaIdx + '][quantita]" class="form-control form-control-sm riga-qty" value="1"></td>' +
346 621
       '<td><select name="righe[' + rigaIdx + '][unita_misura]" class="form-select form-select-sm riga-um">' + optionsHtml(unitaMisura) + '</select></td>' +
347 622
       '<td><input type="number" step="0.01" min="0" name="righe[' + rigaIdx + '][prezzo_unitario]" class="form-control form-control-sm riga-prezzo"></td>' +
348
-      '<td><select name="righe[' + rigaIdx + '][periodo]" class="form-select form-select-sm riga-periodo">' + optionsHtml(periodi) + '</select></td>' +
623
+      '<td><div class="input-group input-group-sm">' +
624
+        '<select name="righe[' + rigaIdx + '][sconto_tipo]" class="form-select riga-sconto-tipo" style="max-width:4.2rem">' +
625
+          '<option value="percentuale" selected>%</option>' +
626
+          '<option value="importo">€</option>' +
627
+        '</select>' +
628
+        '<input type="number" step="0.01" min="0" max="100" name="righe[' + rigaIdx + '][sconto_valore]" class="form-control riga-sconto-valore" placeholder="0">' +
629
+      '</div></td>' +
630
+      '<td class="text-end text-nowrap riga-netto">—</td>' +
631
+      '<td class="field-periodo"><select name="righe[' + rigaIdx + '][periodo]" class="form-select form-select-sm riga-periodo">' + optionsHtml(periodi) + '</select></td>' +
349 632
       '<td><input type="text" name="righe[' + rigaIdx + '][note]" class="form-control form-control-sm"></td>' +
350 633
       '<td><button type="button" class="btn btn-sm btn-icon btn-label-danger remove-riga"><i class="bx bx-trash"></i></button></td>';
351 634
     righeBody.appendChild(tr);
352 635
     rigaIdx++;
636
+    refreshRighePeriodo();
637
+    refreshRigheValidita();
353 638
   });
354 639
   righeBody.addEventListener('click', function (e) {
355 640
     const b = e.target.closest('.remove-riga');
356 641
     if (b) {
357 642
       b.closest('tr').remove();
358 643
       aggiornaCostoContratto();
644
+      refreshRighePeriodo();
645
+      refreshRigheValidita();
359 646
     }
360 647
   });
361 648
   righeBody.addEventListener('change', function (e) {
@@ -363,12 +650,28 @@
363 650
       fillFromProdotto(e.target);
364 651
       return;
365 652
     }
366
-    if (e.target.matches('.riga-qty') || e.target.matches('.riga-prezzo')) {
653
+    if (e.target.matches('.riga-um')) {
654
+      refreshRighePeriodo();
367 655
       aggiornaCostoContratto();
656
+      return;
657
+    }
658
+    if (e.target.matches('.riga-sconto-tipo')) {
659
+      const val = e.target.closest('tr').querySelector('.riga-sconto-valore');
660
+      if (val) {
661
+        if (e.target.value === 'percentuale') {
662
+          val.max = '100';
663
+        } else {
664
+          val.removeAttribute('max');
665
+        }
666
+      }
667
+    }
668
+    if (e.target.matches('.riga-qty') || e.target.matches('.riga-prezzo') || e.target.matches('.riga-sconto-tipo') || e.target.matches('.riga-sconto-valore') || e.target.matches('.riga-periodo') || e.target.matches('.riga-dal') || e.target.matches('.riga-al')) {
669
+      aggiornaCostoContratto();
670
+      refreshRigheValidita();
368 671
     }
369 672
   });
370 673
   righeBody.addEventListener('input', function (e) {
371
-    if (e.target.matches('.riga-qty') || e.target.matches('.riga-prezzo')) {
674
+    if (e.target.matches('.riga-qty') || e.target.matches('.riga-prezzo') || e.target.matches('.riga-sconto-valore')) {
372 675
       aggiornaCostoContratto();
373 676
     }
374 677
   });
@@ -437,7 +740,41 @@
437 740
     if (e.target.matches('.tipo-schedulazione') || e.target.matches('.cron-ricorrenza')) refreshAll();
438 741
   });
439 742
 
743
+  ['contratto_periodo', 'contratto_data_inizio', 'contratto_data_fine'].forEach(function (id) {
744
+    const el = document.getElementById(id);
745
+    if (el) el.addEventListener('change', aggiornaCostoContratto);
746
+  });
747
+
440 748
   refreshAll();
749
+  refreshRighePeriodo();
750
+  refreshRigheValidita();
751
+  aggiornaCostoContratto();
752
+
753
+  function reindexRighe() {
754
+    righeBody.querySelectorAll('tr.riga-row').forEach(function (tr, i) {
755
+      tr.querySelectorAll('[name^="righe["]').forEach(function (el) {
756
+        el.name = el.name.replace(/^righe\[\d+]/, 'righe[' + i + ']');
757
+      });
758
+    });
759
+  }
760
+
761
+  function initRigheSortable() {
762
+    if (!window.Sortable || !righeBody || righeBody.dataset.sortableInit) return;
763
+    window.Sortable.create(righeBody, {
764
+      animation: 150,
765
+      handle: '.riga-drag-handle',
766
+      draggable: 'tr.riga-row',
767
+      ghostClass: 'riga-row-ghost',
768
+      chosenClass: 'riga-row-chosen',
769
+      forceFallback: true,
770
+      fallbackOnBody: true,
771
+      fallbackTolerance: 4,
772
+      onEnd: reindexRighe
773
+    });
774
+    righeBody.dataset.sortableInit = '1';
775
+  }
776
+  initRigheSortable();
777
+  window.addEventListener('load', initRigheSortable);
441 778
 })();
442 779
 </script>
443 780
 @endsection

+ 0
- 3
resources/views/contratto_servizio/menu.blade.php 查看文件

@@ -28,9 +28,6 @@ $mode = $mode ?? 'index';
28 28
       </a>
29 29
     </li>
30 30
     @endcan
31
-    @if($mode === 'embed' && Auth::user()->can('edit-contratto-servizio'))
32
-    <li><a href="#" class="dropdown-item editor_edit"><i class="bx bx-pencil me-1"></i> Modifica rapida</a></li>
33
-    @endif
34 31
     @if(Auth::user()->can('remove-contratto-servizio'))
35 32
     <div class="dropdown-divider"></div>
36 33
     <li>

+ 19
- 0
resources/views/contratto_servizio/partials/riga_validita_chip.blade.php 查看文件

@@ -0,0 +1,19 @@
1
+@php
2
+  $stato = $riga->validita_stato;
3
+@endphp
4
+@if($riga->haValidita())
5
+  @if($stato === 'scaduta')
6
+  <span class="riga-validita-chip riga-validita-chip--scaduta">
7
+    <i class="bx bx-calendar-x"></i> Scaduta · {{ $riga->validita_label }}
8
+  </span>
9
+  @elseif($stato === 'futura')
10
+  <span class="riga-validita-chip riga-validita-chip--futura">
11
+    <i class="bx bx-calendar-plus"></i> Dal {{ $riga->data_inizio->format('d/m/Y') }}
12
+    @if($riga->data_fine) · fino al {{ $riga->data_fine->format('d/m/Y') }}@endif
13
+  </span>
14
+  @else
15
+  <span class="riga-validita-chip riga-validita-chip--attiva">
16
+    <i class="bx bx-calendar-check"></i> {{ $riga->validita_label }}
17
+  </span>
18
+  @endif
19
+@endif

+ 21
- 0
resources/views/contratto_servizio/partials/riga_validita_fields.blade.php 查看文件

@@ -0,0 +1,21 @@
1
+{{--
2
+  Validità riga contratto: Dal → Al sotto il prodotto.
3
+  $i = indice name, $riga = modello o null
4
+--}}
5
+@php
6
+  $dal = old('righe.'.$i.'.data_inizio', optional($riga->data_inizio ?? null)?->format('Y-m-d'));
7
+  $al = old('righe.'.$i.'.data_fine', optional($riga->data_fine ?? null)?->format('Y-m-d'));
8
+@endphp
9
+<div class="riga-validita">
10
+  <div class="riga-validita-inputs">
11
+    <label class="riga-validita-field">
12
+      <span>Dal</span>
13
+      <input type="date" name="righe[{{ $i }}][data_inizio]" class="form-control form-control-sm riga-dal" value="{{ $dal }}">
14
+    </label>
15
+    <span class="riga-validita-sep" aria-hidden="true">→</span>
16
+    <label class="riga-validita-field">
17
+      <span>Al</span>
18
+      <input type="date" name="righe[{{ $i }}][data_fine]" class="form-control form-control-sm riga-al" value="{{ $al }}">
19
+    </label>
20
+  </div>
21
+</div>

+ 31
- 0
resources/views/contratto_servizio/partials/riga_validita_styles.blade.php 查看文件

@@ -0,0 +1,31 @@
1
+<style>
2
+  .riga-validita-chip {
3
+    display: flex;
4
+    width: fit-content;
5
+    align-items: center;
6
+    gap: .3rem;
7
+    margin-top: .35rem;
8
+    padding: .15rem .55rem;
9
+    border-radius: 1rem;
10
+    font-size: .75rem;
11
+    line-height: 1.3;
12
+    white-space: nowrap;
13
+  }
14
+  .riga-validita-chip i { font-size: .95rem; }
15
+  .riga-validita-chip--attiva {
16
+    color: #0d9488;
17
+    background: rgba(13, 148, 136, .1);
18
+  }
19
+  .riga-validita-chip--futura {
20
+    color: #2563eb;
21
+    background: rgba(37, 99, 235, .1);
22
+  }
23
+  .riga-validita-chip--scaduta {
24
+    color: #6b7280;
25
+    background: rgba(107, 114, 128, .12);
26
+  }
27
+  tr.riga-scaduta td { color: #8a9099; }
28
+  tr.riga-futura td:first-child { box-shadow: inset 3px 0 0 #60a5fa; }
29
+  tr.riga-scaduta td:first-child { box-shadow: inset 3px 0 0 #c5c9d0; }
30
+  tr.riga-attiva td:first-child { box-shadow: inset 3px 0 0 #2dd4bf; }
31
+</style>

+ 67
- 58
resources/views/contratto_servizio/pdf.blade.php 查看文件

@@ -2,49 +2,28 @@
2 2
 <html lang="it">
3 3
 <head>
4 4
   <meta charset="UTF-8">
5
-  <style>
6
-    @page { margin: 18mm 15mm 20mm 15mm; }
7
-    body {
8
-      font-family: 'DejaVu Sans', Arial, sans-serif;
9
-      font-size: 12px;
10
-      line-height: 1.45;
11
-      color: #222;
12
-      margin: 0;
13
-      padding: 0;
14
-    }
15
-    h1 { font-size: 20px; margin: 0 0 6px 0; }
16
-    h2 { font-size: 14px; margin: 22px 0 8px 0; border-bottom: 1px solid #ccc; padding-bottom: 4px; }
17
-    .muted { color: #666; }
18
-    .header { margin-bottom: 18px; border-bottom: 2px solid #333; padding-bottom: 12px; }
19
-    .meta td { padding: 3px 8px 3px 0; vertical-align: top; border: none; }
20
-    table.meta { width: 100%; border-collapse: collapse; }
21
-    table.lines {
22
-      width: 100%;
23
-      border-collapse: collapse;
24
-      margin-top: 6px;
25
-    }
26
-    table.lines th, table.lines td {
27
-      border: 1px solid #999;
28
-      padding: 5px 6px;
29
-      text-align: left;
30
-    }
31
-    table.lines th { background: #f2f2f2; font-size: 11px; }
32
-    .text-end { text-align: right; }
33
-    .totals { margin-top: 10px; text-align: right; font-weight: bold; }
34
-    .notes { margin-top: 16px; padding: 8px; background: #f7f7f7; border: 1px solid #ddd; }
35
-  </style>
5
+  @include('pdf.styles')
36 6
 </head>
37 7
 <body>
38
-  <div class="header">
39
-    <h1>Contratto {{ $contratto->label }}</h1>
40
-    <div class="muted">Documento generato il {{ now()->format('d/m/Y H:i') }}</div>
8
+  @include('pdf.header')
9
+
10
+  @php
11
+    $documentoLabel = 'Contratto '.$contratto->label;
12
+    $imponibile = (float) $contratto->totale_righe;
13
+    $aliquotaIva = 22;
14
+    $importoIva = round($imponibile * $aliquotaIva / 100, 2);
15
+    $totaleIvato = round($imponibile + $importoIva, 2);
16
+  @endphp
17
+
18
+  <div class="pdf-doc-title">
19
+    Contratto {{ $contratto->label }}
41 20
   </div>
42 21
 
43
-  <table class="meta">
22
+  <table class="pdf-meta">
44 23
     <tr>
45 24
       <td style="width:50%">
46
-        <strong>Cliente / azienda</strong><br>
47
-        {{ optional($contratto->user)->full_name ?: '—' }}
25
+        <div class="label">Cliente / azienda</div>
26
+        <strong>{{ optional($contratto->user)->full_name ?: '—' }}</strong>
48 27
         @if(optional($contratto->user)->is_azienda)
49 28
         <span class="muted">(azienda)</span>
50 29
         @endif
@@ -53,59 +32,87 @@
53 32
         @endif
54 33
       </td>
55 34
       <td style="width:50%">
56
-        <strong>Validità</strong><br>
35
+        <div class="label">Validità</div>
57 36
         {{ $contratto->data_inizio ? \Carbon\Carbon::parse($contratto->data_inizio)->format('d/m/Y') : '—' }}
58 37
59
-        {{ $contratto->data_fine ? \Carbon\Carbon::parse($contratto->data_fine)->format('d/m/Y') : 'aperto' }}
38
+        {{ $contratto->data_fine ? \Carbon\Carbon::parse($contratto->data_fine)->format('d/m/Y') : 'in corso' }}
60 39
         <br>
61
-        <strong>Periodo:</strong> {{ $contratto->periodo_label }}
40
+        <span class="muted">Periodo: {{ $contratto->periodo_label }}</span>
62 41
         <br>
63
-        <strong>Costo:</strong>
64
-        {{ $contratto->costo !== null ? number_format((float)$contratto->costo, 2, ',', '.').' €' : '—' }}
42
+        <span class="muted">Costo: {{ number_format($contratto->totale_righe, 2, ',', '.') }} €</span>
65 43
       </td>
66 44
     </tr>
67
-    @if($contratto->contrattoTemplate)
68
-    <tr>
69
-      <td colspan="2"><strong>Template:</strong> {{ $contratto->contrattoTemplate->label }}</td>
70
-    </tr>
71
-    @endif
72 45
   </table>
73 46
 
74
-  <h2>Servizi / prodotti inclusi</h2>
75
-  <table class="lines">
47
+  <div class="pdf-section-title">Servizi / prodotti inclusi</div>
48
+  <table class="pdf-lines">
76 49
     <thead>
77 50
       <tr>
78 51
         <th>Prodotto</th>
79 52
         <th>Qty</th>
80 53
         <th>U.M.</th>
81 54
         <th>Prezzo</th>
55
+        <th>Sconto</th>
82 56
         <th>Periodo</th>
83 57
         <th class="text-end">Importo</th>
84 58
       </tr>
85 59
     </thead>
86 60
     <tbody>
87 61
       @forelse($contratto->righe as $riga)
88
-      <tr>
62
+      <tr class="{{ $riga->validita_stato === 'scaduta' ? 'riga-scaduta' : ($riga->validita_stato === 'futura' ? 'riga-futura' : '') }}">
89 63
         <td>
90 64
           {{ optional($riga->prodotto)->nome }}
65
+          @if($riga->haValidita())
66
+          <br><span class="pdf-validita {{ $riga->validita_stato === 'scaduta' ? 'pdf-validita-scaduta' : ($riga->validita_stato === 'futura' ? 'pdf-validita-futura' : '') }}">{{ $riga->validita_label }}</span>
67
+          @endif
91 68
           @if($riga->note)<br><span class="muted">{{ $riga->note }}</span>@endif
92 69
         </td>
93
-        <td>{{ $riga->quantita }}</td>
70
+        <td>
71
+          @if($riga->isAConsumo())
72
+          —
73
+          @else
74
+          {{ $riga->quantita }}
75
+          @if($riga->moltiplicatore_label)
76
+          <br><span class="muted">{{ $riga->moltiplicatore_label }}</span>
77
+          @endif
78
+          @endif
79
+        </td>
94 80
         <td>{{ \App\Models\Prodotto::UNITA_MISURA[$riga->unita_misura] ?? ($riga->unita_misura ?: '—') }}</td>
95
-        <td>{{ $riga->prezzo_unitario !== null ? number_format((float)$riga->prezzo_unitario, 2, ',', '.').' €' : '—' }}</td>
81
+        <td>
82
+          {{ $riga->prezzo_unitario !== null ? number_format((float)$riga->prezzo_unitario, 2, ',', '.').' €' : '—' }}@if($riga->isAConsumo() && $riga->unita_misura)/{{ strtolower(\App\Models\Prodotto::UNITA_MISURA[$riga->unita_misura] ?? $riga->unita_misura) }}@endif
83
+          {{-- @if($riga->sconto_su_prezzo > 0 && $riga->prezzo_netto !== null)
84
+          <br><span class="muted">netto {{ number_format($riga->prezzo_netto, 2, ',', '.') }} €</span>
85
+          @endif --}}
86
+        </td>
87
+        <td>{{ $riga->sconto_label }}</td>
96 88
         <td>{{ $riga->periodo_label }}</td>
97
-        <td class="text-end">{{ $riga->importo !== null ? number_format($riga->importo, 2, ',', '.').' €' : '—' }}</td>
89
+        <td class="text-end">{{ $riga->isAConsumo() ? 'fuori canone' : ($riga->importo !== null ? number_format($riga->importo, 2, ',', '.').' €' : '—') }}</td>
98 90
       </tr>
99 91
       @empty
100
-      <tr><td colspan="6">Nessun servizio</td></tr>
92
+      <tr><td colspan="7">Nessun servizio</td></tr>
101 93
       @endforelse
102 94
     </tbody>
103 95
   </table>
104
-  <div class="totals">Totale servizi: {{ number_format($contratto->totale_righe, 2, ',', '.') }} €</div>
96
+  <div class="pdf-totals">
97
+    <table>
98
+      <tr>
99
+        <td>Totale servizi</td>
100
+        <td>{{ number_format($imponibile, 2, ',', '.') }} €</td>
101
+      </tr>
102
+      <tr>
103
+        <td>IVA {{ $aliquotaIva }}%</td>
104
+        <td>{{ number_format($importoIva, 2, ',', '.') }} €</td>
105
+      </tr>
106
+      <tr>
107
+        <td>Totale IVA inclusa</td>
108
+        <td class="importo">{{ number_format($totaleIvato, 2, ',', '.') }} €</td>
109
+      </tr>
110
+    </table>
111
+  </div>
105 112
 
106 113
   @if($contratto->todos->count())
107
-  <h2>Attività programmate</h2>
108
-  <table class="lines">
114
+  <div class="pdf-section-title">Attività programmate</div>
115
+  <table class="pdf-lines">
109 116
     <thead>
110 117
       <tr>
111 118
         <th>Titolo</th>
@@ -129,10 +136,12 @@
129 136
   @endif
130 137
 
131 138
   @if($contratto->note)
132
-  <div class="notes">
139
+  <div class="pdf-notes">
133 140
     <strong>Note</strong><br>
134 141
     {{ $contratto->note }}
135 142
   </div>
136 143
   @endif
144
+
145
+  @include('pdf.footer', ['documentoLabel' => $documentoLabel])
137 146
 </body>
138 147
 </html>

+ 34
- 7
resources/views/contratto_servizio/show.blade.php 查看文件

@@ -11,6 +11,10 @@
11 11
 </div>
12 12
 @endsection
13 13
 
14
+@section('page-style')
15
+@include('contratto_servizio.partials.riga_validita_styles')
16
+@endsection
17
+
14 18
 @section('content')
15 19
 <div class="d-flex flex-wrap align-items-center gap-2 mb-4 pb-3 border-bottom">
16 20
   @can('edit-contratto-servizio')
@@ -66,6 +70,9 @@
66 70
           @else
67 71
           <span class="badge bg-label-secondary">Non attivo</span>
68 72
           @endif
73
+          @if($contratto->default_import_email)
74
+          <span class="badge bg-label-info">Default email</span>
75
+          @endif
69 76
         </div>
70 77
         <h5 class="card-title">Cliente / azienda</h5>
71 78
         @if($contratto->user)
@@ -79,7 +86,7 @@
79 86
         <hr>
80 87
         <p class="mb-1"><strong>Periodo:</strong> {{ $contratto->periodo_label }}</p>
81 88
         <p class="mb-1"><strong>Costo:</strong>
82
-          {{ $contratto->costo !== null ? number_format((float)$contratto->costo, 2, ',', '.').' €' : '—' }}
89
+          {{ number_format($contratto->totale_righe, 2, ',', '.') }} €
83 90
         </p>
84 91
         <p class="mb-1"><strong>Validità:</strong>
85 92
           {{ $contratto->data_inizio ? \Carbon\Carbon::parse($contratto->data_inizio)->format('d/m/Y') : '—' }}
@@ -107,22 +114,42 @@
107 114
               <th>Qty</th>
108 115
               <th>U.M.</th>
109 116
               <th>Prezzo</th>
117
+              <th>Sconto</th>
110 118
               <th>Periodo</th>
111 119
               <th class="text-end">Importo</th>
112 120
             </tr>
113 121
           </thead>
114 122
           <tbody>
115 123
             @forelse($contratto->righe as $riga)
116
-            <tr>
117
-              <td>{{ optional($riga->prodotto)->nome }}@if($riga->note)<br><small class="text-muted">{{ $riga->note }}</small>@endif</td>
118
-              <td>{{ $riga->quantita }}</td>
124
+            <tr class="{{ $riga->validita_stato === 'scaduta' ? 'riga-scaduta' : ($riga->validita_stato === 'futura' ? 'riga-futura' : ($riga->haValidita() ? 'riga-attiva' : '')) }}">
125
+              <td>
126
+                <div>{{ optional($riga->prodotto)->nome }}</div>
127
+                @include('contratto_servizio.partials.riga_validita_chip', ['riga' => $riga])
128
+                @if($riga->note)<small class="text-muted d-block mt-1">{{ $riga->note }}</small>@endif
129
+              </td>
130
+              <td>
131
+                @if($riga->isAConsumo())
132
+                —
133
+                @else
134
+                {{ $riga->quantita }}
135
+                @if($riga->moltiplicatore_label)
136
+                <br><small class="text-muted">{{ $riga->moltiplicatore_label }}</small>
137
+                @endif
138
+                @endif
139
+              </td>
119 140
               <td>{{ \App\Models\Prodotto::UNITA_MISURA[$riga->unita_misura] ?? ($riga->unita_misura ?: '—') }}</td>
120
-              <td>{{ $riga->prezzo_unitario !== null ? number_format((float)$riga->prezzo_unitario, 2, ',', '.').' €' : '—' }}</td>
141
+              <td>
142
+                {{ $riga->prezzo_unitario !== null ? number_format((float)$riga->prezzo_unitario, 2, ',', '.').' €' : '—' }}@if($riga->isAConsumo() && $riga->unita_misura)/{{ strtolower(\App\Models\Prodotto::UNITA_MISURA[$riga->unita_misura] ?? $riga->unita_misura) }}@endif
143
+                @if($riga->sconto_su_prezzo > 0 && $riga->prezzo_netto !== null)
144
+                <br><small class="text-muted">netto {{ number_format($riga->prezzo_netto, 2, ',', '.') }} €</small>
145
+                @endif
146
+              </td>
147
+              <td>{{ $riga->sconto_label }}</td>
121 148
               <td>{{ $riga->periodo_label }}</td>
122
-              <td class="text-end">{{ $riga->importo !== null ? number_format($riga->importo, 2, ',', '.').' €' : '—' }}</td>
149
+              <td class="text-end">{{ $riga->isAConsumo() ? 'fuori canone' : ($riga->importo !== null ? number_format($riga->importo, 2, ',', '.').' €' : '—') }}</td>
123 150
             </tr>
124 151
             @empty
125
-            <tr><td colspan="6" class="text-muted">Nessun servizio</td></tr>
152
+            <tr><td colspan="7" class="text-muted">Nessun servizio</td></tr>
126 153
             @endforelse
127 154
           </tbody>
128 155
         </table>

+ 1
- 1
resources/views/contratto_template/form.blade.php 查看文件

@@ -60,7 +60,7 @@
60 60
     <div class="card-header d-flex justify-content-between align-items-center">
61 61
       <div>
62 62
         <h5 class="mb-0">Servizi / prodotti inclusi</h5>
63
-        <small class="text-muted">Dal catalogo; verranno importati sul contratto cliente</small>
63
+        <small class="text-muted">Dal catalogo; verranno importati sul contratto cliente. Periodo <em>A consumo</em> = tariffa extra, senza monte ore e fuori canone.</small>
64 64
       </div>
65 65
       <button type="button" class="btn btn-sm btn-outline-primary" id="add-riga-row"><i class="bx bx-plus"></i> Aggiungi</button>
66 66
     </div>

+ 3
- 3
resources/views/contratto_template/show.blade.php 查看文件

@@ -86,11 +86,11 @@
86 86
         @foreach($template->righe as $riga)
87 87
         <tr>
88 88
           <td>{{ optional($riga->prodotto)->nome ?: '—' }}</td>
89
-          <td>{{ $riga->quantita }}</td>
89
+          <td>{{ $riga->isAConsumo() ? '—' : $riga->quantita }}</td>
90 90
           <td>{{ \App\Models\Prodotto::UNITA_MISURA[$riga->unita_misura] ?? ($riga->unita_misura ?: '—') }}</td>
91
-          <td>{{ $riga->prezzo_unitario !== null ? number_format((float)$riga->prezzo_unitario, 2, ',', '.').' €' : '—' }}</td>
91
+          <td>{{ $riga->prezzo_unitario !== null ? number_format((float)$riga->prezzo_unitario, 2, ',', '.').' €' : '—' }}@if($riga->isAConsumo() && $riga->unita_misura)/{{ strtolower(\App\Models\Prodotto::UNITA_MISURA[$riga->unita_misura] ?? $riga->unita_misura) }}@endif</td>
92 92
           <td>{{ $riga->periodo_label }}</td>
93
-          <td>{{ $riga->importo !== null ? number_format($riga->importo, 2, ',', '.').' €' : '—' }}</td>
93
+          <td>{{ $riga->isAConsumo() ? 'fuori canone' : ($riga->importo !== null ? number_format($riga->importo, 2, ',', '.').' €' : '—') }}</td>
94 94
           <td class="small text-muted">{{ $riga->note }}</td>
95 95
         </tr>
96 96
         @endforeach

+ 0
- 165
resources/views/dashboard/dashboard-reparto.blade.php 查看文件

@@ -1,165 +0,0 @@
1
-<?php
2
-use Illuminate\Support\Facades\Auth;
3
-use App\Models\Config;
4
-?>
5
-
6
-<x-guest-layout>
7
-  <style>
8
-    .btn-menu-reparto:hover {
9
-      box-shadow: var(--bs-box-shadow-lg)!important;
10
-      animation: pulse 2s infinite;
11
-      background-color: color-mix(in sRGB,var(--bs-paper-bg) var(--bs-bg-label-tint-amount),var(--bs-primary))!important;
12
-      color: var(--bs-primary)!important;
13
-    }
14
-    .btn-menu-reparto <i> {
15
-      color: var(--bs-primary)!important;
16
-    }
17
-  </style>
18
-  <div class="container-xxl flex-grow-1 p-0">
19
-    <div class="row justify-content-center mt-5">
20
-
21
-
22
-      <div class="col-12">
23
-        <div class="row mb-4">
24
-          <div class="col-12 text-center">
25
-            <h4 class="mb-1">
26
-              <i class="bx bx-building me-2"></i>
27
-              <!-- Dashboard  -->
28
-              Reparti
29
-            </h4>
30
-            <p class="text-muted mb-0">Scegli un reparto per visualizzare o creare segnalazioni</p>
31
-          </div>
32
-        </div>
33
-
34
-        <div class="row mb-4">
35
-          @if(session('success'))
36
-          <div class="col-12">
37
-            <div class="alert alert-success alert-dismissible fade show" role="alert">
38
-              <i class="bx bx-check-circle me-2"></i>
39
-              {{ session('success') }}
40
-              <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
41
-            </div>
42
-          </div>
43
-          @endif
44
-          @if(session('error'))
45
-          <div class="col-12">
46
-            <div class="alert alert-danger alert-dismissible fade show" role="alert">
47
-              <i class="bx bx-error-circle me-2"></i>
48
-              {{ session('error') }}
49
-              <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
50
-            </div>
51
-          </div>
52
-          @endif
53
-          @if(session('warning'))
54
-          <div class="col-12">
55
-            <div class="alert alert-warning alert-dismissible fade show" role="alert">
56
-              <i class="bx bx-warning me-2"></i>
57
-              {{ session('warning') }}
58
-              <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
59
-            </div>
60
-          </div>
61
-          @endif
62
-          @if(session('info'))
63
-          <div class="col-12">
64
-            <div class="alert alert-info alert-dismissible fade show" role="alert">
65
-              <i class="bx bx-info-circle me-2"></i>
66
-              {{ session('info') }}
67
-              <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
68
-            </div>
69
-          </div>
70
-          @endif
71
-        </div>
72
-
73
-
74
-        <div class="row mb-4">
75
-          <div class="col-12 col-md-6 col-lg-4 mx-auto">
76
-            <div class="input-group input-group-merge">
77
-              <span class="input-group-text">
78
-                <i class="bx bx-search"></i>
79
-              </span>
80
-              <input type="text"
81
-              class="form-control"
82
-              id="searchReparto"
83
-              placeholder="Cerca reparto...">
84
-            </div>
85
-          </div>
86
-        </div>
87
-
88
-        <div class="row g-4" id="repartiContainer">
89
-          @foreach($reparti as $reparto)
90
-          <div class="col-lg-4 col-md-6">
91
-            <div class="card shadow-sm h-100">
92
-              <div class="card-body">
93
-
94
-                <div class="d-flex align-items-center justify-content-between mb-3">
95
-                  <div class="d-flex align-items-center">
96
-                    <div class="avatar flex-shrink-0 me-3">
97
-                      <div class="avatar-initial bg-label-primary rounded">
98
-                        <i class="bx bx-building fs-4"></i>
99
-                      </div>
100
-                    </div>
101
-                    <div>
102
-                      <h5 class="mb-0 fw-semibold">{{ $reparto->label }}</h5>
103
-                    </div>
104
-                  </div>
105
-                </div>
106
-                <div class="d-flex gap-2">
107
-                  <a href="{{ route('segnalazione.reparto', ['token' => $reparto->token]) }}"
108
-                    class="btn btn-label-primary /*flex-fill*/">
109
-                    <i class="bx bx-flag me-1"></i>Segnalazioni
110
-                    <span class="badge badge-center" style="margin-left: 1em;">{{ $reparto->segnalazioni()->whereNotIn('stato', Config::getStatiSegnalazione('chiuse'))->count() }}</span>
111
-
112
-                  </a>
113
-                </div>
114
-              </div>
115
-            </div>
116
-          </div>
117
-          @endforeach
118
-
119
-        </div>
120
-      </div>
121
-    </div>
122
-    <script>
123
-      document.addEventListener('DOMContentLoaded', function() {
124
-        var searchInput = document.getElementById('searchReparto');
125
-        if (!searchInput) return;
126
-
127
-        searchInput.addEventListener('keyup', function() {
128
-          var searchTerm = this.value.toLowerCase();
129
-          var cards = document.querySelectorAll('#repartiContainer .col-lg-4');
130
-          var visibleCount = 0;
131
-
132
-          cards.forEach(function(card) {
133
-            var repartoName = card.querySelector('h5');
134
-            if (repartoName && repartoName.textContent.toLowerCase().includes(searchTerm)) {
135
-              card.style.display = '';
136
-              visibleCount++;
137
-            } else {
138
-              card.style.display = 'none';
139
-            }
140
-          });
141
-
142
-          // Mostra messaggio se non ci sono risultati
143
-          var noResultsMsg = document.getElementById('noResultsMessage');
144
-          if (visibleCount === 0 && searchTerm !== '') {
145
-            if (!noResultsMsg) {
146
-              var messageDiv = document.createElement('div');
147
-              messageDiv.className = 'col-12';
148
-              messageDiv.id = 'noResultsMessage';
149
-              messageDiv.innerHTML =
150
-              '<div class="text-center py-5">' +
151
-                '<i class="bx bx-search-alt-2 text-muted" style="font-size: 3rem;"></i>' +
152
-                '<p class="text-muted mt-3 mb-0">Nessun reparto trovato per "' + searchTerm + '"</p>' +
153
-                '</div>';
154
-                document.getElementById('repartiContainer').appendChild(messageDiv);
155
-              }
156
-            } else {
157
-              if (noResultsMsg) {
158
-                noResultsMsg.remove();
159
-              }
160
-            }
161
-          });
162
-        });
163
-      </script>
164
-
165
-    </x-guest-layout>

+ 210
- 1
resources/views/dashboard/index.blade.php 查看文件

@@ -33,7 +33,7 @@ $configData = Helper::appClasses();
33 33
   </div>
34 34
   @endcan
35 35
 
36
-  @canany(['view-segnalazione', 'view-own-segnalazione', 'view-segnalazione-assegnate'])
36
+  @canany(['view-segnalazione', 'view-segnalazione-assegnate'])
37 37
   <div class="col-sm-6 col-lg-4">
38 38
     <a href="{{ route('admin.segnalazione.index') }}" class="card h-100 text-decoration-none text-body shadow-sm">
39 39
       <div class="card-body d-flex align-items-center gap-3">
@@ -50,6 +50,215 @@ $configData = Helper::appClasses();
50 50
     </a>
51 51
   </div>
52 52
   @endcanany
53
+
54
+  @if(auth()->user()->can('view-own-segnalazione') && ! auth()->user()->can('view-segnalazione') && ! auth()->user()->can('view-segnalazione-assegnate'))
55
+  <div class="col-sm-6 col-lg-4">
56
+    <a href="{{ route('segnalazione.index') }}" class="card h-100 text-decoration-none text-body shadow-sm">
57
+      <div class="card-body d-flex align-items-center gap-3">
58
+        <div class="avatar avatar-md">
59
+          <span class="avatar-initial rounded bg-label-info">
60
+            <i class="bx bx-message-detail bx-md"></i>
61
+          </span>
62
+        </div>
63
+        <div>
64
+          <h5 class="mb-1">{{ ($isResponsabileAzienda ?? false) ? 'Segnalazioni azienda' : 'Le mie segnalazioni' }}</h5>
65
+          <small class="text-muted">{{ ($isResponsabileAzienda ?? false) ? 'Ticket della tua azienda' : 'Ticket aperti da te' }}</small>
66
+        </div>
67
+      </div>
68
+    </a>
69
+  </div>
70
+  @endif
53 71
 </div>
54 72
 
73
+@isset($totaleSegnalazioni)
74
+<div class="card mt-6 mb-4">
75
+  <div class="card-header border-bottom">
76
+    <div class="d-flex align-items-center">
77
+      <div class="avatar avatar-sm me-2">
78
+        <span class="avatar-initial rounded bg-label-primary">
79
+          <i class="bx bx-flag fs-4"></i>
80
+        </span>
81
+      </div>
82
+      <div>
83
+        <h5 class="mb-0">Segnalazioni</h5>
84
+        <p class="text-body-secondary mb-0">
85
+          {{ ($isResponsabileAzienda ?? false) ? 'Panoramica dei ticket della tua azienda' : 'Panoramica dei tuoi ticket' }}
86
+        </p>
87
+      </div>
88
+    </div>
89
+  </div>
90
+  <div class="card-body">
91
+    <div class="row">
92
+      <div class="col-lg-3 col-md-6 col-12 mb-4">
93
+        <a class="text-decoration-none" href="{{ route('segnalazione.index') }}">
94
+          <div class="card h-100">
95
+            <div class="card-body">
96
+              <div class="d-flex justify-content-between align-items-start">
97
+                <div>
98
+                  <span class="badge bg-label-primary mb-2">
99
+                    <i class="bx bx-flag me-1"></i>Totale
100
+                  </span>
101
+                  <h4 class="card-title mb-0">{{ $totaleSegnalazioni }}</h4>
102
+                  <p class="text-body-secondary mb-0">Segnalazioni Totali</p>
103
+                </div>
104
+                <div class="avatar flex-shrink-0">
105
+                  <span class="avatar-initial rounded bg-label-primary">
106
+                    <i class="bx bx-flag fs-4"></i>
107
+                  </span>
108
+                </div>
109
+              </div>
110
+            </div>
111
+          </div>
112
+        </a>
113
+      </div>
114
+      <div class="col-lg-3 col-md-6 col-12 mb-4">
115
+        <a class="text-decoration-none" href="{{ route('segnalazione.index', ['status' => 'aperte']) }}">
116
+          <div class="card h-100">
117
+            <div class="card-body">
118
+              <div class="d-flex justify-content-between align-items-start">
119
+                <div>
120
+                  <span class="badge bg-label-warning mb-2">
121
+                    <i class="bx bx-time me-1"></i>Aperte
122
+                  </span>
123
+                  <h4 class="card-title mb-0">{{ $segnalazioniAperte }}</h4>
124
+                  <p class="text-body-secondary mb-0">In Attesa</p>
125
+                </div>
126
+                <div class="avatar flex-shrink-0">
127
+                  <span class="avatar-initial rounded bg-label-warning">
128
+                    <i class="bx bx-time fs-4"></i>
129
+                  </span>
130
+                </div>
131
+              </div>
132
+            </div>
133
+          </div>
134
+        </a>
135
+      </div>
136
+      <div class="col-lg-3 col-md-6 col-12 mb-4">
137
+        <a class="text-decoration-none" href="{{ route('segnalazione.index', ['status' => 'in_lavorazione']) }}">
138
+          <div class="card h-100">
139
+            <div class="card-body">
140
+              <div class="d-flex justify-content-between align-items-start">
141
+                <div>
142
+                  <span class="badge bg-label-info mb-2">
143
+                    <i class="bx bx-cog me-1"></i>In Lavorazione
144
+                  </span>
145
+                  <h4 class="card-title mb-0">{{ $segnalazioniInLavorazione }}</h4>
146
+                  <p class="text-body-secondary mb-0">In Corso</p>
147
+                </div>
148
+                <div class="avatar flex-shrink-0">
149
+                  <span class="avatar-initial rounded bg-label-info">
150
+                    <i class="bx bx-cog fs-4"></i>
151
+                  </span>
152
+                </div>
153
+              </div>
154
+            </div>
155
+          </div>
156
+        </a>
157
+      </div>
158
+      <div class="col-lg-3 col-md-6 col-12 mb-4">
159
+        <a class="text-decoration-none" href="{{ route('segnalazione.index', ['status' => 'chiuse']) }}">
160
+          <div class="card h-100">
161
+            <div class="card-body">
162
+              <div class="d-flex justify-content-between align-items-start">
163
+                <div>
164
+                  <span class="badge bg-label-success mb-2">
165
+                    <i class="bx bx-check-circle me-1"></i>Chiuse
166
+                  </span>
167
+                  <h4 class="card-title mb-0">{{ $segnalazioniChiuse }}</h4>
168
+                  <p class="text-body-secondary mb-0">Completate</p>
169
+                </div>
170
+                <div class="avatar flex-shrink-0">
171
+                  <span class="avatar-initial rounded bg-label-success">
172
+                    <i class="bx bx-check-circle fs-4"></i>
173
+                  </span>
174
+                </div>
175
+              </div>
176
+            </div>
177
+          </div>
178
+        </a>
179
+      </div>
180
+    </div>
181
+  </div>
182
+</div>
183
+
184
+<div class="card mb-4">
185
+  <div class="card-header border-bottom">
186
+    <div class="d-flex align-items-center">
187
+      <div class="avatar avatar-sm me-2">
188
+        <span class="avatar-initial rounded bg-label-success">
189
+          <i class="bx bx-trending-up fs-4"></i>
190
+        </span>
191
+      </div>
192
+      <div>
193
+        <h5 class="mb-0">Performance</h5>
194
+        <p class="text-body-secondary mb-0">Tempi di gestione e stato dei ticket</p>
195
+      </div>
196
+    </div>
197
+  </div>
198
+  <div class="card-body">
199
+    <div class="row">
200
+      <div class="col-lg-4 col-md-6 col-12 mb-4 mb-lg-0">
201
+        <div class="card h-100">
202
+          <div class="card-body">
203
+            <div class="d-flex justify-content-between align-items-start">
204
+              <div>
205
+                <span class="badge bg-label-secondary mb-2">Media</span>
206
+                <h4 class="card-title mb-0">{{ $tempoMedioRisoluzione }}</h4>
207
+                <p class="text-body-secondary mb-0">Giorni per Risoluzione</p>
208
+              </div>
209
+              <div class="avatar flex-shrink-0">
210
+                <span class="avatar-initial rounded bg-label-secondary">
211
+                  <i class="bx bx-timer fs-4"></i>
212
+                </span>
213
+              </div>
214
+            </div>
215
+          </div>
216
+        </div>
217
+      </div>
218
+      <div class="col-lg-4 col-md-6 col-12 mb-4 mb-lg-0">
219
+        <div class="card h-100">
220
+          <div class="card-body">
221
+            <div class="d-flex justify-content-between align-items-start">
222
+              <div>
223
+                <span class="badge bg-label-info mb-2">Questo Mese</span>
224
+                <h4 class="card-title mb-0">{{ $chiuseQuestoMese }}</h4>
225
+                <p class="text-body-secondary mb-0">Segnalazioni Chiuse</p>
226
+              </div>
227
+              <div class="avatar flex-shrink-0">
228
+                <span class="avatar-initial rounded bg-label-info">
229
+                  <i class="bx bx-check-double fs-4"></i>
230
+                </span>
231
+              </div>
232
+            </div>
233
+          </div>
234
+        </div>
235
+      </div>
236
+      <div class="col-lg-4 col-md-6 col-12">
237
+        <a class="text-decoration-none" href="{{ route('segnalazione.index', ['status' => 'in_ritardo']) }}">
238
+          <div class="card h-100">
239
+            <div class="card-body">
240
+              <div class="d-flex justify-content-between align-items-start">
241
+                <div>
242
+                  <span class="badge bg-label-warning mb-2">
243
+                    <i class="bx bx-time me-1"></i>In Ritardo
244
+                  </span>
245
+                  <h4 class="card-title mb-0">{{ $segnalazioniInRitardo }}</h4>
246
+                  <p class="text-body-secondary mb-0">Aperte da &gt;7 giorni</p>
247
+                </div>
248
+                <div class="avatar flex-shrink-0">
249
+                  <span class="avatar-initial rounded bg-label-warning">
250
+                    <i class="bx bx-time fs-4"></i>
251
+                  </span>
252
+                </div>
253
+              </div>
254
+            </div>
255
+          </div>
256
+        </a>
257
+      </div>
258
+    </div>
259
+  </div>
260
+</div>
261
+@endisset
262
+
55 263
 @endsection
264
+

+ 149
- 0
resources/views/fatturazione/genera.blade.php 查看文件

@@ -0,0 +1,149 @@
1
+@php
2
+  use App\Models\Config;
3
+  $from = $esito['from'];
4
+  $to = $esito['to'];
5
+  $totali = $esito['totali'];
6
+@endphp
7
+
8
+@extends('layouts/layoutMaster')
9
+
10
+@section('title', 'Fatturazione '.$from->format('d/m/Y').' – '.$to->format('d/m/Y'))
11
+
12
+@section('pageTitle')
13
+<div class="d-flex flex-column">
14
+  <h4 class="mb-1">Generazione fatturazione</h4>
15
+  <span class="text-muted">{{ $from->format('d/m/Y') }} – {{ $to->format('d/m/Y') }}</span>
16
+</div>
17
+@endsection
18
+
19
+@section('page-style')
20
+<style>
21
+@media print {
22
+  .layout-navbar, .layout-menu, .btn, form, .layout-footer { display: none !important; }
23
+  .layout-page, .content-wrapper, .container-xxl { padding: 0 !important; margin: 0 !important; }
24
+  .accordion-collapse { display: block !important; height: auto !important; }
25
+}
26
+</style>
27
+@endsection
28
+
29
+@section('content')
30
+@if(session('success'))
31
+<div class="alert alert-success">{{ session('success') }}</div>
32
+@endif
33
+@if(session('error'))
34
+<div class="alert alert-danger">{{ session('error') }}</div>
35
+@endif
36
+
37
+<div class="d-flex flex-wrap align-items-center gap-2 mb-4 pb-3 border-bottom d-print-none">
38
+  <a href="{{ route('admin.fatturazione.index') }}" class="btn btn-label-secondary btn-sm">
39
+    <i class="bx bx-arrow-back me-1"></i>Periodo
40
+  </a>
41
+  <form method="GET" action="{{ route('admin.fatturazione.genera') }}" class="d-flex flex-wrap gap-2 align-items-end ms-auto">
42
+    <div>
43
+      <label class="form-label mb-0 small" for="mese">Mese</label>
44
+      <input type="month" name="mese" id="mese" class="form-control form-control-sm" value="{{ $mese }}">
45
+    </div>
46
+    <div>
47
+      <label class="form-label mb-0 small" for="data_da">Dal</label>
48
+      <input type="date" name="data_da" id="data_da" class="form-control form-control-sm" value="{{ $data_da }}">
49
+    </div>
50
+    <div>
51
+      <label class="form-label mb-0 small" for="data_a">Al</label>
52
+      <input type="date" name="data_a" id="data_a" class="form-control form-control-sm" value="{{ $data_a }}">
53
+    </div>
54
+    <button type="submit" class="btn btn-primary btn-sm">
55
+      <i class="bx bx-refresh me-1"></i>Rigenera
56
+    </button>
57
+    <button type="button" class="btn btn-label-secondary btn-sm" onclick="window.print()">
58
+      <i class="bx bx-printer me-1"></i>Stampa
59
+    </button>
60
+  </form>
61
+</div>
62
+
63
+<div class="row g-4 mb-4">
64
+  <div class="col-sm-6 col-xl-3">
65
+    <div class="card h-100">
66
+      <div class="card-body">
67
+        <span class="badge bg-label-primary mb-2">Totale da fatturare</span>
68
+        <h4 class="mb-0">{{ Config::formatCosto($totali['totale']) }}</h4>
69
+        <p class="text-muted mb-0 small">Tempi, spese e canoni</p>
70
+      </div>
71
+    </div>
72
+  </div>
73
+  <div class="col-sm-6 col-xl-3">
74
+    <div class="card h-100">
75
+      <div class="card-body">
76
+        <span class="badge bg-label-warning mb-2">Ore a fatturare</span>
77
+        <h4 class="mb-0">{{ Config::formatTempo($totali['minuti_fatturare']) }}</h4>
78
+        <p class="text-muted mb-0 small">Coperte {{ Config::formatTempo($totali['minuti_coperti']) }} · {{ Config::formatCosto($totali['importo_tempi']) }}</p>
79
+      </div>
80
+    </div>
81
+  </div>
82
+  <div class="col-sm-6 col-xl-3">
83
+    <div class="card h-100">
84
+      <div class="card-body">
85
+        <span class="badge bg-label-info mb-2">Canoni / rinnovi</span>
86
+        <h4 class="mb-0">{{ Config::formatCosto($totali['importo_rinnovi']) }}</h4>
87
+        <p class="text-muted mb-0 small">{{ $totali['scadenze'] }} scadenze da valutare</p>
88
+      </div>
89
+    </div>
90
+  </div>
91
+  <div class="col-sm-6 col-xl-3">
92
+    <div class="card h-100">
93
+      <div class="card-body">
94
+        <span class="badge bg-label-secondary mb-2">Clienti</span>
95
+        <h4 class="mb-0">{{ $totali['clienti'] }}</h4>
96
+        <p class="text-muted mb-0 small">{{ $esito['totali_aziende']['clienti'] }} aziende · {{ $esito['totali_privati']['clienti'] }} privati</p>
97
+      </div>
98
+    </div>
99
+  </div>
100
+</div>
101
+
102
+<p class="text-muted small mb-4">
103
+  Tariffa default {{ number_format($esito['tariffa_default'], 2, ',', '.') }} €/ora.
104
+  Le ore coperte da contratto o ordine non entrano nel totale; extra e ticket liberi sì.
105
+  Le scadenze sono segnalate ma non sommate (importo suggerito a parte).
106
+</p>
107
+
108
+<div class="card mb-4">
109
+  <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
110
+    <div>
111
+      <h5 class="mb-0">Aziende</h5>
112
+      <small class="text-muted">{{ $esito['totali_aziende']['clienti'] }} clienti</small>
113
+    </div>
114
+    <strong>{{ Config::formatCosto($esito['totali_aziende']['totale']) }}</strong>
115
+  </div>
116
+  <div class="card-body">
117
+    @if(count($esito['aziende']) === 0)
118
+    <p class="text-muted mb-0">Nessuna azienda con tempi o rinnovi nel periodo.</p>
119
+    @else
120
+    <div class="accordion" id="acc-aziende">
121
+      @foreach($esito['aziende'] as $cliente)
122
+        @include('fatturazione.partials.cliente', ['cliente' => $cliente, 'parent' => 'acc-aziende', 'prefix' => 'az'])
123
+      @endforeach
124
+    </div>
125
+    @endif
126
+  </div>
127
+</div>
128
+
129
+<div class="card mb-4">
130
+  <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
131
+    <div>
132
+      <h5 class="mb-0">Privati e utenti senza azienda</h5>
133
+      <small class="text-muted">{{ $esito['totali_privati']['clienti'] }} clienti</small>
134
+    </div>
135
+    <strong>{{ Config::formatCosto($esito['totali_privati']['totale']) }}</strong>
136
+  </div>
137
+  <div class="card-body">
138
+    @if(count($esito['privati']) === 0)
139
+    <p class="text-muted mb-0">Nessun privato con tempi o rinnovi nel periodo.</p>
140
+    @else
141
+    <div class="accordion" id="acc-privati">
142
+      @foreach($esito['privati'] as $cliente)
143
+        @include('fatturazione.partials.cliente', ['cliente' => $cliente, 'parent' => 'acc-privati', 'prefix' => 'pr'])
144
+      @endforeach
145
+    </div>
146
+    @endif
147
+  </div>
148
+</div>
149
+@endsection

+ 81
- 0
resources/views/fatturazione/index.blade.php 查看文件

@@ -0,0 +1,81 @@
1
+@extends('layouts/layoutMaster')
2
+
3
+@section('title', 'Fatturazione')
4
+
5
+@section('vendor-style')
6
+@vite([
7
+'resources/assets/vendor/libs/datatables-bs5/datatables.bootstrap5.scss',
8
+'resources/assets/vendor/libs/datatables-responsive-bs5/responsive.bootstrap5.scss',
9
+'resources/assets/vendor/libs/datatables-buttons-bs5/buttons.bootstrap5.scss',
10
+])
11
+@endsection
12
+
13
+@section('vendor-script')
14
+@vite(['resources/assets/vendor/libs/datatables-bs5/datatables-bootstrap5.js'])
15
+@endsection
16
+
17
+@section('pageTitle')
18
+<div class="d-flex flex-column">
19
+  <h4 class="mb-1">Fatturazione</h4>
20
+  <span class="text-muted">Genera i totali del periodo e crea le fatture</span>
21
+</div>
22
+@endsection
23
+
24
+@section('content')
25
+@if(session('success'))
26
+<div class="alert alert-success">{{ session('success') }}</div>
27
+@endif
28
+@if(session('error'))
29
+<div class="alert alert-danger">{{ session('error') }}</div>
30
+@endif
31
+@if($errors->any())
32
+<div class="alert alert-danger">
33
+  <ul class="mb-0">@foreach($errors->all() as $e)<li>{{ $e }}</li>@endforeach</ul>
34
+</div>
35
+@endif
36
+
37
+<div class="card mb-4">
38
+  <div class="card-body">
39
+    <form method="GET" action="{{ route('admin.fatturazione.genera') }}" class="row g-3 align-items-end">
40
+      <div class="col-md-3">
41
+        <label class="form-label" for="mese">Mese di riferimento</label>
42
+        <input type="month" name="mese" id="mese" class="form-control" value="{{ old('mese', $mese) }}">
43
+      </div>
44
+      <div class="col-md-3">
45
+        <label class="form-label" for="data_da">Oppure dal</label>
46
+        <input type="date" name="data_da" id="data_da" class="form-control" value="{{ old('data_da') }}">
47
+      </div>
48
+      <div class="col-md-3">
49
+        <label class="form-label" for="data_a">al</label>
50
+        <input type="date" name="data_a" id="data_a" class="form-control" value="{{ old('data_a') }}">
51
+      </div>
52
+      <div class="col-md-3">
53
+        @can('genera-fatturazione')
54
+        <button type="submit" class="btn btn-primary w-100">
55
+          <i class="bx bx-calculator me-1"></i>Genera
56
+        </button>
57
+        @else
58
+        <button type="button" class="btn btn-primary w-100" disabled>Genera</button>
59
+        @endcan
60
+      </div>
61
+    </form>
62
+    <p class="text-muted small mb-0 mt-3">
63
+      Tariffa oraria di default: <strong>{{ number_format($tariffa_default, 2, ',', '.') }} €/ora</strong>
64
+      (modificabile in <a href="{{ route('admin.config.index') }}">Configurazione</a>).
65
+      Se imposti Dal/Al, il mese viene ignorato.
66
+      Il genera esclude tempi e canoni già presenti in una fattura non annullata.
67
+    </p>
68
+  </div>
69
+</div>
70
+
71
+<div class="card">
72
+  <div class="card-header"><h5 class="mb-0">Fatture</h5></div>
73
+  <div class="card-body">
74
+    {{ $dataTable_fattura->table(['class' => 'table table-bordered']) }}
75
+  </div>
76
+</div>
77
+@endsection
78
+
79
+@section('page-script')
80
+{{ $dataTable_fattura->scripts(attributes: ['type' => 'module']) }}
81
+@endsection

+ 0
- 0
resources/views/fatturazione/menu.blade.php 查看文件


部分文件因为文件数量过多而无法显示

正在加载...
取消
保存