From 0c005442d5e7d5d80224b53c74d6e1c1bd171d70 Mon Sep 17 00:00:00 2001
From: Sajo <75281007+sajotrei@users.noreply.github.com>
Date: Sat, 8 Aug 2026 16:47:02 +0200
Subject: [PATCH 1/4] feat: aggiungi gestione note spese
---
modules/note_spese/actions.php | 459 ++++++++++++++++++
modules/note_spese/add.php | 307 ++++++++++++
modules/note_spese/bulk.php | 169 +++++++
modules/note_spese/buttons.php | 16 +
modules/note_spese/controller_after.php | 344 +++++++++++++
modules/note_spese/controller_before.php | 37 ++
modules/note_spese/edit.php | 112 +++++
modules/note_spese/export.php | 60 +++
modules/note_spese/init.php | 17 +
modules/note_spese/modutil.php | 346 +++++++++++++
.../note_spese/src/PendingExpensesHook.php | 83 ++++
modules/note_spese/update/tables.php | 9 +
modules/note_spese/widgets/indicatori.php | 94 ++++
modules/tipologie_note_spese/actions.php | 116 +++++
modules/tipologie_note_spese/add.php | 23 +
modules/tipologie_note_spese/edit.php | 38 ++
modules/tipologie_note_spese/init.php | 13 +
templates/note_spese/body.php | 80 +++
templates/note_spese/footer.php | 13 +
templates/note_spese/header.php | 23 +
templates/note_spese/init.php | 50 ++
templates/note_spese/settings.php | 14 +
update/2_12.sql | 205 ++++++++
update/tables.php | 5 +
24 files changed, 2633 insertions(+)
create mode 100644 modules/note_spese/actions.php
create mode 100644 modules/note_spese/add.php
create mode 100644 modules/note_spese/bulk.php
create mode 100644 modules/note_spese/buttons.php
create mode 100644 modules/note_spese/controller_after.php
create mode 100644 modules/note_spese/controller_before.php
create mode 100644 modules/note_spese/edit.php
create mode 100644 modules/note_spese/export.php
create mode 100644 modules/note_spese/init.php
create mode 100644 modules/note_spese/modutil.php
create mode 100644 modules/note_spese/src/PendingExpensesHook.php
create mode 100644 modules/note_spese/update/tables.php
create mode 100644 modules/note_spese/widgets/indicatori.php
create mode 100644 modules/tipologie_note_spese/actions.php
create mode 100644 modules/tipologie_note_spese/add.php
create mode 100644 modules/tipologie_note_spese/edit.php
create mode 100644 modules/tipologie_note_spese/init.php
create mode 100644 templates/note_spese/body.php
create mode 100644 templates/note_spese/footer.php
create mode 100644 templates/note_spese/header.php
create mode 100644 templates/note_spese/init.php
create mode 100644 templates/note_spese/settings.php
diff --git a/modules/note_spese/actions.php b/modules/note_spese/actions.php
new file mode 100644
index 000000000..cc102dd73
--- /dev/null
+++ b/modules/note_spese/actions.php
@@ -0,0 +1,459 @@
+ 0;
+}
+
+switch (post('op')) {
+ case 'inline_update':
+ Permissions::check('rw');
+ header('Content-Type: application/json; charset=UTF-8');
+
+ $record_id = (int) $id_record;
+ $field = trim((string) post('field'));
+ $value = post('value');
+ if ($record_id <= 0 || !in_array($field, ['data', 'descrizione', 'controparte', 'importo'], true)) {
+ http_response_code(422);
+ echo json_encode(['success' => false, 'message' => tr('Modifica rapida non valida.')]);
+ break;
+ }
+
+ $current = $dbo->fetchOne('SELECT * FROM `co_note_spese` WHERE `id` = '.prepare($record_id).' LIMIT 1');
+ if (empty($current)) {
+ http_response_code(404);
+ echo json_encode(['success' => false, 'message' => tr('Nota spesa non trovata.')]);
+ break;
+ }
+
+ $update = [];
+ $outside_period = false;
+ if ($field === 'data') {
+ $parsed = noteSpeseParseDate($value);
+ if (empty($parsed)) {
+ http_response_code(422);
+ echo json_encode(['success' => false, 'message' => tr('Data non valida.')]);
+ break;
+ }
+ $update['data'] = $parsed;
+ $outside_period = !noteSpeseIsDateInPeriod($parsed, $_SESSION['period_start'] ?? date('Y-01-01'), $_SESSION['period_end'] ?? date('Y-12-31'));
+ } elseif ($field === 'importo') {
+ $parsed = noteSpeseParseAmount($value);
+ if ($parsed === null || $parsed <= 0) {
+ http_response_code(422);
+ echo json_encode(['success' => false, 'message' => tr('Importo non valido.')]);
+ break;
+ }
+ $update['importo'] = number_format($parsed, 2, '.', '');
+ } else {
+ $parsed = trim(strip_tags((string) $value));
+ if ($field === 'descrizione' && $parsed === '') {
+ http_response_code(422);
+ echo json_encode(['success' => false, 'message' => tr('Descrizione non valida.')]);
+ break;
+ }
+ if ((function_exists('mb_strlen') ? mb_strlen($parsed, 'UTF-8') : strlen($parsed)) > 255) {
+ http_response_code(422);
+ echo json_encode(['success' => false, 'message' => tr('Valore troppo lungo.')]);
+ break;
+ }
+ $update[$field] = $parsed !== '' ? $parsed : null;
+ }
+
+ $new_value = reset($update);
+ $old_value = $current[$field] ?? null;
+ $changed = $field === 'importo'
+ ? number_format((float) $old_value, 2, '.', '') !== number_format((float) $new_value, 2, '.', '')
+ : trim((string) $old_value) !== trim((string) ($new_value ?? ''));
+
+ $requires_review = false;
+ if ($changed) {
+ $confirmed = noteSpeseGetStatusId($dbo, 'confermato');
+ $review = noteSpeseGetStatusId($dbo, 'da_verificare');
+ if (!empty($confirmed) && !empty($review) && (int) $current['id_stato'] === $confirmed) {
+ $update['id_stato'] = $review;
+ $requires_review = true;
+ }
+ $dbo->update('co_note_spese', $update, ['id' => $record_id]);
+ }
+
+ echo json_encode([
+ 'success' => true,
+ 'outside_period' => $outside_period,
+ 'requires_review' => $requires_review,
+ 'message' => $requires_review ? tr('Nota spesa modificata e riportata Da verificare.') : tr('Nota spesa aggiornata.'),
+ ]);
+ break;
+
+ case 'add':
+ Permissions::check('rw');
+
+ $data = noteSpeseParseDate(post('data'));
+ $id_tipologia = (int) post('id_tipologia');
+ $descrizione = trim((string) post('descrizione'));
+ $importo = noteSpeseParseAmount(post('importo'));
+ $controparte = trim((string) post('controparte'));
+ $id_operatore = (int) post('id_operatore') ?: null;
+ $note = trim((string) post('note'));
+ $confirmed = noteSpeseGetStatusId($dbo, 'confermato');
+ $review = noteSpeseGetStatusId($dbo, 'da_verificare');
+ $category = $dbo->fetchOne('SELECT `id` FROM `co_note_spese_tipologie` WHERE `id` = '.prepare($id_tipologia).' AND `enabled` = 1 LIMIT 1');
+
+ if (!noteSpeseValidateBaseData($data, $id_tipologia, $descrizione, $importo) || empty($confirmed) || empty($review) || empty($category) || !noteSpeseOperatorExists($dbo, $id_operatore)) {
+ flash()->error(tr('Compilare correttamente data, tipologia, descrizione e importo.'));
+ break;
+ }
+
+ $duplicate = noteSpeseFindDuplicate($dbo, $data, $importo, $descrizione, $controparte, null, $id_operatore);
+ if (!empty($duplicate)) {
+ $note = noteSpeseAppendNote($note, tr('Possibile duplicato della spesa #_ID_.', ['_ID_' => (int) $duplicate['id']]));
+ }
+
+ $dbo->insert('co_note_spese', [
+ 'data' => $data,
+ 'id_tipologia' => $id_tipologia,
+ 'id_stato' => !empty($duplicate) ? $review : $confirmed,
+ 'descrizione' => $descrizione,
+ 'importo' => $importo,
+ 'id_anagrafica' => null,
+ 'id_operatore' => $id_operatore,
+ 'controparte' => $controparte ?: null,
+ 'origine' => 'manuale',
+ 'id_origine' => null,
+ 'note' => $note ?: null,
+ ]);
+ $id_record = $dbo->lastInsertedID();
+
+ if (!empty($duplicate)) {
+ flash()->warning(tr('Spesa aggiunta come Da verificare: esiste una possibile duplicazione con la spesa #_ID_.', ['_ID_' => (int) $duplicate['id']]));
+ } else {
+ flash()->info(tr('Spesa aggiunta come Confermata.'));
+ }
+ break;
+
+ case 'update':
+ Permissions::check('rw');
+ if (empty($id_record)) {
+ break;
+ }
+
+ $current = $dbo->fetchOne('SELECT * FROM `co_note_spese` WHERE `id` = '.prepare($id_record).' LIMIT 1');
+ if (empty($current)) {
+ break;
+ }
+
+ $data = noteSpeseParseDate(post('data'));
+ $id_tipologia = (int) post('id_tipologia');
+ $id_stato = (int) post('id_stato');
+ $descrizione = trim((string) post('descrizione'));
+ $importo = noteSpeseParseAmount(post('importo'));
+ $controparte = trim((string) post('controparte'));
+ $id_operatore = (int) post('id_operatore') ?: null;
+ $note = trim((string) post('note'));
+
+ $valid_status = $dbo->fetchOne('SELECT `id` FROM `co_note_spese_stati` WHERE `id` = '.prepare($id_stato).' LIMIT 1');
+ $valid_category = $dbo->fetchOne('SELECT `id` FROM `co_note_spese_tipologie` WHERE `id` = '.prepare($id_tipologia).' AND (`enabled` = 1 OR `id` = '.prepare((int) $current['id_tipologia']).') LIMIT 1');
+ if (!noteSpeseValidateBaseData($data, $id_tipologia, $descrizione, $importo) || empty($valid_status) || empty($valid_category) || !noteSpeseOperatorExists($dbo, $id_operatore, $current['id_operatore'] ?? null)) {
+ flash()->error(tr('Compilare correttamente i dati della spesa.'));
+ break;
+ }
+
+ $substantive_changed = (string) $current['data'] !== (string) $data
+ || (int) $current['id_tipologia'] !== $id_tipologia
+ || trim((string) $current['descrizione']) !== $descrizione
+ || number_format((float) $current['importo'], 2, '.', '') !== number_format((float) $importo, 2, '.', '')
+ || trim((string) ($current['controparte'] ?? '')) !== $controparte
+ || (int) ($current['id_operatore'] ?? 0) !== (int) ($id_operatore ?? 0);
+
+ $confirmed = noteSpeseGetStatusId($dbo, 'confermato');
+ $review = noteSpeseGetStatusId($dbo, 'da_verificare');
+ $reset_to_review = $substantive_changed && (int) $current['id_stato'] === (int) $confirmed && $id_stato === (int) $confirmed && !empty($review);
+ if ($reset_to_review) {
+ $id_stato = $review;
+ }
+
+ $dbo->update('co_note_spese', [
+ 'data' => $data,
+ 'id_tipologia' => $id_tipologia,
+ 'id_stato' => $id_stato,
+ 'descrizione' => $descrizione,
+ 'importo' => $importo,
+ 'controparte' => $controparte ?: null,
+ 'id_operatore' => $id_operatore,
+ 'note' => $note ?: null,
+ ], ['id' => $id_record]);
+
+ if ($reset_to_review) {
+ flash()->warning(tr('La spesa era Confermata: dopo la modifica è stata riportata Da verificare.'));
+ } else {
+ flash()->info(tr('Spesa aggiornata correttamente.'));
+ }
+ break;
+
+ case 'delete':
+ Permissions::check('rw');
+ if (!empty($id_record) && noteSpeseDeleteRecord($dbo, $id_module, (int) $id_record)) {
+ flash()->info(tr('Spesa eliminata correttamente.'));
+ }
+ break;
+
+ case 'import_rifornimenti':
+ Permissions::check('rw');
+ $source_module = Models\Module::where('name', 'Automezzi')->first();
+ if (empty($source_module)) {
+ flash()->error(tr('Modulo origine non disponibile.'));
+ break;
+ }
+ Permissions::addModule($source_module->id);
+ Permissions::check(['r', 'rw']);
+
+ $ids = array_values(array_unique(array_filter(array_map('intval', (array) post('rifornimenti')))));
+ $category = noteSpeseGetCategory($dbo, 'carburante');
+ $review = noteSpeseGetStatusId($dbo, 'da_verificare');
+ if (empty($ids) || empty($category) || empty($review)) {
+ flash()->warning(tr('Selezionare almeno un rifornimento da importare.'));
+ break;
+ }
+
+ $period_start = $_SESSION['period_start'] ?? date('Y-01-01');
+ $period_end_ts = ($_SESSION['period_end'] ?? date('Y-12-31')).' 23:59:59';
+ $imported = 0;
+ $skipped = 0;
+ $dbo->beginTransaction();
+ try {
+ foreach ($ids as $id) {
+ if (!empty($dbo->fetchOne('SELECT `id` FROM `co_note_spese` WHERE `origine` = '.prepare('automezzi_rifornimento').' AND `id_origine` = '.prepare($id).' LIMIT 1'))) {
+ ++$skipped;
+ continue;
+ }
+
+ $source = $dbo->fetchOne(
+ 'SELECT r.*, v.`id_tecnico`, v.`id_sede`, s.`nome` AS automezzo_nome, s.`targa`, g.`descrizione` AS gestore, a.`ragione_sociale` AS tecnico '
+ .'FROM `an_automezzi_rifornimenti` r '
+ .'LEFT JOIN `an_automezzi_viaggi` v ON v.`id` = r.`id_viaggio` '
+ .'LEFT JOIN `an_sedi` s ON s.`id` = v.`id_sede` '
+ .'LEFT JOIN `an_automezzi_gestori` g ON g.`id` = r.`id_gestore` '
+ .'LEFT JOIN `an_anagrafiche` a ON a.`id` = v.`id_tecnico` '
+ .'WHERE r.`id` = '.prepare($id).' AND r.`data` >= '.prepare($period_start).' AND r.`data` <= '.prepare($period_end_ts).' LIMIT 1'
+ );
+ if (empty($source)) {
+ ++$skipped;
+ continue;
+ }
+
+ $date = noteSpeseParseDate(substr((string) $source['data'], 0, 10));
+ $amount = noteSpeseParseAmount($source['costo']);
+ if (empty($date) || $amount === null || $amount <= 0) {
+ ++$skipped;
+ continue;
+ }
+
+ $vehicle = trim(($source['automezzo_nome'] ?: '').(!empty($source['targa']) ? ' - '.$source['targa'] : ''));
+ $description = tr('Rifornimento').($vehicle !== '' ? ' - '.$vehicle : '');
+ $counterparty = trim((string) ($source['gestore'] ?: $source['luogo'] ?: ''));
+ $id_operatore = !empty($source['id_tecnico']) && noteSpeseAnagraficaExists($dbo, $source['id_tecnico']) ? (int) $source['id_tecnico'] : null;
+ $notes = [tr('Importato dal registro rifornimenti.')];
+ $duplicate = noteSpeseFindDuplicate($dbo, $date, $amount, $description, $counterparty, null, $id_operatore);
+ if (!empty($duplicate)) {
+ $notes[] = tr('Possibile duplicato della spesa #_ID_.', ['_ID_' => (int) $duplicate['id']]);
+ }
+
+ $dbo->insert('co_note_spese', [
+ 'data' => $date,
+ 'id_tipologia' => $category['id'],
+ 'id_stato' => $review,
+ 'descrizione' => $description,
+ 'importo' => $amount,
+ 'id_anagrafica' => null,
+ 'id_operatore' => $id_operatore,
+ 'controparte' => $counterparty ?: null,
+ 'origine' => 'automezzi_rifornimento',
+ 'id_origine' => $id,
+ 'note' => implode("\n", $notes),
+ ]);
+ ++$imported;
+ }
+ $dbo->commitTransaction();
+ } catch (Throwable $e) {
+ $dbo->rollbackTransaction();
+ throw $e;
+ }
+ flash()->info(tr('Importazione completata: _IMPORTED_ importate, _SKIPPED_ ignorate.', ['_IMPORTED_' => $imported, '_SKIPPED_' => $skipped]));
+ break;
+
+ case 'import_scadenzario':
+ Permissions::check('rw');
+ $source_module = Models\Module::where('name', 'Scadenzario')->first();
+ if (empty($source_module)) {
+ flash()->error(tr('Modulo origine non disponibile.'));
+ break;
+ }
+ Permissions::addModule($source_module->id);
+ Permissions::check(['r', 'rw']);
+
+ $ids = array_values(array_unique(array_filter(array_map('intval', (array) post('scadenze')))));
+ $review = noteSpeseGetStatusId($dbo, 'da_verificare');
+ $period_start = $_SESSION['period_start'] ?? date('Y-01-01');
+ $period_end = $_SESSION['period_end'] ?? date('Y-12-31');
+ $imported = 0;
+ $skipped = 0;
+ if (empty($ids) || empty($review)) {
+ flash()->warning(tr('Selezionare almeno una scadenza da importare.'));
+ break;
+ }
+
+ $dbo->beginTransaction();
+ try {
+ foreach ($ids as $id) {
+ if (!empty($dbo->fetchOne('SELECT `id` FROM `co_note_spese` WHERE `origine` = '.prepare('scadenzario_generico').' AND `id_origine` = '.prepare($id).' LIMIT 1'))) {
+ ++$skipped;
+ continue;
+ }
+
+ $source = $dbo->fetchOne(
+ 'SELECT s.*, a.`ragione_sociale` FROM `co_scadenzario` s '
+ .'LEFT JOIN `an_anagrafiche` a ON a.`id` = s.`id_anagrafica` '
+ .'WHERE s.`id` = '.prepare($id).' AND (s.`id_documento` IS NULL OR s.`id_documento` = 0) '
+ .'AND s.`da_pagare` < 0 AND s.`scadenza` >= '.prepare($period_start).' AND s.`scadenza` <= '.prepare($period_end).' LIMIT 1'
+ );
+ if (empty($source)) {
+ ++$skipped;
+ continue;
+ }
+
+ $date = noteSpeseParseDate(substr((string) $source['scadenza'], 0, 10));
+ $amount = abs((float) $source['da_pagare']);
+ $category = noteSpeseGetCategory($dbo, trim((string) $source['descrizione']).' '.trim((string) $source['tipo']).' '.trim((string) $source['ragione_sociale']));
+ if (empty($date) || $amount <= 0 || empty($category)) {
+ ++$skipped;
+ continue;
+ }
+
+ $description = trim((string) $source['descrizione']) ?: tr('Scadenza generica');
+ $counterparty = trim((string) $source['ragione_sociale']);
+ $notes = [tr('Importato da Scadenzario generico. Verificare competenza e documentazione prima della conferma.')];
+ $duplicate = noteSpeseFindDuplicate($dbo, $date, $amount, $description, $counterparty);
+ if (!empty($duplicate)) {
+ $notes[] = tr('Possibile duplicato della spesa #_ID_.', ['_ID_' => (int) $duplicate['id']]);
+ }
+
+ $id_anagrafica = !empty($source['id_anagrafica']) && noteSpeseAnagraficaExists($dbo, $source['id_anagrafica']) ? (int) $source['id_anagrafica'] : null;
+ $dbo->insert('co_note_spese', [
+ 'data' => $date,
+ 'id_tipologia' => $category['id'],
+ 'id_stato' => $review,
+ 'descrizione' => $description,
+ 'importo' => $amount,
+ 'id_anagrafica' => $id_anagrafica,
+ 'id_operatore' => null,
+ 'controparte' => $counterparty ?: null,
+ 'origine' => 'scadenzario_generico',
+ 'id_origine' => $id,
+ 'note' => implode("\n", $notes),
+ ]);
+ ++$imported;
+ }
+ $dbo->commitTransaction();
+ } catch (Throwable $e) {
+ $dbo->rollbackTransaction();
+ throw $e;
+ }
+ flash()->info(tr('Importazione Scadenzario completata: _IMPORTED_ importate, _SKIPPED_ ignorate.', ['_IMPORTED_' => $imported, '_SKIPPED_' => $skipped]));
+ break;
+
+ case 'import_excel':
+ Permissions::check('rw');
+ $raw = trim((string) post('righe_excel'));
+ if ($raw === '') {
+ flash()->warning(tr('Incollare almeno una riga.'));
+ break;
+ }
+
+ $review = noteSpeseGetStatusId($dbo, 'da_verificare');
+ $rows = preg_split('/\R/u', $raw);
+ $imported = 0;
+ $skipped = 0;
+ $duplicates = 0;
+ $dbo->beginTransaction();
+ try {
+ foreach ($rows as $row) {
+ $row = trim($row);
+ if ($row === '') {
+ continue;
+ }
+ $columns = strpos($row, "\t") !== false ? explode("\t", $row) : str_getcsv($row, ';');
+ $columns = array_map(static fn ($item) => trim((string) $item), $columns);
+ if (!empty($columns[0]) && in_array(noteSpeseLower($columns[0]), ['data', 'date'], true)) {
+ continue;
+ }
+
+ $category_raw = '';
+ $counterparty = '';
+ $user_notes = '';
+ if (count($columns) === 3) {
+ [$date_raw, $description, $amount_raw] = $columns;
+ $category = noteSpeseGetCategory($dbo, $description);
+ } elseif (count($columns) >= 4) {
+ [$date_raw, $category_raw, $description, $amount_raw] = array_slice($columns, 0, 4);
+ $counterparty = $columns[4] ?? '';
+ $user_notes = $columns[5] ?? '';
+ $category = noteSpeseGetCategory($dbo, $category_raw);
+ } else {
+ ++$skipped;
+ continue;
+ }
+
+ $date = noteSpeseParseDate($date_raw);
+ $amount = noteSpeseParseAmount($amount_raw);
+ $amount = $amount !== null ? abs($amount) : null;
+ if (empty($date) || empty($category) || trim($description) === '' || $amount === null || $amount <= 0 || empty($review)) {
+ ++$skipped;
+ continue;
+ }
+
+ $duplicate = noteSpeseFindDuplicate($dbo, $date, $amount, $description, $counterparty);
+ if (!empty($duplicate) && ($duplicate['origine'] ?? '') === 'excel') {
+ ++$duplicates;
+ continue;
+ }
+
+ $notes = [];
+ if ($category_raw !== '' && strcasecmp($category_raw, (string) $category['descrizione']) !== 0 && strcasecmp($category_raw, (string) $category['codice']) !== 0) {
+ $notes[] = tr('Categoria originale: _CATEGORY_', ['_CATEGORY_' => $category_raw]);
+ }
+ if ($user_notes !== '') {
+ $notes[] = $user_notes;
+ }
+ if (!empty($duplicate)) {
+ $notes[] = tr('Possibile duplicato della spesa #_ID_.', ['_ID_' => (int) $duplicate['id']]);
+ }
+
+ $dbo->insert('co_note_spese', [
+ 'data' => $date,
+ 'id_tipologia' => $category['id'],
+ 'id_stato' => $review,
+ 'descrizione' => trim($description),
+ 'importo' => $amount,
+ 'id_anagrafica' => null,
+ 'id_operatore' => null,
+ 'controparte' => trim($counterparty) ?: null,
+ 'origine' => 'excel',
+ 'id_origine' => null,
+ 'note' => !empty($notes) ? implode("\n", $notes) : null,
+ ]);
+ ++$imported;
+ }
+ $dbo->commitTransaction();
+ } catch (Throwable $e) {
+ $dbo->rollbackTransaction();
+ throw $e;
+ }
+ flash()->info(tr('Importazione completata: _IMPORTED_ importate, _SKIPPED_ non valide, _DUPLICATES_ duplicate già importate ignorate.', [
+ '_IMPORTED_' => $imported,
+ '_SKIPPED_' => $skipped,
+ '_DUPLICATES_' => $duplicates,
+ ]));
+ break;
+}
diff --git a/modules/note_spese/add.php b/modules/note_spese/add.php
new file mode 100644
index 000000000..5bb018436
--- /dev/null
+++ b/modules/note_spese/add.php
@@ -0,0 +1,307 @@
+id;
+
+$duplicate_id = (int) get('duplicate_id');
+$duplicate_source = [];
+if ($duplicate_id > 0) {
+ $duplicate_source = $dbo->fetchOne('SELECT `data`, `id_tipologia`, `descrizione`, `importo`, `controparte`, `id_operatore`, `note` FROM `co_note_spese` WHERE `id` = '.prepare($duplicate_id).' LIMIT 1') ?: [];
+}
+
+$is_duplicate = !empty($duplicate_source);
+$default_date = $is_duplicate ? (string) $duplicate_source['data'] : $default_date;
+$default_category = $is_duplicate ? (int) $duplicate_source['id_tipologia'] : null;
+$default_amount = $is_duplicate ? (float) $duplicate_source['importo'] : null;
+$default_description = $is_duplicate ? (string) $duplicate_source['descrizione'] : '';
+$default_counterparty = $is_duplicate ? (string) ($duplicate_source['controparte'] ?? '') : '';
+$current_user = auth_osm()->getUser();
+$current_operator = (int) ($current_user['id_anagrafica'] ?? 0);
+$operator_candidate = $is_duplicate ? (int) ($duplicate_source['id_operatore'] ?? 0) : $current_operator;
+$default_operator = noteSpeseOperatorExists($dbo, $operator_candidate) ? ($operator_candidate ?: null) : null;
+$default_notes = $is_duplicate ? (string) ($duplicate_source['note'] ?? '') : '';
+
+$automezzi_module = Models\Module::where('name', 'Automezzi')->first();
+$scadenzario_module = Models\Module::where('name', 'Scadenzario')->first();
+$can_read_automezzi = !empty($automezzi_module) && in_array(Modules::getPermission($automezzi_module->id), ['r', 'rw'], true);
+$can_read_scadenzario = !empty($scadenzario_module) && in_array(Modules::getPermission($scadenzario_module->id), ['r', 'rw'], true);
+
+$rifornimenti_summary = $can_read_automezzi ? $dbo->fetchOne(
+ 'SELECT COUNT(*) AS totale, COALESCE(SUM(r.`costo`), 0) AS importo FROM `an_automezzi_rifornimenti` r '
+ .'WHERE r.`data` >= '.prepare($period_start).' AND r.`data` <= '.prepare($period_end_ts).' '
+ .'AND NOT EXISTS (SELECT 1 FROM `co_note_spese` n WHERE n.`origine` = '.prepare('automezzi_rifornimento').' AND n.`id_origine` = r.`id`)'
+) : ['totale' => 0, 'importo' => 0];
+$rifornimenti_count = (int) ($rifornimenti_summary['totale'] ?? 0);
+
+$rifornimenti = $can_read_automezzi ? $dbo->fetchArray(
+ 'SELECT r.`id`, r.`data`, r.`luogo`, r.`costo`, g.`descrizione` AS gestore, '
+ .'s.`nome` AS automezzo_nome, s.`targa`, a.`ragione_sociale` AS tecnico '
+ .'FROM `an_automezzi_rifornimenti` r '
+ .'LEFT JOIN `an_automezzi_viaggi` v ON v.`id` = r.`id_viaggio` '
+ .'LEFT JOIN `an_sedi` s ON s.`id` = v.`id_sede` '
+ .'LEFT JOIN `an_automezzi_gestori` g ON g.`id` = r.`id_gestore` '
+ .'LEFT JOIN `an_anagrafiche` a ON a.`id` = v.`id_tecnico` '
+ .'WHERE r.`data` >= '.prepare($period_start).' AND r.`data` <= '.prepare($period_end_ts).' '
+ .'AND NOT EXISTS (SELECT 1 FROM `co_note_spese` n WHERE n.`origine` = '.prepare('automezzi_rifornimento').' AND n.`id_origine` = r.`id`) '
+ .'ORDER BY r.`data` DESC, r.`id` DESC LIMIT 200'
+) : [];
+
+$scadenze_summary = $can_read_scadenzario ? $dbo->fetchOne(
+ 'SELECT COUNT(*) AS totale, COALESCE(SUM(ABS(s.`da_pagare`)), 0) AS importo FROM `co_scadenzario` s '
+ .'WHERE (s.`id_documento` IS NULL OR s.`id_documento` = 0) AND s.`da_pagare` < 0 '
+ .'AND s.`scadenza` >= '.prepare($period_start).' AND s.`scadenza` <= '.prepare($period_end).' '
+ .'AND NOT EXISTS (SELECT 1 FROM `co_note_spese` n WHERE n.`origine` = '.prepare('scadenzario_generico').' AND n.`id_origine` = s.`id`)'
+) : ['totale' => 0, 'importo' => 0];
+$scadenze_count = (int) ($scadenze_summary['totale'] ?? 0);
+
+$scadenze = $can_read_scadenzario ? $dbo->fetchArray(
+ 'SELECT s.`id`, s.`scadenza`, s.`data_emissione`, s.`descrizione`, s.`tipo`, s.`da_pagare`, '
+ .'a.`ragione_sociale` '
+ .'FROM `co_scadenzario` s '
+ .'LEFT JOIN `an_anagrafiche` a ON a.`id` = s.`id_anagrafica` '
+ .'WHERE (s.`id_documento` IS NULL OR s.`id_documento` = 0) AND s.`da_pagare` < 0 '
+ .'AND s.`scadenza` >= '.prepare($period_start).' AND s.`scadenza` <= '.prepare($period_end).' '
+ .'AND NOT EXISTS (SELECT 1 FROM `co_note_spese` n WHERE n.`origine` = '.prepare('scadenzario_generico').' AND n.`id_origine` = s.`id`) '
+ .'ORDER BY s.`scadenza` DESC, s.`id` DESC LIMIT 200'
+) : [];
+
+$category_query = 'SELECT t.`id`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.$lang.' '
+ .'WHERE t.`enabled` = 1 ORDER BY t.`ordine`, `descrizione`';
+$operator_query = noteSpeseOperatorSelectQuery($default_operator);
+?>
+
+
+
+
+
+
+
+
+
+ $rifornimenti_count,
+ '_TOTAL_' => moneyFormat($rifornimenti_summary['importo'] ?? 0, 2),
+ ]); ?>
+
+
+ 200) { ?>
+
$rifornimenti_count]); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $scadenze_count,
+ '_TOTAL_' => moneyFormat($scadenze_summary['importo'] ?? 0, 2),
+ ]); ?>
+
+
+ 200) { ?>
+
$scadenze_count]); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/note_spese/bulk.php b/modules/note_spese/bulk.php
new file mode 100644
index 000000000..ccf3f8f04
--- /dev/null
+++ b/modules/note_spese/bulk.php
@@ -0,0 +1,169 @@
+error(tr('Stato di conferma non disponibile.'));
+ break;
+ }
+
+ // I duplicati esatti restano da verificare: per confermarli è necessario aprire la singola spesa.
+ $duplicate_rows = $dbo->fetchArray(
+ 'SELECT DISTINCT a.`id` FROM `co_note_spese` a '
+ .'INNER JOIN `co_note_spese` b ON b.`id` != a.`id` '
+ .'AND b.`data` = a.`data` '
+ .'AND b.`importo` = a.`importo` '
+ .'AND LOWER(TRIM(b.`descrizione`)) = LOWER(TRIM(a.`descrizione`)) '
+ .'AND LOWER(TRIM(COALESCE(b.`controparte`, ""))) = LOWER(TRIM(COALESCE(a.`controparte`, ""))) '
+ .'AND COALESCE(b.`id_operatore`, 0) = COALESCE(a.`id_operatore`, 0) '
+ .'INNER JOIN `co_note_spese_stati` bst ON bst.`id` = b.`id_stato` AND bst.`name` != '.prepare('escluso').' '
+ .'WHERE a.`id` IN ('.implode(',', $ids).')'
+ );
+ $duplicate_ids = array_map('intval', array_column($duplicate_rows, 'id'));
+ $confirm_ids = array_values(array_diff($ids, $duplicate_ids));
+
+ if (!empty($confirm_ids)) {
+ $dbo->query('UPDATE `co_note_spese` SET `id_stato` = '.prepare($id_stato).' WHERE `id` IN ('.implode(',', $confirm_ids).')');
+ flash()->info(tr('_COUNT_ spese confermate.', ['_COUNT_' => count($confirm_ids)]));
+ }
+ if (!empty($duplicate_ids)) {
+ flash()->warning(tr('_COUNT_ possibili duplicati non sono stati confermati: aprire le singole righe per verificarli.', ['_COUNT_' => count($duplicate_ids)]));
+ }
+ break;
+
+ case 'review_bulk':
+ case 'exclude_bulk':
+ Permissions::check('rw');
+ if (empty($ids)) {
+ break;
+ }
+
+ $status_name = post('op') === 'review_bulk' ? 'da_verificare' : 'escluso';
+ $id_stato = noteSpeseGetStatusId($dbo, $status_name);
+ if (!empty($id_stato)) {
+ $dbo->query('UPDATE `co_note_spese` SET `id_stato` = '.prepare($id_stato).' WHERE `id` IN ('.implode(',', $ids).')');
+ flash()->info(tr('_COUNT_ spese aggiornate.', ['_COUNT_' => count($ids)]));
+ }
+ break;
+
+ case 'duplicate_bulk':
+ Permissions::check('rw');
+ if (empty($ids)) {
+ break;
+ }
+
+ $id_stato = noteSpeseGetStatusId($dbo, 'da_verificare');
+ if (empty($id_stato)) {
+ flash()->error(tr('Stato Da verificare non disponibile.'));
+ break;
+ }
+
+ $rows = $dbo->fetchArray(
+ 'SELECT `data`, `id_tipologia`, `descrizione`, `importo`, `id_anagrafica`, `id_operatore`, `controparte`, `note` '
+ .'FROM `co_note_spese` WHERE `id` IN ('.implode(',', $ids).') ORDER BY `id` ASC'
+ );
+
+ $duplicated = 0;
+ foreach ($rows as $row) {
+ $dbo->insert('co_note_spese', [
+ 'data' => $row['data'],
+ 'id_tipologia' => (int) $row['id_tipologia'],
+ 'id_stato' => $id_stato,
+ 'descrizione' => $row['descrizione'],
+ 'importo' => number_format((float) $row['importo'], 2, '.', ''),
+ 'id_anagrafica' => !empty($row['id_anagrafica']) ? (int) $row['id_anagrafica'] : null,
+ 'id_operatore' => !empty($row['id_operatore']) ? (int) $row['id_operatore'] : null,
+ 'controparte' => $row['controparte'] ?: null,
+ 'origine' => 'manuale',
+ 'id_origine' => null,
+ 'note' => $row['note'] ?: null,
+ ]);
+ ++$duplicated;
+ }
+
+ if ($duplicated > 0) {
+ flash()->info(tr('_COUNT_ note spese duplicate. Le copie sono Da verificare e senza allegati.', ['_COUNT_' => $duplicated]));
+ } else {
+ flash()->warning(tr('Nessuna nota spesa duplicata.'));
+ }
+ break;
+
+ case 'delete_bulk':
+ Permissions::check('rw');
+ if (empty($ids)) {
+ break;
+ }
+
+ $deleted = 0;
+ foreach ($ids as $id) {
+ $exists = $dbo->fetchOne('SELECT `id` FROM `co_note_spese` WHERE `id` = '.prepare($id).' LIMIT 1');
+ if (!empty($exists) && noteSpeseDeleteRecord($dbo, $id_module, $id)) {
+ ++$deleted;
+ }
+ }
+
+ if ($deleted > 0) {
+ flash()->info(tr('_COUNT_ spese eliminate.', ['_COUNT_' => $deleted]));
+ } else {
+ flash()->warning(tr('Nessuna spesa eliminata.'));
+ }
+ break;
+}
+
+return [
+ 'confirm_bulk' => [
+ 'text' => tr('Conferma'),
+ 'data' => [
+ 'title' => tr('Confermare le spese selezionate?'),
+ 'msg' => tr('Le spese selezionate saranno incluse nella stampa, nel CSV e nei totali. Eventuali possibili duplicati resteranno da verificare.'),
+ 'button' => tr('Conferma'),
+ 'class' => 'btn btn-lg btn-success',
+ ],
+ ],
+ 'review_bulk' => [
+ 'text' => tr('Segna da verificare'),
+ 'data' => [
+ 'title' => tr('Segnare le spese come da verificare?'),
+ 'msg' => tr('Le spese selezionate non saranno incluse nella stampa, nel CSV e nei totali finché non verranno confermate.'),
+ 'button' => tr('Segna da verificare'),
+ 'class' => 'btn btn-lg btn-warning',
+ ],
+ ],
+ 'exclude_bulk' => [
+ 'text' => tr('Escludi'),
+ 'data' => [
+ 'title' => tr('Escludere le spese selezionate?'),
+ 'msg' => tr('Le spese resteranno registrate ma saranno escluse dalla stampa, dal CSV e dai totali.'),
+ 'button' => tr('Escludi'),
+ 'class' => 'btn btn-lg btn-secondary',
+ ],
+ ],
+ 'duplicate_bulk' => [
+ 'text' => tr('Duplica'),
+ 'data' => [
+ 'title' => tr('Duplicare le note spese selezionate?'),
+ 'msg' => tr('Verrà creata una copia per ogni nota spesa selezionata. Le copie manterranno inizialmente data e importo originali, saranno Da verificare e senza allegati; potrai modificare rapidamente data e importo direttamente dall’elenco.'),
+ 'button' => tr('Duplica'),
+ 'class' => 'btn btn-lg btn-primary',
+ ],
+ ],
+ 'delete_bulk' => [
+ 'text' => tr('Elimina'),
+ 'data' => [
+ 'title' => tr('Eliminare le spese selezionate?'),
+ 'msg' => tr('Le spese selezionate e i relativi allegati saranno eliminati definitivamente.'),
+ 'button' => tr('Elimina'),
+ 'class' => 'btn btn-lg btn-danger',
+ ],
+ ],
+];
diff --git a/modules/note_spese/buttons.php b/modules/note_spese/buttons.php
new file mode 100644
index 000000000..802db836c
--- /dev/null
+++ b/modules/note_spese/buttons.php
@@ -0,0 +1,16 @@
+permission ?? null) === 'rw' && !empty($id_record)) {
+ $duplicate_url = base_path_osm().'/add.php?id_module='.(int) $id_module.'&duplicate_id='.(int) $id_record;
+ ?>
+
+ permission === 'rw';
+$user = auth_osm()->getUser();
+$group_id = (int) ($user->id_gruppo ?? 0);
+$editable_names = $can_inline ? [
+ 'Data' => 'data',
+ 'Descrizione' => 'descrizione',
+ 'Controparte' => 'controparte',
+ 'Importo' => 'importo',
+] : [];
+
+$views = $dbo->fetchArray(
+ 'SELECT v.`name` FROM `zz_views` v '
+ .'INNER JOIN `zz_group_view` gv ON gv.`id_vista` = v.`id` '
+ .'WHERE v.`id_module` = '.prepare((int) $id_module).' '
+ .'AND gv.`id_gruppo` = '.prepare($group_id).' '
+ .'AND v.`visible` = 1 '
+ .'ORDER BY v.`order` ASC'
+);
+
+// La prima colonna DataTables è il selettore, quindi gli indici delle viste
+// visibili partono da 1.
+$editable_columns = [];
+$amount_column = null;
+$column_index = 1;
+foreach ($views as $view) {
+ $name = (string) ($view['name'] ?? '');
+ if ($name === 'Importo') {
+ $amount_column = $column_index;
+ }
+ if (isset($editable_names[$name])) {
+ $editable_columns[$column_index] = $editable_names[$name];
+ }
+ ++$column_index;
+}
+
+if (empty($editable_columns) && $amount_column === null) {
+ return;
+}
+
+$separators = formatter()->getNumberSeparators();
+?>
+
+
diff --git a/modules/note_spese/controller_before.php b/modules/note_spese/controller_before.php
new file mode 100644
index 000000000..ab6db6759
--- /dev/null
+++ b/modules/note_spese/controller_before.php
@@ -0,0 +1,37 @@
+fetchOne('SELECT `id` FROM `zz_prints` WHERE `name` = '.prepare('Nota spese').' AND `enabled` = 1 LIMIT 1');
+$print_id = (int) ($print['id'] ?? 0);
+
+$print_url = $print_id > 0 ? base_path_osm().'/pdfgen.php?id_print='.$print_id.'&id_record=0' : null;
+$csv_url = base_path_osm().'/modules/note_spese/export.php?id_module='.(int) $id_module;
+?>
+
+
+
+
+ Translator::dateToLocale($period_start),
+ '_END_' => Translator::dateToLocale($period_end),
+ ]); ?>
+ permission === 'rw') { ?>
+
+
+
+
+
+
+
diff --git a/modules/note_spese/edit.php b/modules/note_spese/edit.php
new file mode 100644
index 000000000..766e49f3f
--- /dev/null
+++ b/modules/note_spese/edit.php
@@ -0,0 +1,112 @@
+id;
+$category_query = 'SELECT t.`id`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.$lang.' '
+ .'WHERE t.`enabled` = 1 OR t.`id` = '.(int) $record['id_tipologia'].' ORDER BY t.`ordine`, `descrizione`';
+$status_query = 'SELECT st.`id`, COALESCE(l.`title`, st.`name`) AS `descrizione` '
+ .'FROM `co_note_spese_stati` st LEFT JOIN `co_note_spese_stati_lang` l ON l.`id_record` = st.`id` AND l.`id_lang` = '.$lang.' '
+ .'ORDER BY st.`ordine`, st.`id`';
+$operator_query = noteSpeseOperatorSelectQuery($record['id_operatore'] ?? null);
+
+$source_label = noteSpeseSourceLabel($record['origine'] ?? 'manuale');
+$source_url = null;
+$source_hint = null;
+
+if (($record['origine'] ?? '') === 'automezzi_rifornimento' && !empty($record['id_origine'])) {
+ $module_source = Module::where('name', 'Automezzi')->first();
+ $can_read_source = !empty($module_source) && in_array(Modules::getPermission($module_source->id), ['r', 'rw'], true);
+ if ($can_read_source) {
+ $source = $dbo->fetchOne(
+ 'SELECT v.`id_sede` FROM `an_automezzi_rifornimenti` r '
+ .'LEFT JOIN `an_automezzi_viaggi` v ON v.`id` = r.`id_viaggio` WHERE r.`id` = '.prepare($record['id_origine']).' LIMIT 1'
+ );
+ if (!empty($source['id_sede'])) {
+ $source_url = base_path_osm().'/editor.php?id_module='.$module_source->id.'&id_record='.(int) $source['id_sede'];
+ $source_hint = tr('Rifornimento #_ID_', ['_ID_' => (int) $record['id_origine']]);
+ }
+ }
+} elseif (($record['origine'] ?? '') === 'scadenzario_generico' && !empty($record['id_origine'])) {
+ $module_source = Module::where('name', 'Scadenzario')->first();
+ if (!empty($module_source) && in_array(Modules::getPermission($module_source->id), ['r', 'rw'], true)) {
+ $source_url = base_path_osm().'/editor.php?id_module='.$module_source->id.'&id_record='.(int) $record['id_origine'];
+ $source_hint = tr('Scadenza #_ID_', ['_ID_' => (int) $record['id_origine']]);
+ }
+}
+
+$duplicate = noteSpeseFindDuplicate(
+ $dbo,
+ $record['data'] ?? null,
+ $record['importo'] ?? null,
+ $record['descrizione'] ?? '',
+ $record['controparte'] ?? '',
+ (int) $id_record,
+ $record['id_operatore'] ?? null
+);
+
+$period_start = $_SESSION['period_start'] ?? date('Y-01-01');
+$period_end = $_SESSION['period_end'] ?? date('Y-12-31');
+$is_in_period = noteSpeseIsDateInPeriod($record['data'] ?? null, $period_start, $period_end);
+?>
+
+
+
+
+
+
+ Translator::dateToLocale($period_start),
+ '_END_' => Translator::dateToLocale($period_end),
+ ]); ?>
+
+
+
+
+
+
(int) $duplicate['id']]); ?>
+
+
+
+
+
+
+
+
+{( "name": "filelist_and_upload", "id_module": "$id_module$", "id_record": "$id_record$" )}
+
+
diff --git a/modules/note_spese/export.php b/modules/note_spese/export.php
new file mode 100644
index 000000000..bae036dab
--- /dev/null
+++ b/modules/note_spese/export.php
@@ -0,0 +1,60 @@
+fetchOne('SELECT `id` FROM `zz_modules` WHERE `name` = '.prepare('Note spese').' LIMIT 1')['id'] ?? 0);
+if ($id_module_note_spese <= 0) {
+ exit(tr('Accesso negato'));
+}
+Permissions::addModule($id_module_note_spese);
+Permissions::check(['r', 'rw']);
+
+$date_start = ($_SESSION['period_start'] ?? date('Y-01-01'));
+$date_end = ($_SESSION['period_end'] ?? date('Y-12-31'));
+$lang = (int) Models\Locale::getDefault()->id;
+$rows = $dbo->fetchArray(
+ 'SELECT n.*, COALESCE(tl.`title`, t.`descrizione`) AS tipologia, '
+ .'COALESCE(NULLIF(n.`controparte`, ""), a.`ragione_sociale`, "") AS controparte_display, '
+ .'COALESCE(op.`ragione_sociale`, "") AS operatore, '
+ .'(SELECT COUNT(*) FROM `zz_files` f WHERE f.`id_module` = '.prepare($id_module_note_spese).' AND f.`id_plugin` IS NULL '
+ .'AND f.`id_record` = n.`id` AND (f.`key` IS NULL OR f.`key` = "")) AS allegati '
+ .'FROM `co_note_spese` n '
+ .'INNER JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` AND st.`name` = '.prepare('confermato').' '
+ .'LEFT JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` tl ON tl.`id_record` = t.`id` AND tl.`id_lang` = '.prepare($lang).' '
+ .'LEFT JOIN `an_anagrafiche` a ON a.`id` = n.`id_anagrafica` '
+ .'LEFT JOIN `an_anagrafiche` op ON op.`id` = n.`id_operatore` '
+ .'WHERE n.`data` >= '.prepare($date_start).' AND n.`data` <= '.prepare($date_end).' '
+ .'ORDER BY n.`data`, n.`id`'
+);
+
+if (ob_get_length()) {
+ ob_clean();
+}
+
+$filename = 'note_spese_'.$date_start.'_'.$date_end.'.csv';
+header('Content-Type: text/csv; charset=UTF-8');
+header('Content-Disposition: attachment; filename="'.$filename.'"');
+header('Pragma: no-cache');
+header('Expires: 0');
+
+$out = fopen('php://output', 'wb');
+fwrite($out, "\xEF\xBB\xBF");
+fputcsv($out, [tr('Data'), tr('Tipologia'), tr('Descrizione'), tr('Controparte'), tr('Operatore'), tr('Importo'), tr('Allegati'), tr('Origine'), tr('Note')], ';', '"', '');
+
+foreach ($rows as $row) {
+ fputcsv($out, [
+ Translator::dateToLocale($row['data']),
+ noteSpeseCsvSafeCell($row['tipologia']),
+ noteSpeseCsvSafeCell($row['descrizione']),
+ noteSpeseCsvSafeCell($row['controparte_display']),
+ noteSpeseCsvSafeCell($row['operatore']),
+ number_format((float) $row['importo'], 2, ',', ''),
+ (int) $row['allegati'],
+ noteSpeseCsvSafeCell(noteSpeseSourceLabel($row['origine'])),
+ noteSpeseCsvSafeCell(preg_replace('/\s+/u', ' ', (string) $row['note'])),
+ ], ';', '"', '');
+}
+
+fclose($out);
+exit;
diff --git a/modules/note_spese/init.php b/modules/note_spese/init.php
new file mode 100644
index 000000000..1807aa41b
--- /dev/null
+++ b/modules/note_spese/init.php
@@ -0,0 +1,17 @@
+id;
+ $record = $dbo->fetchOne(
+ 'SELECT n.*, COALESCE(tl.`title`, t.`descrizione`) AS `tipologia`, '
+ .'COALESCE(sl.`title`, st.`name`) AS `stato`, st.`name` AS `stato_name`, st.`colore` AS `stato_colore` '
+ .'FROM `co_note_spese` n '
+ .'LEFT JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` tl ON tl.`id_record` = t.`id` AND tl.`id_lang` = '.prepare($lang).' '
+ .'LEFT JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` '
+ .'LEFT JOIN `co_note_spese_stati_lang` sl ON sl.`id_record` = st.`id` AND sl.`id_lang` = '.prepare($lang).' '
+ .'WHERE n.`id` = '.prepare($id_record)
+ );
+}
diff --git a/modules/note_spese/modutil.php b/modules/note_spese/modutil.php
new file mode 100644
index 000000000..599deb529
--- /dev/null
+++ b/modules/note_spese/modutil.php
@@ -0,0 +1,346 @@
+ $lastDot) {
+ $value = str_replace('.', '', $value);
+ $value = str_replace(',', '.', $value);
+ } else {
+ $value = str_replace(',', '', $value);
+ }
+ } elseif ($lastComma !== false || $lastDot !== false) {
+ $separator = $lastComma !== false ? ',' : '.';
+ $parts = explode($separator, $value);
+
+ if (count($parts) > 2) {
+ $groupsAreThousands = true;
+ foreach (array_slice($parts, 1) as $part) {
+ if (strlen($part) !== 3 || !ctype_digit($part)) {
+ $groupsAreThousands = false;
+ break;
+ }
+ }
+
+ if ($groupsAreThousands) {
+ $value = implode('', $parts);
+ } else {
+ $decimal = array_pop($parts);
+ $value = implode('', $parts).'.'.$decimal;
+ }
+ } else {
+ [$integer, $decimal] = array_pad($parts, 2, '');
+ // Nel registro gli importi hanno due decimali: un singolo gruppo di
+ // tre cifre viene quindi interpretato come separatore delle migliaia.
+ if ($decimal !== '' && strlen($decimal) === 3 && ctype_digit(ltrim($integer, '+-')) && ctype_digit($decimal)) {
+ $value = $integer.$decimal;
+ } elseif ($separator === ',') {
+ $value = str_replace(',', '.', $value);
+ }
+ }
+ }
+
+ return is_numeric($value) ? round((float) $value, 2) : null;
+}
+
+function noteSpeseParseDate($value)
+{
+ $value = trim((string) $value);
+ $formats = ['d/m/Y', 'd-m-Y', 'Y-m-d', 'd/m/y', 'd-m-y'];
+
+ foreach ($formats as $format) {
+ $date = DateTime::createFromFormat('!'.$format, $value);
+ if ($date && $date->format($format) === $value) {
+ return $date->format('Y-m-d');
+ }
+ }
+
+ return null;
+}
+
+function noteSpeseIsDateInPeriod($date, $periodStart, $periodEnd)
+{
+ $date = noteSpeseParseDate($date);
+ $periodStart = noteSpeseParseDate($periodStart);
+ $periodEnd = noteSpeseParseDate($periodEnd);
+
+ return !empty($date) && !empty($periodStart) && !empty($periodEnd) && $date >= $periodStart && $date <= $periodEnd;
+}
+
+function noteSpeseGuessCategoryCode($value)
+{
+ $value = noteSpeseLower(trim(strip_tags((string) $value)));
+
+ $rules = [
+ 'carburante' => ['carburante', 'benzina', 'diesel', 'gasolio', 'rifornimento'],
+ 'pedaggio' => ['pedaggio', 'autostrada', 'telepass'],
+ 'parcheggio' => ['parcheggio', 'parking', 'sosta'],
+ 'vitto' => ['pranzo', 'cena', 'ristorante', 'ristorazione', 'bar', 'vitto'],
+ 'alloggio' => ['hotel', 'albergo', 'alloggio', 'pernottamento'],
+ 'trasporto' => ['taxi', 'treno', 'aereo', 'trasporto', 'bus', 'autobus'],
+ 'materiale_consumo' => ['materiale di consumo', 'consumabile', 'cancelleria'],
+ 'assicurazioni' => ['assicurazione', 'assicurazioni', 'polizza', 'polizze'],
+ 'affitti' => ['affitto', 'locazione', 'canone locazione'],
+ 'contributi_tributi' => ['inps', 'inail', 'contributo', 'contributi', 'tributo', 'tributi', 'f24'],
+ 'spese_bancarie' => ['commissione bancaria', 'commissioni bancarie', 'spese bancarie', 'spesa bancaria', 'commissione', 'commissioni'],
+ 'personale' => ['stipendio', 'stipendi', 'cedolino', 'cedolini', 'busta paga'],
+ ];
+
+ foreach ($rules as $code => $keywords) {
+ foreach ($keywords as $keyword) {
+ if (noteSpeseContains($value, $keyword)) {
+ return $code;
+ }
+ }
+ }
+
+ return 'altro';
+}
+
+function noteSpeseFindDuplicate($dbo, $date, $amount, $description, $counterparty = '', $excludeId = null, $operatorId = null)
+{
+ $date = noteSpeseParseDate($date);
+ $amount = noteSpeseParseAmount($amount);
+ $description = noteSpeseNormalizeText($description);
+ $counterparty = noteSpeseNormalizeText($counterparty);
+ $operatorId = (int) $operatorId;
+
+ if (empty($date) || $amount === null || $amount <= 0 || $description === '') {
+ return null;
+ }
+
+ $whereExclude = !empty($excludeId) ? ' AND `id` != '.prepare((int) $excludeId) : '';
+
+ return $dbo->fetchOne(
+ 'SELECT n.`id`, n.`data`, n.`importo`, n.`descrizione`, n.`controparte`, n.`origine`, n.`id_stato` '
+ .'FROM `co_note_spese` n '
+ .'LEFT JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` '
+ .'WHERE n.`data` = '.prepare($date)
+ .' AND n.`importo` = '.prepare(number_format($amount, 2, '.', ''))
+ .' AND LOWER(TRIM(n.`descrizione`)) = '.prepare($description)
+ .' AND LOWER(TRIM(COALESCE(n.`controparte`, ""))) = '.prepare($counterparty)
+ .' AND COALESCE(n.`id_operatore`, 0) = '.prepare($operatorId)
+ .' AND COALESCE(st.`name`, "") != '.prepare('escluso')
+ .str_replace('`id`', 'n.`id`', $whereExclude)
+ .' ORDER BY n.`id` ASC LIMIT 1'
+ );
+}
+
+
+function noteSpeseAnagraficaExists($dbo, $idAnagrafica)
+{
+ $idAnagrafica = (int) $idAnagrafica;
+ if ($idAnagrafica <= 0) {
+ return false;
+ }
+
+ return !empty($dbo->fetchOne(
+ 'SELECT `id` FROM `an_anagrafiche` WHERE `id` = '.prepare($idAnagrafica).' LIMIT 1'
+ ));
+}
+
+
+function noteSpeseOperatorSelectQuery($currentId = null)
+{
+ $currentId = (int) $currentId;
+
+ $activeTechnician = '(a.`deleted_at` IS NULL '
+ .'AND EXISTS ('
+ .'SELECT 1 FROM `an_tipi_anagrafiche_anagrafiche` ta '
+ .'INNER JOIN `an_tipi_anagrafiche` t ON t.`id` = ta.`id_tipo_anagrafica` '
+ .'WHERE ta.`id_anagrafica` = a.`id` AND t.`name` = \'Tecnico\') '
+ .'AND (NOT EXISTS (SELECT 1 FROM `zz_users` ux WHERE ux.`id_anagrafica` = a.`id`) '
+ .'OR EXISTS (SELECT 1 FROM `zz_users` ua WHERE ua.`id_anagrafica` = a.`id` AND ua.`enabled` = 1)))';
+
+ $where = $currentId > 0
+ ? '('.$activeTechnician.' OR a.`id` = '.$currentId.')'
+ : $activeTechnician;
+
+ return 'SELECT DISTINCT a.`id` AS `id`, '
+ .'CONCAT(a.`ragione_sociale`, IF(COALESCE(a.`codice`, \'\') = \'\', \'\', CONCAT(\' - \', a.`codice`)), '
+ .'IF(('.$activeTechnician.'), \'\', \' (non attivo)\')) AS `descrizione` '
+ .'FROM `an_anagrafiche` a '
+ .'WHERE '.$where.' '
+ .'ORDER BY a.`ragione_sociale`';
+}
+
+function noteSpeseOperatorExists($dbo, $operatorId, $allowedCurrentId = null)
+{
+ $operatorId = (int) $operatorId;
+ $allowedCurrentId = (int) $allowedCurrentId;
+ if ($operatorId <= 0) {
+ return true;
+ }
+
+ if ($allowedCurrentId > 0 && $operatorId === $allowedCurrentId) {
+ return !empty($dbo->fetchOne(
+ 'SELECT `id` FROM `an_anagrafiche` WHERE `id` = '.prepare($operatorId).' LIMIT 1'
+ ));
+ }
+
+ return !empty($dbo->fetchOne(
+ 'SELECT a.`id` FROM `an_anagrafiche` a '
+ .'WHERE a.`id` = '.prepare($operatorId).' '
+ .'AND a.`deleted_at` IS NULL '
+ .'AND EXISTS ('
+ .'SELECT 1 FROM `an_tipi_anagrafiche_anagrafiche` ta '
+ .'INNER JOIN `an_tipi_anagrafiche` t ON t.`id` = ta.`id_tipo_anagrafica` '
+ .'WHERE ta.`id_anagrafica` = a.`id` AND t.`name` = '.prepare('Tecnico').') '
+ .'AND (NOT EXISTS (SELECT 1 FROM `zz_users` ux WHERE ux.`id_anagrafica` = a.`id`) '
+ .'OR EXISTS (SELECT 1 FROM `zz_users` ua WHERE ua.`id_anagrafica` = a.`id` AND ua.`enabled` = 1)) '
+ .'LIMIT 1'
+ ));
+}
+
+function noteSpeseAppendNote($note, $line)
+{
+ $note = trim((string) $note);
+ $line = trim((string) $line);
+
+ if ($line === '') {
+ return $note !== '' ? $note : null;
+ }
+
+ if ($note === '') {
+ return $line;
+ }
+
+ if (noteSpeseContains($note, $line)) {
+ return $note;
+ }
+
+ return $note."\n".$line;
+}
+
+function noteSpeseDeleteRecord($dbo, $idModule, $idRecord)
+{
+ $idModule = (int) $idModule;
+ $idRecord = (int) $idRecord;
+ if ($idModule <= 0 || $idRecord <= 0) {
+ return false;
+ }
+
+ Uploads::deleteLinked([
+ 'id_module' => $idModule,
+ 'id_plugin' => null,
+ 'id_record' => $idRecord,
+ 'key' => null,
+ ]);
+
+ return (bool) $dbo->delete('co_note_spese', ['id' => $idRecord]);
+}
+
+function noteSpeseSourceLabel($source)
+{
+ return match ((string) $source) {
+ 'automezzi_rifornimento' => tr('Automezzi'),
+ 'scadenzario_generico' => tr('Scadenzario'),
+ 'excel' => tr('Importazione dati'),
+ default => tr('Manuale'),
+ };
+}
+
+function noteSpeseGetStatusId($dbo, $name)
+{
+ $row = $dbo->fetchOne('SELECT `id` FROM `co_note_spese_stati` WHERE `name` = '.prepare($name).' LIMIT 1');
+
+ return !empty($row['id']) ? (int) $row['id'] : null;
+}
+
+function noteSpeseGetCategory($dbo, $value)
+{
+ $value = trim((string) $value);
+ $lang = (int) Models\Locale::getDefault()->id;
+
+ if ($value !== '') {
+ $row = $dbo->fetchOne(
+ 'SELECT t.`id`, t.`codice`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.prepare($lang).' '
+ .'WHERE t.`enabled` = 1 AND ('
+ .'LOWER(t.`codice`) = LOWER('.prepare($value).') OR '
+ .'LOWER(t.`descrizione`) = LOWER('.prepare($value).') OR '
+ .'LOWER(l.`title`) = LOWER('.prepare($value).')) LIMIT 1'
+ );
+ if (!empty($row)) {
+ return $row;
+ }
+ }
+
+ $code = noteSpeseGuessCategoryCode($value);
+ $row = $dbo->fetchOne(
+ 'SELECT t.`id`, t.`codice`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.prepare($lang).' '
+ .'WHERE t.`enabled` = 1 AND t.`codice` = '.prepare($code).' LIMIT 1'
+ );
+
+ if (!empty($row)) {
+ return $row;
+ }
+
+ return $dbo->fetchOne(
+ 'SELECT t.`id`, t.`codice`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.prepare($lang).' '
+ .'WHERE t.`enabled` = 1 ORDER BY t.`ordine`, t.`id` LIMIT 1'
+ );
+}
diff --git a/modules/note_spese/src/PendingExpensesHook.php b/modules/note_spese/src/PendingExpensesHook.php
new file mode 100644
index 000000000..cd0993113
--- /dev/null
+++ b/modules/note_spese/src/PendingExpensesHook.php
@@ -0,0 +1,83 @@
+first();
+
+ if (empty($module) || !$dbo->tableExists('co_note_spese')) {
+ return [
+ 'icon' => 'fa fa-money text-yellow',
+ 'link' => '',
+ 'message' => '',
+ 'show' => false,
+ ];
+ }
+
+ $periodStart = $_SESSION['period_start'] ?? date('Y-01-01');
+ $periodEnd = $_SESSION['period_end'] ?? date('Y-12-31');
+ $periodEndTimestamp = $periodEnd.' 23:59:59';
+ $count = 0;
+
+ $automezzi = Module::where('name', 'Automezzi')->first();
+ $canReadAutomezzi = !empty($automezzi)
+ && in_array(\Modules::getPermission($automezzi->id), ['r', 'rw'], true)
+ && $dbo->tableExists('an_automezzi_rifornimenti');
+
+ if ($canReadAutomezzi) {
+ $result = $dbo->fetchOne(
+ 'SELECT COUNT(*) AS totale FROM `an_automezzi_rifornimenti` r '
+ .'WHERE r.`data` >= '.prepare($periodStart).' AND r.`data` <= '.prepare($periodEndTimestamp).' '
+ .'AND NOT EXISTS (SELECT 1 FROM `co_note_spese` n WHERE n.`origine` = '.prepare('automezzi_rifornimento').' AND n.`id_origine` = r.`id`)'
+ );
+ $count += (int) ($result['totale'] ?? 0);
+ }
+
+ $scadenzario = Module::where('name', 'Scadenzario')->first();
+ $canReadScadenzario = !empty($scadenzario)
+ && in_array(\Modules::getPermission($scadenzario->id), ['r', 'rw'], true)
+ && $dbo->tableExists('co_scadenzario');
+
+ if ($canReadScadenzario) {
+ $result = $dbo->fetchOne(
+ 'SELECT COUNT(*) AS totale FROM `co_scadenzario` s '
+ .'WHERE (s.`id_documento` IS NULL OR s.`id_documento` = 0) AND s.`da_pagare` < 0 '
+ .'AND s.`scadenza` >= '.prepare($periodStart).' AND s.`scadenza` <= '.prepare($periodEnd).' '
+ .'AND NOT EXISTS (SELECT 1 FROM `co_note_spese` n WHERE n.`origine` = '.prepare('scadenzario_generico').' AND n.`id_origine` = s.`id`)'
+ );
+ $count += (int) ($result['totale'] ?? 0);
+ }
+
+ $message = $count === 1
+ ? tr("C'e' 1 spesa da importare")
+ : tr('Ci sono _NUM_ spese da importare', ['_NUM_' => $count]);
+
+ return [
+ 'icon' => 'fa fa-money text-yellow',
+ 'link' => base_path_osm().'/controller.php?id_module='.$module->id,
+ 'message' => $message,
+ 'show' => $count > 0,
+ ];
+ }
+}
diff --git a/modules/note_spese/update/tables.php b/modules/note_spese/update/tables.php
new file mode 100644
index 000000000..ca8e6fdf4
--- /dev/null
+++ b/modules/note_spese/update/tables.php
@@ -0,0 +1,9 @@
+fetchOne(
+ 'SELECT '
+ .'COALESCE(SUM(IF(st.`name` = '.prepare('confermato').', n.`importo`, 0)), 0) AS confermato_totale, '
+ .'SUM(IF(st.`name` = '.prepare('confermato').', 1, 0)) AS confermato_righe, '
+ .'COALESCE(SUM(IF(st.`name` = '.prepare('da_verificare').', n.`importo`, 0)), 0) AS verifica_totale, '
+ .'SUM(IF(st.`name` = '.prepare('da_verificare').', 1, 0)) AS verifica_righe '
+ .'FROM `co_note_spese` n '
+ .'LEFT JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` '
+ .'WHERE n.`data` >= '.prepare($period_start).' AND n.`data` <= '.prepare($period_end)
+ ) ?: [];
+
+ $summary['senza_allegati'] = 0;
+ if ($id_module_note_spese > 0) {
+ $result = $dbo->fetchOne(
+ 'SELECT COUNT(*) AS totale '
+ .'FROM `co_note_spese` n '
+ .'INNER JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` AND st.`name` = '.prepare('confermato').' '
+ .'WHERE n.`data` >= '.prepare($period_start).' AND n.`data` <= '.prepare($period_end).' '
+ .'AND NOT EXISTS ('
+ .'SELECT 1 FROM `zz_files` f '
+ .'WHERE f.`id_module` = '.prepare($id_module_note_spese).' '
+ .'AND f.`id_plugin` IS NULL '
+ .'AND f.`id_record` = n.`id` '
+ .'AND (f.`key` IS NULL OR f.`key` = "")'
+ .')'
+ );
+ $summary['senza_allegati'] = (int) ($result['totale'] ?? 0);
+ }
+
+ $cache[$cache_key] = $summary;
+
+ return $summary;
+ }
+}
+
+if (!function_exists('noteSpeseWidgetValue')) {
+ function noteSpeseWidgetValue($primary, $secondary)
+ {
+ return ''
+ .''.$primary.''
+ .''.$secondary.''
+ .'';
+ }
+}
+
+$summary = noteSpeseWidgetSummary($dbo, $period_start, $period_end, $id_module_note_spese);
+$name = $widget['name'] ?? '';
+
+switch ($name) {
+ case 'Note spese - confermate':
+ echo noteSpeseWidgetValue(
+ moneyFormat($summary['confermato_totale'] ?? 0, 2),
+ tr('_NUM_ registrazioni', ['_NUM_' => (int) ($summary['confermato_righe'] ?? 0)])
+ );
+ break;
+
+ case 'Note spese - da verificare':
+ echo noteSpeseWidgetValue(
+ moneyFormat($summary['verifica_totale'] ?? 0, 2),
+ tr('_NUM_ da verificare', ['_NUM_' => (int) ($summary['verifica_righe'] ?? 0)])
+ );
+ break;
+
+ case 'Note spese - senza allegati':
+ echo noteSpeseWidgetValue(
+ (int) ($summary['senza_allegati'] ?? 0),
+ tr('spese confermate')
+ );
+ break;
+
+ default:
+ echo noteSpeseWidgetValue('0', ' ');
+ break;
+}
diff --git a/modules/tipologie_note_spese/actions.php b/modules/tipologie_note_spese/actions.php
new file mode 100644
index 000000000..a38133643
--- /dev/null
+++ b/modules/tipologie_note_spese/actions.php
@@ -0,0 +1,116 @@
+id;
+
+ if ($descrizione === '') {
+ flash()->error(tr('Inserire una descrizione.'));
+ break;
+ }
+
+ $duplicate = $dbo->fetchOne(
+ 'SELECT t.`id` FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` '
+ .'WHERE LOWER(t.`descrizione`) = LOWER('.prepare($descrizione).') OR LOWER(l.`title`) = LOWER('.prepare($descrizione).') LIMIT 1'
+ );
+ if (!empty($duplicate)) {
+ flash()->error(tr('Esiste già una tipologia con questa descrizione.'));
+ break;
+ }
+
+ $dbo->insert('co_note_spese_tipologie', [
+ 'codice' => null,
+ 'descrizione' => $descrizione,
+ 'ordine' => $ordine ?: 100,
+ 'enabled' => 1,
+ 'can_delete' => 1,
+ ]);
+ $id_record = $dbo->lastInsertedID();
+
+ $dbo->insert('co_note_spese_tipologie_lang', [
+ 'id_lang' => $id_lang,
+ 'id_record' => $id_record,
+ 'title' => $descrizione,
+ ]);
+
+ flash()->info(tr('Tipologia aggiunta correttamente.'));
+ break;
+
+ case 'update':
+ Permissions::check('rw');
+
+ if (empty($id_record)) {
+ break;
+ }
+
+ $descrizione = trim((string) post('descrizione'));
+ $ordine = max(0, (int) post('ordine'));
+ $enabled = (int) post('enabled');
+ $id_lang = (int) Models\Locale::getDefault()->id;
+
+ if ($descrizione === '') {
+ flash()->error(tr('Inserire una descrizione.'));
+ break;
+ }
+
+ $duplicate = $dbo->fetchOne(
+ 'SELECT t.`id` FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` '
+ .'WHERE t.`id` != '.prepare($id_record).' AND (LOWER(t.`descrizione`) = LOWER('.prepare($descrizione).') '
+ .'OR LOWER(l.`title`) = LOWER('.prepare($descrizione).')) LIMIT 1'
+ );
+ if (!empty($duplicate)) {
+ flash()->error(tr('Esiste già una tipologia con questa descrizione.'));
+ break;
+ }
+
+ $category_data = [
+ 'ordine' => $ordine,
+ 'enabled' => $enabled ? 1 : 0,
+ ];
+ if (Models\Locale::getDefault()->id == Models\Locale::getPredefined()->id) {
+ $category_data['descrizione'] = $descrizione;
+ }
+ $dbo->update('co_note_spese_tipologie', $category_data, ['id' => $id_record]);
+
+ $translation = $dbo->fetchOne(
+ 'SELECT `id` FROM `co_note_spese_tipologie_lang` WHERE `id_lang` = '.prepare($id_lang).' AND `id_record` = '.prepare($id_record).' LIMIT 1'
+ );
+ if (!empty($translation)) {
+ $dbo->update('co_note_spese_tipologie_lang', ['title' => $descrizione], ['id' => $translation['id']]);
+ } else {
+ $dbo->insert('co_note_spese_tipologie_lang', [
+ 'id_lang' => $id_lang,
+ 'id_record' => $id_record,
+ 'title' => $descrizione,
+ ]);
+ }
+
+ flash()->info(tr('Tipologia aggiornata correttamente.'));
+ break;
+
+ case 'delete':
+ Permissions::check('rw');
+
+ if (empty($id_record)) {
+ break;
+ }
+
+ $used = $dbo->fetchNum('SELECT `id` FROM `co_note_spese` WHERE `id_tipologia` = '.prepare($id_record));
+ $record = $dbo->fetchOne('SELECT `can_delete` FROM `co_note_spese_tipologie` WHERE `id` = '.prepare($id_record));
+
+ if (empty($used) && !empty($record['can_delete'])) {
+ $dbo->delete('co_note_spese_tipologie', ['id' => $id_record]);
+ flash()->info(tr('Tipologia eliminata correttamente.'));
+ } else {
+ flash()->error(tr('La tipologia non può essere eliminata perché è predefinita o già utilizzata.'));
+ }
+ break;
+}
diff --git a/modules/tipologie_note_spese/add.php b/modules/tipologie_note_spese/add.php
new file mode 100644
index 000000000..8bcd9fb8a
--- /dev/null
+++ b/modules/tipologie_note_spese/add.php
@@ -0,0 +1,23 @@
+
+
diff --git a/modules/tipologie_note_spese/edit.php b/modules/tipologie_note_spese/edit.php
new file mode 100644
index 000000000..6e0917bc1
--- /dev/null
+++ b/modules/tipologie_note_spese/edit.php
@@ -0,0 +1,38 @@
+
+
+
+fetchNum('SELECT `id` FROM `co_note_spese` WHERE `id_tipologia` = '.prepare($id_record));
+if (!empty($used)) {
+ echo ' '.tr('La tipologia è già utilizzata: può essere disattivata ma non eliminata.').'
';
+} elseif (!empty($record['can_delete'])) {
+ echo ' '.tr('Elimina').'';
+} else {
+ echo ' '.tr('Questa è una tipologia predefinita: può essere disattivata ma non eliminata.').'
';
+}
+?>
diff --git a/modules/tipologie_note_spese/init.php b/modules/tipologie_note_spese/init.php
new file mode 100644
index 000000000..5e8b2c91d
--- /dev/null
+++ b/modules/tipologie_note_spese/init.php
@@ -0,0 +1,13 @@
+id;
+ $record = $dbo->fetchOne(
+ 'SELECT t.*, COALESCE(l.`title`, t.`descrizione`) AS `title` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.prepare($id_lang).' '
+ .'WHERE t.`id` = '.prepare($id_record).' LIMIT 1'
+ );
+}
diff --git a/templates/note_spese/body.php b/templates/note_spese/body.php
new file mode 100644
index 000000000..a5a5a0ff9
--- /dev/null
+++ b/templates/note_spese/body.php
@@ -0,0 +1,80 @@
+ Translator::dateToLocale($date_start),
+ '_END_' => Translator::dateToLocale($date_end),
+]);
+
+$total = 0;
+$total_attachments = 0;
+
+// Titolo essenziale: la stampa è una Nota spese, il periodo è quello globale selezionato.
+echo ''
+ .''
+ .''.tr('Nota spese').' '
+ .''.$period.' | '
+ .''.tr('Solo confermate').' | '
+ .'
';
+
+echo ''
+ .''
+ .'| '.tr('Data').' | '
+ .''.tr('Tipologia').' | '
+ .''.tr('Descrizione').' | '
+ .''.tr('Controparte').' | '
+ .''.tr('Operatore').' | '
+ .''.tr('Allegati').' | '
+ .''.tr('Importo').' | '
+ .'
';
+
+if (empty($rows)) {
+ echo '| '.tr('Nessuna nota spesa confermata nel periodo selezionato.').' |
';
+} else {
+ foreach ($rows as $row) {
+ $total += (float) $row['importo'];
+ $total_attachments += (int) $row['allegati'];
+ $description = htmlentities((string) $row['descrizione']);
+ if (!empty($row['note'])) {
+ $description .= '
'.nl2br(htmlentities((string) $row['note'])).'';
+ }
+
+ echo ''
+ .'| '.Translator::dateToLocale($row['data']).' | '
+ .''.htmlentities((string) $row['tipologia']).' | '
+ .''.$description.' | '
+ .''.htmlentities((string) $row['controparte_display']).' | '
+ .''.htmlentities((string) ($row['operatore'] ?: '-')).' | '
+ .''.(int) $row['allegati'].' | '
+ .''.moneyFormat($row['importo'], 2).' | '
+ .'
';
+ }
+}
+
+echo ''
+ .'| '.tr('Totale', [], ['upper' => true]).': | '
+ .''.moneyFormat($total, 2).' | '
+ .'
'
+ .'
';
+
+if (!empty($rows)) {
+ echo ''
+ .'| '
+ .''.tr('Registrazioni').': '.count($rows)
+ .' '.tr('Allegati').': '.$total_attachments
+ .' '.tr('Senza allegati').': '.(int) $without_attachments
+ .' |
';
+}
+
+if (!empty($groups)) {
+ echo ''.tr('Totali per tipologia').'
'
+ .''
+ .'| '.tr('Tipologia').' | '.tr('Righe').' | '.tr('Totale').' |
';
+
+ foreach ($groups as $group) {
+ echo '| '.htmlentities((string) $group['tipologia']).' | '.(int) $group['righe'].' | '.moneyFormat($group['totale'], 2).' |
';
+ }
+
+ echo '
';
+}
diff --git a/templates/note_spese/footer.php b/templates/note_spese/footer.php
new file mode 100644
index 000000000..5dc5b1265
--- /dev/null
+++ b/templates/note_spese/footer.php
@@ -0,0 +1,13 @@
+
+
+ | '.tr('Nota spese').' |
+ '.tr('Pagina _PAGE_ di _TOTAL_', [
+ '_PAGE_' => '{PAGENO}',
+ '_TOTAL_' => '{nb}',
+]).' |
+
+';
diff --git a/templates/note_spese/header.php b/templates/note_spese/header.php
new file mode 100644
index 000000000..f656c413f
--- /dev/null
+++ b/templates/note_spese/header.php
@@ -0,0 +1,23 @@
+
+
+ |
+ '.htmlentities($identity).''
+ .(!empty($fiscal) ? ' | '.htmlentities(implode(' · ', $fiscal)) : '').'
+ |
+ '.tr('Documento interno').' |
+
+';
diff --git a/templates/note_spese/init.php b/templates/note_spese/init.php
new file mode 100644
index 000000000..eea4d2736
--- /dev/null
+++ b/templates/note_spese/init.php
@@ -0,0 +1,50 @@
+id;
+$id_module_note_spese = (int) ($dbo->fetchOne('SELECT `id` FROM `zz_modules` WHERE `name` = '.prepare('Note spese').' LIMIT 1')['id'] ?? 0);
+
+$rows = $dbo->fetchArray(
+ 'SELECT n.*, COALESCE(tl.`title`, t.`descrizione`) AS tipologia, '
+ .'COALESCE(NULLIF(n.`controparte`, ""), a.`ragione_sociale`, "") AS controparte_display, '
+ .'COALESCE(op.`ragione_sociale`, "") AS operatore, '
+ .'(SELECT COUNT(*) FROM `zz_files` f WHERE f.`id_module` = '.prepare($id_module_note_spese).' AND f.`id_plugin` IS NULL '
+ .'AND f.`id_record` = n.`id` AND (f.`key` IS NULL OR f.`key` = "")) AS allegati '
+ .'FROM `co_note_spese` n '
+ .'INNER JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` AND st.`name` = '.prepare('confermato').' '
+ .'INNER JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` tl ON tl.`id_record` = t.`id` AND tl.`id_lang` = '.prepare($id_lang).' '
+ .'LEFT JOIN `an_anagrafiche` a ON a.`id` = n.`id_anagrafica` '
+ .'LEFT JOIN `an_anagrafiche` op ON op.`id` = n.`id_operatore` '
+ .'WHERE n.`data` >= '.prepare($date_start).' AND n.`data` <= '.prepare($date_end).' '
+ .'ORDER BY n.`data` ASC, n.`id` ASC'
+);
+
+$groups = $dbo->fetchArray(
+ 'SELECT COALESCE(tl.`title`, t.`descrizione`, '.prepare(tr('Senza tipologia')).') AS tipologia, SUM(n.`importo`) AS totale, COUNT(*) AS righe '
+ .'FROM `co_note_spese` n '
+ .'INNER JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` AND st.`name` = '.prepare('confermato').' '
+ .'LEFT JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` tl ON tl.`id_record` = t.`id` AND tl.`id_lang` = '.prepare($id_lang).' '
+ .'WHERE n.`data` >= '.prepare($date_start).' AND n.`data` <= '.prepare($date_end).' '
+ .'GROUP BY t.`id`, tl.`title`, t.`descrizione`, t.`ordine` '
+ .'ORDER BY t.`ordine`, tipologia'
+);
+
+$without_attachments = 0;
+foreach ($rows as $row) {
+ if (empty($row['allegati'])) {
+ ++$without_attachments;
+ }
+}
diff --git a/templates/note_spese/settings.php b/templates/note_spese/settings.php
new file mode 100644
index 000000000..8ebba37c4
--- /dev/null
+++ b/templates/note_spese/settings.php
@@ -0,0 +1,14 @@
+ 'L',
+ 'format' => 'A4',
+ 'font-size' => 8,
+ 'margins' => [
+ 'top' => 'auto',
+ 'bottom' => 'auto',
+ 'left' => 10,
+ 'right' => 10,
+ ],
+ 'header-font-size' => 7,
+];
diff --git a/update/2_12.sql b/update/2_12.sql
index 201536992..11ff70f58 100644
--- a/update/2_12.sql
+++ b/update/2_12.sql
@@ -102,3 +102,208 @@ INSERT INTO `zz_settings` (`nome`, `valore`, `tipo`, `editable`, `sezione`, `ord
INSERT INTO `zz_settings_lang` (`id_lang`, `id_record`, `title`, `help`) VALUES
(1, (SELECT `id` FROM `zz_settings` WHERE `nome` = 'Tipologia anagrafica predefinita'), 'Tipologia anagrafica predefinita', 'Tipologia (Azienda, Ente pubblico o Privato) preselezionata automaticamente nella finestra di aggiunta di una nuova anagrafica. Se non impostata, nessuna tipologia viene preselezionata.'),
(2, (SELECT `id` FROM `zz_settings` WHERE `nome` = 'Tipologia anagrafica predefinita'), 'Default entity classification', 'Classification (Company, Public entity or Private) automatically preselected in the new entity creation window. If not set, no classification is preselected.');
+
+-- Modulo Note spese (#1461)
+CREATE TABLE `co_note_spese_tipologie` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `codice` VARCHAR(50) NULL,
+ `descrizione` VARCHAR(100) NOT NULL,
+ `ordine` INT NOT NULL DEFAULT 100,
+ `enabled` TINYINT(1) NOT NULL DEFAULT 1,
+ `can_delete` TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `co_note_spese_tipologie_codice_unique` (`codice`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `co_note_spese_tipologie_lang` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `id_lang` INT NOT NULL,
+ `id_record` INT NOT NULL,
+ `title` VARCHAR(100) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `co_note_spese_tipologie_lang_unique` (`id_lang`, `id_record`),
+ KEY `co_note_spese_tipologie_lang_record_index` (`id_record`),
+ CONSTRAINT `co_note_spese_tipologie_lang_ibfk_1` FOREIGN KEY (`id_record`) REFERENCES `co_note_spese_tipologie` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT,
+ CONSTRAINT `co_note_spese_tipologie_lang_ibfk_2` FOREIGN KEY (`id_lang`) REFERENCES `zz_langs` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `co_note_spese_stati` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `name` VARCHAR(50) NOT NULL,
+ `colore` VARCHAR(30) NOT NULL DEFAULT 'secondary',
+ `ordine` INT NOT NULL DEFAULT 100,
+ `can_delete` TINYINT(1) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `co_note_spese_stati_name_unique` (`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `co_note_spese_stati_lang` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `id_lang` INT NOT NULL,
+ `id_record` INT NOT NULL,
+ `title` VARCHAR(100) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `co_note_spese_stati_lang_unique` (`id_lang`, `id_record`),
+ KEY `co_note_spese_stati_lang_record_index` (`id_record`),
+ CONSTRAINT `co_note_spese_stati_lang_ibfk_1` FOREIGN KEY (`id_record`) REFERENCES `co_note_spese_stati` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT,
+ CONSTRAINT `co_note_spese_stati_lang_ibfk_2` FOREIGN KEY (`id_lang`) REFERENCES `zz_langs` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `co_note_spese` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `data` DATE NOT NULL,
+ `id_tipologia` INT NOT NULL,
+ `id_stato` INT NOT NULL,
+ `descrizione` VARCHAR(255) NOT NULL,
+ `importo` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
+ `id_anagrafica` INT NULL,
+ `id_operatore` INT NULL,
+ `controparte` VARCHAR(255) NULL,
+ `origine` VARCHAR(50) NOT NULL DEFAULT 'manuale',
+ `id_origine` INT NULL,
+ `note` TEXT NULL,
+ `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `co_note_spese_data_index` (`data`),
+ KEY `co_note_spese_tipologia_index` (`id_tipologia`),
+ KEY `co_note_spese_stato_index` (`id_stato`),
+ KEY `co_note_spese_anagrafica_index` (`id_anagrafica`),
+ KEY `co_note_spese_operatore_index` (`id_operatore`),
+ UNIQUE KEY `co_note_spese_origine_unique` (`origine`, `id_origine`),
+ CONSTRAINT `co_note_spese_ibfk_1` FOREIGN KEY (`id_tipologia`) REFERENCES `co_note_spese_tipologie` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
+ CONSTRAINT `co_note_spese_ibfk_2` FOREIGN KEY (`id_stato`) REFERENCES `co_note_spese_stati` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
+ CONSTRAINT `co_note_spese_ibfk_3` FOREIGN KEY (`id_anagrafica`) REFERENCES `an_anagrafiche` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
+ CONSTRAINT `co_note_spese_ibfk_4` FOREIGN KEY (`id_operatore`) REFERENCES `an_anagrafiche` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO `co_note_spese_tipologie` (`codice`, `descrizione`, `ordine`, `enabled`, `can_delete`) VALUES
+('carburante', 'Carburante', 10, 1, 0),
+('pedaggio', 'Pedaggio / Autostrada', 20, 1, 0),
+('parcheggio', 'Parcheggio', 30, 1, 0),
+('vitto', 'Vitto', 40, 1, 0),
+('alloggio', 'Alloggio', 50, 1, 0),
+('trasporto', 'Trasporto', 60, 1, 0),
+('materiale_consumo', 'Materiale di consumo', 70, 1, 0),
+('assicurazioni', 'Assicurazioni', 80, 1, 0),
+('affitti', 'Canoni / Affitti', 90, 1, 0),
+('contributi_tributi', 'Contributi / Tributi', 100, 1, 0),
+('spese_bancarie', 'Spese bancarie / Commissioni', 110, 1, 0),
+('personale', 'Personale', 120, 1, 0),
+('altro', 'Altro', 1000, 1, 0);
+
+INSERT INTO `co_note_spese_tipologie_lang` (`id_lang`, `id_record`, `title`)
+SELECT 1, `id`, `descrizione` FROM `co_note_spese_tipologie`;
+INSERT INTO `co_note_spese_tipologie_lang` (`id_lang`, `id_record`, `title`)
+SELECT 2, `id`, CASE `codice`
+ WHEN 'carburante' THEN 'Fuel'
+ WHEN 'pedaggio' THEN 'Toll / motorway'
+ WHEN 'parcheggio' THEN 'Parking'
+ WHEN 'vitto' THEN 'Meals'
+ WHEN 'alloggio' THEN 'Accommodation'
+ WHEN 'trasporto' THEN 'Transport'
+ WHEN 'materiale_consumo' THEN 'Consumables'
+ WHEN 'assicurazioni' THEN 'Insurance'
+ WHEN 'affitti' THEN 'Rent / leases'
+ WHEN 'contributi_tributi' THEN 'Contributions / taxes'
+ WHEN 'spese_bancarie' THEN 'Bank fees / commissions'
+ WHEN 'personale' THEN 'Personnel'
+ ELSE 'Other'
+END FROM `co_note_spese_tipologie`;
+
+INSERT INTO `co_note_spese_stati` (`name`, `colore`, `ordine`, `can_delete`) VALUES
+('da_verificare', 'warning', 10, 0),
+('confermato', 'success', 20, 0),
+('escluso', 'secondary', 30, 0);
+
+INSERT INTO `co_note_spese_stati_lang` (`id_lang`, `id_record`, `title`)
+SELECT 1, `id`, CASE `name` WHEN 'da_verificare' THEN 'Da verificare' WHEN 'confermato' THEN 'Confermata' ELSE 'Esclusa' END FROM `co_note_spese_stati`;
+INSERT INTO `co_note_spese_stati_lang` (`id_lang`, `id_record`, `title`)
+SELECT 2, `id`, CASE `name` WHEN 'da_verificare' THEN 'To review' WHEN 'confermato' THEN 'Confirmed' ELSE 'Excluded' END FROM `co_note_spese_stati`;
+
+INSERT INTO `zz_modules` (`name`, `directory`, `attachments_directory`, `options`, `options2`, `icon`, `version`, `compatibility`, `order`, `parent`, `default`, `enabled`) VALUES
+('Note spese', 'note_spese', 'note_spese',
+'SELECT |select| FROM `co_note_spese` LEFT JOIN `co_note_spese_tipologie` ON `co_note_spese_tipologie`.`id` = `co_note_spese`.`id_tipologia` LEFT JOIN `co_note_spese_tipologie_lang` ON (`co_note_spese_tipologie_lang`.`id_record` = `co_note_spese_tipologie`.`id` AND `co_note_spese_tipologie_lang`.|lang|) LEFT JOIN `co_note_spese_stati` ON `co_note_spese_stati`.`id` = `co_note_spese`.`id_stato` LEFT JOIN `co_note_spese_stati_lang` ON (`co_note_spese_stati_lang`.`id_record` = `co_note_spese_stati`.`id` AND `co_note_spese_stati_lang`.|lang|) LEFT JOIN `an_anagrafiche` ON `an_anagrafiche`.`id` = `co_note_spese`.`id_anagrafica` LEFT JOIN `an_anagrafiche` AS `an_operatori` ON `an_operatori`.`id` = `co_note_spese`.`id_operatore` WHERE 1=1 |date_period(`co_note_spese`.`data`)| HAVING 2=2 ORDER BY `co_note_spese`.`data` DESC, `co_note_spese`.`id` DESC',
+'', 'fa fa-money', '2.12', '2.12', 20, COALESCE((SELECT `parent` FROM `zz_modules` WHERE `name` = 'Prima nota'), (SELECT `id` FROM `zz_modules` WHERE `name` = 'Contabilità')), 1, 1),
+('Tipologie note spese', 'tipologie_note_spese', 'tipologie_note_spese',
+'SELECT |select| FROM `co_note_spese_tipologie` LEFT JOIN `co_note_spese_tipologie_lang` ON (`co_note_spese_tipologie_lang`.`id_record` = `co_note_spese_tipologie`.`id` AND `co_note_spese_tipologie_lang`.|lang|) WHERE 1=1 HAVING 2=2 ORDER BY `co_note_spese_tipologie`.`ordine`, COALESCE(`co_note_spese_tipologie_lang`.`title`, `co_note_spese_tipologie`.`descrizione`)',
+'', 'fa fa-tags', '2.12', '2.12', 20, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Tabelle'), 1, 1);
+
+INSERT INTO `zz_modules_lang` (`id_lang`, `id_record`, `title`, `meta_title`) VALUES
+(1, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Note spese', 'Nota spesa - {descrizione}'),
+(2, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Expense notes', 'Expense note - {descrizione}'),
+(1, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'Tipologie note spese', 'Tipologia nota spesa - {title}'),
+(2, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'Expense categories', 'Expense category - {title}');
+
+INSERT INTO `zz_views` (`id_module`, `name`, `query`, `order`, `search`, `visible`, `format`, `html_format`, `summable`, `default`) VALUES
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'id', '`co_note_spese`.`id`', 1, 0, 0, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Data', '`co_note_spese`.`data`', 2, 1, 1, 1, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Tipologia', 'COALESCE(`co_note_spese_tipologie_lang`.`title`, `co_note_spese_tipologie`.`descrizione`)', 3, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Descrizione', '`co_note_spese`.`descrizione`', 4, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Controparte', 'COALESCE(NULLIF(`co_note_spese`.`controparte`, ''''), `an_anagrafiche`.`ragione_sociale`, '''')', 5, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Operatore', 'COALESCE(`an_operatori`.`ragione_sociale`, '''')', 6, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'icon_Stato', 'CASE `co_note_spese_stati`.`name` WHEN ''confermato'' THEN ''fa fa-check-circle fa-lg text-success'' WHEN ''da_verificare'' THEN ''fa fa-exclamation-triangle fa-lg text-warning'' ELSE ''fa fa-ban fa-lg text-secondary'' END', 7, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'icon_title_Stato', 'COALESCE(`co_note_spese_stati_lang`.`title`, `co_note_spese_stati`.`name`)', 8, 0, 0, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Origine', 'CASE `co_note_spese`.`origine` WHEN ''automezzi_rifornimento'' THEN ''Automezzi'' WHEN ''scadenzario_generico'' THEN ''Scadenzario'' WHEN ''excel'' THEN ''Importazione'' ELSE ''Manuale'' END', 9, 1, 0, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'icon_Allegati', 'IF((SELECT COUNT(*) FROM `zz_files` WHERE `zz_files`.`id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = ''Note spese'') AND `zz_files`.`id_plugin` IS NULL AND `zz_files`.`id_record` = `co_note_spese`.`id` AND (`zz_files`.`key` IS NULL OR `zz_files`.`key` = '''')) > 0, ''fa fa-paperclip fa-lg text-success'', ''fa fa-paperclip fa-lg text-warning'')', 10, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'icon_title_Allegati', 'CAST((SELECT COUNT(*) FROM `zz_files` WHERE `zz_files`.`id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = ''Note spese'') AND `zz_files`.`id_plugin` IS NULL AND `zz_files`.`id_record` = `co_note_spese`.`id` AND (`zz_files`.`key` IS NULL OR `zz_files`.`key` = '''')) AS CHAR)', 11, 0, 0, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Importo', '`co_note_spese`.`importo`', 12, 1, 1, 1, 0, 1, 1);
+
+INSERT INTO `zz_views_lang` (`id_lang`, `id_record`, `title`)
+SELECT 1, `id`, `name` FROM `zz_views` WHERE `id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese');
+INSERT INTO `zz_views_lang` (`id_lang`, `id_record`, `title`)
+SELECT 2, `id`, CASE `name`
+ WHEN 'Data' THEN 'Date' WHEN 'Tipologia' THEN 'Category' WHEN 'Descrizione' THEN 'Description'
+ WHEN 'Controparte' THEN 'Counterparty' WHEN 'Operatore' THEN 'Operator'
+ WHEN 'icon_Stato' THEN 'icon_Status' WHEN 'icon_title_Stato' THEN 'icon_title_Status'
+ WHEN 'Origine' THEN 'Source' WHEN 'icon_Allegati' THEN 'icon_Attachments'
+ WHEN 'icon_title_Allegati' THEN 'icon_title_Attachments' WHEN 'Importo' THEN 'Amount' ELSE `name` END
+FROM `zz_views` WHERE `id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese');
+
+INSERT INTO `zz_views` (`id_module`, `name`, `query`, `order`, `search`, `visible`, `format`, `html_format`, `summable`, `default`) VALUES
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'id', '`co_note_spese_tipologie`.`id`', 1, 0, 0, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'Descrizione', 'COALESCE(`co_note_spese_tipologie_lang`.`title`, `co_note_spese_tipologie`.`descrizione`)', 2, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'Ordine', '`co_note_spese_tipologie`.`ordine`', 3, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'Attiva', 'IF(`co_note_spese_tipologie`.`enabled` = 1, ''SI'', ''NO'')', 4, 1, 1, 0, 0, 0, 1);
+
+INSERT INTO `zz_views_lang` (`id_lang`, `id_record`, `title`)
+SELECT 1, `id`, `name` FROM `zz_views` WHERE `id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese');
+INSERT INTO `zz_views_lang` (`id_lang`, `id_record`, `title`)
+SELECT 2, `id`, CASE `name` WHEN 'Descrizione' THEN 'Description' WHEN 'Ordine' THEN 'Order' WHEN 'Attiva' THEN 'Enabled' ELSE `name` END
+FROM `zz_views` WHERE `id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese');
+
+INSERT INTO `zz_prints` (`id_module`, `is_record`, `name`, `directory`, `previous`, `options`, `icon`, `version`, `compatibility`, `order`, `predefined`, `enabled`) VALUES
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 0, 'Nota spese', 'note_spese', '', '', 'fa fa-print', '2.12', '2.12', 0, 0, 1);
+INSERT INTO `zz_prints_lang` (`id_lang`, `id_record`, `title`, `filename`) VALUES
+(1, (SELECT `id` FROM `zz_prints` WHERE `name` = 'Nota spese'), 'Nota spese', 'Nota spese'),
+(2, (SELECT `id` FROM `zz_prints` WHERE `name` = 'Nota spese'), 'Expense notes', 'Expense notes');
+
+INSERT INTO `zz_widgets` (`name`, `type`, `id_module`, `location`, `class`, `query`, `bgcolor`, `icon`, `print_link`, `more_link`, `more_link_type`, `php_include`, `enabled`, `order`, `help`) VALUES
+('Note spese - confermate', 'custom', (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'controller_top', 'col-md-4', '', 'success', 'fa fa-check-circle', '', '', 'link', 'modules/note_spese/widgets/indicatori.php', 1, 1, 'Totale e numero delle spese confermate nel periodo selezionato.'),
+('Note spese - da verificare', 'custom', (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'controller_top', 'col-md-4', '', 'warning', 'fa fa-exclamation-triangle', '', '', 'link', 'modules/note_spese/widgets/indicatori.php', 1, 2, 'Importo e numero delle note spese che richiedono verifica.'),
+('Note spese - senza allegati', 'custom', (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'controller_top', 'col-md-4', '', 'info', 'fa fa-paperclip', '', '', 'link', 'modules/note_spese/widgets/indicatori.php', 1, 3, 'Spese confermate del periodo che non hanno ancora allegati.');
+
+INSERT INTO `zz_widgets_lang` (`id_lang`, `id_record`, `title`, `text`) VALUES
+(1, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - confermate'), 'Spese confermate', 'Spese confermate'),
+(2, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - confermate'), 'Confirmed expenses', 'Confirmed expenses'),
+(1, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - da verificare'), 'Da verificare', 'Da verificare'),
+(2, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - da verificare'), 'To review', 'To review'),
+(1, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - senza allegati'), 'Senza allegati', 'Senza allegati'),
+(2, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - senza allegati'), 'Without attachments', 'Without attachments');
+
+INSERT INTO `zz_hooks` (`name`, `class`, `enabled`, `id_module`) VALUES
+('Note spese da importare', 'Modules\\NoteSpese\\PendingExpensesHook', 1, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'));
+INSERT INTO `zz_hooks_lang` (`id_lang`, `id_record`, `title`) VALUES
+(1, (SELECT `id` FROM `zz_hooks` WHERE `name` = 'Note spese da importare'), 'Note spese da importare'),
+(2, (SELECT `id` FROM `zz_hooks` WHERE `name` = 'Note spese da importare'), 'Expense notes to import');
+
+INSERT INTO `zz_permissions` (`id_gruppo`, `id_module`, `permessi`)
+SELECT g.`id`, m.`id`, 'rw'
+FROM `zz_groups` g
+CROSS JOIN `zz_modules` m
+WHERE g.`nome` = 'Amministratori' AND m.`name` IN ('Note spese', 'Tipologie note spese');
+
+INSERT INTO `zz_group_view` (`id_gruppo`, `id_vista`)
+SELECT g.`id`, v.`id`
+FROM `zz_groups` g
+INNER JOIN `zz_views` v ON v.`id_module` IN (SELECT `id` FROM `zz_modules` WHERE `name` IN ('Note spese', 'Tipologie note spese'));
diff --git a/update/tables.php b/update/tables.php
index ad542e845..eeb2d5a40 100755
--- a/update/tables.php
+++ b/update/tables.php
@@ -43,6 +43,11 @@
'co_mandati_sepa',
'co_movimenti',
'co_movimenti_modelli',
+ 'co_note_spese',
+ 'co_note_spese_stati',
+ 'co_note_spese_stati_lang',
+ 'co_note_spese_tipologie',
+ 'co_note_spese_tipologie_lang',
'co_pagamenti',
'co_pagamenti_lang',
'co_piano_dei_conti1',
From f66d2022c8e1bb0b5d392d711d7def3c1fa07ef6 Mon Sep 17 00:00:00 2001
From: Sajo <75281007+sajotrei@users.noreply.github.com>
Date: Mon, 10 Aug 2026 00:02:27 +0200
Subject: [PATCH 2/4] Aggiunge intestazione GPL e autore a Note spese
---
modules/note_spese/init.php | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/modules/note_spese/init.php b/modules/note_spese/init.php
index 1807aa41b..e86d2a9d5 100644
--- a/modules/note_spese/init.php
+++ b/modules/note_spese/init.php
@@ -1,5 +1,30 @@
.
+ */
+
+/**
+ * Modulo Note spese.
+ *
+ * @author sajotrei
+ * @link https://github.com/sajotrei
+ */
+
include_once __DIR__.'/../../core.php';
if (!empty($id_record)) {
From 0c8a2cbf457a148890c1d5652c4cbcedbd84ab92 Mon Sep 17 00:00:00 2001
From: Sajo <75281007+sajotrei@users.noreply.github.com>
Date: Mon, 10 Aug 2026 00:03:23 +0200
Subject: [PATCH 3/4] Aggiunge intestazione GPL e autore alle tipologie Note
spese
---
modules/tipologie_note_spese/init.php | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/modules/tipologie_note_spese/init.php b/modules/tipologie_note_spese/init.php
index 5e8b2c91d..50f4b2887 100644
--- a/modules/tipologie_note_spese/init.php
+++ b/modules/tipologie_note_spese/init.php
@@ -1,5 +1,30 @@
.
+ */
+
+/**
+ * Tipologie del modulo Note spese.
+ *
+ * @author sajotrei
+ * @link https://github.com/sajotrei
+ */
+
include_once __DIR__.'/../../core.php';
if (!empty($id_record)) {
From 89433a3a7d24455f2307f86e0626b114775bcf61 Mon Sep 17 00:00:00 2001
From: Sajo <75281007+sajotrei@users.noreply.github.com>
Date: Mon, 10 Aug 2026 00:03:41 +0200
Subject: [PATCH 4/4] Aggiunge intestazione GPL e autore alla stampa Note spese
---
templates/note_spese/init.php | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/templates/note_spese/init.php b/templates/note_spese/init.php
index eea4d2736..07e5c6e43 100644
--- a/templates/note_spese/init.php
+++ b/templates/note_spese/init.php
@@ -1,5 +1,30 @@
.
+ */
+
+/**
+ * Stampa riepilogativa del modulo Note spese.
+ *
+ * @author sajotrei
+ * @link https://github.com/sajotrei
+ */
+
include_once __DIR__.'/../../core.php';
// La stampa e' di periodo e non e' legata a una singola anagrafica/documento.