Author SHA1 Message Date
RMubarakzyanovandClaude Opus 5 e13e0c9b2a Translate the Playwright e2e suite to English
Test titles, describe blocks, comments and assertion messages are now in
English, so the runner output and the code are readable without Italian.

Deliberately left in Italian, because they are matched against the app or
typed into it:
- UI text used in locators (page headings, badges, empty states, SweetAlert
  messages, sidebar entries, category names)
- form values filled in by the tests, so screenshots and demo videos keep
  showing an Italian UI with Italian data
- fixture labels passed to name(), for the same reason

Screenshot files are renamed to the English labels the suite now writes
(desktop-registro.png -> desktop-registry.png and so on); the docs that
quote test titles or screenshot names are updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:11:59 +03:00
solocla 41c4838dab fixed sort scadenze 2026-07-30 15:53:36 +02:00
solocla 35e57c303f fixing worksheet 2026-07-30 09:17:52 +02:00
solocla e0fbd26207 modifica mescola worksheet 2026-07-30 08:22:41 +02:00
solocla b430c798dc Added userdb profile 2026-07-24 16:53:30 +02:00
solocla 135519ae5e fixed mescole delete deactivate 2026-07-24 14:24:33 +02:00
solocla d564561a4e fixed mescole space 2026-07-24 14:21:41 +02:00
solocla 0427c73e8a added nome to mescole 2026-07-24 14:18:05 +02:00
solocla 151e3bbe95 added mescole files drag drop 2026-07-24 08:53:49 +02:00
solocla c11adf19e8 added assorbimento corso 2026-07-24 08:32:50 +02:00
solocla 6d60f89be2 fixed table scadenze 2026-07-19 15:22:17 +02:00
solocla f2cb6343f7 fixed table dscaeznairio 2026-07-19 15:17:18 +02:00
solocla 58af735082 added alert flag and variation flag 2026-07-19 15:06:18 +02:00
solocla 4e61426be7 fixed scadenziario new field, dpi, dpi trash 2026-07-16 12:05:37 +02:00
solocla 1ef6cca149 fixed button cad area 2026-07-06 12:06:14 +02:00
solocla 4fc2828aaf added cad area in navbar 2026-07-06 11:10:36 +02:00
solocla db6e73bde5 fixed cad area and added dfx 2026-07-06 11:06:33 +02:00
solocla 376252e263 added files for mescole 2026-07-03 09:28:21 +02:00
solocla c675eb766e logincontroller update 2026-07-02 10:54:23 +02:00
solocla 27cbc9f449 cad area update con autocontorno 2026-06-16 12:05:50 +02:00
solocla 4c09a0dcb4 cad area punti mpodifica manuale 2026-06-16 09:44:19 +02:00
solocla 8bb23ee563 cad area 2026-06-16 09:23:40 +02:00
solocla 20571c9e4b fixed navbar 2026-06-11 10:34:41 +02:00
solocla fdde16b113 added functions email and flag 2026-06-11 10:20:46 +02:00
solocla 33b627f328 image cad area size 2026-06-11 09:02:22 +02:00
solocla d96b4be9e0 fixed add sub roles 2026-06-05 14:53:34 +02:00
solocla 088e518db1 fixed migration 2026-06-05 14:50:51 +02:00
solocla 789c547bc7 Ensure job_roles table exists before job_sub_roles migration 2026-06-05 14:47:11 +02:00
solocla e5bf546ae7 fixed funzioni aziendali 2026-06-05 14:35:19 +02:00
solocla 6dd13e5d7d fixed employess job roles navbar 2026-06-05 10:45:34 +02:00
solocla b1f2bb60e3 added subroles and dpi association fixed all pages and migration 2026-06-04 12:17:17 +02:00
RMubarakzyanov f7e97f55e9 bulk operations for dpi 2026-05-26 20:11:55 +03:00
solocla 70b712ff3b trainings changed details 2026-05-26 16:55:04 +02:00
141 changed files with 23277 additions and 1026 deletions
+6
View File
@@ -46,6 +46,7 @@ public/userarea/last_url.txt
public/userarea/class/curl_auth_debug.log public/userarea/class/curl_auth_debug.log
public/userarea/class/curl_request_debug.log public/userarea/class/curl_request_debug.log
public/userarea/uploads/cad_area/originals/*
# Ignora tutti i log # Ignora tutti i log
*.log *.log
@@ -69,3 +70,8 @@ public/userarea/logsapi/last_auth_url.txt
# User uploaded files # User uploaded files
/public/userarea/files/ /public/userarea/files/
# Documenti e allegati caricati dagli utenti (mescole, dipendenti, formazione, DPI)
/public/userarea/uploads/
!/public/userarea/uploads/.gitkeep
@@ -113,12 +113,20 @@ class LoginController extends Controller
return redirect()->to('userarea/production_dashboard.php'); return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('HR')) { } elseif ($user->hasRole('HR')) {
return redirect()->to('userarea/production_dashboard.php'); return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('employee-hr')) {
return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('SuperUser')) { } elseif ($user->hasRole('SuperUser')) {
return redirect()->to('userarea/production_dashboard.php'); return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('Management')) { } elseif ($user->hasRole('Management')) {
return redirect()->to('userarea/production_dashboard.php'); return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('manager')) {
return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('employee')) {
return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('Quality')) { } elseif ($user->hasRole('Quality')) {
return redirect()->to('userarea/production_dashboard.php'); return redirect()->to('userarea/production_dashboard.php');
} elseif ($user->hasRole('UserDatabase')) {
return redirect()->to('userarea/production_dashboard.php');
} }
// Se il ruolo non è specificato, reindirizza alla home predefinita // Se il ruolo non è specificato, reindirizza alla home predefinita
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateJobSubRolesTable extends AbstractMigration
{
public function change(): void
{
if (!$this->hasTable('job_roles')) {
$rolesTable = $this->table('job_roles', [
'id' => false,
'primary_key' => ['id'],
'collation' => 'utf8mb4_unicode_ci',
'encoding' => 'utf8mb4',
]);
$rolesTable
->addColumn('id', 'integer', [
'identity' => true,
'signed' => false,
])
->addColumn('name', 'string', [
'limit' => 255,
'null' => false,
])
->addColumn('description', 'text', [
'null' => true,
'default' => null,
])
->addColumn('sort_order', 'integer', [
'signed' => false,
'null' => false,
'default' => 999,
])
->addColumn('is_active', 'boolean', [
'null' => false,
'default' => 1,
])
->addColumn('created_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
])
->addColumn('updated_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
'update' => 'CURRENT_TIMESTAMP',
])
->addIndex(['is_active'], [
'name' => 'idx_job_roles_is_active',
])
->addIndex(['sort_order'], [
'name' => 'idx_job_roles_sort_order',
])
->create();
}
if (!$this->hasTable('job_sub_roles')) {
$table = $this->table('job_sub_roles', [
'id' => false,
'primary_key' => ['id'],
'collation' => 'utf8mb4_unicode_ci',
'encoding' => 'utf8mb4',
]);
$table
->addColumn('id', 'integer', [
'identity' => true,
'signed' => false,
])
->addColumn('job_role_id', 'integer', [
'signed' => false,
'null' => false,
])
->addColumn('name', 'string', [
'limit' => 255,
'null' => false,
])
->addColumn('description', 'text', [
'null' => true,
'default' => null,
])
->addColumn('sort_order', 'integer', [
'signed' => false,
'null' => false,
'default' => 999,
])
->addColumn('is_active', 'boolean', [
'null' => false,
'default' => 1,
])
->addColumn('created_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
])
->addColumn('updated_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
'update' => 'CURRENT_TIMESTAMP',
])
->addIndex(['job_role_id'], [
'name' => 'idx_job_sub_roles_job_role_id',
])
->addIndex(['is_active'], [
'name' => 'idx_job_sub_roles_is_active',
])
->addIndex(['sort_order'], [
'name' => 'idx_job_sub_roles_sort_order',
])
->addForeignKey(
'job_role_id',
'job_roles',
'id',
[
'delete' => 'CASCADE',
'update' => 'CASCADE',
'constraint' => 'fk_job_sub_roles_job_role',
]
)
->create();
}
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreatePpeItemsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->table('ppe_items', [
'id' => false,
'primary_key' => ['id'],
'collation' => 'utf8mb4_unicode_ci',
'encoding' => 'utf8mb4',
]);
$table
->addColumn('id', 'integer', [
'identity' => true,
'signed' => false,
])
->addColumn('name', 'string', [
'limit' => 255,
'null' => false,
])
->addColumn('description', 'text', [
'null' => true,
'default' => null,
])
->addColumn('category', 'string', [
'limit' => 100,
'null' => true,
'default' => null,
'comment' => 'PPE category, for example Head, Hands, Eyes, Feet, Respiratory',
])
->addColumn('photo', 'string', [
'limit' => 255,
'null' => true,
'default' => null,
'comment' => 'PPE image path or filename',
])
->addColumn('standard_reference', 'string', [
'limit' => 255,
'null' => true,
'default' => null,
'comment' => 'Reference standard, for example EN ISO 20345',
])
->addColumn('validity_months', 'integer', [
'signed' => false,
'null' => true,
'default' => null,
'comment' => 'Default validity in months after assignment',
])
->addColumn('sort_order', 'integer', [
'signed' => false,
'null' => false,
'default' => 999,
])
->addColumn('is_active', 'boolean', [
'null' => false,
'default' => 1,
])
->addColumn('created_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
])
->addColumn('updated_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
'update' => 'CURRENT_TIMESTAMP',
])
->addIndex(['category'], [
'name' => 'idx_ppe_items_category',
])
->addIndex(['is_active'], [
'name' => 'idx_ppe_items_is_active',
])
->addIndex(['sort_order'], [
'name' => 'idx_ppe_items_sort_order',
])
->create();
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateEmployeePpeItemsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->table('employee_ppe_items', [
'id' => false,
'primary_key' => ['id'],
'collation' => 'utf8mb4_unicode_ci',
'encoding' => 'utf8mb4',
]);
$table
->addColumn('id', 'integer', [
'identity' => true,
'signed' => false,
])
->addColumn('employee_id', 'integer', [
'signed' => false,
'null' => false,
])
->addColumn('ppe_item_id', 'integer', [
'signed' => false,
'null' => false,
])
->addColumn('assigned_date', 'date', [
'null' => true,
'default' => null,
])
->addColumn('expiry_date', 'date', [
'null' => true,
'default' => null,
])
->addColumn('quantity', 'integer', [
'signed' => false,
'null' => false,
'default' => 1,
])
->addColumn('status', 'enum', [
'values' => [
'assigned',
'returned',
'expired',
'lost',
'damaged',
],
'null' => false,
'default' => 'assigned',
])
->addColumn('notes', 'text', [
'null' => true,
'default' => null,
])
->addColumn('created_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
])
->addColumn('updated_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
'update' => 'CURRENT_TIMESTAMP',
])
->addIndex(['employee_id'], [
'name' => 'idx_employee_ppe_items_employee_id',
])
->addIndex(['ppe_item_id'], [
'name' => 'idx_employee_ppe_items_ppe_item_id',
])
->addIndex(['status'], [
'name' => 'idx_employee_ppe_items_status',
])
->addIndex(['expiry_date'], [
'name' => 'idx_employee_ppe_items_expiry_date',
])
->addForeignKey(
'employee_id',
'employees',
'id',
[
'delete' => 'CASCADE',
'update' => 'CASCADE',
'constraint' => 'fk_employee_ppe_items_employee',
]
)
->addForeignKey(
'ppe_item_id',
'ppe_items',
'id',
[
'delete' => 'RESTRICT',
'update' => 'CASCADE',
'constraint' => 'fk_employee_ppe_items_ppe_item',
]
)
->create();
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateJobSubRolePpeItemsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->table('job_sub_role_ppe_items', [
'id' => false,
'primary_key' => ['id'],
'collation' => 'utf8mb4_unicode_ci',
'encoding' => 'utf8mb4',
]);
$table
->addColumn('id', 'integer', [
'identity' => true,
'signed' => false,
])
->addColumn('job_sub_role_id', 'integer', [
'signed' => false,
'null' => false,
])
->addColumn('ppe_item_id', 'integer', [
'signed' => false,
'null' => false,
])
->addColumn('requirement_type', 'enum', [
'values' => [
'mandatory',
'recommended',
'optional',
],
'null' => false,
'default' => 'mandatory',
'comment' => 'Defines if the PPE is mandatory, recommended or optional for the sub role',
])
->addColumn('notes', 'text', [
'null' => true,
'default' => null,
])
->addColumn('sort_order', 'integer', [
'signed' => false,
'null' => false,
'default' => 999,
])
->addColumn('is_active', 'boolean', [
'null' => false,
'default' => 1,
])
->addColumn('created_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
])
->addColumn('updated_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
'update' => 'CURRENT_TIMESTAMP',
])
->addIndex(['job_sub_role_id'], [
'name' => 'idx_job_sub_role_ppe_items_sub_role_id',
])
->addIndex(['ppe_item_id'], [
'name' => 'idx_job_sub_role_ppe_items_ppe_item_id',
])
->addIndex(['requirement_type'], [
'name' => 'idx_job_sub_role_ppe_items_requirement_type',
])
->addIndex(['is_active'], [
'name' => 'idx_job_sub_role_ppe_items_is_active',
])
->addIndex(['job_sub_role_id', 'ppe_item_id'], [
'unique' => true,
'name' => 'uq_job_sub_role_ppe_item',
])
->addForeignKey(
'job_sub_role_id',
'job_sub_roles',
'id',
[
'delete' => 'CASCADE',
'update' => 'CASCADE',
'constraint' => 'fk_job_sub_role_ppe_items_sub_role',
]
)
->addForeignKey(
'ppe_item_id',
'ppe_items',
'id',
[
'delete' => 'CASCADE',
'update' => 'CASCADE',
'constraint' => 'fk_job_sub_role_ppe_items_ppe_item',
]
)
->create();
}
}
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddJobSubRoleIdToEmployeesTable extends AbstractMigration
{
public function up(): void
{
if (!$this->hasTable('employees')) {
throw new RuntimeException('Table employees does not exist.');
}
$table = $this->table('employees');
if (!$table->hasColumn('job_role_id')) {
$table
->addColumn('job_role_id', 'integer', [
'signed' => false,
'null' => true,
'after' => 'department_id',
])
->addIndex(['job_role_id'], [
'name' => 'idx_employees_job_role_id',
])
->addForeignKey(
'job_role_id',
'job_roles',
'id',
[
'delete' => 'SET_NULL',
'update' => 'CASCADE',
'constraint' => 'fk_employees_job_role',
]
)
->update();
}
$table = $this->table('employees');
if (!$table->hasColumn('job_sub_role_id')) {
$afterColumn = $table->hasColumn('job_role_id') ? 'job_role_id' : 'department_id';
$table
->addColumn('job_sub_role_id', 'integer', [
'signed' => false,
'null' => true,
'after' => $afterColumn,
])
->addIndex(['job_sub_role_id'], [
'name' => 'idx_employees_job_sub_role_id',
])
->addForeignKey(
'job_sub_role_id',
'job_sub_roles',
'id',
[
'delete' => 'SET_NULL',
'update' => 'CASCADE',
'constraint' => 'fk_employees_job_sub_role',
]
)
->update();
}
}
public function down(): void
{
if (!$this->hasTable('employees')) {
return;
}
$table = $this->table('employees');
if ($table->hasForeignKey('job_sub_role_id')) {
$table->dropForeignKey('job_sub_role_id')->update();
}
if ($table->hasForeignKey('job_role_id')) {
$table->dropForeignKey('job_role_id')->update();
}
$table = $this->table('employees');
if ($table->hasIndexByName('idx_employees_job_sub_role_id')) {
$table->removeIndexByName('idx_employees_job_sub_role_id')->update();
}
if ($table->hasIndexByName('idx_employees_job_role_id')) {
$table->removeIndexByName('idx_employees_job_role_id')->update();
}
$table = $this->table('employees');
if ($table->hasColumn('job_sub_role_id')) {
$table->removeColumn('job_sub_role_id')->update();
}
if ($table->hasColumn('job_role_id')) {
$table->removeColumn('job_role_id')->update();
}
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddDeliveryFieldsToEmployeePpeItemsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->table('employee_ppe_items');
$table
->addColumn('delivered_by', 'string', [
'limit' => 255,
'null' => true,
'default' => null,
'after' => 'expiry_date',
])
->addColumn('created_by', 'integer', [
'signed' => false,
'null' => true,
'default' => null,
'after' => 'notes',
])
->addIndex(['created_by'], [
'name' => 'idx_employee_ppe_items_created_by',
])
->addForeignKey(
'created_by',
'auth_users',
'id',
[
'delete' => 'SET_NULL',
'update' => 'CASCADE',
'constraint' => 'fk_employee_ppe_items_created_by',
]
)
->update();
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateEmployeeJobSubRolesTable extends AbstractMigration
{
public function up(): void
{
if (!$this->hasTable('employee_job_sub_roles')) {
$table = $this->table('employee_job_sub_roles', [
'id' => false,
'primary_key' => ['id'],
'signed' => false,
'collation' => 'utf8mb4_general_ci',
'encoding' => 'utf8mb4',
]);
$table
->addColumn('id', 'integer', [
'identity' => true,
'signed' => false,
])
->addColumn('employee_id', 'integer', [
'signed' => false,
'null' => false,
])
->addColumn('job_sub_role_id', 'integer', [
'signed' => false,
'null' => false,
])
->addColumn('is_primary', 'boolean', [
'null' => false,
'default' => false,
])
->addColumn('created_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
])
->addIndex(['employee_id', 'job_sub_role_id'], [
'unique' => true,
'name' => 'uq_employee_subrole',
])
->addIndex(['employee_id'], [
'name' => 'idx_employee_job_sub_roles_employee',
])
->addIndex(['job_sub_role_id'], [
'name' => 'idx_employee_job_sub_roles_subrole',
])
->addForeignKey(
'employee_id',
'employees',
'id',
[
'delete' => 'CASCADE',
'update' => 'CASCADE',
'constraint' => 'fk_employee_job_sub_roles_employee',
]
)
->addForeignKey(
'job_sub_role_id',
'job_sub_roles',
'id',
[
'delete' => 'CASCADE',
'update' => 'CASCADE',
'constraint' => 'fk_employee_job_sub_roles_subrole',
]
)
->create();
}
// Import existing single sub-role assignments from employees.job_sub_role_id
// into the new bridge table.
$this->execute("
INSERT IGNORE INTO employee_job_sub_roles
(employee_id, job_sub_role_id, is_primary, created_at)
SELECT
e.id,
e.job_sub_role_id,
1,
NOW()
FROM employees e
WHERE e.job_sub_role_id IS NOT NULL
AND e.job_sub_role_id > 0
");
}
public function down(): void
{
if ($this->hasTable('employee_job_sub_roles')) {
$this->table('employee_job_sub_roles')->drop()->save();
}
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateCompanyFunctionsTable extends AbstractMigration
{
public function up(): void
{
if (!$this->hasTable('company_functions')) {
$table = $this->table('company_functions', [
'id' => false,
'primary_key' => ['id'],
'signed' => false,
'collation' => 'utf8mb4_general_ci',
'encoding' => 'utf8mb4',
]);
$table
->addColumn('id', 'integer', [
'identity' => true,
'signed' => false,
])
->addColumn('function_name', 'string', [
'limit' => 150,
'null' => false,
'comment' => 'Function name, for example RSPP, Medico del lavoro, RLS',
])
->addColumn('person_full_name', 'string', [
'limit' => 200,
'null' => false,
'comment' => 'Full name and surname of the person assigned to the function',
])
->addColumn('phone', 'string', [
'limit' => 80,
'null' => true,
])
->addColumn('email', 'string', [
'limit' => 190,
'null' => true,
])
->addColumn('notes', 'text', [
'null' => true,
])
->addColumn('sort_order', 'integer', [
'signed' => false,
'null' => false,
'default' => 0,
])
->addColumn('is_active', 'boolean', [
'null' => false,
'default' => true,
])
->addColumn('created_at', 'timestamp', [
'null' => true,
'default' => 'CURRENT_TIMESTAMP',
])
->addColumn('updated_at', 'timestamp', [
'null' => true,
'default' => null,
'update' => 'CURRENT_TIMESTAMP',
])
->addIndex(['function_name'], [
'name' => 'idx_company_functions_function_name',
])
->addIndex(['person_full_name'], [
'name' => 'idx_company_functions_person_full_name',
])
->addIndex(['email'], [
'name' => 'idx_company_functions_email',
])
->addIndex(['is_active', 'sort_order'], [
'name' => 'idx_company_functions_active_sort',
])
->create();
}
$this->execute("
INSERT INTO company_functions
(function_name, person_full_name, phone, email, notes, sort_order, is_active, created_at, updated_at)
VALUES
('RSPP', '', NULL, NULL, NULL, 10, 1, NOW(), NOW()),
('Medico del lavoro', '', NULL, NULL, NULL, 20, 1, NOW(), NOW()),
('RLS', '', NULL, NULL, NULL, 30, 1, NOW(), NOW())
");
}
public function down(): void
{
if ($this->hasTable('company_functions')) {
$this->table('company_functions')->drop()->save();
}
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AlterScadFunctionsAddContactFields extends AbstractMigration
{
public function up(): void
{
if (!$this->hasTable('scad_functions')) {
throw new RuntimeException('Table scad_functions does not exist.');
}
$table = $this->table('scad_functions');
if (!$table->hasColumn('person_full_name')) {
$table->addColumn('person_full_name', 'string', [
'limit' => 200,
'null' => true,
'after' => 'description',
'comment' => 'Full name and surname of the person assigned to the function',
]);
}
if (!$table->hasColumn('phone')) {
$table->addColumn('phone', 'string', [
'limit' => 80,
'null' => true,
'after' => 'person_full_name',
]);
}
if (!$table->hasColumn('email')) {
$table->addColumn('email', 'string', [
'limit' => 190,
'null' => true,
'after' => 'phone',
]);
}
if (!$table->hasColumn('notes')) {
$table->addColumn('notes', 'text', [
'null' => true,
'after' => 'email',
]);
}
if (!$table->hasColumn('sort_order')) {
$table->addColumn('sort_order', 'integer', [
'signed' => false,
'null' => false,
'default' => 0,
'after' => 'status',
]);
}
if (!$table->hasIndexByName('idx_scad_functions_name')) {
$table->addIndex(['name'], [
'name' => 'idx_scad_functions_name',
]);
}
if (!$table->hasIndexByName('idx_scad_functions_person_full_name')) {
$table->addIndex(['person_full_name'], [
'name' => 'idx_scad_functions_person_full_name',
]);
}
if (!$table->hasIndexByName('idx_scad_functions_email')) {
$table->addIndex(['email'], [
'name' => 'idx_scad_functions_email',
]);
}
if (!$table->hasIndexByName('idx_scad_functions_status_sort')) {
$table->addIndex(['status', 'sort_order'], [
'name' => 'idx_scad_functions_status_sort',
]);
}
$table->update();
// Set a default order for existing rows without changing their names.
$this->execute("
UPDATE scad_functions
SET sort_order = id * 10
WHERE sort_order = 0
");
}
public function down(): void
{
if (!$this->hasTable('scad_functions')) {
return;
}
$table = $this->table('scad_functions');
if ($table->hasIndexByName('idx_scad_functions_status_sort')) {
$table->removeIndexByName('idx_scad_functions_status_sort');
}
if ($table->hasIndexByName('idx_scad_functions_email')) {
$table->removeIndexByName('idx_scad_functions_email');
}
if ($table->hasIndexByName('idx_scad_functions_person_full_name')) {
$table->removeIndexByName('idx_scad_functions_person_full_name');
}
if ($table->hasIndexByName('idx_scad_functions_name')) {
$table->removeIndexByName('idx_scad_functions_name');
}
if ($table->hasColumn('sort_order')) {
$table->removeColumn('sort_order');
}
if ($table->hasColumn('notes')) {
$table->removeColumn('notes');
}
if ($table->hasColumn('email')) {
$table->removeColumn('email');
}
if ($table->hasColumn('phone')) {
$table->removeColumn('phone');
}
if ($table->hasColumn('person_full_name')) {
$table->removeColumn('person_full_name');
}
$table->update();
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateCadAreaJobsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->table('cad_area_jobs');
$table
->addColumn('iduser', 'integer', [
'null' => true,
'signed' => false,
'limit' => 10,
])
->addColumn('original_filename', 'string', [
'limit' => 255,
'null' => false,
])
->addColumn('stored_filename', 'string', [
'limit' => 255,
'null' => false,
])
->addColumn('file_path', 'string', [
'limit' => 500,
'null' => false,
])
->addColumn('file_url', 'string', [
'limit' => 500,
'null' => true,
])
->addColumn('file_size', 'integer', [
'null' => true,
'signed' => false,
])
->addColumn('status', 'enum', [
'values' => [
'uploaded',
'processing',
'completed',
'error',
],
'default' => 'uploaded',
'null' => false,
])
->addColumn('area_mm2', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
])
->addColumn('area_cm2', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
])
->addColumn('area_m2', 'decimal', [
'precision' => 18,
'scale' => 9,
'null' => true,
])
->addColumn('scale_detected', 'string', [
'limit' => 50,
'null' => true,
])
->addColumn('confidence', 'string', [
'limit' => 50,
'null' => true,
])
->addColumn('message', 'text', [
'null' => true,
])
->addColumn('python_response', 'text', [
'null' => true,
])
->addColumn('created_at', 'timestamp', [
'default' => 'CURRENT_TIMESTAMP',
'null' => true,
])
->addColumn('updated_at', 'timestamp', [
'default' => 'CURRENT_TIMESTAMP',
'update' => 'CURRENT_TIMESTAMP',
'null' => true,
])
->addIndex(['iduser'], [
'name' => 'idx_cad_area_jobs_iduser',
])
->addIndex(['status'], [
'name' => 'idx_cad_area_jobs_status',
])
->create();
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddNotifyFunctionToScadDeadlines extends AbstractMigration
{
public function up(): void
{
if (!$this->hasTable('scad_deadlines')) {
throw new RuntimeException('Table scad_deadlines does not exist.');
}
$table = $this->table('scad_deadlines');
if (!$table->hasColumn('notify_function')) {
$table
->addColumn('notify_function', 'boolean', [
'null' => false,
'default' => false,
'after' => 'function_id',
'comment' => 'Send deadline reminder also to the linked function email',
])
->update();
}
}
public function down(): void
{
if (!$this->hasTable('scad_deadlines')) {
return;
}
$table = $this->table('scad_deadlines');
if ($table->hasColumn('notify_function')) {
$table
->removeColumn('notify_function')
->update();
}
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddRoiFieldsToCadAreaJobsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->table('cad_area_jobs');
if (!$table->hasColumn('roi_x')) {
$table->addColumn('roi_x', 'decimal', [
'precision' => 12,
'scale' => 6,
'null' => true,
'after' => 'file_size',
]);
}
if (!$table->hasColumn('roi_y')) {
$table->addColumn('roi_y', 'decimal', [
'precision' => 12,
'scale' => 6,
'null' => true,
'after' => 'roi_x',
]);
}
if (!$table->hasColumn('roi_width')) {
$table->addColumn('roi_width', 'decimal', [
'precision' => 12,
'scale' => 6,
'null' => true,
'after' => 'roi_y',
]);
}
if (!$table->hasColumn('roi_height')) {
$table->addColumn('roi_height', 'decimal', [
'precision' => 12,
'scale' => 6,
'null' => true,
'after' => 'roi_width',
]);
}
if (!$table->hasColumn('roi_page')) {
$table->addColumn('roi_page', 'integer', [
'null' => true,
'default' => 1,
'after' => 'roi_height',
]);
}
if (!$table->hasColumn('calculation_mode')) {
$table->addColumn('calculation_mode', 'string', [
'limit' => 50,
'null' => true,
'default' => 'auto_roi',
'after' => 'roi_page',
]);
}
$table->update();
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddResultDetailFieldsToCadAreaJobsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->table('cad_area_jobs');
if (!$table->hasColumn('width_mm')) {
$table->addColumn('width_mm', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('height_mm')) {
$table->addColumn('height_mm', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('scale_used')) {
$table->addColumn('scale_used', 'decimal', [
'precision' => 12,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('strategy_used')) {
$table->addColumn('strategy_used', 'string', [
'limit' => 100,
'null' => true,
]);
}
$table->update();
}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddManualTracingFieldsToCadAreaJobsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->table('cad_area_jobs');
if (!$table->hasColumn('width_mm')) {
$table->addColumn('width_mm', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('height_mm')) {
$table->addColumn('height_mm', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('scale_used')) {
$table->addColumn('scale_used', 'decimal', [
'precision' => 12,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('strategy_used')) {
$table->addColumn('strategy_used', 'string', [
'limit' => 100,
'null' => true,
]);
}
if (!$table->hasColumn('manual_calibration_px')) {
$table->addColumn('manual_calibration_px', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('manual_calibration_mm')) {
$table->addColumn('manual_calibration_mm', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('manual_mm_per_px')) {
$table->addColumn('manual_mm_per_px', 'decimal', [
'precision' => 18,
'scale' => 10,
'null' => true,
]);
}
if (!$table->hasColumn('manual_polygon_json')) {
$table->addColumn('manual_polygon_json', 'text', [
'null' => true,
]);
}
if (!$table->hasColumn('manual_area_mm2')) {
$table->addColumn('manual_area_mm2', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('manual_area_cm2')) {
$table->addColumn('manual_area_cm2', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('manual_width_mm')) {
$table->addColumn('manual_width_mm', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('manual_height_mm')) {
$table->addColumn('manual_height_mm', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('manual_status')) {
$table->addColumn('manual_status', 'string', [
'limit' => 50,
'null' => true,
]);
}
$table->update();
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddManualHoleFieldsToCadAreaJobsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->table('cad_area_jobs');
if (!$table->hasColumn('manual_outer_area_mm2')) {
$table->addColumn('manual_outer_area_mm2', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('manual_holes_area_mm2')) {
$table->addColumn('manual_holes_area_mm2', 'decimal', [
'precision' => 18,
'scale' => 6,
'null' => true,
]);
}
if (!$table->hasColumn('manual_holes_json')) {
$table->addColumn('manual_holes_json', 'text', [
'null' => true,
]);
}
$table->update();
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateMescoleFilesTable extends AbstractMigration
{
public function change(): void
{
$table = $this->table('mescole_files', [
'id' => 'id',
'signed' => true,
'engine' => 'InnoDB',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_general_ci',
]);
$table
->addColumn('idmescola', 'integer', [
'signed' => true,
'null' => false,
])
->addColumn('categoria', 'string', [
'limit' => 100,
'null' => false,
'comment' => 'slug categoria: schede_tecniche, certificati_analisi, ...',
])
->addColumn('titolo', 'string', [
'limit' => 255,
'null' => false,
])
->addColumn('filename', 'string', [
'limit' => 255,
'null' => false,
'comment' => 'nome fisico del file salvato su disco (random, non l\'originale)',
])
->addColumn('original_filename', 'string', [
'limit' => 255,
'null' => false,
])
->addColumn('mime_type', 'string', [
'limit' => 150,
'null' => true,
])
->addColumn('filesize', 'integer', [
'null' => true,
])
->addColumn('uploaded_at', 'datetime', [
'default' => 'CURRENT_TIMESTAMP',
])
->addIndex(['idmescola'])
->addIndex(['categoria'])
->addForeignKey('idmescola', 'mescole', 'id', [
'delete' => 'CASCADE',
'update' => 'CASCADE',
])
->create();
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddDxfToCadAreaJobs extends AbstractMigration
{
public function change(): void
{
$this->table('cad_area_jobs')
->addColumn('dxf_filename', 'string', [
'limit' => 255,
'null' => true,
'default' => null,
'after' => 'manual_holes_json'
])
->addColumn('dxf_url', 'string', [
'limit' => 500,
'null' => true,
'default' => null,
'after' => 'dxf_filename'
])
->update();
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddVariazioniAndAttivaAlertToScadDeadlines extends AbstractMigration
{
public function change(): void
{
$this->table('scad_deadlines')
->addColumn('variazioni', 'text', [
'null' => true,
'default' => null,
'after' => 'notes'
])
->addColumn('attiva_alert', 'boolean', [
'null' => false,
'default' => false,
'after' => 'variazioni'
])
->update();
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class ChangeVariazioniToFlag extends AbstractMigration
{
public function up(): void
{
$this->execute("ALTER TABLE `scad_deadlines`
CHANGE `variazioni` `variazioni` TINYINT(1) NOT NULL DEFAULT 0
COMMENT 'Flag: in caso di variazioni'");
}
public function down(): void
{
$this->execute("ALTER TABLE `scad_deadlines`
CHANGE `variazioni` `variazioni` TEXT NULL DEFAULT NULL");
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class SetAttivaAlertDefaultOn extends AbstractMigration
{
/**
* Change Method.
*
* Write your reversible migrations using this method.
*
* More information on writing migrations is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
*
* Remember to call "create()" or "update()" and NOT "save()" when working
* with the Table class.
*/
public function up(): void
{
$this->execute("ALTER TABLE `scad_deadlines`
CHANGE `attiva_alert` `attiva_alert` TINYINT(1) NOT NULL DEFAULT 1");
}
public function down(): void
{
$this->execute("ALTER TABLE `scad_deadlines`
CHANGE `attiva_alert` `attiva_alert` TINYINT(1) NOT NULL DEFAULT 0");
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class EnableAttivaAlertOnExisting extends AbstractMigration
{
public function up(): void
{
$this->execute("UPDATE `scad_deadlines`
SET `attiva_alert` = 1
WHERE `status` = 'active'");
}
public function down(): void
{
$this->execute("UPDATE `scad_deadlines`
SET `attiva_alert` = 0
WHERE `status` = 'active'");
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateTrainingTopicCoverage extends AbstractMigration
{
public function change(): void
{
$table = $this->table('training_topic_coverage', [
'id' => 'id',
'signed' => false,
'engine' => 'InnoDB',
'encoding' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
]);
$table
->addColumn('topic_id', 'integer', [
'signed' => false,
'null' => false,
'comment' => 'Corso madre (es. RSPP)',
])
->addColumn('covered_topic_id', 'integer', [
'signed' => false,
'null' => false,
'comment' => 'Corso assorbito (es. Antincendio)',
])
->addColumn('inherit_expiry', 'enum', [
'values' => ['own', 'source', 'none'],
'default' => 'own',
'null' => false,
'comment' => 'own = ricalcola con frequenza propria del corso coperto',
])
->addColumn('notes', 'string', [
'limit' => 255,
'null' => true,
])
->addColumn('created_at', 'timestamp', [
'default' => 'CURRENT_TIMESTAMP',
'null' => true,
])
->addIndex(['topic_id', 'covered_topic_id'], [
'unique' => true,
'name' => 'uq_topic_coverage',
])
->addIndex(['covered_topic_id'], ['name' => 'idx_covered_topic'])
->addForeignKey('topic_id', 'training_topics', 'id', [
'delete' => 'CASCADE',
'update' => 'CASCADE',
'constraint' => 'fk_cov_topic',
])
->addForeignKey('covered_topic_id', 'training_topics', 'id', [
'delete' => 'CASCADE',
'update' => 'CASCADE',
'constraint' => 'fk_cov_covered',
])
->create();
}
}
+275
View File
@@ -0,0 +1,275 @@
# Manutenzioni — тест-план
Полный список проверок. **Бо́льшая часть автоматизирована** — руками остаётся только то, что действительно требует глаз (список ниже).
---
## Автопрогон — одна команда
```bash
make test # всё: 20 юнитов + 68 API-проверок + 69 браузерных тестов
```
По отдельности (`make` без аргументов покажет весь список):
```bash
make test-unit # логика расписания, без базы и сервера (0.02 с)
make test-api # логин, ajax, БД, файлы, права — 68 проверок
make test-e2e # браузер на 1440 px — 69 тестов
make test-sizes # полный прогон на каждом размере: 1440 / 820 / 390 px
make test-e2e-ui # интерактивный режим, видно каждый шаг
```
### Прогон по размерам экрана
`make test-sizes` гоняет **всю** сюиту трижды — на PC (1440), планшете (820) и телефоне (390):
| Размер | Тестов | Что проверяется отдельно |
|---|---|---|
| desktop 1440 | 69 | + вёрстка на трёх ширинах и сквозной обход |
| tablet 820 | 56 | целевое устройство по ТЗ |
| mobile 390 | 56 | карточки вместо таблиц, полноэкранные модалки |
Итого 181 браузерная проверка. Тесты написаны без привязки к вёрстке: хелпер `listItem()` берёт строку таблицы или карточку — смотря что видно на этой ширине.
## Видео вместо кликанья
```bash
make demo # ~50 с: сквозной обход модуля → tests/e2e/video/demo.mp4
make video # весь прогон, склеенный по устройствам → tests/e2e/video/
make video-sizes # то же для каждого размера → tests/e2e/video/<формат>/
```
`make demo` прогоняет `tour.spec.js` — один непрерывный сценарий: реестр и фильтры → импортированная карточка `mod. 6.3-1` → создание оборудования → добавление ТО → регистрация работы с подписью мышью → пересчёт срока → история → печатная форма → оба календаря → категории → телефон и планшет. Это полноценный тест: если что-то сломано, он упадёт.
`make video` записывает все 69 тестов и склеивает их в пять файлов (`ffmpeg`, склейка по разрешениям — иначе кадры разного размера не соединить):
| Файл | Что внутри | Длительность |
|---|---|---|
| `demo.mp4` | сквозной обход | ~50 с |
| `suite-funzionali.mp4` | реестр, ТО, интервенции, календари, печать, права | ~70 с |
| `suite-desktop.mp4` | вёрстка 1440 px | ~15 с |
| `suite-tablet.mp4` | вёрстка 820 px + подпись пальцем | ~35 с |
| `suite-mobile.mp4` | вёрстка 390 px | ~15 с |
Прогоны API и браузерный делают настоящий логин, работают с живой страницей и сверяют результат в базе. Заводят только данные с префиксом `ZZTEST_` / `ZZE2E_` и **удаляют их за собой**, в том числе при падении. Оба возвращают ненулевой код при ошибке — годятся для CI.
Полезное для e2e:
```bash
npm run test:e2e:ui # интерактивный режим: видно каждый шаг
npm run test:e2e:headed # с открытым браузером
npx playwright show-trace tests/e2e/.artifacts/<папка>/trace.zip # разбор падения
```
**Скриншоты.** Каждый прогон e2e складывает в `tests/e2e/screenshots/` по 5 картинок на устройство (390 / 820 / 1440 px): реестр, список ТО, карточка, календарь, модалка интервенции, плюс `tablet-signature.png` с реальным росчерком пальцем. Проще пролистать их, чем кликать вручную.
**Что закрыто автоматикой:** разделы 1, 2, 3, 5, 6, 7, 8, 9 (включая генерацию PDF), 10, 11, 12 (запуск без ошибок).
## Что смотреть глазами (короткий список)
Осталось немного — то, что машина проверить не может в принципе:
| Что | Пункты |
|---|---|
| Субъективное качество вёрстки — по скриншотам из `tests/e2e/screenshots/` | 2.1, 11.1–11.4 |
| Как выглядит PDF после реальной печати из браузера | 9.4, 9.5 |
| **Реальная доставка писем** на живой ящик | 12.2, 12.4 |
| Импортированные из XLS данные — выборочная сверка с их файлами | 13.2–13.7 |
| Подпись пальцем на **настоящем** планшете (в браузере эмулируется, но железо стоит потрогать) | 6.6 |
Ниже — полный список, если хочется пройти всё подряд руками.
---
## 0. Подготовка
- База: `http://localhost:8001/zibo-dashboard/public` (контейнеры `zibo-app` + `zibo-db`, база на порту 3308).
Если не подняты: `docker compose up -d` (порт 3307 может быть занят — тогда нужен override на 3308).
- Логин под админом (роль Admin, id 1). В форме логина поле называется **username**, туда e-mail.
- Дев-база уже наполнена импортом: **101 единица оборудования, 126 ТО, 20 подрядчиков, 5 категорий**.
- Всё меню модуля: сайдбар → **Manutenzioni**.
---
## 1. Ничего старого не сломалось (сначала это!)
Переименование `production_tools` → `inv_equipment` затронуло модуль производства.
- [ ] **1.1** `userarea/production_tools.php` — открывается по прежнему адресу, в списке видны `Taglierina` (×2), `Marcatura Laser`, `Coestrusore diametro 70` **и** оборудование из импорта.
- [ ] **1.2** Там же: кнопка «Aggiungi Strumento» — создать тестовый инструмент. Сохраняется, появляется в списке.
- [ ] **1.3** Он же появился в новом реестре (`Manutenzioni → Registro Attrezzature`) в категории **Attrezzature**.
- [ ] **1.4** Редактирование инструмента на старой странице: в поле «Stato» теперь три варианта (Attivo / Fuori servizio / Dismesso). Меняем на «Fuori servizio» → сохранилось.
- [ ] **1.5** Кнопка удаления на старой странице — раньше **всегда падала** с «Invalid ID.», теперь помечает как «Dismesso». Проверить на тестовом инструменте.
- [ ] **1.6** `userarea/skills.php` — открывается, выпадающий список «Attrezzature» заполнен.
- [ ] **1.7** `userarea/production_line_view2.php` — открывается, колонка с инструментами у записей производства не пустая и не сломана.
- [ ] **1.8** `userarea/production_dashboard.php` — открывается, кнопка «Attrezzature» ведёт куда вела.
---
## 2. Реестр оборудования
`Manutenzioni → Registro Attrezzature`
- [ ] **2.1** Список открывается, у части позиций видна обложка-заглушка (шестерёнка), у категорий свои цветные точки.
- [ ] **2.2** Поиск «CARRELLO» → находит погрузчики.
- [ ] **2.3** Фильтр по категории **Edifici e impianti** → ~24 позиции (здания, противопожарные двери, чиллеры, котёл).
- [ ] **2.4** Фильтр по линии **Linea 1 — UHF** → 10 позиций, все с «LINEA 1» в названии.
- [ ] **2.5** Фильтр по статусу «Dismesso» → пусто (или только то, что сам пометил в п.1.4).
- [ ] **2.6** Кнопка «Azzera» сбрасывает все фильтры.
- [ ] **2.7** «Nuova attrezzatura» → заполнить имя + категорию **Attrezzature** → сохраняется, появляется в списке.
- [ ] **2.8** В той же форме выбрать категорию **Linea di produzione** → появляется обязательное поле «Linea di produzione». Попытка сохранить без линии → предупреждение.
- [ ] **2.9** Редактирование (карандаш) — форма открывается **с заполненными** полями, изменения сохраняются.
- [ ] **2.10** Удаление (корзина) своей тестовой записи → удаляется после подтверждения.
- [ ] **2.11** Удаление `Taglierina` (id 1) → **отказ**: «используется в 2 записях производства, пометьте как Dismesso».
---
## 3. Категории
`Manutenzioni → Categorie`
- [ ] **3.1** Пять категорий: Linea di produzione, Edifici e impianti, Attrezzature, Accessori, Qualità. У «Linea di produzione» стоит галка «Richiede linea».
- [ ] **3.2** Создать категорию «Test QA» с цветом и порядком → появилась.
- [ ] **3.3** Создать вторую с тем же именем → отказ «Esiste già una categoria con questo nome».
- [ ] **3.4** Отредактировать «Test QA», поставить «Richiede una linea di produzione» → сохранилось; в форме оборудования при выборе этой категории появляется поле линии.
- [ ] **3.5** Удалить «Test QA» (пустую) → удаляется.
- [ ] **3.6** Удалить «Attrezzature» → **отказ**: используется N оборудованием.
---
## 4. Карточка оборудования, фото и документы
Открыть `CARRELLO ELEVATORE CAR.MI. MOD. PL416` (из реестра).
- [ ] **4.1** Слева блок «Dati»: матрикола `55551002N5069`, производитель `CAR.MI.`, дата ввода в эксплуатацию 01/01/2002.
- [ ] **4.2** Справа 8 работ с кодами **A–H**, частоты: A — «Ogni 2 settimane», B–E — «Ogni 3 mesi», F,G — «Ogni 6 mesi», H — «Ogni 1 anno».
- [ ] **4.3** Загрузить фото (кнопка «+» в блоке «Foto») → появилось в галерее и **сразу стало обложкой** (рамка), обложка видна в списке реестра.
- [ ] **4.4** Загрузить второе фото → обложка не поменялась. Нажать на нём звёздочку → стало обложкой.
- [ ] **4.5** Загрузить документ PDF в блок «Documenti» → появился, открывается по клику в новой вкладке.
- [ ] **4.6** Попробовать загрузить `.php` файл → отказ «Tipo di file non consentito».
- [ ] **4.7** Скопировать ссылку на фото, открыть в **приватном окне** (без сессии) → 401, файл не отдаётся.
- [ ] **4.8** Удалить фото и документ → исчезают.
---
## 5. Манутенции (ТО)
- [ ] **5.1** «Nuova manutenzione» на карточке: код `Z`, название, тип **Programmata**, исполнение **Interna**, частота «Ogni 2 mesi», преавизо 15, исполнитель и ответственный → сохранилось, видно в списке.
- [ ] **5.2** Исполнение **Esterna** → появляется обязательный «Fornitore esterno». Сохранить без него → предупреждение.
- [ ] **5.3** Частота **«Al bisogno»** → поле «Ogni» прячется; сохранённая запись показывает «Al bisogno», даты «Prossimo» нет.
- [ ] **5.4** Поле «Nota sulla frequenza»: вписать «prima dell'accensione» → текст виден в строке ТО после частоты.
- [ ] **5.5** Галка «Manutenzione critica» → красная плашка «Critica» и красная полоса слева.
- [ ] **5.6** Галка «Attiva» снята → строка тускнеет, плашка «Disattivata»; такое ТО пропадает из общего списка `Manutenzioni`.
- [ ] **5.7** Приложить файл-инструкцию в форме ТО → после сохранения счётчик «N istruzioni» в строке; при повторном открытии формы файл в списке, скачивается, удаляется.
- [ ] **5.8** Удалить своё тестовое ТО → предупреждение о числе интервентов, после подтверждения исчезает.
- [ ] **5.9** Открыть `CHILLER UFFICI E POMPA DI CALORE ELCO` → 4 работы A–D **без даты** (сезонные), в примечании частоты полный текст «prima dell'accensione / durante il periodo estivo… / dopo lo spegnimento». Плюс одна внешняя от `Piacenza Impianti`.
- [ ] **5.10** Открыть `EVAPORATORE SOTTOVUOTO…` → **три** ТО: внутреннее + два внешних, `Ecotechno` и `Ecogenerazione` (не должны схлопнуться в одно).
---
## 6. Регистрация интервенций и пересчёт срока
Это ядро модуля — проверить внимательно. Взять любое ТО с частотой (например CARMI, код B — «Ogni 3 mesi»).
- [ ] **6.1** Кнопка «✓» → открывается форма, дата подставлена сегодняшняя, вверху написано, к какому оборудованию и ТО относится.
- [ ] **6.2** Поставить дату **три месяца назад**, статус **Completato**, эсито **Conforme**, оператора, заметку, материалы → сохранить.
- [ ] **6.3** В строке ТО: «Ultimo» = введённая дата, «Prossimo» = она же + 3 месяца, плашка состояния пересчиталась.
- [ ] **6.4** Кнопка «Storico interventi (N)» разворачивает таймлайн, там видна запись с оператором, эсито и заметками.
- [ ] **6.5** **Подпись:** в форме интервенции расписаться мышью в поле подписи → после сохранения миниатюра подписи видна в таймлайне и в печатной форме.
- [ ] **6.6** На планшете/телефоне: подпись рисуется пальцем, страница при этом не скроллится.
- [ ] **6.7** «Cancella firma» очищает поле; при редактировании существующей интервенции стирает и сохранённую подпись.
- [ ] **6.8** Добавить интервенцию в статусе **Pianificato** на будущую дату → «Prossimo» **не изменился** (двигают только завершённые).
- [ ] **6.9** Приложить файл к интервенции (модуль подрядчика) → счётчик скрепки в таймлайне, файл открывается.
- [ ] **6.10** Удалить последнюю завершённую интервенцию → «Prossimo» откатился на предыдущую дату (или опустел, если она была единственной).
- [ ] **6.11** Редактирование интервенции (карандаш в таймлайне) → поля подставлены, изменение даты пересчитывает срок.
---
## 7. Общий список ТО
`Manutenzioni → Manutenzioni`
- [ ] **7.1** Четыре плитки сверху: Scadute / In scadenza / In regola / Al bisogno, сумма ≈ числу активных ТО активного оборудования.
- [ ] **7.2** Клик по плитке «Scadute» фильтрует список; повторный клик снимает фильтр.
- [ ] **7.3** Фильтр «Solo programmata» / «Solo straordinaria» работает.
- [ ] **7.4** Галка «Solo critiche» → остаются только с красной плашкой.
- [ ] **7.5** Галка «Assegnate a me» → остаются ТО, где ты исполнитель или ответственный (нужно, чтобы твой `auth_users` был связан с `employees.auth_user_id`; если связи нет — список пустой, это ожидаемо).
- [ ] **7.6** Сортировка: просроченные и ближайшие сверху, «Al bisogno» — в конце.
- [ ] **7.7** Кнопка «✓» прямо из списка открывает ту же форму интервенции.
---
## 8. Календари
- [ ] **8.1** `Manutenzioni → Calendario`: события на датах, цвет по состоянию (красный — просрочено, жёлтый — скоро, иначе цвет категории).
- [ ] **8.2** Клик по событию → открывается карточка нужного оборудования.
- [ ] **8.3** Фильтры «тип» и «категория» перерисовывают календарь.
- [ ] **8.4** На телефоне календарь открывается сразу в виде списка (listMonth), не ломается по ширине.
- [ ] **8.5** `Manutenzioni → Calendario Cumulativo`: три переключателя — Scadenze 📅, Formazione 🎓, Manutenzioni 🔧.
- [ ] **8.6** Выключение слоя убирает его события, включение возвращает.
- [ ] **8.7** Клик по событию scadenza ведёт в scadenzario, по formazione — в trainings, по manutenzione — в карточку оборудования.
- [ ] **8.8** Выключить все три слоя → календарь пуст, ошибок нет.
---
## 9. Печать / PDF
- [ ] **9.1** На карточке кнопка «Scheda PDF» → открывается вёрстка их бланка: шапка «SCHEDA MANUTENZIONE», справа «ZIBOGOMMA / mod. 6.3-1», строка «MACCHINA:».
- [ ] **9.2** Таблица «Interventi previsti» — коды, описания, частоты, исполнение (внутр./фирма).
- [ ] **9.3** Таблица «Registro interventi eseguiti» — дата, код, описание, оператор, картинка подписи.
- [ ] **9.4** Ctrl/Cmd+P → в предпросмотре печати кнопки скрыты, таблицы не рвутся по строкам, шапка таблицы повторяется на второй странице.
- [ ] **9.5** Сохранить как PDF — файл читаемый.
---
## 10. Права (важно — проверяется на бэке, не только в меню)
Понадобится второй пользователь с ролью без прав `maintenance.*` (например `employee`).
- [ ] **10.1** Под таким пользователем пункт «Manutenzioni» в меню не виден.
- [ ] **10.2** Прямой заход на `userarea/manutenzioni/index.php` → «Permesso negato», а не страница.
- [ ] **10.3** Выдать в «Autorizzazioni» роли только `maintenance.equipment.view` → реестр открывается, но **кнопок создания/редактирования нет**, блок «Manutenzioni» на карточке пишет, что прав нет.
- [ ] **10.4** Добавить `maintenance.manage` → кнопки появились, создание работает.
---
## 11. Мобильный и планшет
Проверять на реальном планшете или в devtools (375px и 768px).
- [ ] **11.1** 375px: реестр показан **карточками**, не таблицей; ничего не уезжает за край, горизонтального скролла нет.
- [ ] **11.2** 375px: список ТО — карточками; плитки-счётчики по две в ряд.
- [ ] **11.3** Модалки открываются на весь экран, кнопки внизу доступны.
- [ ] **11.4** 768px (планшет — целевое устройство по ТЗ): таблицы читаемы, сайдбар по умолчанию скрыт, гамбургер работает.
- [ ] **11.5** Форма интервенции на планшете: подпись пальцем, все поля доступны без зума.
---
## 12. Уведомления (cron)
- [ ] **12.1** Поставить у ТО исполнителя, у которого `employees.auth_user_id` связан с реальным почтовым ящиком, и дату «Prossimo» в прошлом.
- [ ] **12.2** Запустить: `docker exec zibo-app php /var/www/zibo-dashboard/public/userarea/manutenzioni/cron/send_notifications.php`
→ в консоли «Inviate: N», письмо приходит исполнителю **и** ответственному.
- [ ] **12.3** Запустить второй раз в тот же день → «saltate», повторных писем нет.
- [ ] **12.4** В письме: тема с названием оборудования, дата, для критичного ТО — пометка, кнопка ведёт на карточку.
- [ ] **12.5** ТО с частотой «Al bisogno» писем не порождает.
---
## 13. Импортированные данные (выборочно, глазами)
- [ ] **13.1** Всего в реестре ~101 позиция; фильтр по категориям даёт ~74 Attrezzature, ~24 Edifici e impianti, 3 Linee.
- [ ] **13.2** У `FABBRICATI` — 8 работ уборки от `PULINET di Angelini Bassano` (giornaliero / settimanale / mensile / trimestrale / al bisogno).
- [ ] **13.3** У позиций с «LINEA 1/2/3» в названии проставлена соответствующая линия (в карточке строка «Linea»).
- [ ] **13.4** Исполнители расставлены: Alfonso, Gabriele, Junior, Marco, Ester, Marina.
- [ ] **13.5** У `DERATTIZZAZIONE CON ESCHE DOMESTICHE` исполнитель пуст, а в заметках ТО написано «Incaricato da XLS: M.GRANATA» — это фирма, записанная в колонке внутренних работ; надо решить, переводить ли её в подрядчики.
- [ ] **13.6** В заметках оборудования сохранены имена их бумажных карточек («Schede originali: …»).
- [ ] **13.7** **Ожидаемо и требует решения клиента:** у 110 из 125 ТО частота «Al bisogno» — в сводном XLS частот попросту нет, они лежат в отдельных карточках `mod. 6.3-1`, а прислали три из 57.
---
## Чего в модуле нет намеренно
- Калибровка/поверка приборов — вынесена за скоуп самим клиентом. Структура данных её примет отдельной таблицей без переделок.
- Импорт истории выполненных работ из старых карточек — журналы в присланных файлах пустые.
+105
View File
@@ -0,0 +1,105 @@
# Manutenzioni — трассируемость: ТЗ → тесты
Каждый пункт исходного документа клиента (`Manutenzioni - ACS.pdf`) и то, чем он подтверждён.
Условные обозначения: **UI** — браузерный тест (Playwright, гоняется на 1440/820/390), **API** — `tests/manutenzioni/api_test.php`, **U** — юнит-тест PHPUnit.
---
## 1. Реестр оборудования
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Единая база оборудования, у каждой позиции категория | UI | `§1.2 le quattro categorie del capitolato esistono e sono gestibili` |
| Категории: Linea di produzione, Attrezzature, Accessori, Qualità | UI | там же |
| Категории **не хардкод**, свой CRUD | UI + API | `Categories › CRUD from the modal, and categories in use cannot be deleted`; API: создание, дубль имени, запрет удаления занятой |
| Для категории «линия» обязательно указать линию | UI + API | `§1.5 il campo «linea» compare solo per le categorie che lo richiedono`; API: отказ без линии |
| Фильтрация по категории / линии / отделу | UI | `§1.5 the registry filters by production line and by department`; `Equipment registry › the filters really narrow the list` |
| Перенос существующих `production_lines` и `production_tools` | UI + API | `§1.6 le linee e gli attrezzi già esistenti sono nel registro unico`; API: старые страницы производства работают после переименования |
| Поля: название, серийник, партия, дата покупки, дата ввода, производитель, тип, ubicazione, отдел, заметки | UI | `§1.7 tutti i campi dell'anagrafica si salvano e si rivedono` — сохранение, возврат в форму и отображение в карточке |
| Статус (attivo / fuori servizio / dismesso) исключает неактивное из планировочных вью | UI | `§1.7 un'attrezzatura non attiva sparisce dalle viste di pianificazione` — проверяет и список ТО, и календарь |
| Вложения: файлы-инструкции и мануалы | UI + API | `Photos, cover image and documents › documents upload and stay downloadable`; отказ на недопустимый тип |
| Обложка + дополнительные фото | UI | `la prima foto caricata diventa copertina, anche nel registro`; `la stellina sposta la copertina sulla seconda foto`; `la foto si elimina previa conferma` |
| Калибровка — вне скоупа | — | намеренно не реализована; отдельной таблицы нет |
## 2. Модуль обслуживания
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| 0..N обслуживаний на единицу; 0 = вне планирования | UI | `§2.1 un'attrezzatura senza manutenzioni resta fuori dalla pianificazione` |
| Несколько видов ТО одновременно | UI | `§2.2 la stessa attrezzatura porta più manutenzioni insieme` (три работы на одной машине) |
| Типы: программное и внеплановое | UI + API | `«solo critiche» e «programmate/straordinarie» restringono l'elenco`; `riaprendo una manutenzione i campi sono quelli salvati` |
| Флаг критичности | UI | `una manutenzione critica è evidenziata nella scheda` |
| Внешние исполнители: указать подрядчика | UI + API | `«esterna» chiede il fornitore e blocca il salvataggio senza` |
| Приложить форму подрядчика | UI | `un allegato dell'intervento compare nello storico` |
| Название/описание | UI | `riaprendo una manutenzione i campi sono quelli salvati` |
| Частота через выпадающий список интервалов | UI + U | `«al bisogno» nasconde il campo della frequenza`; `la nota di frequenza resta visibile`; юниты на все единицы интервала |
| Файлы-инструкции к ТО | UI | `le istruzioni allegate a una manutenzione si vedono, si riaprono e si eliminano` |
| Срок предупреждения | UI | `riaprendo una manutenzione i campi sono quelli salvati` (alert_days) |
| Один исполнитель + один ответственный | UI | `§2.6 incaricato e responsabile si salvano e si leggono nella scheda` |
| Следующий срок = от последней выполненной работы | UI + U | `registrare un intervento completato aggiorna le date a schermo`; `un intervento pianificato non sposta la scadenza`; `eliminando l'ultimo intervento completato la scadenza torna indietro`; 20 юнитов на арифметику дат |
## 3. Журнал интервенций
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Привязка к конкретному ТО | UI | `la registrazione rapida dall'elenco aggiorna la scadenza` (контекст в модалке) |
| Дата | UI | `registrare un intervento completato aggiorna le date a schermo` |
| Заметки | UI | `riaprendo un intervento i campi sono quelli salvati` |
| Оператор + подпись | UI | `la firma disegnata finisce nello storico`; `la firma si cancella con il pulsante dedicato`; `si firma col dito e la pagina non scorre sotto il tratto` (планшет/телефон) |
| Статус (pianificato / in corso / completato) | UI | `un intervento pianificato non sposta la scadenza` |
| Результат (conforme / bloccato …) | UI | `riaprendo un intervento i campi sono quelli salvati` |
| Материалы / замененные части | UI | там же |
| Для внешних: подрядчик и его форма | UI | `un allegato dell'intervento compare nello storico` |
## 4. Фильтры и вью
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Фильтр между программными и внеплановыми | UI | `«solo critiche» e «programmate/straordinarie» restringono l'elenco` |
| Календарь ТО по срокам | UI | `il calendario disegna gli eventi e il click porta alla scheda`; `i filtri del calendario rifanno la richiesta` |
## 5. Выгрузка PDF
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Карточка с историей обслуживания по единице | UI | `la scheda riproduce il modulo cartaceo` (шапка mod. 6.3-1, работы, журнал) |
| Собственно PDF | UI | `in stampa la barra dei pulsanti sparisce e il PDF si genera` |
## 6. Уведомления
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Письма о просрочке и заранее | API | cron запускается без ошибок; выборка находит просроченное ТО |
| Получатели: ответственный **и** исполнитель | — | **браузером не проверяется**: письма уходят из cron, а не из UI. Логика получателей сверена запросом; фактическая доставка — ручной пункт 12 тест-плана |
## 7. Сводный календарь
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Scadenze + formazione + manutenzioni в одном календаре | UI | `ogni strato porta i propri eventi`; `il calendario cumulativo accende e spegne gli strati` |
| Разные цветные бейджи по типу срока | UI | `§7 i tre tipi di scadenza hanno colori distinti` (свой цвет и свой значок у каждого слоя) |
| Переход из календаря в нужный модуль | UI | `dal calendario cumulativo si arriva alla scheda dell'attrezzatura` |
## 8. Техтребования
| Пункт ТЗ | Чем подтверждён | Тест |
|---|---|---|
| Работа на PC и планшете → адаптивность | UI × 3 | весь набор гоняется на 1440 / 820 / 390; `Layout › …` (нет горизонтальной прокрутки, карточки против таблицы, модалка достижима, сайдбар не прыгает) |
---
## Что осталось за пределами автотестов
| Пункт | Почему | Где проверять |
|---|---|---|
| Фактическая доставка писем | нужен живой SMTP и ящик | ручной чеклист, раздел 12 |
| Внешний вид PDF после печати из браузера | субъективно | ручной чеклист, разделы 9.4–9.5 |
| Подпись на физическом планшете | эмуляция ≠ железо | ручной чеклист, 6.6 |
| Соответствие импортированных данных их XLS | сверка с бумагой | ручной чеклист, раздел 13 |
## Как перепроверить
```bash
make test-sizes # весь набор на трёх размерах
npx playwright test tests/e2e/capitolato.spec.js # только пункты ТЗ
```
+82
View File
@@ -0,0 +1,82 @@
// @ts-check
const { defineConfig, devices } = require('@playwright/test')
/**
* E2E suite for the Manutenzioni (maintenance) module.
*
* Covers what the PHP tests cannot see: modals, canvas signature, calendars,
* printing and the layout on tablet/phone.
*
* Prerequisite: the project containers are up
* docker compose up -d
* Base URL can be overridden with MNT_BASE_URL.
*/
// The trailing slash is mandatory: without it the relative paths used by the
// tests would resolve against the domain root instead of the app.
const baseURL = (process.env.MNT_BASE_URL || 'http://localhost:8001/zibo-dashboard/public').replace(/\/?$/, '/')
/** Viewports under test: phone, tablet (the reference device), desktop. */
const SIZES = {
desktop: { width: 1440, height: 900 },
tablet: { width: 820, height: 1180 },
mobile: { width: 390, height: 844 },
}
const SIZE = SIZES[process.env.MNT_SIZE] ? process.env.MNT_SIZE : 'desktop'
module.exports = defineConfig({
testDir: './tests/e2e',
outputDir: './tests/e2e/.artifacts',
globalSetup: require.resolve('./tests/e2e/global-setup'),
globalTeardown: require.resolve('./tests/e2e/global-teardown'),
// the tests write to the same fixture data: no parallelism
workers: 1,
fullyParallel: false,
// Video recording slows every page down noticeably: with the same limits the
// longer tests would time out without anything actually being broken.
timeout: process.env.MNT_VIDEO ? 90_000 : 45_000,
expect: { timeout: process.env.MNT_VIDEO ? 20_000 : 10_000 },
// The HTML report moves attachments into hash-named folders: handy to browse,
// but it makes grouping the videos per device impossible. With MNT_VIDEO we
// therefore keep the plain reporter and leave the files in .artifacts.
reporter: process.env.MNT_VIDEO
// The JSON feeds the stitching step: it maps every video to the exact test
// title, which then gets burned into the caption bar.
? [['list'], ['json', { outputFile: 'tests/e2e/.report/results.json' }]]
: process.env.CI
? [['list'], ['html', { open: 'never', outputFolder: 'tests/e2e/.report' }]]
: [['list']],
use: {
baseURL,
storageState: 'tests/e2e/.auth/state.json',
locale: 'it-IT',
screenshot: 'only-on-failure',
trace: process.env.MNT_VIDEO ? 'on' : 'retain-on-failure',
// By default the video is kept only for failing tests: with MNT_VIDEO=1
// (npm run test:e2e:video) everything is recorded, one .webm per test,
// handy to show the module running without clicking through it.
video: process.env.MNT_VIDEO
? { mode: 'on', size: { width: 1280, height: 720 } }
: 'retain-on-failure',
},
// One viewport per invocation: the tests create data under fixed names, and
// running them in parallel across viewports would make them collide on the
// same database. The loop over the three viewports lives in the Makefile
// (make test-sizes).
projects: [
{
name: SIZE,
use: {
...devices['Desktop Chrome'],
viewport: SIZES[SIZE],
hasTouch: SIZE !== 'desktop',
},
},
],
})
@@ -1,26 +1,38 @@
<?php <?php
require_once(__DIR__ . '/../hr_auth_check.php'); include('../../include/headscript.php');
header('Content-Type: application/json'); header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Metodo non consentito.']);
exit;
}
$pdo = DBHandlerSelect::getInstance()->getConnection();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
echo json_encode(['success' => false, 'message' => 'ID DPI non valido.']);
exit;
}
try { try {
$stmt = $pdo->prepare("DELETE FROM employee_ppe WHERE id = :id"); $pdo = DBHandlerSelect::getInstance()->getConnection();
$stmt->execute(['id' => $id]);
echo json_encode(['success' => true]); $id = (int)($_POST['id'] ?? 0);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]); if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => 'ID DPI non valido.'
]);
exit;
}
$stmt = $pdo->prepare("
UPDATE employee_ppe_items
SET status = 'returned',
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([$id]);
echo json_encode([
'success' => true,
'message' => 'DPI rimosso correttamente.'
]);
exit;
} catch (Throwable $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
exit;
} }
@@ -0,0 +1,44 @@
<?php
include('../../include/headscript.php');
header('Content-Type: application/json; charset=utf-8');
try {
$pdo = DBHandlerSelect::getInstance()->getConnection();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
echo json_encode([
'success' => false,
'message' => 'ID DPI non valido.'
]);
exit;
}
$stmt = $pdo->prepare("
DELETE FROM employee_ppe_items
WHERE id = ?
");
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) {
echo json_encode([
'success' => false,
'message' => 'Record non trovato o già eliminato.'
]);
exit;
}
echo json_encode([
'success' => true,
'message' => 'Record DPI eliminato definitivamente.'
]);
exit;
} catch (Throwable $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
exit;
}
@@ -0,0 +1,86 @@
<?php
/**
* Bulk-assign a single DPI (PPE) item to several employees at once:
* one employee_ppe row per selected employee, all sharing the same
* item name / delivery date / delivered-by / notes.
* Mirrors ajax/trainings/save_bulk_training.php. HR-only.
*/
require_once(__DIR__ . '/../hr_auth_check.php');
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Metodo non consentito.']);
exit;
}
// $pdo and $currentUserId from hr_auth_check.php
$itemName = trim($_POST['item_name'] ?? '');
$deliveryDate = trim($_POST['delivery_date'] ?? '');
$deliveredBy = trim($_POST['delivered_by'] ?? '');
$notes = trim($_POST['notes'] ?? '');
$employeeIds = $_POST['employee_ids'] ?? [];
if (!is_array($employeeIds)) {
$employeeIds = [];
}
$employeeIds = array_values(array_unique(array_filter(array_map('intval', $employeeIds), fn($v) => $v > 0)));
if ($itemName === '') {
echo json_encode(['success' => false, 'message' => 'Il nome del DPI è obbligatorio.']);
exit;
}
if ($deliveryDate !== '' && !DateTime::createFromFormat('Y-m-d', $deliveryDate)) {
echo json_encode(['success' => false, 'message' => 'Data di consegna non valida.']);
exit;
}
if (empty($employeeIds)) {
echo json_encode(['success' => false, 'message' => 'Selezionare almeno un dipendente.']);
exit;
}
$deliveryDate = $deliveryDate === '' ? null : $deliveryDate;
$deliveredBy = $deliveredBy !== '' ? $deliveredBy : null;
$notes = $notes !== '' ? $notes : null;
try {
$pdo->beginTransaction();
// Only insert for employees that actually exist
$checkEmp = $pdo->prepare("SELECT id FROM employees WHERE id = :id");
$ins = $pdo->prepare("
INSERT INTO employee_ppe
(employee_id, item_name, delivery_date, delivered_by, notes, created_by, created_at, updated_at)
VALUES
(:employee_id, :item_name, :delivery_date, :delivered_by, :notes, :created_by, NOW(), NOW())
");
$created = 0;
foreach ($employeeIds as $eid) {
$checkEmp->execute(['id' => $eid]);
if (!$checkEmp->fetchColumn()) {
continue;
}
$ins->execute([
'employee_id' => $eid,
'item_name' => $itemName,
'delivery_date' => $deliveryDate,
'delivered_by' => $deliveredBy,
'notes' => $notes,
'created_by' => $currentUserId,
]);
$created++;
}
$pdo->commit();
echo json_encode([
'success' => true,
'created' => $created,
'message' => 'DPI assegnato a ' . $created . ' dipendent' . ($created === 1 ? 'e' : 'i') . '.',
]);
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
@@ -1,82 +1,153 @@
<?php <?php
require_once(__DIR__ . '/../hr_auth_check.php'); include('../../include/headscript.php');
header('Content-Type: application/json'); header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Metodo non consentito.']);
exit;
}
$pdo = DBHandlerSelect::getInstance()->getConnection();
$id = (int)($_POST['id'] ?? 0);
$employeeId = (int)($_POST['employee_id'] ?? 0);
$itemName = trim($_POST['item_name'] ?? '');
$deliveryDate = trim($_POST['delivery_date'] ?? '');
$deliveredBy = trim($_POST['delivered_by'] ?? '');
$notes = trim($_POST['notes'] ?? '');
if ($employeeId <= 0) {
echo json_encode(['success' => false, 'message' => 'ID dipendente non valido.']);
exit;
}
if ($itemName === '') {
echo json_encode(['success' => false, 'message' => 'Il nome del DPI è obbligatorio.']);
exit;
}
$deliveryDate = $deliveryDate === '' ? null : $deliveryDate;
$deliveredBy = $deliveredBy !== '' ? $deliveredBy : null;
$notes = $notes !== '' ? $notes : null;
try { try {
if ($id > 0) { $pdo = DBHandlerSelect::getInstance()->getConnection();
$stmt = $pdo->prepare("
UPDATE employee_ppe $id = isset($_POST['id']) && $_POST['id'] !== '' ? (int)$_POST['id'] : null;
SET item_name = :item_name, $employeeId = (int)($_POST['employee_id'] ?? 0);
delivery_date = :delivery_date, $ppeItemId = (int)($_POST['ppe_item_id'] ?? 0);
delivered_by = :delivered_by, $assignedDate = trim($_POST['assigned_date'] ?? '');
notes = :notes, $expiryDate = trim($_POST['expiry_date'] ?? '');
updated_at = NOW() $deliveredBy = trim($_POST['delivered_by'] ?? '');
WHERE id = :id AND employee_id = :eid $status = trim($_POST['status'] ?? 'assigned');
"); $notes = trim($_POST['notes'] ?? '');
$stmt->execute([
'item_name' => $itemName, $allowedStatuses = [
'delivery_date' => $deliveryDate, 'assigned',
'delivered_by' => $deliveredBy, 'returned',
'notes' => $notes, 'expired',
'id' => $id, 'lost',
'eid' => $employeeId, 'damaged',
];
if ($employeeId <= 0) {
echo json_encode([
'success' => false,
'message' => 'Dipendente non valido.'
]); ]);
echo json_encode(['success' => true, 'id' => $id]);
exit; exit;
} }
$check = $pdo->prepare("SELECT COUNT(*) FROM employees WHERE id = :id"); if ($ppeItemId <= 0) {
$check->execute(['id' => $employeeId]); echo json_encode([
if ((int)$check->fetchColumn() === 0) { 'success' => false,
echo json_encode(['success' => false, 'message' => 'Dipendente non trovato.']); 'message' => 'Selezionare un DPI.'
]);
exit;
}
if (!in_array($status, $allowedStatuses, true)) {
$status = 'assigned';
}
$checkEmployee = $pdo->prepare("SELECT id FROM employees WHERE id = ? LIMIT 1");
$checkEmployee->execute([$employeeId]);
if (!$checkEmployee->fetchColumn()) {
echo json_encode([
'success' => false,
'message' => 'Dipendente non trovato.'
]);
exit;
}
$checkPpe = $pdo->prepare("SELECT id FROM ppe_items WHERE id = ? LIMIT 1");
$checkPpe->execute([$ppeItemId]);
if (!$checkPpe->fetchColumn()) {
echo json_encode([
'success' => false,
'message' => 'DPI non trovato.'
]);
exit;
}
if ($id) {
$stmt = $pdo->prepare("
UPDATE employee_ppe_items
SET ppe_item_id = :ppe_item_id,
assigned_date = :assigned_date,
expiry_date = :expiry_date,
delivered_by = :delivered_by,
status = :status,
notes = :notes,
updated_at = NOW()
WHERE id = :id
AND employee_id = :employee_id
");
$stmt->execute([
'ppe_item_id' => $ppeItemId,
'assigned_date' => $assignedDate !== '' ? $assignedDate : null,
'expiry_date' => $expiryDate !== '' ? $expiryDate : null,
'delivered_by' => $deliveredBy !== '' ? $deliveredBy : null,
'status' => $status,
'notes' => $notes !== '' ? $notes : null,
'id' => $id,
'employee_id' => $employeeId,
]);
echo json_encode([
'success' => true,
'message' => 'DPI aggiornato.'
]);
exit; exit;
} }
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
INSERT INTO employee_ppe INSERT INTO employee_ppe_items
(employee_id, item_name, delivery_date, delivered_by, notes, created_by, created_at, updated_at) (
employee_id,
ppe_item_id,
assigned_date,
expiry_date,
delivered_by,
quantity,
status,
notes,
created_by,
created_at,
updated_at
)
VALUES VALUES
(:employee_id, :item_name, :delivery_date, :delivered_by, :notes, :created_by, NOW(), NOW()) (
:employee_id,
:ppe_item_id,
:assigned_date,
:expiry_date,
:delivered_by,
1,
:status,
:notes,
:created_by,
NOW(),
NOW()
)
"); ");
$stmt->execute([ $stmt->execute([
'employee_id' => $employeeId, 'employee_id' => $employeeId,
'item_name' => $itemName, 'ppe_item_id' => $ppeItemId,
'delivery_date' => $deliveryDate, 'assigned_date' => $assignedDate !== '' ? $assignedDate : null,
'delivered_by' => $deliveredBy, 'expiry_date' => $expiryDate !== '' ? $expiryDate : null,
'notes' => $notes, 'delivered_by' => $deliveredBy !== '' ? $deliveredBy : null,
'created_by' => $currentUserId, 'status' => $status,
'notes' => $notes !== '' ? $notes : null,
'created_by' => isset($iduserlogin) ? (int)$iduserlogin : null,
]); ]);
echo json_encode(['success' => true, 'id' => (int)$pdo->lastInsertId()]); echo json_encode([
} catch (Exception $e) { 'success' => true,
echo json_encode(['success' => false, 'message' => $e->getMessage()]); 'message' => 'DPI assegnato.'
]);
exit;
} catch (Throwable $e) {
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
exit;
} }
+66 -2
View File
@@ -1,6 +1,7 @@
<?php <?php
require_once(__DIR__ . '/../auth_check.php'); require_once(__DIR__ . '/../auth_check.php');
require_once(__DIR__ . '/../../class/db-functions.php'); require_once(__DIR__ . '/../../class/db-functions.php');
require_once(__DIR__ . '/../../include/training_coverage.php');
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -24,16 +25,58 @@ $is_mandatory = isset($_POST['is_mandatory']) && (int)$_POST['is_mandatory'] ===
$freq = ($freqRaw === '' || $freqRaw === null) ? null : max(0, (int)$freqRaw); $freq = ($freqRaw === '' || $freqRaw === null) ? null : max(0, (int)$freqRaw);
$rem = ($remRaw === '' || $remRaw === null) ? 30 : max(0, (int)$remRaw); $rem = ($remRaw === '' || $remRaw === null) ? 30 : max(0, (int)$remRaw);
/* Coperture: corsi assorbiti da questo */
$coveredIds = isset($_POST['covered_ids']) ? (array)$_POST['covered_ids'] : [];
$coveredIds = array_values(array_unique(array_filter(array_map('intval', $coveredIds), function ($v) {
return $v > 0;
})));
if ($name === '') { if ($name === '') {
echo json_encode(['success' => false, 'message' => 'Il nome del corso è obbligatorio.']); echo json_encode(['success' => false, 'message' => 'Il nome del corso è obbligatorio.']);
exit; exit;
} }
/**
* Riscrive le coperture del corso: cancella le vecchie e inserisce le nuove,
* scartando auto-copertura e relazioni che creerebbero un ciclo.
*/
function saveCoverage(PDO $pdo, int $topicId, array $coveredIds): array
{
$skipped = [];
$pdo->prepare("DELETE FROM training_topic_coverage WHERE topic_id = :tid")
->execute(['tid' => $topicId]);
if (!$coveredIds) return $skipped;
$ins = $pdo->prepare("
INSERT IGNORE INTO training_topic_coverage
(topic_id, covered_topic_id, inherit_expiry, created_at)
VALUES (:tid, :cid, 'own', NOW())
");
foreach ($coveredIds as $cid) {
if ($cid === $topicId) {
continue;
}
if (tc_wouldCreateCycle($pdo, $topicId, $cid)) {
$skipped[] = $cid;
continue;
}
$ins->execute(['tid' => $topicId, 'cid' => $cid]);
}
return $skipped;
}
try { try {
$pdo->beginTransaction();
if ($id > 0) { if ($id > 0) {
$check = $pdo->prepare("SELECT COUNT(*) FROM training_topics WHERE name = :name AND id <> :id"); $check = $pdo->prepare("SELECT COUNT(*) FROM training_topics WHERE name = :name AND id <> :id");
$check->execute(['name' => $name, 'id' => $id]); $check->execute(['name' => $name, 'id' => $id]);
if ((int)$check->fetchColumn() > 0) { if ((int)$check->fetchColumn() > 0) {
$pdo->rollBack();
echo json_encode(['success' => false, 'message' => 'Esiste già un altro corso con questo nome.']); echo json_encode(['success' => false, 'message' => 'Esiste già un altro corso con questo nome.']);
exit; exit;
} }
@@ -61,13 +104,27 @@ try {
'id' => $id, 'id' => $id,
]); ]);
echo json_encode(['success' => true, 'id' => $id]); $skipped = saveCoverage($pdo, $id, $coveredIds);
$pdo->commit();
$msg = $skipped
? 'Corso aggiornato. Alcune coperture sono state ignorate perché avrebbero creato un riferimento circolare.'
: null;
echo json_encode(array_filter([
'success' => true,
'id' => $id,
'message' => $msg,
], function ($v) {
return $v !== null;
}));
exit; exit;
} }
$check = $pdo->prepare("SELECT COUNT(*) FROM training_topics WHERE name = :name"); $check = $pdo->prepare("SELECT COUNT(*) FROM training_topics WHERE name = :name");
$check->execute(['name' => $name]); $check->execute(['name' => $name]);
if ((int)$check->fetchColumn() > 0) { if ((int)$check->fetchColumn() > 0) {
$pdo->rollBack();
echo json_encode(['success' => false, 'message' => 'Esiste già un corso con questo nome.']); echo json_encode(['success' => false, 'message' => 'Esiste già un corso con questo nome.']);
exit; exit;
} }
@@ -88,7 +145,14 @@ try {
'is_mandatory' => $is_mandatory, 'is_mandatory' => $is_mandatory,
]); ]);
echo json_encode(['success' => true, 'id' => (int)$pdo->lastInsertId()]); $newId = (int)$pdo->lastInsertId();
saveCoverage($pdo, $newId, $coveredIds);
$pdo->commit();
echo json_encode(['success' => true, 'id' => $newId]);
} catch (Exception $e) { } catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
echo json_encode(['success' => false, 'message' => $e->getMessage()]); echo json_encode(['success' => false, 'message' => $e->getMessage()]);
} }
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
<?php
header('Content-Type: application/json');
require_once(__DIR__ . '/include/headscript.php');
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$iduser = $iduserlogin ?? null;
$input = json_decode(file_get_contents('php://input'), true);
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
throw new Exception('ID non valido.');
}
if ($iduser === null) {
$stmt = $pdo->prepare("
SELECT *
FROM cad_area_jobs
WHERE id = :id
LIMIT 1
");
$stmt->execute([
':id' => $id
]);
} else {
$stmt = $pdo->prepare("
SELECT *
FROM cad_area_jobs
WHERE id = :id
AND iduser = :iduser
LIMIT 1
");
$stmt->execute([
':id' => $id,
':iduser' => $iduser
]);
}
$job = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$job) {
throw new Exception('Record non trovato.');
}
if (!empty($job['file_path']) && file_exists($job['file_path'])) {
unlink($job['file_path']);
}
$stmtDelete = $pdo->prepare("
DELETE FROM cad_area_jobs
WHERE id = :id
");
$stmtDelete->execute([':id' => $id]);
echo json_encode([
'success' => true
]);
} catch (Throwable $e) {
error_log('CAD area delete error: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
}
+338
View File
@@ -0,0 +1,338 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once(__DIR__ . '/include/headscript.php');
function jsonResponse(array $data): void
{
echo json_encode($data);
exit;
}
function updateJobProcessing(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare("
UPDATE cad_area_jobs
SET
status = 'processing',
message = 'Elaborazione in corso...',
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([$id]);
}
function updateJobError(PDO $pdo, int $id, string $message, ?array $pythonResponse = null): void
{
$stmt = $pdo->prepare("
UPDATE cad_area_jobs
SET
status = 'error',
message = ?,
python_response = ?,
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([
$message,
$pythonResponse ? json_encode($pythonResponse) : null,
$id
]);
}
function updateJobCompleted(PDO $pdo, int $id, array $response): void
{
$stmt = $pdo->prepare("
UPDATE cad_area_jobs
SET
status = 'completed',
message = ?,
area_mm2 = ?,
area_cm2 = ?,
area_m2 = ?,
width_mm = ?,
height_mm = ?,
scale_detected = ?,
scale_used = ?,
confidence = ?,
python_response = ?,
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([
$response['message'] ?? 'Area calcolata correttamente.',
$response['area_mm2'] ?? null,
$response['area_cm2'] ?? null,
$response['area_m2'] ?? null,
$response['width_mm'] ?? null,
$response['height_mm'] ?? null,
$response['scale_detected'] ?? null,
$response['scale_used'] ?? null,
$response['confidence'] ?? null,
json_encode($response),
$id
]);
}
function normalizeCalculationMode(?string $mode): string
{
$allowed = [
'auto_roi',
'stitch_contour',
'filled_union',
'closed_path'
];
if (!$mode || !in_array($mode, $allowed, true)) {
return 'auto_roi';
}
return $mode;
}
function hasValidRoi(array $job): bool
{
return (
array_key_exists('roi_x', $job) &&
array_key_exists('roi_y', $job) &&
array_key_exists('roi_width', $job) &&
array_key_exists('roi_height', $job) &&
$job['roi_x'] !== null &&
$job['roi_y'] !== null &&
$job['roi_width'] !== null &&
$job['roi_height'] !== null &&
(float)$job['roi_width'] > 0 &&
(float)$job['roi_height'] > 0
);
}
function callPythonAreaService(string $url, array $job): array
{
$filePath = $job['file_path'] ?? '';
$originalFilename = $job['original_filename'] ?? basename($filePath);
if (!$filePath || !file_exists($filePath)) {
return [
'success' => false,
'message' => 'File PDF non trovato sul server: ' . $filePath
];
}
$mode = normalizeCalculationMode($job['calculation_mode'] ?? 'auto_roi');
$scaleRatio = $job['scale_used'] ?? null;
if ($scaleRatio === null || $scaleRatio === '' || (float)$scaleRatio <= 0) {
$scaleRatio = '1';
}
$curlFile = new CURLFile(
$filePath,
'application/pdf',
$originalFilename
);
$postFields = [
'file' => $curlFile,
'mode' => $mode,
'scale_ratio' => (string)$scaleRatio,
'roi_x' => (string)$job['roi_x'],
'roi_y' => (string)$job['roi_y'],
'roi_width' => (string)$job['roi_width'],
'roi_height' => (string)$job['roi_height'],
'roi_page' => (string)($job['roi_page'] ?? 1)
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postFields,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 180,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Accept: application/json'
]
]);
$rawResponse = curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($rawResponse === false) {
return [
'success' => false,
'message' => 'Errore cURL verso Python: ' . $curlError
];
}
$decoded = json_decode($rawResponse, true);
if (!is_array($decoded)) {
return [
'success' => false,
'message' => 'Risposta Python non JSON valida.',
'http_code' => $httpCode,
'raw_response' => $rawResponse
];
}
if ($httpCode < 200 || $httpCode >= 300) {
return [
'success' => false,
'message' => $decoded['message'] ?? ('Servizio Python HTTP ' . $httpCode),
'http_code' => $httpCode,
'python_response' => $decoded,
'raw_response' => $rawResponse
];
}
return $decoded;
}
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$iduser = $iduserlogin ?? null;
$input = json_decode(file_get_contents('php://input'), true);
if (!is_array($input)) {
jsonResponse([
'success' => false,
'message' => 'Payload JSON non valido.'
]);
}
$ids = $input['ids'] ?? [];
if (!is_array($ids) || count($ids) === 0) {
jsonResponse([
'success' => false,
'message' => 'Nessun ID ricevuto.'
]);
}
$ids = array_values(array_unique(array_map('intval', $ids)));
$ids = array_filter($ids, fn($id) => $id > 0);
if (count($ids) === 0) {
jsonResponse([
'success' => false,
'message' => 'Nessun ID valido ricevuto.'
]);
}
$pythonServiceUrl = 'http://127.0.0.1:5055/calculate';
$results = [];
foreach ($ids as $id) {
if ($iduser === null || $iduser === '') {
$stmt = $pdo->prepare("
SELECT *
FROM cad_area_jobs
WHERE id = ?
LIMIT 1
");
$stmt->execute([$id]);
} else {
$stmt = $pdo->prepare("
SELECT *
FROM cad_area_jobs
WHERE id = ?
AND iduser = ?
LIMIT 1
");
$stmt->execute([$id, $iduser]);
}
$job = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$job) {
$results[] = [
'id' => $id,
'success' => false,
'message' => 'Record non trovato.'
];
continue;
}
if (!hasValidRoi($job)) {
$message = 'Prima devi definire la sezione da misurare tramite il pulsante Sezione.';
updateJobError($pdo, $id, $message, [
'success' => false,
'message' => $message,
'job_roi_debug' => [
'roi_x' => $job['roi_x'] ?? null,
'roi_y' => $job['roi_y'] ?? null,
'roi_width' => $job['roi_width'] ?? null,
'roi_height' => $job['roi_height'] ?? null,
'roi_page' => $job['roi_page'] ?? null,
'calculation_mode' => $job['calculation_mode'] ?? null
]
]);
$results[] = [
'id' => $id,
'success' => false,
'message' => $message
];
continue;
}
updateJobProcessing($pdo, $id);
$pythonResponse = callPythonAreaService($pythonServiceUrl, $job);
if (!($pythonResponse['success'] ?? false)) {
$message = $pythonResponse['message'] ?? 'Errore durante il calcolo Python.';
updateJobError($pdo, $id, $message, $pythonResponse);
$results[] = [
'id' => $id,
'success' => false,
'message' => $message,
'python_response' => $pythonResponse
];
continue;
}
updateJobCompleted($pdo, $id, $pythonResponse);
$results[] = [
'id' => $id,
'success' => true,
'message' => $pythonResponse['message'] ?? 'Area calcolata.',
'area_mm2' => $pythonResponse['area_mm2'] ?? null,
'area_cm2' => $pythonResponse['area_cm2'] ?? null
];
}
jsonResponse([
'success' => true,
'results' => $results
]);
} catch (Throwable $e) {
error_log('CAD area process error: ' . $e->getMessage());
jsonResponse([
'success' => false,
'message' => $e->getMessage()
]);
}
@@ -0,0 +1,348 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once(__DIR__ . '/include/headscript.php');
function jsonResponse(array $data): void
{
echo json_encode($data);
exit;
}
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$iduser = $iduserlogin ?? null;
$rawInput = file_get_contents('php://input');
$input = json_decode($rawInput, true);
if (!is_array($input)) {
jsonResponse([
'success' => false,
'message' => 'Payload JSON non valido.'
]);
}
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
jsonResponse([
'success' => false,
'message' => 'ID non valido.'
]);
}
$areaMm2 = isset($input['area_mm2']) ? (float)$input['area_mm2'] : 0;
$areaCm2 = isset($input['area_cm2']) ? (float)$input['area_cm2'] : 0;
$outerAreaMm2 = isset($input['manual_outer_area_mm2']) ? (float)$input['manual_outer_area_mm2'] : $areaMm2;
$holesAreaMm2 = isset($input['manual_holes_area_mm2']) ? (float)$input['manual_holes_area_mm2'] : 0;
$widthMm = isset($input['width_mm']) ? (float)$input['width_mm'] : null;
$heightMm = isset($input['height_mm']) ? (float)$input['height_mm'] : null;
$calibrationPx = isset($input['manual_calibration_px']) ? (float)$input['manual_calibration_px'] : 0;
$calibrationMm = isset($input['manual_calibration_mm']) ? (float)$input['manual_calibration_mm'] : 0;
$mmPerPx = isset($input['manual_mm_per_px']) ? (float)$input['manual_mm_per_px'] : 0;
$outerPolygon = $input['manual_polygon'] ?? null;
$holes = $input['manual_holes'] ?? [];
$roi = $input['roi'] ?? null;
$dxfContent = isset($input['dxf_content']) ? (string)$input['dxf_content'] : '';
if ($areaMm2 <= 0) {
jsonResponse([
'success' => false,
'message' => 'Area finale non valida.'
]);
}
if ($outerAreaMm2 <= 0) {
jsonResponse([
'success' => false,
'message' => 'Area esterna non valida.'
]);
}
if ($holesAreaMm2 < 0) {
jsonResponse([
'success' => false,
'message' => 'Area fori non valida.'
]);
}
if ($calibrationPx <= 0 || $calibrationMm <= 0 || $mmPerPx <= 0) {
jsonResponse([
'success' => false,
'message' => 'Calibrazione non valida.'
]);
}
if (!is_array($outerPolygon) || count($outerPolygon) < 3) {
jsonResponse([
'success' => false,
'message' => 'Poligono esterno non valido. Servono almeno 3 punti.'
]);
}
if (!is_array($holes)) {
$holes = [];
}
$manualPolygonJson = json_encode([
'outer_polygon' => $outerPolygon,
'holes' => $holes,
'roi' => $roi,
'calibration' => $input['calibration'] ?? null,
'canvas' => $input['canvas'] ?? null,
'areas' => [
'outer_area_mm2' => $outerAreaMm2,
'holes_area_mm2' => $holesAreaMm2,
'final_area_mm2' => $areaMm2,
'final_area_cm2' => $areaCm2
]
]);
$manualHolesJson = json_encode($holes);
$dxfFilename = null;
$dxfUrl = null;
if ($dxfContent !== '') {
if (strlen($dxfContent) > 5 * 1024 * 1024) {
jsonResponse([
'success' => false,
'message' => 'DXF troppo grande.'
]);
}
if (strpos($dxfContent, 'SECTION') === false || strpos($dxfContent, 'EOF') === false) {
jsonResponse([
'success' => false,
'message' => 'Contenuto DXF non valido.'
]);
}
$dxfDir = __DIR__ . '/uploads/cad_area/dxf/';
if (!is_dir($dxfDir)) {
mkdir($dxfDir, 0755, true);
}
$dxfFilename = 'profilo_' . $id . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.dxf';
if (file_put_contents($dxfDir . $dxfFilename, $dxfContent) === false) {
jsonResponse([
'success' => false,
'message' => 'Impossibile salvare il file DXF sul server.'
]);
}
$dxfUrl = 'uploads/cad_area/dxf/' . $dxfFilename;
}
$roiX = null;
$roiY = null;
$roiW = null;
$roiH = null;
if (is_array($roi)) {
$roiX = isset($roi['x']) ? (float)$roi['x'] : null;
$roiY = isset($roi['y']) ? (float)$roi['y'] : null;
$roiW = isset($roi['width']) ? (float)$roi['width'] : null;
$roiH = isset($roi['height']) ? (float)$roi['height'] : null;
}
if ($iduser === null || $iduser === '') {
$stmt = $pdo->prepare("
UPDATE cad_area_jobs
SET
roi_x = COALESCE(?, roi_x),
roi_y = COALESCE(?, roi_y),
roi_width = COALESCE(?, roi_width),
roi_height = COALESCE(?, roi_height),
roi_page = 1,
status = 'completed',
message = 'Area calcolata tramite tracciamento manuale calibrato.',
area_mm2 = ?,
area_cm2 = ?,
area_m2 = ?,
manual_area_mm2 = ?,
manual_area_cm2 = ?,
manual_outer_area_mm2 = ?,
manual_holes_area_mm2 = ?,
manual_width_mm = ?,
manual_height_mm = ?,
width_mm = ?,
height_mm = ?,
manual_calibration_px = ?,
manual_calibration_mm = ?,
manual_mm_per_px = ?,
manual_polygon_json = ?,
manual_holes_json = ?,
manual_status = 'completed',
dxf_filename = COALESCE(?, dxf_filename),
dxf_url = COALESCE(?, dxf_url),
scale_used = ?,
scale_detected = ?,
confidence = 'manual_validated',
strategy_used = 'manual_tracing_with_exclusions',
python_response = NULL,
updated_at = NOW()
WHERE id = ?
");
$stmt->execute([
$roiX,
$roiY,
$roiW,
$roiH,
$areaMm2,
$areaCm2,
$areaMm2 / 1000000,
$areaMm2,
$areaCm2,
$outerAreaMm2,
$holesAreaMm2,
$widthMm,
$heightMm,
$widthMm,
$heightMm,
$calibrationPx,
$calibrationMm,
$mmPerPx,
$manualPolygonJson,
$manualHolesJson,
$dxfFilename,
$dxfUrl,
$mmPerPx,
'manual',
$id
]);
} else {
$stmt = $pdo->prepare("
UPDATE cad_area_jobs
SET
roi_x = COALESCE(?, roi_x),
roi_y = COALESCE(?, roi_y),
roi_width = COALESCE(?, roi_width),
roi_height = COALESCE(?, roi_height),
roi_page = 1,
status = 'completed',
message = 'Area calcolata tramite tracciamento manuale calibrato.',
area_mm2 = ?,
area_cm2 = ?,
area_m2 = ?,
manual_area_mm2 = ?,
manual_area_cm2 = ?,
manual_outer_area_mm2 = ?,
manual_holes_area_mm2 = ?,
manual_width_mm = ?,
manual_height_mm = ?,
width_mm = ?,
height_mm = ?,
manual_calibration_px = ?,
manual_calibration_mm = ?,
manual_mm_per_px = ?,
manual_polygon_json = ?,
manual_holes_json = ?,
manual_status = 'completed',
dxf_filename = COALESCE(?, dxf_filename),
dxf_url = COALESCE(?, dxf_url),
scale_used = ?,
scale_detected = ?,
confidence = 'manual_validated',
strategy_used = 'manual_tracing_with_exclusions',
python_response = NULL,
updated_at = NOW()
WHERE id = ?
AND iduser = ?
");
$stmt->execute([
$roiX,
$roiY,
$roiW,
$roiH,
$areaMm2,
$areaCm2,
$areaMm2 / 1000000,
$areaMm2,
$areaCm2,
$outerAreaMm2,
$holesAreaMm2,
$widthMm,
$heightMm,
$widthMm,
$heightMm,
$calibrationPx,
$calibrationMm,
$mmPerPx,
$manualPolygonJson,
$manualHolesJson,
$dxfFilename,
$dxfUrl,
$mmPerPx,
'manual',
$id,
$iduser
]);
}
if ($stmt->rowCount() === 0) {
jsonResponse([
'success' => false,
'message' => 'Nessun record aggiornato. Controlla ID o utente.'
]);
}
jsonResponse([
'success' => true,
'message' => 'Area manuale salvata correttamente.',
'area_mm2' => $areaMm2,
'area_cm2' => $areaCm2,
'outer_area_mm2' => $outerAreaMm2,
'holes_area_mm2' => $holesAreaMm2,
'dxf_url' => $dxfUrl
]);
} catch (Throwable $e) {
error_log('CAD manual area save error: ' . $e->getMessage());
jsonResponse([
'success' => false,
'message' => $e->getMessage()
]);
}
+124
View File
@@ -0,0 +1,124 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once(__DIR__ . '/include/headscript.php');
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$iduser = $iduserlogin ?? null;
$rawInput = file_get_contents('php://input');
$input = json_decode($rawInput, true);
if (!is_array($input)) {
throw new Exception('Payload JSON non valido.');
}
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
throw new Exception('ID non valido.');
}
$roiX = isset($input['roi_x']) ? (float)$input['roi_x'] : null;
$roiY = isset($input['roi_y']) ? (float)$input['roi_y'] : null;
$roiW = isset($input['roi_width']) ? (float)$input['roi_width'] : null;
$roiH = isset($input['roi_height']) ? (float)$input['roi_height'] : null;
$roiPage = isset($input['roi_page']) ? (int)$input['roi_page'] : 1;
$mode = $input['calculation_mode'] ?? 'auto_roi';
if ($roiX === null || $roiY === null || $roiW === null || $roiH === null) {
throw new Exception('ROI non valida.');
}
if ($roiW <= 0 || $roiH <= 0) {
throw new Exception('Dimensioni ROI non valide.');
}
if ($roiX < 0 || $roiY < 0 || $roiX > 1 || $roiY > 1 || $roiW > 1 || $roiH > 1) {
throw new Exception('Coordinate ROI fuori scala.');
}
$allowedModes = [
'auto_roi',
'stitch_contour',
'filled_union',
'closed_path'
];
if (!in_array($mode, $allowedModes, true)) {
$mode = 'auto_roi';
}
if ($iduser === null || $iduser === '') {
$stmt = $pdo->prepare("
UPDATE cad_area_jobs
SET
roi_x = ?,
roi_y = ?,
roi_width = ?,
roi_height = ?,
roi_page = ?,
calculation_mode = ?,
status = 'uploaded',
message = NULL
WHERE id = ?
");
$stmt->execute([
$roiX,
$roiY,
$roiW,
$roiH,
$roiPage,
$mode,
$id
]);
} else {
$stmt = $pdo->prepare("
UPDATE cad_area_jobs
SET
roi_x = ?,
roi_y = ?,
roi_width = ?,
roi_height = ?,
roi_page = ?,
calculation_mode = ?,
status = 'uploaded',
message = NULL
WHERE id = ?
AND iduser = ?
");
$stmt->execute([
$roiX,
$roiY,
$roiW,
$roiH,
$roiPage,
$mode,
$id,
$iduser
]);
}
if ($stmt->rowCount() === 0) {
throw new Exception('Nessun record aggiornato. Controlla ID o utente.');
}
echo json_encode([
'success' => true,
'message' => 'ROI salvata correttamente.'
]);
exit;
} catch (Throwable $e) {
error_log('CAD area save ROI error: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
exit;
}
+106
View File
@@ -0,0 +1,106 @@
<?php
header('Content-Type: application/json');
require_once(__DIR__ . '/include/headscript.php');
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$iduser = $iduserlogin ?? null;
$uploadDir = __DIR__ . '/uploads/cad_area/originals/';
$publicBaseUrl = 'uploads/cad_area/originals/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
if (empty($_FILES['pdf_files'])) {
throw new Exception('Nessun file ricevuto.');
}
$files = $_FILES['pdf_files'];
$insertedIds = [];
for ($i = 0; $i < count($files['name']); $i++) {
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
continue;
}
$originalName = $files['name'][$i];
$tmpName = $files['tmp_name'][$i];
$size = (int)$files['size'][$i];
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if ($extension !== 'pdf') {
continue;
}
if ($size > 25 * 1024 * 1024) {
continue;
}
$safeBaseName = preg_replace('/[^a-zA-Z0-9_\-]/', '_', pathinfo($originalName, PATHINFO_FILENAME));
$storedName = date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '_' . $safeBaseName . '.pdf';
$targetPath = $uploadDir . $storedName;
if (!move_uploaded_file($tmpName, $targetPath)) {
continue;
}
$relativeUrl = $publicBaseUrl . $storedName;
$stmt = $pdo->prepare("
INSERT INTO cad_area_jobs
(
iduser,
original_filename,
stored_filename,
file_path,
file_url,
file_size,
status
)
VALUES
(
:iduser,
:original_filename,
:stored_filename,
:file_path,
:file_url,
:file_size,
'uploaded'
)
");
$stmt->execute([
':iduser' => $iduser,
':original_filename' => $originalName,
':stored_filename' => $storedName,
':file_path' => $targetPath,
':file_url' => $relativeUrl,
':file_size' => $size
]);
$insertedIds[] = (int)$pdo->lastInsertId();
}
if (empty($insertedIds)) {
throw new Exception('Nessun PDF valido caricato.');
}
echo json_encode([
'success' => true,
'ids' => $insertedIds
]);
} catch (Throwable $e) {
error_log('CAD area upload error: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
}
+617
View File
@@ -0,0 +1,617 @@
<?php
ini_set('display_errors', 1);
error_reporting(E_ALL);
include('include/headscript.php');
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
function jsonResponse(array $data): void
{
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data);
exit;
}
function normalizeNullableInt($value): ?int
{
return (isset($value) && $value !== '') ? (int)$value : null;
}
function normalizeBoolValue($value): int
{
return ((string)$value === '0') ? 0 : 1;
}
function cleanString(?string $value): string
{
return trim((string)$value);
}
/* ==========================================
AJAX HANDLERS
========================================== */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ajax']) && $_POST['ajax'] == '1') {
$action = $_POST['action'] ?? '';
try {
if ($action === 'add') {
$functionName = cleanString($_POST['function_name'] ?? '');
$personFullName = cleanString($_POST['person_full_name'] ?? '');
$phone = cleanString($_POST['phone'] ?? '');
$email = cleanString($_POST['email'] ?? '');
$notes = cleanString($_POST['notes'] ?? '');
$sortOrder = normalizeNullableInt($_POST['sort_order'] ?? '0') ?? 0;
$isActive = normalizeBoolValue($_POST['is_active'] ?? '1');
if ($functionName === '') {
jsonResponse(['success' => false, 'message' => 'Il nome funzione è obbligatorio.']);
}
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => false, 'message' => 'Email non valida.']);
}
$stmt = $pdo->prepare("\n INSERT INTO company_functions\n (function_name, person_full_name, phone, email, notes, sort_order, is_active, created_at, updated_at)\n VALUES\n (:function_name, :person_full_name, :phone, :email, :notes, :sort_order, :is_active, NOW(), NOW())\n ");
$stmt->execute([
'function_name' => $functionName,
'person_full_name' => $personFullName !== '' ? $personFullName : '',
'phone' => $phone !== '' ? $phone : null,
'email' => $email !== '' ? $email : null,
'notes' => $notes !== '' ? $notes : null,
'sort_order' => $sortOrder,
'is_active' => $isActive,
]);
jsonResponse(['success' => true, 'message' => 'Funzione salvata correttamente.']);
}
if ($action === 'edit') {
$id = (int)($_POST['id'] ?? 0);
$functionName = cleanString($_POST['function_name'] ?? '');
$personFullName = cleanString($_POST['person_full_name'] ?? '');
$phone = cleanString($_POST['phone'] ?? '');
$email = cleanString($_POST['email'] ?? '');
$notes = cleanString($_POST['notes'] ?? '');
$sortOrder = normalizeNullableInt($_POST['sort_order'] ?? '0') ?? 0;
$isActive = normalizeBoolValue($_POST['is_active'] ?? '1');
if ($id <= 0) {
jsonResponse(['success' => false, 'message' => 'ID funzione non valido.']);
}
if ($functionName === '') {
jsonResponse(['success' => false, 'message' => 'Il nome funzione è obbligatorio.']);
}
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => false, 'message' => 'Email non valida.']);
}
$stmt = $pdo->prepare("\n UPDATE company_functions\n SET function_name = :function_name,\n person_full_name = :person_full_name,\n phone = :phone,\n email = :email,\n notes = :notes,\n sort_order = :sort_order,\n is_active = :is_active,\n updated_at = NOW()\n WHERE id = :id\n ");
$stmt->execute([
'function_name' => $functionName,
'person_full_name' => $personFullName !== '' ? $personFullName : '',
'phone' => $phone !== '' ? $phone : null,
'email' => $email !== '' ? $email : null,
'notes' => $notes !== '' ? $notes : null,
'sort_order' => $sortOrder,
'is_active' => $isActive,
'id' => $id,
]);
jsonResponse(['success' => true, 'message' => 'Funzione aggiornata correttamente.']);
}
if ($action === 'delete') {
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
jsonResponse(['success' => false, 'message' => 'ID funzione non valido.']);
}
$stmt = $pdo->prepare("DELETE FROM company_functions WHERE id = :id");
$stmt->execute(['id' => $id]);
jsonResponse(['success' => true, 'message' => 'Funzione cancellata correttamente.']);
}
jsonResponse(['success' => false, 'message' => 'Azione non riconosciuta.']);
} catch (Exception $e) {
jsonResponse(['success' => false, 'message' => $e->getMessage()]);
}
}
/* ==========================================
PAGE DATA
========================================== */
$stmtFunctions = $pdo->query("\n SELECT id, function_name, person_full_name, phone, email, notes, sort_order, is_active, created_at, updated_at\n FROM company_functions\n ORDER BY is_active DESC, sort_order ASC, function_name ASC, person_full_name ASC\n");
$functions = $stmtFunctions->fetchAll(PDO::FETCH_ASSOC);
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
<?php include('cssinclude.php'); ?>
<title>Funzioni Aziendali - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<style>
body {
font-size: 1.05rem;
background: #f8fafc;
}
.card {
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.back-dashboard {
background-color: #cfe3ff !important;
color: #1f2d3d !important;
border: 1px solid #bcd4f4 !important;
border-radius: 10px;
font-weight: 600;
font-size: 1rem;
padding: 10px 18px;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease-in-out;
}
.back-dashboard:hover {
background-color: #b9d3ff !important;
transform: translateY(-2px);
}
.btn-main-action,
.btn-add {
background-color: #0d6efd;
color: #fff;
border-radius: 8px;
padding: 10px 20px;
font-weight: 500;
transition: all 0.2s ease-in-out;
}
.btn-main-action:hover,
.btn-add:hover {
background-color: #0b5ed7;
color: #fff;
transform: scale(1.02);
}
.table thead {
background-color: #cfe3ff;
color: #1f2d3d;
}
#tabCompanyFunctions thead th {
text-align: center;
vertical-align: middle;
}
.modal-content {
border-radius: 16px;
}
.function-name {
font-weight: 700;
color: #1f2937;
}
.person-name {
font-weight: 600;
color: #334155;
}
.contact-line {
display: block;
font-size: 0.9rem;
text-decoration: none;
}
.notes-small {
color: #64748b;
font-size: 0.9rem;
max-width: 420px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge-status {
padding: 0.25rem 0.65rem;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
}
.badge-status.active {
background-color: #d1fae5;
color: #065f46;
}
.badge-status.inactive {
background-color: #e5e7eb;
color: #374151;
}
.empty-text {
color: #94a3b8;
font-style: italic;
}
@media (max-width: 767.98px) {
.card-header {
flex-direction: column;
align-items: flex-start !important;
gap: .5rem;
}
.back-dashboard,
.btn-main-action {
width: 100%;
}
}
</style>
</head>
<body>
<div class="wrapper" id="appWrapper">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="card p-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h5 class="mb-0">Funzioni Aziendali</h5>
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
↩️ Torna alla Dashboard
</button>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div>
<h6 class="fw-semibold mb-1">Elenco Funzioni</h6>
<div class="text-muted small">Gestione di RSPP, medico del lavoro, RLS e altre funzioni aziendali.</div>
</div>
<button class="btn btn-main-action" data-bs-toggle="modal" data-bs-target="#companyFunctionModal" onclick="openCompanyFunctionModal()">
➕ Aggiungi Funzione
</button>
</div>
<div class="table-responsive">
<table id="tabCompanyFunctions" class="table table-striped align-middle text-center" style="width:100%;">
<thead>
<tr>
<th>Funzione</th>
<th>Nominativo</th>
<th>Contatti</th>
<th>Note</th>
<th>Ordine</th>
<th>Stato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
<?php foreach ($functions as $row): ?>
<?php
$id = (int)$row['id'];
$functionName = (string)($row['function_name'] ?? '');
$personFullName = (string)($row['person_full_name'] ?? '');
$phone = (string)($row['phone'] ?? '');
$email = (string)($row['email'] ?? '');
$notes = (string)($row['notes'] ?? '');
$sortOrder = (int)($row['sort_order'] ?? 0);
$isActive = (int)($row['is_active'] ?? 1);
?>
<tr>
<td class="text-start">
<div class="function-name"><?= htmlspecialchars($functionName, ENT_QUOTES, 'UTF-8') ?></div>
</td>
<td class="text-start">
<?php if ($personFullName !== ''): ?>
<div class="person-name"><?= htmlspecialchars($personFullName, ENT_QUOTES, 'UTF-8') ?></div>
<?php else: ?>
<span class="empty-text">Da definire</span>
<?php endif; ?>
</td>
<td class="text-start">
<?php if ($phone !== ''): ?>
<a class="contact-line" href="tel:<?= htmlspecialchars($phone, ENT_QUOTES, 'UTF-8') ?>">
📞 <?= htmlspecialchars($phone, ENT_QUOTES, 'UTF-8') ?>
</a>
<?php endif; ?>
<?php if ($email !== ''): ?>
<a class="contact-line" href="mailto:<?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?>">
✉️ <?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?>
</a>
<?php endif; ?>
<?php if ($phone === '' && $email === ''): ?>
<span class="empty-text">Nessun contatto</span>
<?php endif; ?>
</td>
<td class="text-start">
<?php if ($notes !== ''): ?>
<div class="notes-small" title="<?= htmlspecialchars($notes, ENT_QUOTES, 'UTF-8') ?>">
<?= htmlspecialchars($notes, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php else: ?>
<span class="empty-text">—</span>
<?php endif; ?>
</td>
<td><?= $sortOrder ?></td>
<td>
<?php if ($isActive === 1): ?>
<span class="badge-status active">Attiva</span>
<?php else: ?>
<span class="badge-status inactive">Non attiva</span>
<?php endif; ?>
</td>
<td>
<button
type="button"
class="btn btn-sm btn-outline-secondary edit-function mb-1"
data-id="<?= $id ?>"
data-function_name="<?= htmlspecialchars($functionName, ENT_QUOTES, 'UTF-8') ?>"
data-person_full_name="<?= htmlspecialchars($personFullName, ENT_QUOTES, 'UTF-8') ?>"
data-phone="<?= htmlspecialchars($phone, ENT_QUOTES, 'UTF-8') ?>"
data-email="<?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?>"
data-notes="<?= htmlspecialchars($notes, ENT_QUOTES, 'UTF-8') ?>"
data-sort_order="<?= $sortOrder ?>"
data-is_active="<?= $isActive ?>">
✏️ Modifica
</button>
<button
type="button"
class="btn btn-sm btn-outline-danger delete-function mb-1"
data-id="<?= $id ?>"
data-name="<?= htmlspecialchars($functionName, ENT_QUOTES, 'UTF-8') ?>">
🗑️ Cancella
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
<!-- MODALE ADD / EDIT FUNZIONE -->
<div class="modal fade" id="companyFunctionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header" style="background-color:#cfe3ff;">
<h5 class="modal-title" id="companyFunctionModalTitle">Aggiungi Funzione</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="companyFunctionForm">
<input type="hidden" id="functionId">
<div class="mb-3">
<label class="form-label fw-semibold">Nome funzione <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="functionName" placeholder="Es. RSPP, Medico del lavoro, RLS" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Nome e Cognome persona</label>
<input type="text" class="form-control" id="personFullName" placeholder="Es. Mario Rossi">
</div>
<div class="row">
<div class="col-12 col-md-6 mb-3">
<label class="form-label fw-semibold">Telefono</label>
<input type="text" class="form-control" id="phone" placeholder="Es. +39 333 1234567">
</div>
<div class="col-12 col-md-6 mb-3">
<label class="form-label fw-semibold">Email</label>
<input type="email" class="form-control" id="email" placeholder="nome@azienda.it">
</div>
</div>
<div class="row">
<div class="col-12 col-md-6 mb-3">
<label class="form-label fw-semibold">Ordine</label>
<input type="number" class="form-control" id="sortOrder" value="0" min="0" step="1">
<small class="text-muted">Serve solo per ordinare la visualizzazione.</small>
</div>
<div class="col-12 col-md-6 mb-3">
<label class="form-label fw-semibold">Stato</label>
<select class="form-select" id="isActive">
<option value="1">Attiva</option>
<option value="0">Non attiva</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Note</label>
<textarea class="form-control" id="notes" rows="3" placeholder="Note interne, riferimenti, disponibilità, ecc."></textarea>
</div>
<div class="text-center">
<button type="submit" class="btn btn-add">💾 Salva</button>
</div>
</form>
</div>
</div>
</div>
</div>
<?php include('jsinclude.php'); ?>
<script>
function escapeHtml(value) {
return $('<div>').text(value || '').html();
}
function openCompanyFunctionModal() {
$('#functionId').val('');
$('#functionName').val('');
$('#personFullName').val('');
$('#phone').val('');
$('#email').val('');
$('#notes').val('');
$('#sortOrder').val('0');
$('#isActive').val('1');
$('#companyFunctionModalTitle').text('Aggiungi Funzione');
}
$(document).ready(function() {
$('#tabCompanyFunctions').DataTable({
order: [
[5, 'asc'],
[4, 'asc'],
[0, 'asc']
],
pageLength: 25,
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json',
emptyTable: 'Nessuna funzione presente'
},
columnDefs: [{
targets: -1,
orderable: false,
searchable: false
}]
});
$(document).on('click', '.edit-function', function() {
const btn = $(this);
$('#functionId').val(btn.data('id'));
$('#functionName').val(btn.data('function_name'));
$('#personFullName').val(btn.data('person_full_name'));
$('#phone').val(btn.data('phone'));
$('#email').val(btn.data('email'));
$('#notes').val(btn.data('notes'));
$('#sortOrder').val(btn.data('sort_order'));
$('#isActive').val(String(btn.data('is_active')));
$('#companyFunctionModalTitle').text('Modifica Funzione');
$('#companyFunctionModal').modal('show');
});
$('#companyFunctionForm').on('submit', function(e) {
e.preventDefault();
const id = $('#functionId').val();
const payload = new URLSearchParams();
payload.append('ajax', '1');
payload.append('action', id ? 'edit' : 'add');
payload.append('id', id);
payload.append('function_name', $('#functionName').val().trim());
payload.append('person_full_name', $('#personFullName').val().trim());
payload.append('phone', $('#phone').val().trim());
payload.append('email', $('#email').val().trim());
payload.append('notes', $('#notes').val().trim());
payload.append('sort_order', $('#sortOrder').val() || '0');
payload.append('is_active', $('#isActive').val());
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: payload.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) {
Swal.fire({
icon: 'success',
title: 'Salvato!',
text: data.message || 'Operazione completata.',
confirmButtonColor: '#3085d6'
}).then(() => location.reload());
} else {
Swal.fire('Errore', data.message || 'Impossibile salvare.', 'error');
}
})
.catch(err => {
console.error(err);
Swal.fire('Errore', 'Errore di comunicazione.', 'error');
});
});
$(document).on('click', '.delete-function', function() {
const id = $(this).data('id');
const name = $(this).data('name');
Swal.fire({
title: 'Confermi la cancellazione?',
text: name ? ('Funzione: ' + name) : 'La funzione verrà cancellata.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Sì, cancella',
cancelButtonText: 'Annulla'
}).then((result) => {
if (!result.isConfirmed) return;
const payload = new URLSearchParams();
payload.append('ajax', '1');
payload.append('action', 'delete');
payload.append('id', id);
fetch('', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: payload.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) {
Swal.fire({
icon: 'success',
title: 'Cancellato!',
text: data.message || 'Funzione cancellata.',
confirmButtonColor: '#3085d6'
}).then(() => location.reload());
} else {
Swal.fire('Errore', data.message || 'Impossibile cancellare.', 'error');
}
})
.catch(err => {
console.error(err);
Swal.fire('Errore', 'Errore di comunicazione.', 'error');
});
});
});
});
</script>
</body>
</html>
@@ -0,0 +1,14 @@
<?php
/**
* Categorie disponibili per i file associati alle mescole.
* Chiave = slug salvato nel DB (colonna mescole_files.categoria)
* Valore = etichetta mostrata in UI
*
* Per aggiungere una nuova categoria in futuro basta aggiungere
* una riga qui, senza toccare il database.
*/
return [
'schede_tecniche' => 'Schede Tecniche',
'certificati_analisi' => 'Certificati di Analisi',
];
+50
View File
@@ -0,0 +1,50 @@
<?php
include('include/headscript.php');
header('Content-Type: application/json; charset=utf-8');
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
if ($id <= 0) {
echo json_encode(['success' => false, 'message' => 'ID mescola non valido']);
exit;
}
try {
$pdo = DBHandlerSelect::getInstance()->getConnection();
// Blocca la cancellazione se ci sono dati collegati: meglio disattivare
$chk = $pdo->prepare("SELECT COUNT(*) FROM mescole_supplier_lots WHERE idmescola = ?");
$chk->execute([$id]);
$nLotti = (int)$chk->fetchColumn();
$chk = $pdo->prepare("SELECT COUNT(*) FROM mescole_files WHERE idmescola = ?");
$chk->execute([$id]);
$nFile = (int)$chk->fetchColumn();
if ($nLotti > 0 || $nFile > 0) {
$parti = [];
if ($nLotti > 0) $parti[] = $nLotti . ' lotto/i fornitore';
if ($nFile > 0) $parti[] = $nFile . ' file';
echo json_encode([
'success' => false,
'message' => 'Impossibile eliminare: la mescola ha ' . implode(' e ', $parti)
. '. Rimuovili prima, oppure disattiva la mescola.'
]);
exit;
}
$pdo->beginTransaction();
// Le associazioni con le linee non sono dati storici: si possono rimuovere
$pdo->prepare("DELETE FROM mescole_lines WHERE idmescola = ?")->execute([$id]);
$pdo->prepare("DELETE FROM mescole WHERE id = ?")->execute([$id]);
$pdo->commit();
echo json_encode(['success' => true]);
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
echo json_encode(['success' => false, 'message' => 'Errore database: ' . $e->getMessage()]);
}
+36
View File
@@ -0,0 +1,36 @@
<?php
include('include/headscript.php');
header('Content-Type: application/json; charset=utf-8');
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
if ($id <= 0) {
echo json_encode(['success' => false, 'message' => 'ID non valido']);
exit;
}
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->prepare("SELECT idmescola, filename FROM mescole_files WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
echo json_encode(['success' => false, 'message' => 'File non trovato']);
exit;
}
$del = $pdo->prepare("DELETE FROM mescole_files WHERE id = ?");
$del->execute([$id]);
$path = __DIR__ . '/uploads/mescole/' . $row['idmescola'] . '/' . $row['filename'];
if (is_file($path)) {
@unlink($path);
}
echo json_encode(['success' => true]);
} catch (Throwable $e) {
echo json_encode(['success' => false, 'message' => 'Errore database: ' . $e->getMessage()]);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
<?php
include('include/headscript.php');
header('Content-Type: application/json; charset=utf-8');
$idmescola = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$categoria = $_GET['categoria'] ?? null;
if ($idmescola <= 0) {
echo json_encode(['success' => false, 'message' => 'ID mescola non valido']);
exit;
}
$categorie = require __DIR__ . '/config/mescole_file_categories.php';
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$sql = "SELECT id, idmescola, categoria, titolo, filename, original_filename, mime_type, filesize, uploaded_at
FROM mescole_files
WHERE idmescola = ?";
$params = [$idmescola];
if (!empty($categoria)) {
$sql .= " AND categoria = ?";
$params[] = $categoria;
}
$sql .= " ORDER BY uploaded_at DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = [];
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$r['categoria_label'] = $categorie[$r['categoria']] ?? $r['categoria'];
$r['url'] = 'uploads/mescole/' . $r['idmescola'] . '/' . $r['filename'];
$rows[] = $r;
}
echo json_encode(['success' => true, 'rows' => $rows, 'categorie' => $categorie]);
} catch (Throwable $e) {
echo json_encode(['success' => false, 'message' => 'Errore server: ' . $e->getMessage()]);
}
+48
View File
@@ -0,0 +1,48 @@
<?php
include('include/headscript.php');
header('Content-Type: application/json; charset=utf-8');
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id <= 0) {
echo json_encode(['success' => false, 'message' => 'ID non valido']);
exit;
}
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$sql = "
SELECT
m.id,
m.nome,
m.nomeuscita,
m.is_active,
IFNULL(q.qty_totale, 0) AS qty_totale,
GROUP_CONCAT(DISTINCT pl.name SEPARATOR ', ') AS linee
FROM mescole m
LEFT JOIN (
SELECT idmescola, SUM(qty) AS qty_totale
FROM mescole_supplier_lots
GROUP BY idmescola
) q ON q.idmescola = m.id
LEFT JOIN mescole_lines ml ON m.id = ml.idmescola
LEFT JOIN production_lines pl ON ml.idlinea = pl.id
WHERE m.id = ?
GROUP BY m.id
";
$stmt = $pdo->prepare($sql);
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
echo json_encode(['success' => false, 'message' => 'Mescola non trovata']);
exit;
}
echo json_encode(['success' => true, 'data' => $row]);
} catch (Throwable $e) {
echo json_encode(['success' => false, 'message' => 'Errore database: ' . $e->getMessage()]);
}
+32 -20
View File
@@ -289,6 +289,16 @@
<i class='bx bx-radio-circle'></i>Dipendenti <i class='bx bx-radio-circle'></i>Dipendenti
</a> </a>
</li> </li>
<li>
<a href="job-roles.php">
<i class='bx bx-radio-circle'></i>Mansioni
</a>
</li>
<li>
<a href="ppe-items.php">
<i class='bx bx-radio-circle'></i>DPI
</a>
</li>
<?php endif; ?> <?php endif; ?>
<?php if (userCan('hr.departments.view')) : ?> <?php if (userCan('hr.departments.view')) : ?>
@@ -299,33 +309,15 @@
</li> </li>
<?php endif; ?> <?php endif; ?>
<?php if (userCan('hr.job_roles.view')) : ?>
<li>
<a href="job_roles.php">
<i class='bx bx-radio-circle'></i>Mansioni
</a>
</li>
<?php endif; ?>
<?php if (userCan('hr.training_topics.view')) : ?>
<li>
<a href="training_topics.php">
<i class='bx bx-radio-circle'></i>Corsi di Formazione
</a>
</li>
<?php endif; ?>
<?php if (userCan('hr.trainings.view')) : ?> <?php if (userCan('hr.trainings.view')) : ?>
<li> <li>
<a href="trainings.php"> <a href="trainings.php">
<i class='bx bx-radio-circle'></i>Storico Formazione <i class='bx bx-radio-circle'></i>Gestione Formazione
</a>
</li>
<li>
<a href="training_calendar.php">
<i class='bx bx-radio-circle'></i>Calendario Formazione
</a> </a>
</li> </li>
<?php endif; ?> <?php endif; ?>
<?php if (userCan('hr.skills.view')) : ?> <?php if (userCan('hr.skills.view')) : ?>
@@ -361,11 +353,31 @@
<i class='bx bx-radio-circle'></i>Calendario <i class='bx bx-radio-circle'></i>Calendario
</a> </a>
</li> </li>
<li>
<a href="scadenzario/functions/index.php">
<i class='bx bx-radio-circle'></i>Funzioni Aziendali
</a>
</li>
</ul> </ul>
</li> </li>
<?php endif; ?> <?php endif; ?>
<li>
<a href="javascript:;" class="has-arrow">
<div class="parent-icon">
<i class="bx bx-ruler"></i>
</div>
<div class="menu-title">Misure</div>
</a>
<ul>
<li>
<a href="cad-area.php">
<i class='bx bx-radio-circle'></i>Cad Area
</a>
</li>
</ul>
</li>
<li class="menu-label">Others</li> <li class="menu-label">Others</li>
<li> <li>
@@ -0,0 +1,199 @@
<?php
/**
* Helper per la gestione delle coperture tra corsi di formazione.
* Un corso "madre" (topic_id) assorbe uno o più corsi "coperti" (covered_topic_id).
*/
/**
* Mappa completa delle coperture: [topic_id => [covered_topic_id => inherit_expiry]]
*/
function tc_getCoverageMap(PDO $pdo): array
{
static $cache = null;
if ($cache !== null) return $cache;
$rows = $pdo->query("
SELECT topic_id, covered_topic_id, inherit_expiry
FROM training_topic_coverage
")->fetchAll(PDO::FETCH_ASSOC);
$map = [];
foreach ($rows as $r) {
$map[(int)$r['topic_id']][(int)$r['covered_topic_id']] = $r['inherit_expiry'];
}
return $cache = $map;
}
/**
* Mappa inversa: [covered_topic_id => [topic_id, ...]]
*/
function tc_getReverseCoverageMap(PDO $pdo): array
{
static $cache = null;
if ($cache !== null) return $cache;
$map = [];
foreach (tc_getCoverageMap($pdo) as $topicId => $covered) {
foreach (array_keys($covered) as $coveredId) {
$map[$coveredId][] = $topicId;
}
}
return $cache = $map;
}
/**
* Elenco dei corsi coperti da un dato corso, con i nomi.
* @return array[] [['id'=>int,'name'=>string,'inherit_expiry'=>string], ...]
*/
function tc_getCoveredTopics(PDO $pdo, int $topicId): array
{
$st = $pdo->prepare("
SELECT tt.id, tt.name, c.inherit_expiry
FROM training_topic_coverage c
JOIN training_topics tt ON tt.id = c.covered_topic_id
WHERE c.topic_id = :tid
ORDER BY tt.sort_order, tt.name
");
$st->execute(['tid' => $topicId]);
return $st->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Calcola la data di scadenza per un corso coperto.
*
* @param string $completedDate data completamento del corso madre (Y-m-d)
* @param string $inherit 'own' | 'source' | 'none'
* @param int|null $coveredFreq frequenza (mesi) del corso coperto
* @param string|null $sourceNextDue next_due_date del corso madre
*/
function tc_computeDueDate(
string $completedDate,
string $inherit,
?int $coveredFreq,
?string $sourceNextDue
): ?string {
if ($inherit === 'none') return null;
if ($inherit === 'source') return $sourceNextDue ?: null;
// 'own'
if ($coveredFreq === null || $coveredFreq <= 0) return null;
$d = DateTime::createFromFormat('Y-m-d', $completedDate);
if (!$d) return null;
$d->modify('+' . $coveredFreq . ' months');
return $d->format('Y-m-d');
}
/**
* Dato l'elenco dei training reali di uno o più dipendenti, produce le
* righe "virtuali" derivate dalle coperture.
*
* @param array $realRows righe da employee_trainings (servono almeno:
* employee_id, training_topic_id, completed_date,
* next_due_date, e i dati anagrafici del dipendente)
* @param array $topicsById [id => ['name'=>..,'default_frequency_months'=>..,'default_reminder_days'=>..]]
* @return array righe virtuali pronte per il rendering
*/
function tc_buildVirtualRows(PDO $pdo, array $realRows, array $topicsById): array
{
$coverage = tc_getCoverageMap($pdo);
if (!$coverage) return [];
// Indice dei record diretti già esistenti: "empId:topicId"
$direct = [];
foreach ($realRows as $r) {
$direct[$r['employee_id'] . ':' . $r['training_topic_id']] = true;
}
$virtual = [];
foreach ($realRows as $r) {
$srcTopicId = (int)$r['training_topic_id'];
if (empty($coverage[$srcTopicId])) continue;
foreach ($coverage[$srcTopicId] as $coveredId => $inherit) {
$key = $r['employee_id'] . ':' . $coveredId;
// Precedenza al record diretto
if (isset($direct[$key])) continue;
// Il corso coperto deve esistere ed essere attivo
if (!isset($topicsById[$coveredId])) continue;
// Evita doppioni se più corsi madre coprono lo stesso corso:
// tiene quello con completed_date più recente
if (isset($virtual[$key]) && $virtual[$key]['completed_date'] >= $r['completed_date']) continue;
$covFreq = $topicsById[$coveredId]['default_frequency_months'];
$covFreq = ($covFreq === null || $covFreq === '') ? null : (int)$covFreq;
$due = tc_computeDueDate(
(string)$r['completed_date'],
(string)$inherit,
$covFreq,
$r['next_due_date'] ?: null
);
$virtual[$key] = [
'id' => null,
'_covered' => true,
'_covered_by_id' => $srcTopicId,
'_covered_by_name' => $topicsById[$srcTopicId]['name'] ?? ('#' . $srcTopicId),
'employee_id' => $r['employee_id'],
'first_name' => $r['first_name'] ?? '',
'last_name' => $r['last_name'] ?? '',
'employee_code' => $r['employee_code'] ?? null,
'department_name' => $r['department_name'] ?? null,
'department_color' => $r['department_color'] ?? null,
'training_topic_id' => $coveredId,
'topic_name' => $topicsById[$coveredId]['name'],
'topic_default_rem' => $topicsById[$coveredId]['default_reminder_days'] ?? null,
'training_type' => $r['training_type'] ?? 'initial',
'completed_date' => $r['completed_date'],
'next_due_date' => $due,
'reminder_days' => null,
'attachments_count' => 0,
];
}
}
return array_values($virtual);
}
/**
* Insieme di chiavi "empId:topicId" coperte, usato per escludere
* i corsi obbligatori dai "Non presenti".
*/
function tc_coveredKeys(array $virtualRows): array
{
$keys = [];
foreach ($virtualRows as $v) {
$keys[$v['employee_id'] . ':' . $v['training_topic_id']] = true;
}
return $keys;
}
/**
* Verifica se aggiungere topicId -> coveredId creerebbe un ciclo.
*/
function tc_wouldCreateCycle(PDO $pdo, int $topicId, int $coveredId): bool
{
if ($topicId === $coveredId) return true;
$map = [];
$rows = $pdo->query("SELECT topic_id, covered_topic_id FROM training_topic_coverage")
->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$map[(int)$r['topic_id']][] = (int)$r['covered_topic_id'];
}
$map[$topicId][] = $coveredId;
// DFS da coveredId: se raggiungo topicId c'è ciclo
$stack = [$coveredId];
$visited = [];
while ($stack) {
$cur = array_pop($stack);
if ($cur === $topicId) return true;
if (isset($visited[$cur])) continue;
$visited[$cur] = true;
foreach ($map[$cur] ?? [] as $next) $stack[] = $next;
}
return false;
}
File diff suppressed because it is too large Load Diff
+29 -2
View File
@@ -496,14 +496,41 @@ function h($v)
} }
}); });
// Select2 for category filter + modal selects // Select2 solo per il filtro categoria (fuori dai modali)
$('#categoryFilter, #add_category, #edit_category').select2({ $('#categoryFilter').select2({
theme: 'bootstrap4', theme: 'bootstrap4',
width: '100%', width: '100%',
placeholder: '-- Seleziona --', placeholder: '-- Seleziona --',
allowClear: true allowClear: true
}); });
// Select2 per il modale ADD: inizializza quando il modale è visibile
// e aggancia il dropdown dentro il modale (altrimenti le opzioni non si vedono)
$('#addModal').on('shown.bs.modal', function() {
if (!$('#add_category').data('select2')) {
$('#add_category').select2({
theme: 'bootstrap4',
width: '100%',
placeholder: '-- Seleziona --',
allowClear: true,
dropdownParent: $('#addModal')
});
}
});
// Select2 per il modale EDIT: stessa logica
$('#editModal').on('shown.bs.modal', function() {
if (!$('#edit_category').data('select2')) {
$('#edit_category').select2({
theme: 'bootstrap4',
width: '100%',
placeholder: '-- Seleziona --',
allowClear: true,
dropdownParent: $('#editModal')
});
}
});
// Filter redirect // Filter redirect
$('#categoryFilter').on('change', function() { $('#categoryFilter').on('change', function() {
const v = $(this).val() || ''; const v = $(this).val() || '';
+83 -29
View File
@@ -365,18 +365,13 @@ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
} }
} }
// Load packaging items from dedicated table // Load ALL packaging items (tutte le categorie, distinct via GROUP nel dropdown)
$packagingLookup = [ $packagingByCategory = []; // categoria => [ {value, label}, ... ]
'PACKAGING_TYPE' => [],
'BOX' => [],
'PALLET' => []
];
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT category, item_name, item_code SELECT id, category, item_name, item_code
FROM packaging_items FROM packaging_items
WHERE is_active = 1 WHERE is_active = 1
AND category IN ('PACKAGING_TYPE', 'BOX', 'PALLET')
ORDER BY category ASC, item_name ASC ORDER BY category ASC, item_name ASC
"); ");
$stmt->execute(); $stmt->execute();
@@ -387,12 +382,20 @@ foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$label .= ' — ' . $r['item_code']; $label .= ' — ' . $r['item_code'];
} }
$packagingLookup[$r['category']][] = [ $packagingByCategory[$r['category']][] = [
'value' => $r['item_code'], 'value' => (int)$r['id'], // salviamo l'ID
'label' => $label 'label' => $label
]; ];
} }
// Elenco categorie distinte (per il primo dropdown)
$packagingCategories = array_keys($packagingByCategory);
sort($packagingCategories);
// Questi verranno calcolati DOPO il caricamento del worksheet
$savedPackageCode = '';
$savedPackageCategory = '';
// Load mescole dropdown for modal // Load mescole dropdown for modal
$mescole = $pdo->query(" $mescole = $pdo->query("
SELECT id, nome, nomeuscita SELECT id, nome, nomeuscita
@@ -432,6 +435,19 @@ if ($worksheet_id > 0) {
} }
} }
// Ora che $worksheet è caricato, ricava categoria + codice salvati per l'imballo
$savedPackageCode = $worksheet['requested_package_code'] ?? '';
if ($savedPackageCode !== '' && $savedPackageCode !== null) {
foreach ($packagingByCategory as $cat => $items) {
foreach ($items as $it) {
if ((string)$it['value'] === (string)$savedPackageCode) {
$savedPackageCategory = $cat;
break 2;
}
}
}
}
// Helpers // Helpers
function h($v) function h($v)
{ {
@@ -1045,14 +1061,28 @@ $isEdit = ($worksheet_id > 0);
<div class="soft-badge">Packaging</div> <div class="soft-badge">Packaging</div>
</div> </div>
<div class="section-body"> <div class="section-body">
<div class="section-grid-note">Compila prima la confezione, poi eventuali dettagli di scatola e bancale</div> <div class="section-grid-note">Seleziona prima la categoria, poi lo specifico elemento di imballo</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-lg-4"> <div class="col-lg-4">
<label class="form-label">Codice confezione</label> <label class="form-label">Categoria imballo</label>
<select class="form-select" name="requested_package_code" id="requested_package_code"> <select class="form-select" id="packaging_category">
<?= packaging_options($packagingLookup['PACKAGING_TYPE'] ?? [], $worksheet['requested_package_code'] ?? '') ?> <option value="">-- Seleziona --</option>
<?php foreach ($packagingCategories as $cat): ?>
<option value="<?= h($cat) ?>" <?= $savedPackageCategory === $cat ? 'selected' : '' ?>>
<?= h($cat) ?>
</option>
<?php endforeach; ?>
</select> </select>
<div class="input-tip">Es. PACKAGING_TYPE, BOX, PALLET…</div>
</div>
<div class="col-lg-4">
<label class="form-label">Elemento imballo</label>
<select class="form-select" name="requested_package_code" id="requested_package_code">
<option value="">-- Prima scegli una categoria --</option>
</select>
<div class="input-tip">Il valore salvato è il codice dell'elemento</div>
</div> </div>
<div class="col-lg-4 col-md-6"> <div class="col-lg-4 col-md-6">
@@ -1074,13 +1104,6 @@ $isEdit = ($worksheet_id > 0);
value="<?= $worksheet ? h($worksheet['meters_per_package_notes']) : '' ?>"> value="<?= $worksheet ? h($worksheet['meters_per_package_notes']) : '' ?>">
</div> </div>
<div class="col-lg-3 col-md-6">
<label class="form-label">Scatola (tipo)</label>
<select class="form-select" name="box_type" id="box_type">
<?= packaging_options($packagingLookup['BOX'] ?? [], $worksheet['box_type'] ?? '') ?>
</select>
</div>
<div class="col-lg-3 col-md-6"> <div class="col-lg-3 col-md-6">
<label class="form-label">N° conf./pezzi per scatola</label> <label class="form-label">N° conf./pezzi per scatola</label>
<input type="number" class="form-control" name="packages_or_pieces_per_box" <input type="number" class="form-control" name="packages_or_pieces_per_box"
@@ -1093,13 +1116,6 @@ $isEdit = ($worksheet_id > 0);
value="<?= $worksheet ? h($worksheet['meters_per_box']) : '' ?>"> value="<?= $worksheet ? h($worksheet['meters_per_box']) : '' ?>">
</div> </div>
<div class="col-lg-3 col-md-6">
<label class="form-label">Bancale (tipo)</label>
<select class="form-select" name="pallet_type" id="pallet_type">
<?= packaging_options($packagingLookup['PALLET'] ?? [], $worksheet['pallet_type'] ?? '') ?>
</select>
</div>
<div class="col-lg-3 col-md-6"> <div class="col-lg-3 col-md-6">
<label class="form-label">Scatole/conf. per bancale</label> <label class="form-label">Scatole/conf. per bancale</label>
<input type="number" class="form-control" name="boxes_or_packages_per_pallet" <input type="number" class="form-control" name="boxes_or_packages_per_pallet"
@@ -1338,6 +1354,39 @@ $isEdit = ($worksheet_id > 0);
}); });
} }
// ---- Imballo: categoria -> elemento filtrato ----
const packagingData = <?= json_encode($packagingByCategory, JSON_UNESCAPED_UNICODE) ?>;
const savedPackageCode = <?= json_encode($savedPackageCode, JSON_UNESCAPED_UNICODE) ?>;
function fillPackagingItems(category, selectedValue) {
const $items = $('#requested_package_code');
$items.empty();
if (!category || !packagingData[category]) {
$items.append('<option value="">-- Prima scegli una categoria --</option>');
return;
}
$items.append('<option value="">-- Seleziona --</option>');
packagingData[category].forEach(function(it) {
const sel = (String(it.value) === String(selectedValue)) ? ' selected' : '';
$items.append('<option value="' + it.value + '"' + sel + '>' + it.label + '</option>');
});
}
// Al cambio categoria, ricarica gli elementi (svuota selezione)
$('#packaging_category').on('change', function() {
fillPackagingItems($(this).val(), '');
});
// Popola all'avvio se in modifica c'è già una categoria selezionata
// Usiamo il valore direttamente dal PHP, non dal .val() del select
const initialCat = <?= json_encode($savedPackageCategory, JSON_UNESCAPED_UNICODE) ?>;
if (initialCat) {
$('#packaging_category').val(initialCat);
fillPackagingItems(initialCat, savedPackageCode);
}
$('#mixModal').on('shown.bs.modal', function() { $('#mixModal').on('shown.bs.modal', function() {
if (!$('#idmescola').data('select2')) { if (!$('#idmescola').data('select2')) {
$('#idmescola').select2({ $('#idmescola').select2({
@@ -1457,6 +1506,11 @@ $isEdit = ($worksheet_id > 0);
return false; return false;
} }
// Se stiamo modificando una riga esistente, non resettare il form
if (parseInt($('#mixRowId').val() || '0', 10) > 0) {
return;
}
$('#mixModalTitle').text('Aggiungi Mescola'); $('#mixModalTitle').text('Aggiungi Mescola');
$('#mixForm')[0].reset(); $('#mixForm')[0].reset();
$('#mixRowId').val('0'); $('#mixRowId').val('0');
@@ -1470,8 +1524,8 @@ $isEdit = ($worksheet_id > 0);
let row = $(this).attr('data-row'); let row = $(this).attr('data-row');
row = JSON.parse(row); row = JSON.parse(row);
$('#mixModalTitle').text('Modifica Mescola');
$('#mixForm')[0].reset(); $('#mixForm')[0].reset();
$('#mixModalTitle').text('Modifica Mescola');
$('#mixRowId').val(row.id); $('#mixRowId').val(row.id);
$('#mixWorksheetId').val(row.worksheet_id); $('#mixWorksheetId').val(row.worksheet_id);
File diff suppressed because it is too large Load Diff
+102 -4
View File
@@ -272,6 +272,13 @@
<td><span class='qty-badge'>{$qtyTot}</span></td> <td><span class='qty-badge'>{$qtyTot}</span></td>
<td>{$badge}</td> <td>{$badge}</td>
<td> <td>
<button class='btn btn-sm btn-outline-primary edit-item'
data-id='{$r['id']}'
data-category='" . htmlspecialchars($r['category'], ENT_QUOTES) . "'
data-name='" . htmlspecialchars($r['item_name'], ENT_QUOTES) . "'
data-code='" . htmlspecialchars($r['item_code'], ENT_QUOTES) . "'>
✏️ Modifica
</button>
<button class='btn btn-sm btn-outline-dark manage-stock' <button class='btn btn-sm btn-outline-dark manage-stock'
data-id='{$r['id']}' data-id='{$r['id']}'
data-name='" . htmlspecialchars($r['item_name'], ENT_QUOTES) . "'> data-name='" . htmlspecialchars($r['item_name'], ENT_QUOTES) . "'>
@@ -342,7 +349,47 @@
</div> </div>
</div> </div>
</div> </div>
<!-- MODALE: MODIFICA IMBALLO -->
<div class="modal fade" id="editItemModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header" style="background-color:#cfe3ff;">
<h5 class="modal-title">Modifica Imballo</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editItemForm">
<input type="hidden" id="editItemId">
<div class="mb-3">
<label class="form-label fw-semibold">Categoria</label>
<select class="form-select" id="editCategory" required>
<option value="PACKAGING_TYPE">Tipo Confezione</option>
<option value="BOX">Scatola</option>
<option value="PALLET">Pallet</option>
<option value="OTHER">Altro</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Nome</label>
<input type="text" class="form-control" id="editItemName" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Codice</label>
<input type="text" class="form-control" id="editItemCode" required>
</div>
<div class="text-center">
<button type="submit" class="btn btn-add">💾 Salva modifiche</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- MODALE: STOCK FORNITORI/LOTTI --> <!-- MODALE: STOCK FORNITORI/LOTTI -->
<div class="modal fade" id="stockModal" tabindex="-1"> <div class="modal fade" id="stockModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered"> <div class="modal-dialog modal-xl modal-dialog-centered">
@@ -505,7 +552,48 @@
$('#filterActive, #filterCategory').on('change', refreshUrlParams); $('#filterActive, #filterCategory').on('change', refreshUrlParams);
}); });
// Open edit item modal
$(document).on("click", ".edit-item", function() {
$("#editItemId").val($(this).data("id"));
$("#editCategory").val($(this).data("category"));
$("#editItemName").val($(this).data("name"));
$("#editItemCode").val($(this).data("code"));
$("#editItemModal").modal("show");
});
// Save edited item
$("#editItemForm").on("submit", function(e) {
e.preventDefault();
const id = $("#editItemId").val();
const category = $("#editCategory").val();
const item_name = $("#editItemName").val().trim();
const item_code = $("#editItemCode").val().trim();
fetch("update_packaging_item.php", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: `id=${encodeURIComponent(id)}&category=${encodeURIComponent(category)}&item_name=${encodeURIComponent(item_name)}&item_code=${encodeURIComponent(item_code)}`
})
.then(r => r.json())
.then(data => {
if (data.success) {
Swal.fire({
icon: "success",
title: "Aggiornato!"
}).then(() => location.reload());
} else {
Swal.fire({
icon: "error",
title: "Errore",
text: data.message || "Non aggiornato"
});
}
});
});
// Create new item // Create new item
$("#addItemForm").on("submit", function(e) { $("#addItemForm").on("submit", function(e) {
e.preventDefault(); e.preventDefault();
@@ -558,11 +646,16 @@
}); });
}); });
// Flag: true se lo stock è stato modificato mentre il modale era aperto
let stockChanged = false;
// Open stock modal // Open stock modal
$(document).on("click", ".manage-stock", function() { $(document).on("click", ".manage-stock", function() {
const id = $(this).data("id"); const id = $(this).data("id");
const name = $(this).data("name"); const name = $(this).data("name");
stockChanged = false; // reset ad ogni apertura
$("#stockItemId").val(id); $("#stockItemId").val(id);
$("#stockItemName").text(name); $("#stockItemName").text(name);
@@ -571,6 +664,13 @@
loadSuppliersSelect($("#stockSupplier"), $("#stockModal")).then(() => stockLoadRows(id)); loadSuppliersSelect($("#stockSupplier"), $("#stockModal")).then(() => stockLoadRows(id));
}); });
// Alla chiusura del modale, se qualcosa è cambiato ricarica la pagina
$("#stockModal").on("hidden.bs.modal", function() {
if (stockChanged) {
location.reload();
}
});
// Click edit stock row // Click edit stock row
$(document).on("click", ".stock-edit", function() { $(document).on("click", ".stock-edit", function() {
$("#stockEditId").val($(this).data("id")); $("#stockEditId").val($(this).data("id"));
@@ -629,11 +729,9 @@
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
stockChanged = true;
stockResetForm(); stockResetForm();
stockLoadRows(itemId); stockLoadRows(itemId);
// Refresh totals by reloading page (simple and safe)
// If you want live update without reload, we can do it.
// location.reload();
} else { } else {
Swal.fire({ Swal.fire({
icon: "error", icon: "error",
@@ -669,8 +767,8 @@
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
stockChanged = true;
stockLoadRows(itemId); stockLoadRows(itemId);
// location.reload();
} else { } else {
Swal.fire({ Swal.fire({
icon: "error", icon: "error",
File diff suppressed because it is too large Load Diff
+92 -19
View File
@@ -150,17 +150,24 @@ $dashboardSections = [
[ [
'id' => 'secPersonale', 'id' => 'secPersonale',
'title' => 'Personale', 'title' => 'Personale',
'subtitle' => 'Dipendenti, skill', 'subtitle' => 'Dipendenti, formazione, skill',
'icon' => '👥', 'icon' => '👥',
'open' => false, 'open' => false,
'buttons' => [ 'buttons' => [
[ [
'label' => 'Employees', 'label' => 'Dipendenti',
'icon' => '👥', 'icon' => '👥',
'class' => 'btn-employees', 'class' => 'btn-employees',
'url' => 'employees.php', 'url' => 'employees.php',
'permission' => 'hr.employees.view', 'permission' => 'hr.employees.view',
], ],
[
'label' => 'Mansioni',
'icon' => '🧩',
'class' => 'btn-setup',
'url' => 'job-roles.php',
'permission' => 'hr.employees.view',
],
[ [
'label' => 'Departments', 'label' => 'Departments',
'icon' => '🏢', 'icon' => '🏢',
@@ -168,6 +175,20 @@ $dashboardSections = [
'url' => 'departments.php', 'url' => 'departments.php',
'permission' => 'hr.departments.view', 'permission' => 'hr.departments.view',
], ],
[
'label' => 'DPI',
'icon' => '🦺',
'class' => 'btn-setup',
'url' => 'ppe-items.php',
'permission' => 'hr.employees.view',
],
[
'label' => 'Gestione Formazione',
'icon' => '🎓',
'class' => 'btn-setup',
'url' => 'trainings.php',
'permission' => 'hr.trainings.view',
],
[ [
'label' => 'Skills', 'label' => 'Skills',
'icon' => '🧠', 'icon' => '🧠',
@@ -494,45 +515,97 @@ $dashboardSections = [
margin-bottom: 1rem; margin-bottom: 1rem;
width: 100%; width: 100%;
} }
.my-deadlines-widgets:empty { display: none; }
.my-deadlines-widgets:empty {
display: none;
}
/* Each widget wraps itself in .my-deadlines-widgets; collapse the nested /* Each widget wraps itself in .my-deadlines-widgets; collapse the nested
wrapper so all cards flow into the outer flex (single row). */ wrapper so all cards flow into the outer flex (single row). */
.my-deadlines-widgets .my-deadlines-widgets { .my-deadlines-widgets .my-deadlines-widgets {
display: contents; display: contents;
} }
.my-deadlines-widgets .mdw { .my-deadlines-widgets .mdw {
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
display: flex; align-items: center; gap: 0.75rem; display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.8rem 0.9rem; padding: 0.8rem 0.9rem;
border-radius: 0.6rem; border-radius: 0.6rem;
text-decoration: none; text-decoration: none;
color: #fff; color: #fff;
box-shadow: 0 2px 6px rgba(0,0,0,0.08); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
transition: transform 0.15s, box-shadow 0.15s; transition: transform 0.15s, box-shadow 0.15s;
} }
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.my-deadlines-widgets .mdw { flex: 1 1 calc(50% - 0.375rem); } .my-deadlines-widgets .mdw {
flex: 1 1 calc(50% - 0.375rem);
}
} }
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.my-deadlines-widgets .mdw { flex: 1 1 100%; } .my-deadlines-widgets .mdw {
flex: 1 1 100%;
}
} }
.my-deadlines-widgets .mdw:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); color: #fff; }
.my-deadlines-widgets .mdw-red { background: linear-gradient(135deg, #dc3545 0%, #b02a37 100%); } .my-deadlines-widgets .mdw:hover {
.my-deadlines-widgets .mdw-orange { background: linear-gradient(135deg, #e8930c 0%, #c77a00 100%); } transform: translateY(-1px);
.my-deadlines-widgets .mdw-gray { background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); } box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
color: #fff;
}
.my-deadlines-widgets .mdw-red {
background: linear-gradient(135deg, #dc3545 0%, #b02a37 100%);
}
.my-deadlines-widgets .mdw-orange {
background: linear-gradient(135deg, #e8930c 0%, #c77a00 100%);
}
.my-deadlines-widgets .mdw-gray {
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
}
.my-deadlines-widgets .mdw-icon { .my-deadlines-widgets .mdw-icon {
width: 38px; height: 38px; border-radius: 50%; width: 38px;
display: flex; align-items: center; justify-content: center; height: 38px;
background: rgba(255,255,255,0.22); font-size: 1.05rem; flex-shrink: 0; border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.22);
font-size: 1.05rem;
flex-shrink: 0;
} }
.my-deadlines-widgets .mdw-body { flex: 1; line-height: 1.2; min-width: 0; }
.my-deadlines-widgets .mdw-count { font-size: 1.5rem; font-weight: 700; } .my-deadlines-widgets .mdw-body {
flex: 1;
line-height: 1.2;
min-width: 0;
}
.my-deadlines-widgets .mdw-count {
font-size: 1.5rem;
font-weight: 700;
}
.my-deadlines-widgets .mdw-label { .my-deadlines-widgets .mdw-label {
font-size: 0.78rem; opacity: 0.95; font-size: 0.78rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.95;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.my-deadlines-widgets .mdw-arrow {
opacity: 0.7;
font-size: 0.85rem;
flex-shrink: 0;
} }
.my-deadlines-widgets .mdw-arrow { opacity: 0.7; font-size: 0.85rem; flex-shrink: 0; }
</style> </style>
<div class="my-deadlines-widgets"> <div class="my-deadlines-widgets">
<?php include(__DIR__ . '/scadenzario/include/my_deadlines_widget.php'); ?> <?php include(__DIR__ . '/scadenzario/include/my_deadlines_widget.php'); ?>
+28
View File
@@ -0,0 +1,28 @@
<?php
include('include/headscript.php');
header('Content-Type: application/json; charset=utf-8');
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
$titolo = trim($_POST['titolo'] ?? '');
if ($id <= 0) {
echo json_encode(['success' => false, 'message' => 'ID non valido']);
exit;
}
if ($titolo === '') {
echo json_encode(['success' => false, 'message' => 'Il titolo non può essere vuoto']);
exit;
}
$titolo = mb_substr($titolo, 0, 255);
try {
$pdo = DBHandlerSelect::getInstance()->getConnection();
$stmt = $pdo->prepare("UPDATE mescole_files SET titolo = ? WHERE id = ?");
$stmt->execute([$titolo, $id]);
echo json_encode(['success' => true, 'titolo' => $titolo]);
} catch (Throwable $e) {
echo json_encode(['success' => false, 'message' => 'Errore database: ' . $e->getMessage()]);
}
+104
View File
@@ -0,0 +1,104 @@
<?php
include('include/headscript.php');
header('Content-Type: application/json; charset=utf-8');
$categorie = require __DIR__ . '/config/mescole_file_categories.php';
$idmescola = isset($_POST['idmescola']) ? (int)$_POST['idmescola'] : 0;
$categoria = $_POST['categoria'] ?? '';
$titolo = trim($_POST['titolo'] ?? '');
if ($idmescola <= 0) {
echo json_encode(['success' => false, 'message' => 'ID mescola non valido']);
exit;
}
if (!array_key_exists($categoria, $categorie)) {
echo json_encode(['success' => false, 'message' => 'Categoria non valida']);
exit;
}
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['success' => false, 'message' => 'File mancante o upload non riuscito']);
exit;
}
// --- Validazioni di sicurezza sul file ---
$allowedExt = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png'];
$maxSize = 15 * 1024 * 1024; // 15 MB
$originalName = $_FILES['file']['name'];
$tmpPath = $_FILES['file']['tmp_name'];
$size = (int)$_FILES['file']['size'];
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
// Titolo automatico dal nome file se non fornito
if ($titolo === '') {
$titolo = pathinfo($originalName, PATHINFO_FILENAME);
$titolo = trim(preg_replace('/[_\-]+/', ' ', $titolo));
if ($titolo === '') {
$titolo = 'Documento del ' . date('d/m/Y');
}
}
$titolo = mb_substr($titolo, 0, 255);
if (!in_array($ext, $allowedExt, true)) {
echo json_encode(['success' => false, 'message' => 'Estensione file non consentita (' . $ext . ')']);
exit;
}
if ($size <= 0 || $size > $maxSize) {
echo json_encode(['success' => false, 'message' => 'Il file supera la dimensione massima di 15MB']);
exit;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $tmpPath);
finfo_close($finfo);
// Cartella dedicata per mescola: uploads/mescole/{idmescola}/
$destDir = __DIR__ . '/uploads/mescole/' . $idmescola . '/';
if (!is_dir($destDir)) {
mkdir($destDir, 0775, true);
}
// Nome fisico casuale: evita collisioni e path traversal
$storedName = bin2hex(random_bytes(16)) . '.' . $ext;
$destPath = $destDir . $storedName;
if (!move_uploaded_file($tmpPath, $destPath)) {
echo json_encode(['success' => false, 'message' => 'Impossibile salvare il file sul server']);
exit;
}
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->prepare("
INSERT INTO mescole_files (idmescola, categoria, titolo, filename, original_filename, mime_type, filesize, uploaded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
");
$stmt->execute([$idmescola, $categoria, $titolo, $storedName, $originalName, $mimeType, $size]);
$newId = (int)$pdo->lastInsertId();
echo json_encode([
'success' => true,
'id' => $newId,
'row' => [
'id' => $newId,
'idmescola' => $idmescola,
'categoria' => $categoria,
'categoria_label' => $categorie[$categoria],
'titolo' => $titolo,
'filename' => $storedName,
'original_filename' => $originalName,
'mime_type' => $mimeType,
'filesize' => $size,
'uploaded_at' => date('Y-m-d H:i:s'),
'url' => 'uploads/mescole/' . $idmescola . '/' . $storedName,
],
]);
} catch (Throwable $e) {
// rollback file se il DB fallisce
@unlink($destPath);
echo json_encode(['success' => false, 'message' => 'Errore database: ' . $e->getMessage()]);
}
@@ -0,0 +1,154 @@
<?php
include('../../include/headscript.php');
header('Content-Type: application/json; charset=utf-8');
$pdo = DBHandlerSelect::getInstance()->getConnection();
function jsonResponse(array $data): void
{
echo json_encode($data);
exit;
}
function normalizeNullableInt($value): ?int
{
return (isset($value) && $value !== '') ? (int)$value : null;
}
try {
$isHrManager = Auth::user()->hasRole('Admin')
|| Auth::user()->hasRole('Superuser')
|| Auth::user()->hasRole('employee-hr')
|| Auth::user()->hasRole('manager');
if (!$isHrManager) {
jsonResponse(['success' => false, 'message' => 'Non autorizzato.']);
}
$employeeId = (int)($_POST['employee_id'] ?? 0);
$firstName = trim($_POST['first_name'] ?? '');
$lastName = trim($_POST['last_name'] ?? '');
$employeeCode = trim($_POST['employee_code'] ?? '');
$hireDate = trim($_POST['hire_date'] ?? '');
$address = trim($_POST['address'] ?? '');
$phone = trim($_POST['phone'] ?? '');
$email = trim($_POST['email'] ?? '');
$departmentId = normalizeNullableInt($_POST['department_id'] ?? '');
$status = trim($_POST['status'] ?? 'active');
$authUserId = normalizeNullableInt($_POST['auth_user_id'] ?? '');
$roleId = normalizeNullableInt($_POST['role_id'] ?? '');
$jobSubRoleIds = $_POST['job_sub_role_ids'] ?? [];
if (!is_array($jobSubRoleIds)) {
$jobSubRoleIds = [$jobSubRoleIds];
}
$jobSubRoleIds = array_values(array_unique(array_filter(array_map('intval', $jobSubRoleIds))));
if ($employeeId <= 0) {
jsonResponse(['success' => false, 'message' => 'ID dipendente non valido.']);
}
if ($firstName === '' || $lastName === '') {
jsonResponse(['success' => false, 'message' => 'Nome e cognome sono obbligatori.']);
}
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
jsonResponse(['success' => false, 'message' => 'Email non valida.']);
}
if (!in_array($status, ['active', 'inactive', 'suspended'], true)) {
$status = 'active';
}
$stmtEmployee = $pdo->prepare('SELECT id FROM employees WHERE id = ? LIMIT 1');
$stmtEmployee->execute([$employeeId]);
if (!$stmtEmployee->fetchColumn()) {
jsonResponse(['success' => false, 'message' => 'Dipendente non trovato.']);
}
$primaryJobRoleId = null;
$primaryJobSubRoleId = null;
if ($jobSubRoleIds) {
$placeholders = implode(',', array_fill(0, count($jobSubRoleIds), '?'));
$stmtSubRoles = $pdo->prepare("\n SELECT id, job_role_id\n FROM job_sub_roles\n WHERE id IN ($placeholders)\n AND is_active = 1\n ");
$stmtSubRoles->execute($jobSubRoleIds);
$validRows = $stmtSubRoles->fetchAll(PDO::FETCH_ASSOC);
$validMap = [];
foreach ($validRows as $row) {
$validMap[(int)$row['id']] = (int)$row['job_role_id'];
}
$jobSubRoleIds = array_values(array_filter($jobSubRoleIds, static function ($id) use ($validMap) {
return isset($validMap[(int)$id]);
}));
if ($jobSubRoleIds) {
$primaryJobSubRoleId = (int)$jobSubRoleIds[0];
$primaryJobRoleId = $validMap[$primaryJobSubRoleId] ?? null;
}
}
$pdo->beginTransaction();
$stmt = $pdo->prepare("\n UPDATE employees\n SET first_name = :first_name,\n last_name = :last_name,\n employee_code = :employee_code,\n hire_date = :hire_date,\n address = :address,\n phone = :phone,\n email = :email,\n department_id = :department_id,\n job_role_id = :job_role_id,\n job_sub_role_id = :job_sub_role_id,\n status = :status,\n auth_user_id = :auth_user_id,\n updated_at = NOW()\n WHERE id = :employee_id\n ");
$stmt->execute([
'first_name' => $firstName,
'last_name' => $lastName,
'employee_code' => $employeeCode !== '' ? $employeeCode : null,
'hire_date' => $hireDate !== '' ? $hireDate : null,
'address' => $address !== '' ? $address : null,
'phone' => $phone !== '' ? $phone : null,
'email' => $email !== '' ? $email : null,
'department_id' => $departmentId,
'job_role_id' => $primaryJobRoleId,
'job_sub_role_id' => $primaryJobSubRoleId,
'status' => $status,
'auth_user_id' => $authUserId,
'employee_id' => $employeeId,
]);
$stmtDelete = $pdo->prepare('DELETE FROM employee_job_sub_roles WHERE employee_id = ?');
$stmtDelete->execute([$employeeId]);
if ($jobSubRoleIds) {
$stmtInsert = $pdo->prepare("\n INSERT INTO employee_job_sub_roles\n (employee_id, job_sub_role_id, is_primary, created_at)\n VALUES\n (:employee_id, :job_sub_role_id, :is_primary, NOW())\n ");
foreach ($jobSubRoleIds as $index => $jobSubRoleId) {
$stmtInsert->execute([
'employee_id' => $employeeId,
'job_sub_role_id' => (int)$jobSubRoleId,
'is_primary' => $index === 0 ? 1 : 0,
]);
}
}
if ($authUserId !== null && $roleId !== null) {
$checkRole = $pdo->prepare('SELECT COUNT(*) FROM auth_roles WHERE id = ?');
$checkRole->execute([$roleId]);
if ((int)$checkRole->fetchColumn() > 0) {
$stmtRole = $pdo->prepare('UPDATE auth_users SET role_id = :role_id, updated_at = NOW() WHERE id = :auth_user_id');
$stmtRole->execute([
'role_id' => $roleId,
'auth_user_id' => $authUserId,
]);
}
}
$pdo->commit();
jsonResponse(['success' => true]);
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
jsonResponse([
'success' => false,
'message' => $e->getMessage(),
]);
}
@@ -50,17 +50,38 @@ try {
$documentDate = $deadline['document_date'] ? new DateTime($deadline['document_date']) : null; $documentDate = $deadline['document_date'] ? new DateTime($deadline['document_date']) : null;
switch ($deadline['recurrence_type']) { switch ($deadline['recurrence_type']) {
case 'monthly': $interval = new DateInterval('P1M'); break; case 'monthly':
case 'quarterly': $interval = new DateInterval('P3M'); break; $interval = new DateInterval('P1M');
case 'semiannual': $interval = new DateInterval('P6M'); break; break;
case 'annual': $interval = new DateInterval('P1Y'); break; case 'quarterly':
case 'biennial': $interval = new DateInterval('P2Y'); break; $interval = new DateInterval('P3M');
case 'triennial': $interval = new DateInterval('P3Y'); break; break;
case 'quadriennial': $interval = new DateInterval('P4Y'); break; case 'semiannual':
case 'quinquennial': $interval = new DateInterval('P5Y'); break; $interval = new DateInterval('P6M');
case 'decennial': $interval = new DateInterval('P10Y'); break; break;
case 'quindecennial': $interval = new DateInterval('P15Y'); break; case 'annual':
default: $interval = null; $interval = new DateInterval('P1Y');
break;
case 'biennial':
$interval = new DateInterval('P2Y');
break;
case 'triennial':
$interval = new DateInterval('P3Y');
break;
case 'quadriennial':
$interval = new DateInterval('P4Y');
break;
case 'quinquennial':
$interval = new DateInterval('P5Y');
break;
case 'decennial':
$interval = new DateInterval('P10Y');
break;
case 'quindecennial':
$interval = new DateInterval('P15Y');
break;
default:
$interval = null;
} }
if ($interval) { if ($interval) {
@@ -70,17 +91,27 @@ try {
$ins = $pdo->prepare(" $ins = $pdo->prepare("
INSERT INTO scad_deadlines INSERT INTO scad_deadlines
(subject_id, function_id, topic, law_regulation, recurrence_type, due_date, check_date, (subject_id, function_id, notify_function, topic, law_regulation, recurrence_type, due_date, check_date,
document_date, notification_days, storage_location, notes, created_by, departments) document_date, notification_days, storage_location, notes, variazioni, attiva_alert, created_by, departments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"); ");
$ins->execute([ $ins->execute([
$deadline['subject_id'], $deadline['function_id'], $deadline['topic'], $deadline['law_regulation'], $deadline['subject_id'],
$deadline['recurrence_type'], $dueDate->format('Y-m-d'), $deadline['function_id'],
$deadline['notify_function'],
$deadline['topic'],
$deadline['law_regulation'],
$deadline['recurrence_type'],
$dueDate->format('Y-m-d'),
$checkDate ? $checkDate->format('Y-m-d') : null, $checkDate ? $checkDate->format('Y-m-d') : null,
$documentDate ? $documentDate->format('Y-m-d') : null, $documentDate ? $documentDate->format('Y-m-d') : null,
$deadline['notification_days'], $deadline['storage_location'], $deadline['notification_days'],
$deadline['notes'], $deadline['created_by'], $deadline['departments'] $deadline['storage_location'],
$deadline['notes'],
$deadline['variazioni'],
$deadline['attiva_alert'],
$deadline['created_by'],
$deadline['departments']
]); ]);
$newId = $pdo->lastInsertId(); $newId = $pdo->lastInsertId();
@@ -137,7 +168,6 @@ try {
} }
echo json_encode(['success' => true, 'message' => $msg, 'new_id' => $newId]); echo json_encode(['success' => true, 'message' => $msg, 'new_id' => $newId]);
} catch (Exception $e) { } catch (Exception $e) {
if (isset($pdo) && $pdo->inTransaction()) { if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack(); $pdo->rollBack();
@@ -10,6 +10,7 @@ try {
$id = isset($_POST['id']) && is_numeric($_POST['id']) ? (int)$_POST['id'] : null; $id = isset($_POST['id']) && is_numeric($_POST['id']) ? (int)$_POST['id'] : null;
$subject_id = isset($_POST['subject_id']) && is_numeric($_POST['subject_id']) && (int)$_POST['subject_id'] > 0 ? (int)$_POST['subject_id'] : null; $subject_id = isset($_POST['subject_id']) && is_numeric($_POST['subject_id']) && (int)$_POST['subject_id'] > 0 ? (int)$_POST['subject_id'] : null;
$function_id = isset($_POST['function_id']) && is_numeric($_POST['function_id']) && (int)$_POST['function_id'] > 0 ? (int)$_POST['function_id'] : null; $function_id = isset($_POST['function_id']) && is_numeric($_POST['function_id']) && (int)$_POST['function_id'] > 0 ? (int)$_POST['function_id'] : null;
$notify_function = isset($_POST['notify_function']) && (int)$_POST['notify_function'] === 1 ? 1 : 0;
$topic = trim($_POST['topic'] ?? ''); $topic = trim($_POST['topic'] ?? '');
$law_regulation = trim($_POST['law_regulation'] ?? '') ?: null; $law_regulation = trim($_POST['law_regulation'] ?? '') ?: null;
$recurrence_type = $_POST['recurrence_type'] ?? 'once'; $recurrence_type = $_POST['recurrence_type'] ?? 'once';
@@ -19,6 +20,8 @@ try {
$notification_days = isset($_POST['notification_days']) && is_numeric($_POST['notification_days']) ? (int)$_POST['notification_days'] : 7; $notification_days = isset($_POST['notification_days']) && is_numeric($_POST['notification_days']) ? (int)$_POST['notification_days'] : 7;
$storage_location = trim($_POST['storage_location'] ?? '') ?: null; $storage_location = trim($_POST['storage_location'] ?? '') ?: null;
$notes = trim($_POST['notes'] ?? '') ?: null; $notes = trim($_POST['notes'] ?? '') ?: null;
$variazioni = !empty($_POST['variazioni']) ? 1 : 0;
$attiva_alert = !empty($_POST['attiva_alert']) ? 1 : 0;
$employee_ids = $_POST['employee_ids'] ?? []; $employee_ids = $_POST['employee_ids'] ?? [];
$department_names = $_POST['department_names'] ?? []; $department_names = $_POST['department_names'] ?? [];
@@ -53,14 +56,15 @@ try {
if ($id) { if ($id) {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
UPDATE scad_deadlines SET UPDATE scad_deadlines SET
subject_id = ?, function_id = ?, topic = ?, law_regulation = ?, recurrence_type = ?, subject_id = ?, function_id = ?, notify_function = ?, topic = ?, law_regulation = ?, recurrence_type = ?,
due_date = ?, check_date = ?, document_date = ?, notification_days = ?, due_date = ?, check_date = ?, document_date = ?, notification_days = ?,
storage_location = ?, notes = ?, departments = ? storage_location = ?, notes = ?, variazioni = ?, attiva_alert = ?, departments = ?
WHERE id = ? WHERE id = ?
"); ");
$stmt->execute([ $stmt->execute([
$subject_id, $subject_id,
$function_id, $function_id,
$notify_function,
$topic, $topic,
$law_regulation, $law_regulation,
$recurrence_type, $recurrence_type,
@@ -70,6 +74,8 @@ try {
$notification_days, $notification_days,
$storage_location, $storage_location,
$notes, $notes,
$variazioni,
$attiva_alert,
$departmentsStr, $departmentsStr,
$id $id
]); ]);
@@ -86,13 +92,14 @@ try {
// INSERT // INSERT
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
INSERT INTO scad_deadlines INSERT INTO scad_deadlines
(subject_id, function_id, topic, law_regulation, recurrence_type, due_date, check_date, (subject_id, function_id, notify_function, topic, law_regulation, recurrence_type, due_date, check_date,
document_date, notification_days, storage_location, notes, created_by, departments) document_date, notification_days, storage_location, notes, variazioni, attiva_alert, created_by, departments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"); ");
$stmt->execute([ $stmt->execute([
$subject_id, $subject_id,
$function_id, $function_id,
$notify_function,
$topic, $topic,
$law_regulation, $law_regulation,
$recurrence_type, $recurrence_type,
@@ -102,6 +109,8 @@ try {
$notification_days, $notification_days,
$storage_location, $storage_location,
$notes, $notes,
$variazioni,
$attiva_alert,
$currentUserId, $currentUserId,
$departmentsStr $departmentsStr
]); ]);
@@ -1,4 +1,5 @@
<?php <?php
/** /**
* Scadenzario — Email notification cron script * Scadenzario — Email notification cron script
* Run daily: 0 7 * * * php /var/www/html/public/userarea/scadenzario/cron/send_notifications.php * Run daily: 0 7 * * * php /var/www/html/public/userarea/scadenzario/cron/send_notifications.php
@@ -42,16 +43,27 @@ $errors = 0;
// Get active deadlines that are approaching or overdue // Get active deadlines that are approaching or overdue
$stmt = $pdo->query(" $stmt = $pdo->query("
SELECT d.id, d.topic, s.name AS subject_name, d.due_date, d.notification_days SELECT
d.id,
d.topic,
s.name AS subject_name,
d.due_date,
d.notification_days,
d.notify_function,
f.email AS function_email,
f.person_full_name AS function_person,
f.name AS function_name
FROM scad_deadlines d FROM scad_deadlines d
LEFT JOIN scad_subjects s ON s.id = d.subject_id LEFT JOIN scad_subjects s ON s.id = d.subject_id
LEFT JOIN scad_functions f ON f.id = d.function_id
WHERE d.status = 'active' WHERE d.status = 'active'
AND d.attiva_alert = 1
AND d.due_date <= DATE_ADD(CURDATE(), INTERVAL d.notification_days DAY) AND d.due_date <= DATE_ADD(CURDATE(), INTERVAL d.notification_days DAY)
"); ");
$deadlines = $stmt->fetchAll(PDO::FETCH_ASSOC); $deadlines = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($deadlines)) { if (empty($deadlines)) {
echo date('Y-m-d H:i:s') . " — Nessuna scadenza da notificare.\n"; echo date('Y-m-d H:i:s') . " — Nessuna scadenza da notificare (alert attivo).\n";
exit(0); exit(0);
} }
@@ -101,20 +113,28 @@ foreach ($deadlines as $dl) {
$type = $isOverdue ? 'overdue' : 'approaching'; $type = $isOverdue ? 'overdue' : 'approaching';
$daysLeft = (int)((strtotime($dl['due_date']) - strtotime($today)) / 86400); $daysLeft = (int)((strtotime($dl['due_date']) - strtotime($today)) / 86400);
// Collect all recipients (direct + department) // Collect all recipients (direct + department + optional function email)
$recipients = []; $recipients = [];
$functionRecipient = null;
$getRecipients->execute([$dl['id']]); $getRecipients->execute([$dl['id']]);
foreach ($getRecipients->fetchAll(PDO::FETCH_ASSOC) as $r) { foreach ($getRecipients->fetchAll(PDO::FETCH_ASSOC) as $r) {
$recipients[$r['employee_id']] = $r; $recipients[$r['employee_id']] = $r;
} }
$getDeptRecipients->execute([$dl['id']]); // Optional: also notify the linked function email if enabled on the deadline.
foreach ($getDeptRecipients->fetchAll(PDO::FETCH_ASSOC) as $r) { if (
$recipients[$r['employee_id']] = $r; !empty($dl['notify_function'])
&& !empty($dl['function_email'])
&& filter_var($dl['function_email'], FILTER_VALIDATE_EMAIL)
) {
$functionRecipient = [
'email' => $dl['function_email'],
'name' => trim(($dl['function_person'] ?? '') !== '' ? $dl['function_person'] : ($dl['function_name'] ?? 'Funzione')),
];
} }
if (empty($recipients)) { if (empty($recipients) && empty($functionRecipient)) {
continue; continue;
} }
@@ -193,15 +213,99 @@ foreach ($deadlines as $dl) {
$sent++; $sent++;
echo date('H:i:s') . " ✓ {$type} → {$emp['email']} — {$dl['topic']}\n"; echo date('H:i:s') . " ✓ {$type} → {$emp['email']} — {$dl['topic']}\n";
} catch (Exception $e) { } catch (Exception $e) {
$errors++; $errors++;
echo date('H:i:s') . " ✗ Errore {$emp['email']}: {$e->getMessage()}\n"; echo date('H:i:s') . " ✗ Errore {$emp['email']}: {$e->getMessage()}\n";
} }
} }
// Send notification to function email if enabled.
// It is tracked with employee_id = 0 to avoid duplicate daily sends.
if ($functionRecipient) {
$functionEmployeeId = 0;
$checkSent->execute([$dl['id'], $functionEmployeeId, $type]);
if ($checkSent->fetchColumn() > 0) {
$skipped++;
} else {
try {
$mail = new PHPMailer(true);
$mailer = $_ENV['MAIL_MAILER'] ?? 'mail';
if ($mailer === 'smtp') {
$mail->isSMTP();
$mail->Host = $_ENV['MAIL_HOST'] ?? 'localhost';
$mail->Port = (int)($_ENV['MAIL_PORT'] ?? 587);
if (!empty($_ENV['MAIL_USERNAME']) && $_ENV['MAIL_USERNAME'] !== 'null') {
$mail->SMTPAuth = true;
$mail->Username = $_ENV['MAIL_USERNAME'];
$mail->Password = $_ENV['MAIL_PASSWORD'] ?? '';
}
$enc = $_ENV['MAIL_ENCRYPTION'] ?? '';
if ($enc && $enc !== 'null') {
$mail->SMTPSecure = $enc;
}
}
$mail->CharSet = 'UTF-8';
$mail->setFrom(
$_ENV['MAIL_FROM_ADDRESS'] ?? 'noreply@zibogomma.it',
$_ENV['MAIL_FROM_NAME'] ?? 'Scadenzario ZIBOGOMMA'
);
$mail->addAddress($functionRecipient['email'], $functionRecipient['name']);
if ($managerCcEmail && strcasecmp($managerCcEmail, $functionRecipient['email']) !== 0) {
$mail->addCC($managerCcEmail);
}
$detailUrl = $appUrl . '/userarea/scadenzario/detail.php?id=' . $dl['id'];
$topicText = (!empty($dl['subject_name']) ? $dl['subject_name'] . ' — ' : '') . $dl['topic'];
if ($isOverdue) {
$mail->Subject = '⚠️ Scadenza superata: ' . $dl['topic'];
$mail->Body = buildHtml(
'Scadenza superata',
$topicText,
'La scadenza era prevista per il <strong>' . date('d/m/Y', strtotime($dl['due_date'])) . '</strong> ed è stata superata da <strong>' . abs($daysLeft) . ' giorni</strong>.',
'#dc3545',
$detailUrl
);
} else {
$mail->Subject = '📅 Scadenza in arrivo: ' . $dl['topic'];
$daysText = $daysLeft === 0 ? 'oggi' : 'tra <strong>' . $daysLeft . ' giorni</strong>';
$mail->Body = buildHtml(
'Scadenza in arrivo',
$topicText,
'La scadenza è prevista per il <strong>' . date('d/m/Y', strtotime($dl['due_date'])) . '</strong> (' . $daysText . ').',
'#e8930c',
$detailUrl
);
}
$mail->isHTML(true);
$mail->AltBody = strip_tags(str_replace('<br>', "\n", $mail->Body));
$mail->send();
$insertNotif->execute([$dl['id'], $functionEmployeeId, $type]);
$sent++;
echo date('H:i:s') . " ✓ {$type} → funzione {$functionRecipient['email']} — {$dl['topic']}\n";
} catch (Exception $e) {
$errors++;
echo date('H:i:s') . " ✗ Errore funzione {$functionRecipient['email']}: {$e->getMessage()}\n";
}
}
}
// History (one per deadline, not per recipient) // History (one per deadline, not per recipient)
$recipientNames = implode(', ', array_map(fn($r) => trim($r['first_name'] . ' ' . $r['last_name']), $recipients)); $recipientNames = implode(', ', array_map(fn($r) => trim($r['first_name'] . ' ' . $r['last_name']), $recipients));
if ($functionRecipient) {
$recipientNames .= ($recipientNames !== '' ? ', ' : '') . 'Funzione: ' . $functionRecipient['name'] . ' <' . $functionRecipient['email'] . '>';
}
$insertHistory->execute([$dl['id'], "Notifica {$type} inviata a: {$recipientNames}"]); $insertHistory->execute([$dl['id'], "Notifica {$type} inviata a: {$recipientNames}"]);
} }
+18
View File
@@ -646,6 +646,24 @@ if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
<div class="detail-label">Note</div> <div class="detail-label">Note</div>
<div class="detail-value"><?= nl2br(htmlspecialchars($deadline['notes'], ENT_QUOTES, 'UTF-8')) ?></div> <div class="detail-value"><?= nl2br(htmlspecialchars($deadline['notes'], ENT_QUOTES, 'UTF-8')) ?></div>
<?php endif; ?> <?php endif; ?>
<div class="detail-label">Alert</div>
<div class="detail-value">
<?php if (!empty($deadline['attiva_alert'])): ?>
<i class="fa-solid fa-bell text-warning me-1"></i> Attivo
<?php else: ?>
<span class="text-muted">Non attivo</span>
<?php endif; ?>
</div>
<div class="detail-label">In caso di variazioni</div>
<div class="detail-value">
<?php if (!empty($deadline['variazioni'])): ?>
<i class="fa-solid fa-arrows-rotate text-info me-1"></i> Sì
<?php else: ?>
<span class="text-muted">No</span>
<?php endif; ?>
</div>
</div> </div>
</div> </div>
+367 -50
View File
@@ -1,15 +1,122 @@
<?php include('../../include/headscript.php'); ?> <?php include('../../include/headscript.php'); ?>
<?php <?php
ini_set('display_errors', 1);
error_reporting(E_ALL);
$db = DBHandlerSelect::getInstance(); $db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection(); $pdo = $db->getConnection();
$functions = $pdo->query(" function scadJsonResponse(array $data): void
SELECT f.*, {
(SELECT COUNT(*) FROM scad_deadlines d WHERE d.function_id = f.id) AS deadline_count, header('Content-Type: application/json; charset=utf-8');
(SELECT COUNT(*) FROM scad_deadlines d WHERE d.function_id = f.id AND d.status <> 'completed') AS open_count echo json_encode($data);
FROM scad_functions f exit;
ORDER BY f.name ASC }
")->fetchAll(PDO::FETCH_ASSOC);
function scadNullableString($value): ?string
{
$value = trim((string)($value ?? ''));
return $value !== '' ? $value : null;
}
function scadNormalizeStatus(string $status): string
{
return in_array($status, ['active', 'inactive'], true) ? $status : 'active';
}
function scadValidateEmail($email): ?string
{
$email = scadNullableString($email);
if ($email !== null && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Email non valida.');
}
return $email;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ajax']) && $_POST['ajax'] === '1') {
try {
$action = $_POST['action'] ?? '';
if ($action === 'save') {
$id = isset($_POST['id']) && $_POST['id'] !== '' ? (int)$_POST['id'] : 0;
$name = trim($_POST['name'] ?? '');
$description = scadNullableString($_POST['description'] ?? null);
$personFullName = scadNullableString($_POST['person_full_name'] ?? null);
$phone = scadNullableString($_POST['phone'] ?? null);
$email = scadValidateEmail($_POST['email'] ?? null);
$notes = scadNullableString($_POST['notes'] ?? null);
$sortOrder = isset($_POST['sort_order']) && $_POST['sort_order'] !== '' ? (int)$_POST['sort_order'] : 0;
$status = scadNormalizeStatus(trim($_POST['status'] ?? 'active'));
if ($name === '') {
scadJsonResponse(['success' => false, 'message' => 'Il nome funzione è obbligatorio.']);
}
if ($id > 0) {
$stmt = $pdo->prepare("\n UPDATE scad_functions\n SET name = :name,\n description = :description,\n person_full_name = :person_full_name,\n phone = :phone,\n email = :email,\n notes = :notes,\n sort_order = :sort_order,\n status = :status,\n updated_at = NOW()\n WHERE id = :id\n ");
$stmt->execute([
'name' => $name,
'description' => $description,
'person_full_name' => $personFullName,
'phone' => $phone,
'email' => $email,
'notes' => $notes,
'sort_order' => $sortOrder,
'status' => $status,
'id' => $id,
]);
scadJsonResponse(['success' => true, 'message' => 'Funzione aggiornata correttamente.']);
}
$stmt = $pdo->prepare("\n INSERT INTO scad_functions\n (name, description, person_full_name, phone, email, notes, sort_order, status, created_at, updated_at)\n VALUES\n (:name, :description, :person_full_name, :phone, :email, :notes, :sort_order, :status, NOW(), NOW())\n ");
$stmt->execute([
'name' => $name,
'description' => $description,
'person_full_name' => $personFullName,
'phone' => $phone,
'email' => $email,
'notes' => $notes,
'sort_order' => $sortOrder,
'status' => $status,
]);
scadJsonResponse(['success' => true, 'message' => 'Funzione creata correttamente.']);
}
if ($action === 'delete') {
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
scadJsonResponse(['success' => false, 'message' => 'ID funzione non valido.']);
}
$stmtUse = $pdo->prepare('SELECT COUNT(*) FROM scad_deadlines WHERE function_id = ?');
$stmtUse->execute([$id]);
$inUse = (int)$stmtUse->fetchColumn();
if ($inUse > 0) {
scadJsonResponse([
'success' => false,
'message' => 'Impossibile eliminare: la funzione è utilizzata in ' . $inUse . ' scadenza/e.'
]);
}
$stmt = $pdo->prepare('DELETE FROM scad_functions WHERE id = :id');
$stmt->execute(['id' => $id]);
scadJsonResponse(['success' => true, 'message' => 'Funzione eliminata correttamente.']);
}
scadJsonResponse(['success' => false, 'message' => 'Azione non riconosciuta.']);
} catch (Exception $e) {
scadJsonResponse(['success' => false, 'message' => $e->getMessage()]);
}
}
$functions = $pdo->query("\n SELECT f.*,\n (SELECT COUNT(*) FROM scad_deadlines d WHERE d.function_id = f.id) AS deadline_count,\n (SELECT COUNT(*) FROM scad_deadlines d WHERE d.function_id = f.id AND d.status <> 'completed') AS open_count\n FROM scad_functions f\n ORDER BY COALESCE(f.sort_order, 0) ASC, f.name ASC\n")->fetchAll(PDO::FETCH_ASSOC);
?> ?>
<!doctype html> <!doctype html>
<html lang="it"> <html lang="it">
@@ -24,11 +131,13 @@ $functions = $pdo->query("
<base href="<?= $baseHref ?>"> <base href="<?= $baseHref ?>">
<?php include('../../cssinclude.php'); ?> <?php include('../../cssinclude.php'); ?>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<title>Scadenzario - Funzioni</title> <title>Scadenzario - Funzioni</title>
<script> <script>
if (window.innerWidth > 1024) { if (window.innerWidth > 1024) {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
document.getElementById('appWrapper').classList.add('toggled'); const wrapper = document.getElementById('appWrapper');
if (wrapper) wrapper.classList.add('toggled');
}); });
} }
</script> </script>
@@ -39,6 +148,7 @@ $functions = $pdo->query("
--scad-heading: #2c3e6b; --scad-heading: #2c3e6b;
--scad-card-bg: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%); --scad-card-bg: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%);
--scad-card-border: #dde4f0; --scad-card-border: #dde4f0;
--scad-muted: #6c757d;
} }
.scad-card { .scad-card {
@@ -127,6 +237,63 @@ $functions = $pdo->query("
color: #fff; color: #fff;
} }
.function-table th {
font-size: 0.82rem;
color: var(--scad-heading);
background: #f8fafc;
border-bottom: 1px solid var(--scad-card-border);
white-space: nowrap;
}
.function-table td {
font-size: 0.9rem;
vertical-align: middle;
}
.function-name {
font-weight: 700;
color: var(--scad-heading);
}
.function-description,
.function-notes {
color: var(--scad-muted);
font-size: 0.82rem;
margin-top: 2px;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-line {
font-size: 0.85rem;
line-height: 1.45;
}
.contact-line a {
text-decoration: none;
}
.badge-function-status {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 10px;
font-size: 0.78rem;
font-weight: 700;
}
.badge-function-status.active {
background: #d1fae5;
color: #065f46;
}
.badge-function-status.inactive {
background: #e5e7eb;
color: #374151;
}
.function-card { .function-card {
background: #fff; background: #fff;
border: 1px solid var(--scad-card-border); border: 1px solid var(--scad-card-border);
@@ -141,12 +308,19 @@ $functions = $pdo->query("
font-size: 0.95rem; font-size: 0.95rem;
} }
.function-card .fc-meta {
font-size: 0.82rem;
color: var(--scad-muted);
margin-top: 0.35rem;
}
.function-card .fc-stats { .function-card .fc-stats {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
font-size: 0.8rem; font-size: 0.8rem;
color: #6c757d; color: var(--scad-muted);
margin: 0.5rem 0; margin: 0.5rem 0;
flex-wrap: wrap;
} }
.function-card .fc-stats strong { .function-card .fc-stats strong {
@@ -156,7 +330,7 @@ $functions = $pdo->query("
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 3rem 1rem; padding: 3rem 1rem;
color: #6c757d; color: var(--scad-muted);
} }
.empty-state i { .empty-state i {
@@ -165,6 +339,15 @@ $functions = $pdo->query("
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.modal-content {
border-radius: 0.75rem;
}
.modal-header {
background: var(--scad-card-bg);
border-bottom: 1px solid var(--scad-card-border);
}
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.scad-card .card-header { .scad-card .card-header {
flex-direction: column; flex-direction: column;
@@ -223,25 +406,48 @@ $functions = $pdo->query("
<?php else: ?> <?php else: ?>
<div id="functionsList"> <div id="functionsList">
<div class="d-md-none"> <div class="d-md-none">
<?php foreach ($functions as $f): ?> <?php foreach ($functions as $f): ?>
<?php
$status = $f['status'] === 'inactive' ? 'inactive' : 'active';
$statusLabel = $status === 'active' ? 'Attiva' : 'Non attiva';
?>
<div class="function-card" <div class="function-card"
data-id="<?= (int)$f['id'] ?>" data-id="<?= (int)$f['id'] ?>"
data-name="<?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?>" data-name="<?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?>"
data-description="<?= htmlspecialchars($f['description'] ?? '', ENT_QUOTES, 'UTF-8') ?>" data-description="<?= htmlspecialchars($f['description'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
data-status="<?= htmlspecialchars($f['status'], ENT_QUOTES, 'UTF-8') ?>" data-person-full-name="<?= htmlspecialchars($f['person_full_name'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
data-phone="<?= htmlspecialchars($f['phone'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
data-email="<?= htmlspecialchars($f['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
data-notes="<?= htmlspecialchars($f['notes'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
data-sort-order="<?= (int)($f['sort_order'] ?? 0) ?>"
data-status="<?= htmlspecialchars($status, ENT_QUOTES, 'UTF-8') ?>"
data-in-use="<?= (int)$f['deadline_count'] ?>"> data-in-use="<?= (int)$f['deadline_count'] ?>">
<div class="fc-name"><?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?></div> <div class="d-flex justify-content-between align-items-start gap-2">
<div class="fc-name"><?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?></div>
<span class="badge-function-status <?= $status ?>"><?= $statusLabel ?></span>
</div>
<?php if (!empty($f['description'])): ?> <?php if (!empty($f['description'])): ?>
<div class="text-muted small mt-1"><?= htmlspecialchars($f['description'], ENT_QUOTES, 'UTF-8') ?></div> <div class="fc-meta"><?= htmlspecialchars($f['description'], ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if (!empty($f['person_full_name'])): ?>
<div class="fc-meta"><strong>Referente:</strong> <?= htmlspecialchars($f['person_full_name'], ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if (!empty($f['phone']) || !empty($f['email'])): ?>
<div class="fc-meta">
<?php if (!empty($f['phone'])): ?>📞 <?= htmlspecialchars($f['phone'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
<?php if (!empty($f['email'])): ?><?= !empty($f['phone']) ? '<br>' : '' ?>✉️ <?= htmlspecialchars($f['email'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
</div>
<?php endif; ?> <?php endif; ?>
<div class="fc-stats"> <div class="fc-stats">
<span>Scadenze: <strong><?= (int)$f['deadline_count'] ?></strong></span> <span>Scadenze: <strong><?= (int)$f['deadline_count'] ?></strong></span>
<span>Aperte: <strong><?= (int)$f['open_count'] ?></strong></span> <span>Aperte: <strong><?= (int)$f['open_count'] ?></strong></span>
<span>Ordine: <strong><?= (int)($f['sort_order'] ?? 0) ?></strong></span>
</div> </div>
<div class="d-flex gap-1 justify-content-end"> <div class="d-flex gap-1 justify-content-end">
@@ -258,30 +464,63 @@ $functions = $pdo->query("
<div class="d-none d-md-block"> <div class="d-none d-md-block">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0 function-table">
<thead> <thead>
<tr> <tr>
<th>Nome</th> <th style="width:70px" class="text-center">Ord.</th>
<th>Descrizione</th> <th>Funzione</th>
<th class="text-center" style="width:120px">Scadenze</th> <th style="width:220px">Referente</th>
<th class="text-center" style="width:120px">Aperte</th> <th style="width:230px">Contatti</th>
<th class="text-center" style="width:120px">Azioni</th> <th style="width:120px" class="text-center">Stato</th>
<th style="width:120px" class="text-center">Scadenze</th>
<th style="width:120px" class="text-center">Aperte</th>
<th style="width:120px" class="text-center">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($functions as $f): ?> <?php foreach ($functions as $f): ?>
<?php
$status = $f['status'] === 'inactive' ? 'inactive' : 'active';
$statusLabel = $status === 'active' ? 'Attiva' : 'Non attiva';
?>
<tr data-id="<?= (int)$f['id'] ?>" <tr data-id="<?= (int)$f['id'] ?>"
data-name="<?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?>" data-name="<?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?>"
data-description="<?= htmlspecialchars($f['description'] ?? '', ENT_QUOTES, 'UTF-8') ?>" data-description="<?= htmlspecialchars($f['description'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
data-status="<?= htmlspecialchars($f['status'], ENT_QUOTES, 'UTF-8') ?>" data-person-full-name="<?= htmlspecialchars($f['person_full_name'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
data-phone="<?= htmlspecialchars($f['phone'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
data-email="<?= htmlspecialchars($f['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
data-notes="<?= htmlspecialchars($f['notes'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
data-sort-order="<?= (int)($f['sort_order'] ?? 0) ?>"
data-status="<?= htmlspecialchars($status, ENT_QUOTES, 'UTF-8') ?>"
data-in-use="<?= (int)$f['deadline_count'] ?>"> data-in-use="<?= (int)$f['deadline_count'] ?>">
<td class="fw-semibold" style="color:var(--scad-heading)"> <td class="text-center"><?= (int)($f['sort_order'] ?? 0) ?></td>
<?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?> <td>
<div class="function-name"><?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?></div>
<?php if (!empty($f['description'])): ?>
<div class="function-description" title="<?= htmlspecialchars($f['description'], ENT_QUOTES, 'UTF-8') ?>">
<?= htmlspecialchars($f['description'], ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
<?php if (!empty($f['notes'])): ?>
<div class="function-notes" title="<?= htmlspecialchars($f['notes'], ENT_QUOTES, 'UTF-8') ?>">
Note: <?= htmlspecialchars($f['notes'], ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
</td> </td>
<td class="text-muted"> <td><?= !empty($f['person_full_name']) ? htmlspecialchars($f['person_full_name'], ENT_QUOTES, 'UTF-8') : '<span class="text-muted">—</span>' ?></td>
<?= htmlspecialchars($f['description'] ?? '—', ENT_QUOTES, 'UTF-8') ?> <td>
<?php if (!empty($f['phone'])): ?>
<div class="contact-line">📞 <a href="tel:<?= htmlspecialchars($f['phone'], ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($f['phone'], ENT_QUOTES, 'UTF-8') ?></a></div>
<?php endif; ?>
<?php if (!empty($f['email'])): ?>
<div class="contact-line">✉️ <a href="mailto:<?= htmlspecialchars($f['email'], ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($f['email'], ENT_QUOTES, 'UTF-8') ?></a></div>
<?php endif; ?>
<?php if (empty($f['phone']) && empty($f['email'])): ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td> </td>
<td class="text-center"><span class="badge-function-status <?= $status ?>"><?= $statusLabel ?></span></td>
<td class="text-center"><?= (int)$f['deadline_count'] ?></td> <td class="text-center"><?= (int)$f['deadline_count'] ?></td>
<td class="text-center"><?= (int)$f['open_count'] ?></td> <td class="text-center"><?= (int)$f['open_count'] ?></td>
<td class="text-center"> <td class="text-center">
@@ -300,9 +539,7 @@ $functions = $pdo->query("
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
@@ -314,7 +551,7 @@ $functions = $pdo->query("
</div> </div>
<div class="modal fade" id="functionModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="functionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="functionModalTitle">Nuova Funzione</h5> <h5 class="modal-title" id="functionModalTitle">Nuova Funzione</h5>
@@ -325,20 +562,57 @@ $functions = $pdo->query("
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="functionId" name="id" value=""> <input type="hidden" id="functionId" name="id" value="">
<div class="mb-3"> <div class="row">
<label for="functionName" class="form-label fw-semibold">Nome <span class="text-danger">*</span></label> <div class="col-12 col-md-8 mb-3">
<input type="text" class="form-control" id="functionName" name="name" maxlength="255" required> <label for="functionName" class="form-label fw-semibold">Nome funzione <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="functionName" name="name" maxlength="255" required placeholder="Es. RSPP, Medico del lavoro, RLS">
</div>
<div class="col-12 col-md-4 mb-3">
<label for="functionSortOrder" class="form-label fw-semibold">Ordine</label>
<input type="number" class="form-control" id="functionSortOrder" name="sort_order" min="0" step="1" value="0">
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="functionDescription" class="form-label fw-semibold">Descrizione</label> <label for="functionDescription" class="form-label fw-semibold">Descrizione</label>
<textarea class="form-control" id="functionDescription" name="description" rows="3"></textarea> <textarea class="form-control" id="functionDescription" name="description" rows="2"></textarea>
</div>
<div class="mb-3">
<label for="functionPersonFullName" class="form-label fw-semibold">Nome e cognome referente</label>
<input type="text" class="form-control" id="functionPersonFullName" name="person_full_name" maxlength="200" placeholder="Es. Mario Rossi">
</div>
<div class="row">
<div class="col-12 col-md-6 mb-3">
<label for="functionPhone" class="form-label fw-semibold">Telefono</label>
<input type="text" class="form-control" id="functionPhone" name="phone" maxlength="80">
</div>
<div class="col-12 col-md-6 mb-3">
<label for="functionEmail" class="form-label fw-semibold">Email</label>
<input type="email" class="form-control" id="functionEmail" name="email" maxlength="190">
</div>
</div>
<div class="row">
<div class="col-12 col-md-6 mb-3">
<label for="functionStatus" class="form-label fw-semibold">Stato</label>
<select class="form-select" id="functionStatus" name="status">
<option value="active">Attiva</option>
<option value="inactive">Non attiva</option>
</select>
</div>
</div>
<div class="mb-3">
<label for="functionNotes" class="form-label fw-semibold">Note operative</label>
<textarea class="form-control" id="functionNotes" name="notes" rows="3"></textarea>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button> <button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-scad-primary">Salva</button> <button type="submit" class="btn btn-scad-primary" id="functionSaveBtn">Salva</button>
</div> </div>
</form> </form>
</div> </div>
@@ -349,6 +623,33 @@ $functions = $pdo->query("
<script> <script>
$(function() { $(function() {
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>'"]/g, function(c) {
return ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;'
})[c];
});
}
function getRowData($row) {
return {
id: $row.data('id') || '',
name: $row.data('name') || '',
description: $row.data('description') || '',
person_full_name: $row.data('person-full-name') || '',
phone: $row.data('phone') || '',
email: $row.data('email') || '',
notes: $row.data('notes') || '',
sort_order: $row.data('sort-order') || 0,
status: $row.data('status') || 'active',
in_use: parseInt($row.data('in-use') || 0, 10)
};
}
function openModal(data) { function openModal(data) {
const isEdit = !!data; const isEdit = !!data;
@@ -356,6 +657,13 @@ $functions = $pdo->query("
$('#functionId').val(isEdit ? data.id : ''); $('#functionId').val(isEdit ? data.id : '');
$('#functionName').val(isEdit ? data.name : ''); $('#functionName').val(isEdit ? data.name : '');
$('#functionDescription').val(isEdit ? data.description : ''); $('#functionDescription').val(isEdit ? data.description : '');
$('#functionPersonFullName').val(isEdit ? data.person_full_name : '');
$('#functionPhone').val(isEdit ? data.phone : '');
$('#functionEmail').val(isEdit ? data.email : '');
$('#functionNotes').val(isEdit ? data.notes : '');
$('#functionSortOrder').val(isEdit ? data.sort_order : 0);
$('#functionStatus').val(isEdit ? data.status : 'active');
$('#functionSaveBtn').prop('disabled', false).html('Salva');
new bootstrap.Modal('#functionModal').show(); new bootstrap.Modal('#functionModal').show();
} }
@@ -366,30 +674,24 @@ $functions = $pdo->query("
$('#functionsList').on('click', '.btn-edit', function() { $('#functionsList').on('click', '.btn-edit', function() {
const $row = $(this).closest('[data-id]'); const $row = $(this).closest('[data-id]');
openModal(getRowData($row));
openModal({
id: $row.data('id'),
name: $row.data('name'),
description: $row.data('description')
});
}); });
$('#functionsList').on('click', '.btn-delete', function() { $('#functionsList').on('click', '.btn-delete', function() {
const $row = $(this).closest('[data-id]'); const $row = $(this).closest('[data-id]');
const inUse = parseInt($row.data('in-use') || 0, 10); const data = getRowData($row);
const name = $row.data('name');
if (inUse > 0) { if (data.in_use > 0) {
Swal.fire({ Swal.fire({
icon: 'warning', icon: 'warning',
title: 'Impossibile eliminare', title: 'Impossibile eliminare',
text: `La funzione "${name}" è utilizzata in ${inUse} scadenz${inUse === 1 ? 'a' : 'e'}.` text: 'La funzione "' + data.name + '" è utilizzata in ' + data.in_use + ' scadenza/e.'
}); });
return; return;
} }
Swal.fire({ Swal.fire({
title: `Eliminare "${name}"?`, title: 'Eliminare "' + data.name + '"?',
icon: 'warning', icon: 'warning',
showCancelButton: true, showCancelButton: true,
confirmButtonText: 'Elimina', confirmButtonText: 'Elimina',
@@ -398,8 +700,10 @@ $functions = $pdo->query("
}).then(function(result) { }).then(function(result) {
if (!result.isConfirmed) return; if (!result.isConfirmed) return;
$.post('scadenzario/functions/ajax/delete_function.php', { $.post(window.location.href.split('#')[0], {
id: $row.data('id') ajax: '1',
action: 'delete',
id: data.id
}) })
.done(function(res) { .done(function(res) {
if (res.success) { if (res.success) {
@@ -408,7 +712,7 @@ $functions = $pdo->query("
Swal.fire({ Swal.fire({
icon: 'error', icon: 'error',
title: 'Errore', title: 'Errore',
text: res.message text: res.message || 'Impossibile eliminare.'
}); });
} }
}) })
@@ -424,10 +728,19 @@ $functions = $pdo->query("
$('#functionForm').on('submit', function(e) { $('#functionForm').on('submit', function(e) {
e.preventDefault(); e.preventDefault();
const $btn = $('#functionSaveBtn');
const payload = { const payload = {
ajax: '1',
action: 'save',
id: $('#functionId').val(), id: $('#functionId').val(),
name: $('#functionName').val().trim(), name: $('#functionName').val().trim(),
description: $('#functionDescription').val().trim() description: $('#functionDescription').val().trim(),
person_full_name: $('#functionPersonFullName').val().trim(),
phone: $('#functionPhone').val().trim(),
email: $('#functionEmail').val().trim(),
notes: $('#functionNotes').val().trim(),
sort_order: $('#functionSortOrder').val(),
status: $('#functionStatus').val()
}; };
if (!payload.name) { if (!payload.name) {
@@ -438,19 +751,23 @@ $functions = $pdo->query("
return; return;
} }
$.post('scadenzario/functions/ajax/save_function.php', payload) $btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Salvataggio...');
$.post(window.location.href.split('#')[0], payload)
.done(function(res) { .done(function(res) {
if (res.success) { if (res.success) {
location.reload(); location.reload();
} else { } else {
$btn.prop('disabled', false).html('Salva');
Swal.fire({ Swal.fire({
icon: 'error', icon: 'error',
title: 'Errore', title: 'Errore',
text: res.message text: res.message || 'Impossibile salvare.'
}); });
} }
}) })
.fail(function() { .fail(function() {
$btn.prop('disabled', false).html('Salva');
Swal.fire({ Swal.fire({
icon: 'error', icon: 'error',
title: 'Errore di rete' title: 'Errore di rete'
@@ -51,6 +51,15 @@
<i class="fa-solid fa-gear"></i> <i class="fa-solid fa-gear"></i>
</a> </a>
</div> </div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="notify_function" name="notify_function" value="1">
<label class="form-check-label" for="notify_function">
Invia promemoria anche alla funzione selezionata
</label>
<div class="form-text">
Se attivo, la mail giornaliera verrà inviata anche all’email collegata alla funzione.
</div>
</div>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label for="dlLaw" class="form-label fw-semibold">Legge / Articolo</label> <label for="dlLaw" class="form-label fw-semibold">Legge / Articolo</label>
@@ -96,7 +105,7 @@
</div> </div>
<!-- Group 3: Responsabili --> <!-- Group 3: Responsabili -->
<div class="form-section-title">Responsabili</div> <div class="form-section-title">Esecutore</div>
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-12"> <div class="col-12">
<label for="dlDepartments" class="form-label fw-semibold">Reparti</label> <label for="dlDepartments" class="form-label fw-semibold">Reparti</label>
@@ -137,6 +146,22 @@
<label for="dlNotes" class="form-label fw-semibold">Note</label> <label for="dlNotes" class="form-label fw-semibold">Note</label>
<textarea class="form-control" id="dlNotes" name="notes" rows="3" placeholder="es. Scadenza 09/06/2026, Attività in appalto a Ditta specializzata..."></textarea> <textarea class="form-control" id="dlNotes" name="notes" rows="3" placeholder="es. Scadenza 09/06/2026, Attività in appalto a Ditta specializzata..."></textarea>
</div> </div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="dlVariazioni" name="variazioni" value="1">
<label class="form-check-label fw-semibold" for="dlVariazioni">
In caso di variazioni
</label>
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="dlAttivaAlert" name="attiva_alert" value="1">
<label class="form-check-label fw-semibold" for="dlAttivaAlert">
<i class="fa-solid fa-bell me-1 text-warning"></i> Attiva alert
</label>
</div>
</div>
</div> </div>
<!-- Group 5: Allegati --> <!-- Group 5: Allegati -->
@@ -36,23 +36,51 @@
language: 'it', language: 'it',
width: '100%' width: '100%'
}; };
$('#dlSubject').select2($.extend({}, s2Opts, { placeholder: 'Seleziona argomento...' })); $('#dlSubject').select2($.extend({}, s2Opts, {
$('#dlDepartments').select2($.extend({}, s2Opts, { placeholder: 'Seleziona reparti...' })); placeholder: 'Seleziona argomento...'
$('#dlEmployees').select2($.extend({}, s2Opts, { placeholder: 'Seleziona persone...' })); }));
$('#dlFunction').select2($.extend({}, s2Opts, { placeholder: 'Seleziona funzione...' })); $('#dlDepartments').select2($.extend({}, s2Opts, {
placeholder: 'Seleziona reparti...'
}));
$('#dlEmployees').select2($.extend({}, s2Opts, {
placeholder: 'Seleziona persone...'
}));
$('#dlFunction').select2($.extend({}, s2Opts, {
placeholder: 'Seleziona funzione...'
}));
// --- Auto-calc due_date from document_date + recurrence --- // --- Auto-calc due_date from document_date + recurrence ---
var RECURRENCE_OFFSETS = { var RECURRENCE_OFFSETS = {
monthly: { months: 1 }, monthly: {
quarterly: { months: 3 }, months: 1
semiannual: { months: 6 }, },
annual: { years: 1 }, quarterly: {
biennial: { years: 2 }, months: 3
triennial: { years: 3 }, },
quadriennial: { years: 4 }, semiannual: {
quinquennial: { years: 5 }, months: 6
decennial: { years: 10 }, },
quindecennial: { years: 15 } annual: {
years: 1
},
biennial: {
years: 2
},
triennial: {
years: 3
},
quadriennial: {
years: 4
},
quinquennial: {
years: 5
},
decennial: {
years: 10
},
quindecennial: {
years: 15
}
}; };
function computeDueDate() { function computeDueDate() {
@@ -106,6 +134,9 @@
$('#dlDepartments').val(null).trigger('change'); $('#dlDepartments').val(null).trigger('change');
$('#dlEmployees').val(null).trigger('change'); $('#dlEmployees').val(null).trigger('change');
$('#dlFunction').val('').trigger('change'); $('#dlFunction').val('').trigger('change');
$('#notify_function').prop('checked', false);
$('#dlVariazioni').prop('checked', false);
$('#dlAttivaAlert').prop('checked', false);
renderAttachments([]); renderAttachments([]);
modal.show(); modal.show();
}; };
@@ -126,6 +157,7 @@
$('#dlSubject').val(d.subject_id || '').trigger('change'); $('#dlSubject').val(d.subject_id || '').trigger('change');
document.getElementById('dlTopic').value = d.topic || ''; document.getElementById('dlTopic').value = d.topic || '';
$('#dlFunction').val(d.function_id || '').trigger('change'); $('#dlFunction').val(d.function_id || '').trigger('change');
$('#notify_function').prop('checked', Number(d.notify_function) === 1);
document.getElementById('dlLaw').value = d.law_regulation || ''; document.getElementById('dlLaw').value = d.law_regulation || '';
document.getElementById('dlRecurrence').value = d.recurrence_type || 'once'; document.getElementById('dlRecurrence').value = d.recurrence_type || 'once';
fpDocDate.setDate(d.document_date || null, false, 'Y-m-d'); fpDocDate.setDate(d.document_date || null, false, 'Y-m-d');
@@ -134,6 +166,8 @@
document.getElementById('dlNotifDays').value = d.notification_days || 7; document.getElementById('dlNotifDays').value = d.notification_days || 7;
document.getElementById('dlStorage').value = d.storage_location || ''; document.getElementById('dlStorage').value = d.storage_location || '';
document.getElementById('dlNotes').value = d.notes || ''; document.getElementById('dlNotes').value = d.notes || '';
$('#dlVariazioni').prop('checked', Number(d.variazioni) === 1);
$('#dlAttivaAlert').prop('checked', Number(d.attiva_alert) === 1);
document.getElementById('dlFiles').value = ''; document.getElementById('dlFiles').value = '';
document.getElementById('modalTitle').textContent = 'Modifica Scadenza'; document.getElementById('modalTitle').textContent = 'Modifica Scadenza';
$('#dlDepartments').val(d.department_names || []).trigger('change'); $('#dlDepartments').val(d.department_names || []).trigger('change');
+141 -21
View File
@@ -370,11 +370,13 @@ function getContrastTextColor($hexColor)
display: inline-block; display: inline-block;
padding: 0.15rem 0.55rem; padding: 0.15rem 0.55rem;
border-radius: 1rem; border-radius: 1rem;
font-size: 0.72rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
color: #fff; color: #fff;
letter-spacing: 0.02em; letter-spacing: 0.02em;
white-space: nowrap; line-height: 1.3;
white-space: normal;
word-break: break-word;
} }
.deadline-card[data-subject-color] { .deadline-card[data-subject-color] {
@@ -468,16 +470,68 @@ function getContrastTextColor($hexColor)
display: block; display: block;
} }
/* Table text overflow */ /* Table column widths */
#deadlinesTable {
table-layout: fixed;
}
#deadlinesTable th,
#deadlinesTable td { #deadlinesTable td {
max-width: 250px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
#deadlinesTable td:first-child { /* 1 Argomento */
max-width: 110px; #deadlinesTable th:nth-child(1),
width: 110px; #deadlinesTable td:nth-child(1) {
width: 15%;
}
/* 2 Dettaglio — la colonna larga */
#deadlinesTable th:nth-child(2),
#deadlinesTable td:nth-child(2) {
width: 34%;
white-space: normal;
}
/* 3 Scadenza */
#deadlinesTable th:nth-child(3),
#deadlinesTable td:nth-child(3) {
width: 8%;
}
/* 4 Verifica */
#deadlinesTable th:nth-child(4),
#deadlinesTable td:nth-child(4) {
width: 8%;
}
/* 5 Funzione */
#deadlinesTable th:nth-child(5),
#deadlinesTable td:nth-child(5) {
width: 10%;
font-size: 0.8rem;
}
/* 6 Esecutore — compatto */
#deadlinesTable th:nth-child(6),
#deadlinesTable td:nth-child(6) {
width: 12%;
font-size: 0.78rem;
line-height: 1.25;
white-space: normal;
}
/* 7 Stato */
#deadlinesTable th:nth-child(7),
#deadlinesTable td:nth-child(7) {
width: 8%;
}
/* 8 Azioni */
#deadlinesTable th:nth-child(8),
#deadlinesTable td:nth-child(8) {
width: 105px;
} }
/* Attachment list in modal */ /* Attachment list in modal */
@@ -653,6 +707,38 @@ function getContrastTextColor($hexColor)
width: 100%; width: 100%;
} }
} }
/* Law badge */
.law-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1rem 0.45rem;
border-radius: 0.35rem;
font-size: 0.68rem;
font-weight: 700;
background: #eef3ff;
color: var(--scad-primary);
border: 1px solid #d0d9e8;
cursor: pointer;
vertical-align: middle;
white-space: nowrap;
}
.law-badge:hover {
background: var(--scad-primary);
color: #fff;
border-color: var(--scad-primary);
}
.popover {
max-width: 320px;
}
.popover-body {
font-size: 0.85rem;
color: var(--scad-heading);
}
</style> </style>
</head> </head>
@@ -941,6 +1027,13 @@ function getContrastTextColor($hexColor)
<?php if ((int)$row['attachment_count'] > 0): ?> <?php if ((int)$row['attachment_count'] > 0): ?>
<span><i class="fa-solid fa-paperclip"></i> <?= (int)$row['attachment_count'] ?></span> <span><i class="fa-solid fa-paperclip"></i> <?= (int)$row['attachment_count'] ?></span>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($row['attiva_alert'])): ?>
<span><i class="fa-solid fa-bell text-warning"></i> Alert</span>
<?php endif; ?>
<?php if (!empty($row['variazioni'])): ?>
<span><i class="fa-solid fa-arrows-rotate text-info"></i> Variazioni</span>
<?php endif; ?>
</div> </div>
<?php if (!$row['_isCompleted']): ?> <?php if (!$row['_isCompleted']): ?>
<div class="card-actions"> <div class="card-actions">
@@ -961,15 +1054,14 @@ function getContrastTextColor($hexColor)
<table id="deadlinesTable" class="table table-hover align-middle mb-0" style="width:100%"> <table id="deadlinesTable" class="table table-hover align-middle mb-0" style="width:100%">
<thead> <thead>
<tr> <tr>
<th style="width:110px">Argomento</th> <th>Argomento</th>
<th>Dettaglio</th> <th>Dettaglio</th>
<th class="d-none d-lg-table-cell">Legge/Art.</th>
<th>Scadenza</th> <th>Scadenza</th>
<th class="d-none d-lg-table-cell">Verifica</th> <th class="d-none d-lg-table-cell">Verifica</th>
<th>Funzione</th> <th>Funzione</th>
<th>Responsabili</th> <th>Esecutore</th>
<th>Stato</th> <th>Stato</th>
<th class="text-center" style="width:120px">Azioni</th> <th class="text-center">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1009,14 +1101,29 @@ function getContrastTextColor($hexColor)
<td> <td>
<a href="scadenzario/detail.php?id=<?= (int)$row['id'] ?>" class="fw-semibold text-decoration-none" style="color:var(--scad-heading)"><?= htmlspecialchars($row['topic'], ENT_QUOTES, 'UTF-8') ?></a> <a href="scadenzario/detail.php?id=<?= (int)$row['id'] ?>" class="fw-semibold text-decoration-none" style="color:var(--scad-heading)"><?= htmlspecialchars($row['topic'], ENT_QUOTES, 'UTF-8') ?></a>
<?php if (!empty($row['attiva_alert'])): ?>
<i class="fa-solid fa-bell text-warning ms-1" title="Alert attivo"></i>
<?php endif; ?>
<?php if (!empty($row['variazioni'])): ?>
<i class="fa-solid fa-arrows-rotate text-info ms-1" title="In caso di variazioni"></i>
<?php endif; ?>
<?php if ((int)$row['attachment_count'] > 0): ?> <?php if ((int)$row['attachment_count'] > 0): ?>
<span class="text-muted ms-1" title="<?= (int)$row['attachment_count'] ?> allegati"><i class="fa-solid fa-paperclip"></i> <?= (int)$row['attachment_count'] ?></span> <span class="text-muted ms-1" title="<?= (int)$row['attachment_count'] ?> allegati"><i class="fa-solid fa-paperclip"></i> <?= (int)$row['attachment_count'] ?></span>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($row['law_regulation'])): ?>
<span class="law-badge ms-1 js-law-popover"
data-bs-toggle="popover"
data-bs-trigger="focus"
data-bs-placement="top"
data-bs-title="Legge / Articolo"
data-bs-content="<?= htmlspecialchars($row['law_regulation'], ENT_QUOTES, 'UTF-8') ?>"
tabindex="0" role="button">
<i class="fa-solid fa-scale-balanced"></i> Legge
</span>
<?php endif; ?>
</td> </td>
<td class="d-none d-lg-table-cell text-muted"><?= htmlspecialchars($row['law_regulation'] ?? '—', ENT_QUOTES, 'UTF-8') ?></td> <td data-order="<?= htmlspecialchars($row['due_date'] ?? '', ENT_QUOTES, 'UTF-8') ?>"><span class="text-nowrap"><?= $row['_dueFmt'] ?></span></td>
<td><span class="text-nowrap"><?= $row['_dueFmt'] ?></span></td> <td class="d-none d-lg-table-cell text-muted" data-order="<?= htmlspecialchars($row['check_date'] ?? '', ENT_QUOTES, 'UTF-8') ?>"><?= $row['_checkFmt'] ?></td>
<td class="d-none d-lg-table-cell text-muted"><?= $row['_checkFmt'] ?></td>
<td> <td>
<?php if (!empty($row['function_name'])): ?> <?php if (!empty($row['function_name'])): ?>
<span class="text-muted"> <span class="text-muted">
@@ -1027,10 +1134,9 @@ function getContrastTextColor($hexColor)
<span class="text-muted">—</span> <span class="text-muted">—</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td title="<?= htmlspecialchars(trim(($row['reparti'] ?? '') . ' ' . ($row['responsabili'] ?? '')), ENT_QUOTES, 'UTF-8') ?>">
<?php if ($row['reparti']): ?><span class="text-muted"><i class="fa-regular fa-building me-1"></i><?= htmlspecialchars($row['reparti'], ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?> <?php if ($row['reparti']): ?><div class="text-muted text-truncate"><i class="fa-regular fa-building me-1"></i><?= htmlspecialchars($row['reparti'], ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($row['reparti'] && $row['responsabili']): ?><br><?php endif; ?> <?php if ($row['responsabili']): ?><div class="text-truncate"><i class="fa-regular fa-user me-1"></i><?= htmlspecialchars($row['responsabili'], ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($row['responsabili']): ?><span><i class="fa-regular fa-user me-1"></i><?= htmlspecialchars($row['responsabili'], ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?>
<?php if (!$row['reparti'] && !$row['responsabili']): ?>—<?php endif; ?> <?php if (!$row['reparti'] && !$row['responsabili']): ?>—<?php endif; ?>
</td> </td>
<td><span class="badge-status <?= $row['_statusClass'] ?>"><?= $row['_statusLabel'] ?></span></td> <td><span class="badge-status <?= $row['_statusClass'] ?>"><?= $row['_statusLabel'] ?></span></td>
@@ -1120,7 +1226,7 @@ function getContrastTextColor($hexColor)
if ($('#deadlinesTable').length) { if ($('#deadlinesTable').length) {
table = $('#deadlinesTable').DataTable({ table = $('#deadlinesTable').DataTable({
order: [ order: [
[3, 'asc'] [2, 'asc']
], ],
pageLength: 25, pageLength: 25,
language: { language: {
@@ -1132,7 +1238,21 @@ function getContrastTextColor($hexColor)
}] }]
}); });
} }
// --- Popover Legge/Articolo ---
function initLawPopovers() {
$('.js-law-popover').each(function() {
if (!bootstrap.Popover.getInstance(this)) {
new bootstrap.Popover(this, {
html: false,
container: 'body'
});
}
});
}
initLawPopovers();
if (table) {
table.on('draw', initLawPopovers);
}
// --- Filters --- // --- Filters ---
function filterCards() { function filterCards() {
var statusVal = $('#filterStatus').val(); var statusVal = $('#filterStatus').val();
+397
View File
@@ -0,0 +1,397 @@
<?php include('include/headscript.php'); ?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
<?php include('cssinclude.php'); ?>
<title>Scheda Mescola - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<style>
body {
font-size: 1.05rem;
background: #f8fafc;
}
.card {
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.back-dashboard {
background-color: #cfe3ff !important;
color: #1f2d3d !important;
border: 1px solid #bcd4f4 !important;
border-radius: 10px;
font-weight: 600;
padding: 10px 18px;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease-in-out;
}
.back-dashboard:hover {
background-color: #b9d3ff !important;
transform: translateY(-2px);
}
.info-label {
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .04em;
color: #6c757d;
margin-bottom: 2px;
}
.info-value {
font-size: 1.05rem;
font-weight: 600;
color: #1f2d3d;
}
.nav-tabs .nav-link.active {
background-color: #cfe3ff;
font-weight: 600;
}
.btn-add {
background-color: #0d6efd;
color: #fff;
border-radius: 8px;
font-weight: 500;
}
.btn-add:hover {
background-color: #0b5ed7;
}
</style>
</head>
<body>
<div class="wrapper" id="appWrapper">
<?php include('include/navbar.php'); ?>
<?php include('include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<!-- HEADER INFO GENERALI -->
<div class="card p-3 mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" id="hNomeUscita">Scheda Mescola</h5>
<button type="button" class="btn back-dashboard" onclick="location.href='mescole.php'">
↩️ Torna a Gestione Mescole
</button>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-2">
<div class="info-label">ID</div>
<div class="info-value" id="hId">-</div>
</div>
<div class="col-md-3">
<div class="info-label">Nome Interno</div>
<div class="info-value" id="hNome">-</div>
</div>
<div class="col-md-3">
<div class="info-label">Q.tà Totale</div>
<div class="info-value" id="hQty">-</div>
</div>
<div class="col-md-2">
<div class="info-label">Stato</div>
<div class="info-value" id="hStato">-</div>
</div>
<div class="col-md-2">
<div class="info-label">Linee Associate</div>
<div class="info-value" id="hLinee">-</div>
</div>
</div>
</div>
</div>
<!-- TAB FILE PER CATEGORIA -->
<div class="card p-3">
<div class="card-body">
<ul class="nav nav-tabs" id="fileTabs" role="tablist"></ul>
<div class="tab-content pt-3" id="fileTabsContent"></div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
<!-- MODALE AGGIUNGI FILE -->
<div class="modal fade" id="addFileModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header" style="background-color:#cfe3ff;">
<h5 class="modal-title">Aggiungi File</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addFileForm">
<input type="hidden" id="fCategoria">
<div class="mb-3">
<label class="form-label fw-semibold">Titolo File</label>
<input type="text" class="form-control" id="fTitolo" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">File</label>
<input type="file" class="form-control" id="fFile" required>
</div>
<div class="text-center">
<button type="submit" class="btn btn-add">💾 Carica File</button>
</div>
</form>
</div>
</div>
</div>
</div>
<?php include('jsinclude.php'); ?>
<script>
const idMescola = new URLSearchParams(window.location.search).get('id');
let CATEGORIE = {};
if (!idMescola) {
Swal.fire({
icon: 'error',
title: 'Errore',
text: 'ID mescola mancante'
}).then(() => location.href = 'mescole.php');
}
function fmtQty(n) {
return Number(n || 0).toLocaleString('it-IT', {
minimumFractionDigits: 3,
maximumFractionDigits: 3
});
}
function loadGeneral() {
fetch('get_mescola_general.php?id=' + encodeURIComponent(idMescola))
.then(r => r.json())
.then(res => {
if (!res.success) {
Swal.fire({
icon: 'error',
title: 'Errore',
text: res.message
}).then(() => location.href = 'mescole.php');
return;
}
const d = res.data;
document.title = 'Scheda Mescola - ' + d.nomeuscita;
$('#hNomeUscita').text('🧪 ' + d.nomeuscita);
$('#hId').text(d.id);
$('#hNome').text(d.nome || '-');
$('#hQty').text(fmtQty(d.qty_totale));
$('#hStato').html(
Number(d.is_active) === 1 ?
"<span class='badge bg-success'>Attiva</span>" :
"<span class='badge bg-secondary'>Inattiva</span>"
);
$('#hLinee').text(d.linee || 'Nessuna');
});
}
function buildTabs() {
fetch('get_mescola_files.php?id=' + encodeURIComponent(idMescola))
.then(r => r.json())
.then(res => {
if (!res.success) return;
CATEGORIE = res.categorie;
const tabsUl = $('#fileTabs');
const tabsContent = $('#fileTabsContent');
tabsUl.empty();
tabsContent.empty();
let first = true;
Object.keys(CATEGORIE).forEach(slug => {
const label = CATEGORIE[slug];
const activeTab = first ? 'active' : '';
const activePane = first ? 'show active' : '';
tabsUl.append(`
<li class="nav-item" role="presentation">
<button class="nav-link ${activeTab}" id="tab-${slug}" data-bs-toggle="tab"
data-bs-target="#pane-${slug}" type="button" role="tab">
${label}
</button>
</li>
`);
tabsContent.append(`
<div class="tab-pane fade ${activePane}" id="pane-${slug}" role="tabpanel">
<div class="d-flex justify-content-end mb-2">
<button class="btn btn-add btn-sm add-file-btn" data-categoria="${slug}">
➕ Aggiungi File
</button>
</div>
<div class="table-responsive">
<table class="table table-striped align-middle text-center" id="table-${slug}">
<thead class="table-light">
<tr>
<th>Titolo</th>
<th>Nome File</th>
<th>Caricato il</th>
<th>Azioni</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
`);
first = false;
});
// popola ogni tab con i file già caricati
Object.keys(CATEGORIE).forEach(slug => loadFilesForCategory(slug));
});
}
function loadFilesForCategory(slug) {
fetch('get_mescola_files.php?id=' + encodeURIComponent(idMescola) + '&categoria=' + encodeURIComponent(slug))
.then(r => r.json())
.then(res => {
const tbody = $('#table-' + slug + ' tbody');
tbody.empty();
if (!res.success || res.rows.length === 0) {
tbody.append(`<tr>
<td class="text-muted" colspan="4">Nessun file caricato in questa categoria</td>
</tr>`);
return;
}
res.rows.forEach(f => {
tbody.append(`
<tr>
<td>${f.titolo}</td>
<td><a href="${f.url}" target="_blank" rel="noopener">${f.original_filename}</a></td>
<td>${f.uploaded_at}</td>
<td>
<a class="btn btn-sm btn-outline-primary" href="${f.url}" target="_blank" rel="noopener">⬇️ Scarica</a>
<button class="btn btn-sm btn-outline-danger del-file-btn" data-id="${f.id}" data-categoria="${slug}">🗑️ Elimina</button>
</td>
</tr>
`);
});
});
}
$(document).on('click', '.add-file-btn', function() {
$('#fCategoria').val($(this).data('categoria'));
$('#fTitolo').val('');
$('#fFile').val('');
$('#addFileModal').modal('show');
});
$('#addFileForm').on('submit', function(e) {
e.preventDefault();
const categoria = $('#fCategoria').val();
const titolo = $('#fTitolo').val().trim();
const fileInput = document.getElementById('fFile');
if (!fileInput.files.length) {
Swal.fire({
icon: 'warning',
title: 'Attenzione',
text: 'Seleziona un file'
});
return;
}
const formData = new FormData();
formData.append('idmescola', idMescola);
formData.append('categoria', categoria);
formData.append('titolo', titolo);
formData.append('file', fileInput.files[0]);
fetch('save_mescola_file.php', {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(res => {
if (res.success) {
$('#addFileModal').modal('hide');
Swal.fire({
icon: 'success',
title: 'File caricato!'
});
loadFilesForCategory(categoria);
} else {
Swal.fire({
icon: 'error',
title: 'Errore',
text: res.message || 'Upload non riuscito'
});
}
});
});
$(document).on('click', '.del-file-btn', function() {
const id = $(this).data('id');
const categoria = $(this).data('categoria');
Swal.fire({
title: 'Eliminare il file?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Sì, elimina',
cancelButtonText: 'Annulla',
confirmButtonColor: '#d33'
}).then(res => {
if (!res.isConfirmed) return;
fetch('delete_mescola_file.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `id=${encodeURIComponent(id)}`
})
.then(r => r.json())
.then(data => {
if (data.success) {
loadFilesForCategory(categoria);
} else {
Swal.fire({
icon: 'error',
title: 'Errore',
text: data.message || 'Cancellazione non riuscita'
});
}
});
});
});
$(document).ready(function() {
loadGeneral();
buildTabs();
});
</script>
</body>
</html>
+178 -31
View File
@@ -43,37 +43,172 @@ $departments = $pdo->query("
<title>Calendario Formazione - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title> <title>Calendario Formazione - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
<style> <style>
body { font-size: 1.05rem; background: #f8fafc; } body {
.card { border-radius: 16px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } font-size: 1.05rem;
.back-dashboard { background: #f8fafc;
background-color: #cfe3ff !important; color: #1f2d3d !important;
border: 1px solid #bcd4f4 !important; border-radius: 10px;
font-weight: 600; padding: 10px 18px;
} }
.legend { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem; }
.legend-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: #64748b; }
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
/* FullCalendar overrides */ .card {
.fc { font-size: 0.95rem; } border-radius: 16px;
.fc .fc-toolbar-title { font-size: 1.15rem; font-weight: 700; color: #2c3e6b; } box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
.fc .fc-button-primary { }
background: #5a8fd8; border-color: #5a8fd8;
font-weight: 600; font-size: 0.82rem; border-radius: 0.4rem; .back-dashboard {
background-color: #cfe3ff !important;
color: #1f2d3d !important;
border: 1px solid #bcd4f4 !important;
border-radius: 10px;
font-weight: 600;
padding: 10px 18px;
}
.training-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.btn-training-action {
border-radius: 10px;
font-weight: 500;
padding: 10px 16px;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease-in-out;
color: #fff !important;
text-decoration: none;
}
.btn-training-action:hover {
transform: translateY(-2px);
color: #fff !important;
}
.btn-training-topics {
background-color: #0d6efd !important;
border: 1px solid #0b5ed7 !important;
}
.btn-training-topics:hover {
background-color: #0b5ed7 !important;
}
.btn-training-history {
background-color: #2563eb !important;
border: 1px solid #1d4ed8 !important;
}
.btn-training-history:hover {
background-color: #1d4ed8 !important;
} }
.fc .fc-button-primary:hover { background: #4578c0; border-color: #4578c0; }
.fc .fc-button-primary:disabled { background: #9bbce6; border-color: #9bbce6; }
.fc .fc-button-primary:not(:disabled).fc-button-active { background: #2c3e6b; border-color: #2c3e6b; }
.fc .fc-daygrid-day-number { color: #2c3e6b; font-weight: 500; }
.fc .fc-daygrid-day.fc-day-today { background: #f0f4ff; }
.fc .fc-event { border-radius: 0.3rem; padding: 2px 4px; font-weight: 600; cursor: pointer; }
.fc .fc-event:hover { filter: brightness(0.92); }
.fc .fc-list-event:hover td { background: #f0f4ff; }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.card-header { flex-direction: column; align-items: flex-start !important; gap: .5rem; } .training-header-actions {
.fc .fc-toolbar { flex-direction: column; gap: 0.5rem; } width: 100%;
.fc .fc-toolbar-title { font-size: 1rem; } flex-direction: column;
}
.training-header-actions .btn,
.training-header-actions a {
width: 100%;
}
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: #64748b;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
/* FullCalendar overrides */
.fc {
font-size: 0.95rem;
}
.fc .fc-toolbar-title {
font-size: 1.15rem;
font-weight: 700;
color: #2c3e6b;
}
.fc .fc-button-primary {
background: #5a8fd8;
border-color: #5a8fd8;
font-weight: 600;
font-size: 0.82rem;
border-radius: 0.4rem;
}
.fc .fc-button-primary:hover {
background: #4578c0;
border-color: #4578c0;
}
.fc .fc-button-primary:disabled {
background: #9bbce6;
border-color: #9bbce6;
}
.fc .fc-button-primary:not(:disabled).fc-button-active {
background: #2c3e6b;
border-color: #2c3e6b;
}
.fc .fc-daygrid-day-number {
color: #2c3e6b;
font-weight: 500;
}
.fc .fc-daygrid-day.fc-day-today {
background: #f0f4ff;
}
.fc .fc-event {
border-radius: 0.3rem;
padding: 2px 4px;
font-weight: 600;
cursor: pointer;
}
.fc .fc-event:hover {
filter: brightness(0.92);
}
.fc .fc-list-event:hover td {
background: #f0f4ff;
}
@media (max-width: 767.98px) {
.card-header {
flex-direction: column;
align-items: flex-start !important;
gap: .5rem;
}
.fc .fc-toolbar {
flex-direction: column;
gap: 0.5rem;
}
.fc .fc-toolbar-title {
font-size: 1rem;
}
} }
</style> </style>
</head> </head>
@@ -88,10 +223,20 @@ $departments = $pdo->query("
<div class="card p-3"> <div class="card p-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h5 class="mb-0">📅 Calendario Formazione</h5> <h5 class="mb-0">📅 Calendario Formazione</h5>
<div class="d-flex gap-2 flex-wrap">
<a href="trainings.php" class="btn btn-light border d-inline-flex align-items-center gap-2"> <div class="training-header-actions">
📚 <span>Storico Formazione</span> <?php if (userCan('hr.training_topics.view')): ?>
</a> <a href="training_topics.php" class="btn btn-training-action btn-training-topics">
📘 Corsi Formazione
</a>
<?php endif; ?>
<?php if (userCan('hr.trainings.view')): ?>
<a href="trainings.php" class="btn btn-training-action btn-training-history">
📚 Gestione Formazione
</a>
<?php endif; ?>
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'"> <button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
↩️ Torna alla Dashboard ↩️ Torna alla Dashboard
</button> </button>
@@ -201,7 +346,9 @@ $departments = $pdo->query("
calendar.render(); calendar.render();
document.querySelectorAll('#filterStatus, #filterTopic, #filterDepartment, #filterEmployee').forEach(function(el) { document.querySelectorAll('#filterStatus, #filterTopic, #filterDepartment, #filterEmployee').forEach(function(el) {
el.addEventListener('change', function() { calendar.refetchEvents(); }); el.addEventListener('change', function() {
calendar.refetchEvents();
});
}); });
document.getElementById('btnResetFilters').addEventListener('click', function() { document.getElementById('btnResetFilters').addEventListener('click', function() {
+399 -49
View File
@@ -7,6 +7,8 @@ $pdo = $db->getConnection();
/* ========================================== /* ==========================================
PAGE DATA PAGE DATA
========================================== */ ========================================== */
require_once __DIR__ . '/include/training_coverage.php';
$sql = " $sql = "
SELECT tt.*, SELECT tt.*,
(SELECT COUNT(*) FROM employee_trainings et WHERE et.training_topic_id = tt.id) AS trainings_count (SELECT COUNT(*) FROM employee_trainings et WHERE et.training_topic_id = tt.id) AS trainings_count
@@ -14,6 +16,30 @@ $sql = "
ORDER BY tt.sort_order ASC, tt.name ASC ORDER BY tt.sort_order ASC, tt.name ASC
"; ";
$topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
/* Coperture: [topic_id => [ ['id'=>..,'name'=>..], ... ]] */
$coverageRows = $pdo->query("
SELECT c.topic_id, c.covered_topic_id, tt.name AS covered_name
FROM training_topic_coverage c
JOIN training_topics tt ON tt.id = c.covered_topic_id
ORDER BY tt.sort_order, tt.name
")->fetchAll(PDO::FETCH_ASSOC);
$coverageByTopic = [];
foreach ($coverageRows as $cr) {
$coverageByTopic[(int)$cr['topic_id']][] = [
'id' => (int)$cr['covered_topic_id'],
'name' => $cr['covered_name'],
];
}
/* Mappa inversa: chi copre questo corso */
$coveredByTopic = [];
foreach ($coverageRows as $cr) {
$coveredByTopic[(int)$cr['covered_topic_id']][] = (int)$cr['topic_id'];
}
$topicNames = [];
foreach ($topics as $t) $topicNames[(int)$t['id']] = $t['name'];
?> ?>
<!doctype html> <!doctype html>
<html lang="it"> <html lang="it">
@@ -33,36 +59,193 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script> <script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
<style> <style>
body { font-size: 1.05rem; background: #f8fafc; } body {
.card { border-radius: 16px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } font-size: 1.05rem;
background: #f8fafc;
}
.card {
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.back-dashboard { .back-dashboard {
background-color: #cfe3ff !important; color: #1f2d3d !important; background-color: #cfe3ff !important;
border: 1px solid #bcd4f4 !important; border-radius: 10px; color: #1f2d3d !important;
font-weight: 600; padding: 10px 18px; border: 1px solid #bcd4f4 !important;
border-radius: 10px;
font-weight: 600;
padding: 10px 18px;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }
.back-dashboard:hover { background-color: #b9d3ff !important; transform: translateY(-2px); }
.btn-add { background-color: #0d6efd; color: #fff; border-radius: 8px; padding: 10px 20px; font-weight: 500; } .back-dashboard:hover {
.btn-add:hover { background-color: #0b5ed7; transform: scale(1.02); } background-color: #b9d3ff !important;
.table thead { background-color: #cfe3ff; color: #1f2d3d; } transform: translateY(-2px);
.modal-content { border-radius: 16px; }
#tabellaTopics thead th { text-align: center; vertical-align: middle; }
.badge-status { padding: 0.25rem 0.6rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
.badge-status.active { background-color: #d1fae5; color: #065f46; }
.badge-status.inactive { background-color: #e5e7eb; color: #374151; }
.description-cell {
max-width: 280px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; text-align: left;
} }
.num-pill {
display: inline-block; padding: 2px 10px; border-radius: 999px; .training-header-actions {
background: #eef2ff; color: #3730a3; font-weight: 600; font-size: 0.85rem; display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
} }
.btn-training-action {
border-radius: 10px;
font-weight: 500;
padding: 10px 16px;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease-in-out;
color: #fff !important;
}
.btn-training-action:hover {
transform: translateY(-2px);
color: #fff !important;
}
.btn-training-history {
background-color: #0d6efd !important;
border: 1px solid #0b5ed7 !important;
}
.btn-training-history:hover {
background-color: #0b5ed7 !important;
}
.btn-training-calendar {
background-color: #2563eb !important;
border: 1px solid #1d4ed8 !important;
}
.btn-training-calendar:hover {
background-color: #1d4ed8 !important;
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.card-header { flex-direction: column; align-items: flex-start !important; gap: .5rem; } .training-header-actions {
.back-dashboard { width: 100%; } width: 100%;
.btn-add { width: 100%; } flex-direction: column;
}
.training-header-actions .btn {
width: 100%;
}
}
.btn-add {
background-color: #0d6efd;
color: #fff;
border-radius: 8px;
padding: 10px 20px;
font-weight: 500;
}
.btn-add:hover {
background-color: #0b5ed7;
transform: scale(1.02);
}
.training-shortcuts {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 18px;
}
.training-shortcut-btn {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 10px;
padding: 10px 16px;
font-weight: 600;
text-decoration: none;
border: 1px solid #bcd4f4;
background: #cfe3ff;
color: #1f2d3d;
box-shadow: 0 3px 8px rgba(0, 0, 0, .08);
transition: all .2s ease-in-out;
}
.training-shortcut-btn:hover {
background: #b9d3ff;
color: #1f2d3d;
transform: translateY(-2px);
}
@media (max-width: 767.98px) {
.training-shortcut-btn {
width: 100%;
justify-content: center;
}
}
.table thead {
background-color: #cfe3ff;
color: #1f2d3d;
}
.modal-content {
border-radius: 16px;
}
#tabellaTopics thead th {
text-align: center;
vertical-align: middle;
}
.badge-status {
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.badge-status.active {
background-color: #d1fae5;
color: #065f46;
}
.badge-status.inactive {
background-color: #e5e7eb;
color: #374151;
}
.description-cell {
max-width: 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.num-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-weight: 600;
font-size: 0.85rem;
}
@media (max-width: 767.98px) {
.card-header {
flex-direction: column;
align-items: flex-start !important;
gap: .5rem;
}
.back-dashboard {
width: 100%;
}
.btn-add {
width: 100%;
}
} }
.tt-card { .tt-card {
@@ -73,6 +256,7 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
background: #fff; background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
} }
.tt-card-title { .tt-card-title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
@@ -80,12 +264,14 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
margin: 0 0 4px 0; margin: 0 0 4px 0;
word-break: break-word; word-break: break-word;
} }
.tt-card-desc { .tt-card-desc {
color: #475569; color: #475569;
font-size: 0.95rem; font-size: 0.95rem;
margin: 0 0 10px 0; margin: 0 0 10px 0;
word-break: break-word; word-break: break-word;
} }
.tt-card-meta { .tt-card-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -94,17 +280,62 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
color: #64748b; color: #64748b;
margin-bottom: 12px; margin-bottom: 12px;
} }
.tt-card-meta b { color: #1f2937; font-weight: 600; }
.tt-card-meta b {
color: #1f2937;
font-weight: 600;
}
.tt-card-actions { .tt-card-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.tt-card-actions .btn { flex: 1; }
.tt-card-actions .btn {
flex: 1;
}
.tt-empty { .tt-empty {
text-align: center; text-align: center;
color: #94a3b8; color: #94a3b8;
padding: 24px 0; padding: 24px 0;
} }
.cov-chip {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
background: #ede9fe;
color: #5b21b6;
border: 1px solid #ddd6fe;
font-size: 0.78rem;
font-weight: 600;
margin: 2px 3px 2px 0;
white-space: nowrap;
}
.cov-chip-parent {
background: #fef3c7;
color: #92400e;
border-color: #fde68a;
}
.cov-cell {
max-width: 240px;
text-align: left;
white-space: normal;
}
.cov-help {
font-size: .85rem;
color: #64748b;
margin-top: 4px;
}
#addCovered,
#editCovered {
min-height: 140px;
}
</style> </style>
</head> </head>
@@ -117,13 +348,28 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
<div class="page-content"> <div class="page-content">
<div class="card p-3"> <div class="card p-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h5 class="mb-0">Gestione Corsi di Formazione</h5> <h5 class="mb-0">Corsi Formazione</h5>
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
↩️ Torna alla Dashboard <div class="training-header-actions">
</button> <?php if (userCan('hr.trainings.view')): ?>
<a href="trainings.php" class="btn btn-training-action btn-training-history">
🎓 Gestione Formazione
</a>
<a href="training_calendar.php" class="btn btn-training-action btn-training-calendar">
📅 Calendario Formazione
</a>
<?php endif; ?>
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
↩️ Torna alla Dashboard
</button>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2"> <div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h6 class="fw-semibold mb-0">Elenco Corsi / Training Topics</h6> <h6 class="fw-semibold mb-0">Elenco Corsi / Training Topics</h6>
<button class="btn btn-add" data-bs-toggle="modal" data-bs-target="#addTopicModal"> <button class="btn btn-add" data-bs-toggle="modal" data-bs-target="#addTopicModal">
@@ -144,6 +390,7 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
<th>Ordine</th> <th>Ordine</th>
<th>Stato</th> <th>Stato</th>
<th>Formazioni</th> <th>Formazioni</th>
<th>Copre</th>
<th>Azioni</th> <th>Azioni</th>
</tr> </tr>
</thead> </thead>
@@ -184,6 +431,25 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
<td><?= $sortOrder ?></td> <td><?= $sortOrder ?></td>
<td><span class="badge-status <?= $statusClass ?>"><?= $statusLabel ?></span></td> <td><span class="badge-status <?= $statusClass ?>"><?= $statusLabel ?></span></td>
<td><?= $cnt ?></td> <td><?= $cnt ?></td>
<td class="cov-cell">
<?php $covList = $coverageByTopic[$id] ?? []; ?>
<?php if ($covList): ?>
<?php foreach ($covList as $cv): ?>
<span class="cov-chip">⛓️ <?= htmlspecialchars($cv['name']) ?></span>
<?php endforeach; ?>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
<?php if (!empty($coveredByTopic[$id])): ?>
<div class="mt-1">
<?php foreach ($coveredByTopic[$id] as $parentId): ?>
<span class="cov-chip cov-chip-parent" title="Questo corso è assorbito da">
↑ <?= htmlspecialchars($topicNames[$parentId] ?? ('#' . $parentId)) ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</td>
<td> <td>
<button class="btn btn-sm btn-outline-secondary edit-topic" <button class="btn btn-sm btn-outline-secondary edit-topic"
data-id="<?= $id ?>" data-id="<?= $id ?>"
@@ -193,7 +459,8 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
data-rem="<?= $rem ?>" data-rem="<?= $rem ?>"
data-sort_order="<?= $sortOrder ?>" data-sort_order="<?= $sortOrder ?>"
data-is_active="<?= $isActive ?>" data-is_active="<?= $isActive ?>"
data-is_mandatory="<?= $isMandatory ?>"> data-is_mandatory="<?= $isMandatory ?>"
data-covered="<?= htmlspecialchars(json_encode(array_column($coverageByTopic[$id] ?? [], 'id')), ENT_QUOTES) ?>">
✏️ Modifica ✏️ Modifica
</button> </button>
<button class="btn btn-sm btn-outline-danger delete-topic" <button class="btn btn-sm btn-outline-danger delete-topic"
@@ -246,6 +513,17 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
<span><b>Formazioni:</b> <?= $cnt ?></span> <span><b>Formazioni:</b> <?= $cnt ?></span>
<span><b>Ordine:</b> <?= $sortOrder ?></span> <span><b>Ordine:</b> <?= $sortOrder ?></span>
</div> </div>
<?php $covList = $coverageByTopic[$id] ?? []; ?>
<?php if ($covList || !empty($coveredByTopic[$id])): ?>
<div class="mb-2">
<?php foreach ($covList as $cv): ?>
<span class="cov-chip">⛓️ <?= htmlspecialchars($cv['name']) ?></span>
<?php endforeach; ?>
<?php foreach (($coveredByTopic[$id] ?? []) as $parentId): ?>
<span class="cov-chip cov-chip-parent">↑ <?= htmlspecialchars($topicNames[$parentId] ?? ('#' . $parentId)) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="tt-card-actions"> <div class="tt-card-actions">
<button class="btn btn-sm btn-outline-secondary edit-topic" <button class="btn btn-sm btn-outline-secondary edit-topic"
data-id="<?= $id ?>" data-id="<?= $id ?>"
@@ -255,7 +533,8 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
data-rem="<?= $rem ?>" data-rem="<?= $rem ?>"
data-sort_order="<?= $sortOrder ?>" data-sort_order="<?= $sortOrder ?>"
data-is_active="<?= $isActive ?>" data-is_active="<?= $isActive ?>"
data-is_mandatory="<?= $isMandatory ?>"> data-is_mandatory="<?= $isMandatory ?>"
data-covered="<?= htmlspecialchars(json_encode(array_column($coverageByTopic[$id] ?? [], 'id')), ENT_QUOTES) ?>">
✏️ Modifica ✏️ Modifica
</button> </button>
<button class="btn btn-sm btn-outline-danger delete-topic" <button class="btn btn-sm btn-outline-danger delete-topic"
@@ -336,6 +615,19 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
Se attivo, i dipendenti senza registrazione di questo corso compaiono come "Non presente" nello storico. Se attivo, i dipendenti senza registrazione di questo corso compaiono come "Non presente" nello storico.
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-label fw-semibold">⛓️ Corsi assorbiti da questo</label>
<select class="form-select" id="addCovered" multiple size="6">
<?php foreach ($topics as $t): ?>
<option value="<?= (int)$t['id'] ?>"><?= htmlspecialchars($t['name']) ?></option>
<?php endforeach; ?>
</select>
<div class="cov-help">
Chi completa questo corso risulta automaticamente in regola anche per quelli selezionati.
La scadenza del corso assorbito viene ricalcolata con la sua frequenza propria.
</div>
</div>
<div class="text-center"> <div class="text-center">
<button type="submit" class="btn btn-add">💾 Salva</button> <button type="submit" class="btn btn-add">💾 Salva</button>
</div> </div>
@@ -406,6 +698,18 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
Se attivo, i dipendenti senza registrazione di questo corso compaiono come "Non presente" nello storico. Se attivo, i dipendenti senza registrazione di questo corso compaiono come "Non presente" nello storico.
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-label fw-semibold">⛓️ Corsi assorbiti da questo</label>
<select class="form-select" id="editCovered" multiple size="6">
<?php foreach ($topics as $t): ?>
<option value="<?= (int)$t['id'] ?>"><?= htmlspecialchars($t['name']) ?></option>
<?php endforeach; ?>
</select>
<div class="cov-help">
Chi completa questo corso risulta automaticamente in regola anche per quelli selezionati.
</div>
</div>
<div class="text-center"> <div class="text-center">
<button type="submit" class="btn btn-add">💾 Salva Modifiche</button> <button type="submit" class="btn btn-add">💾 Salva Modifiche</button>
</div> </div>
@@ -420,7 +724,14 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
<script> <script>
$(document).ready(function() { $(document).ready(function() {
$('#tabellaTopics').DataTable({ $('#tabellaTopics').DataTable({
order: [[5, 'asc'], [1, 'asc']], columnDefs: [{
targets: [8, 9],
orderable: false
}],
order: [
[5, 'asc'],
[1, 'asc']
],
pageLength: 25, pageLength: 25,
language: { language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json', url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json',
@@ -430,23 +741,37 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
function ajaxPost(url, payload, successTitle, errorFallback) { function ajaxPost(url, payload, successTitle, errorFallback) {
return fetch(url, { return fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: {
body: payload.toString() "Content-Type": "application/x-www-form-urlencoded"
}) },
.then(r => r.json()) body: payload.toString()
.then(data => { })
if (data.success) { .then(r => r.json())
Swal.fire({ icon: "success", title: successTitle, confirmButtonColor: "#3085d6" }) .then(data => {
.then(() => location.reload()); if (data.success) {
} else { Swal.fire({
Swal.fire({ icon: "error", title: "Errore", text: data.message || errorFallback }); icon: "success",
} title: successTitle,
}) confirmButtonColor: "#3085d6"
.catch(err => { })
Swal.fire({ icon: "error", title: "Errore", text: "Errore di comunicazione." }); .then(() => location.reload());
console.error(err); } else {
}); Swal.fire({
icon: "error",
title: "Errore",
text: data.message || errorFallback
});
}
})
.catch(err => {
Swal.fire({
icon: "error",
title: "Errore",
text: "Errore di comunicazione."
});
console.error(err);
});
} }
$("#addTopicForm").on("submit", function(e) { $("#addTopicForm").on("submit", function(e) {
@@ -459,6 +784,9 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
p.append('sort_order', $("#addSortOrder").val()); p.append('sort_order', $("#addSortOrder").val());
p.append('is_active', $("#addIsActive").val()); p.append('is_active', $("#addIsActive").val());
p.append('is_mandatory', $("#addIsMandatory").is(':checked') ? '1' : '0'); p.append('is_mandatory', $("#addIsMandatory").is(':checked') ? '1' : '0');
($("#addCovered").val() || []).forEach(function(v) {
p.append('covered_ids[]', v);
});
ajaxPost("ajax/training_topics/save.php", p, "Salvato!", "Impossibile salvare il corso."); ajaxPost("ajax/training_topics/save.php", p, "Salvato!", "Impossibile salvare il corso.");
}); });
@@ -477,6 +805,24 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
$("#editSortOrder").val(b.data("sort_order")); $("#editSortOrder").val(b.data("sort_order"));
$("#editIsActive").val(String(b.data("is_active"))); $("#editIsActive").val(String(b.data("is_active")));
$("#editIsMandatory").prop('checked', String(b.data("is_mandatory")) === '1'); $("#editIsMandatory").prop('checked', String(b.data("is_mandatory")) === '1');
// Coperture: nascondi il corso stesso e preseleziona le esistenti
var selfId = String(b.data("id"));
var covered = b.data("covered") || [];
if (typeof covered === 'string') {
try {
covered = JSON.parse(covered);
} catch (e) {
covered = [];
}
}
covered = covered.map(String);
$("#editCovered option").each(function() {
$(this).prop('hidden', this.value === selfId)
.prop('disabled', this.value === selfId);
});
$("#editCovered").val(covered);
$("#editTopicModal").modal("show"); $("#editTopicModal").modal("show");
}); });
@@ -491,6 +837,9 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
p.append('sort_order', $("#editSortOrder").val()); p.append('sort_order', $("#editSortOrder").val());
p.append('is_active', $("#editIsActive").val()); p.append('is_active', $("#editIsActive").val());
p.append('is_mandatory', $("#editIsMandatory").is(':checked') ? '1' : '0'); p.append('is_mandatory', $("#editIsMandatory").is(':checked') ? '1' : '0');
($("#editCovered").val() || []).forEach(function(v) {
p.append('covered_ids[]', v);
});
ajaxPost("ajax/training_topics/save.php", p, "Aggiornato!", "Impossibile aggiornare il corso."); ajaxPost("ajax/training_topics/save.php", p, "Aggiornato!", "Impossibile aggiornare il corso.");
}); });
@@ -527,4 +876,5 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}); });
</script> </script>
</body> </body>
</html> </html>
+510 -87
View File
@@ -23,7 +23,7 @@ $fEmployeeId = isset($_GET['employee_id']) && $_GET['employee_id'] !== '' ?
$fTopicId = isset($_GET['topic_id']) && $_GET['topic_id'] !== '' ? (int)$_GET['topic_id'] : 0; $fTopicId = isset($_GET['topic_id']) && $_GET['topic_id'] !== '' ? (int)$_GET['topic_id'] : 0;
$fStatus = isset($_GET['status']) ? trim($_GET['status']) : ''; $fStatus = isset($_GET['status']) ? trim($_GET['status']) : '';
$fType = isset($_GET['type']) ? trim($_GET['type']) : ''; $fType = isset($_GET['type']) ? trim($_GET['type']) : '';
$fDepartmentId = isset($_GET['department_id'])&& $_GET['department_id']!== '' ? (int)$_GET['department_id']: 0; $fDepartmentId = isset($_GET['department_id']) && $_GET['department_id'] !== '' ? (int)$_GET['department_id'] : 0;
/* ========================================== /* ==========================================
LOAD DATA LOAD DATA
@@ -39,13 +39,22 @@ $where[] = "NOT EXISTS (
AND (et2.completed_date > et.completed_date AND (et2.completed_date > et.completed_date
OR (et2.completed_date = et.completed_date AND et2.id > et.id)) OR (et2.completed_date = et.completed_date AND et2.id > et.id))
)"; )";
if ($fEmployeeId > 0) { $where[] = 'et.employee_id = :eid'; $params['eid'] = $fEmployeeId; } if ($fEmployeeId > 0) {
if ($fTopicId > 0) { $where[] = 'et.training_topic_id = :tid'; $params['tid'] = $fTopicId; } $where[] = 'et.employee_id = :eid';
$params['eid'] = $fEmployeeId;
}
if ($fTopicId > 0) {
$where[] = 'et.training_topic_id = :tid';
$params['tid'] = $fTopicId;
}
if ($fType !== '' && in_array($fType, ['initial', 'refresher'], true)) { if ($fType !== '' && in_array($fType, ['initial', 'refresher'], true)) {
$where[] = 'et.training_type = :ty'; $where[] = 'et.training_type = :ty';
$params['ty'] = $fType; $params['ty'] = $fType;
} }
if ($fDepartmentId > 0) { $where[] = 'e.department_id = :did'; $params['did'] = $fDepartmentId; } if ($fDepartmentId > 0) {
$where[] = 'e.department_id = :did';
$params['did'] = $fDepartmentId;
}
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; $whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
@@ -65,8 +74,18 @@ $stmt = $pdo->prepare("
$stmt->execute($params); $stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
require_once __DIR__ . '/include/training_coverage.php';
/* Mappa di tutti i corsi (serve all'helper coperture) */
$allTopicsById = [];
foreach ($pdo->query("SELECT id, name, default_frequency_months, default_reminder_days, is_active FROM training_topics")->fetchAll(PDO::FETCH_ASSOC) as $t) {
if ((int)$t['is_active'] !== 1) continue;
$allTopicsById[(int)$t['id']] = $t;
}
/* Filter by computed status */ /* Filter by computed status */
function trainingStatus(?string $nextDue, ?int $reminderDays, ?int $topicDefaultRem): array { function trainingStatus(?string $nextDue, ?int $reminderDays, ?int $topicDefaultRem): array
{
if (!$nextDue) { if (!$nextDue) {
return ['code' => 'compliant', 'label' => 'Conforme', 'class' => 'success']; return ['code' => 'compliant', 'label' => 'Conforme', 'class' => 'success'];
} }
@@ -80,12 +99,68 @@ function trainingStatus(?string $nextDue, ?int $reminderDays, ?int $topicDefault
return ['code' => 'compliant', 'label' => 'Conforme', 'class' => 'success', 'days' => $daysLeft]; return ['code' => 'compliant', 'label' => 'Conforme', 'class' => 'success', 'days' => $daysLeft];
} }
/* ==========================================
COPERTURE — righe virtuali derivate.
Vanno calcolate sui record NON filtrati per topic,
altrimenti filtrando su "Antincendio" perderemmo l'RSPP che lo copre.
========================================== */
$coverageBase = $rows;
if ($fTopicId > 0) {
$baseWhere = ["NOT EXISTS (
SELECT 1 FROM employee_trainings et2
WHERE et2.employee_id = et.employee_id
AND et2.training_topic_id = et.training_topic_id
AND (et2.completed_date > et.completed_date
OR (et2.completed_date = et.completed_date AND et2.id > et.id))
)"];
$baseParams = [];
if ($fEmployeeId > 0) {
$baseWhere[] = 'et.employee_id = :eid';
$baseParams['eid'] = $fEmployeeId;
}
if ($fType !== '' && in_array($fType, ['initial', 'refresher'], true)) {
$baseWhere[] = 'et.training_type = :ty';
$baseParams['ty'] = $fType;
}
if ($fDepartmentId > 0) {
$baseWhere[] = 'e.department_id = :did';
$baseParams['did'] = $fDepartmentId;
}
$baseSql = 'WHERE ' . implode(' AND ', $baseWhere);
$bstmt = $pdo->prepare("
SELECT et.*, tt.name AS topic_name, tt.default_reminder_days AS topic_default_rem,
e.first_name, e.last_name, e.employee_code,
d.name AS department_name, d.color AS department_color
FROM employee_trainings et
JOIN training_topics tt ON tt.id = et.training_topic_id
JOIN employees e ON e.id = et.employee_id
LEFT JOIN departments d ON d.id = e.department_id
$baseSql
");
$bstmt->execute($baseParams);
$coverageBase = $bstmt->fetchAll(PDO::FETCH_ASSOC);
}
$virtualRows = tc_buildVirtualRows($pdo, $coverageBase, $allTopicsById);
/* Applica i filtri attivi alle righe virtuali */
if ($fTopicId > 0) {
$virtualRows = array_values(array_filter($virtualRows, function ($v) use ($fTopicId) {
return (int)$v['training_topic_id'] === $fTopicId;
}));
}
$coveredKeys = tc_coveredKeys($virtualRows);
$filtered = []; $filtered = [];
$counters = ['compliant' => 0, 'due_soon' => 0, 'expired' => 0, 'not_present' => 0, 'all' => 0]; $counters = ['compliant' => 0, 'due_soon' => 0, 'expired' => 0, 'not_present' => 0, 'all' => 0];
foreach ($rows as $r) { foreach ($rows as $r) {
$s = trainingStatus($r['next_due_date'] ?: null, $s = trainingStatus(
$r['next_due_date'] ?: null,
$r['reminder_days'] !== null ? (int)$r['reminder_days'] : null, $r['reminder_days'] !== null ? (int)$r['reminder_days'] : null,
$r['topic_default_rem'] !== null ? (int)$r['topic_default_rem'] : null); $r['topic_default_rem'] !== null ? (int)$r['topic_default_rem'] : null
);
$r['_status'] = $s; $r['_status'] = $s;
$counters['all']++; $counters['all']++;
$counters[$s['code']] = ($counters[$s['code']] ?? 0) + 1; $counters[$s['code']] = ($counters[$s['code']] ?? 0) + 1;
@@ -94,6 +169,21 @@ foreach ($rows as $r) {
$filtered[] = $r; $filtered[] = $r;
} }
/* Righe coperte: stesso calcolo di stato delle righe reali */
foreach ($virtualRows as $v) {
$s = trainingStatus(
$v['next_due_date'] ?: null,
null,
$v['topic_default_rem'] !== null ? (int)$v['topic_default_rem'] : null
);
$v['_status'] = $s;
$counters['all']++;
$counters[$s['code']] = ($counters[$s['code']] ?? 0) + 1;
if ($fStatus !== '' && $fStatus !== $s['code']) continue;
$filtered[] = $v;
}
/* ========================================== /* ==========================================
"NOT PRESENT" — mandatory topics without any record for an employee. "NOT PRESENT" — mandatory topics without any record for an employee.
Apply the same filters (employee_id / topic_id / department_id / type=initial). Apply the same filters (employee_id / topic_id / department_id / type=initial).
@@ -101,9 +191,18 @@ foreach ($rows as $r) {
if ($fType === '' || $fType === 'initial') { if ($fType === '' || $fType === 'initial') {
$missingWhere = []; $missingWhere = [];
$missingParams = []; $missingParams = [];
if ($fEmployeeId > 0) { $missingWhere[] = 'e.id = :eid'; $missingParams['eid'] = $fEmployeeId; } if ($fEmployeeId > 0) {
if ($fTopicId > 0) { $missingWhere[] = 'tt.id = :tid'; $missingParams['tid'] = $fTopicId; } $missingWhere[] = 'e.id = :eid';
if ($fDepartmentId > 0) { $missingWhere[] = 'e.department_id = :did'; $missingParams['did'] = $fDepartmentId; } $missingParams['eid'] = $fEmployeeId;
}
if ($fTopicId > 0) {
$missingWhere[] = 'tt.id = :tid';
$missingParams['tid'] = $fTopicId;
}
if ($fDepartmentId > 0) {
$missingWhere[] = 'e.department_id = :did';
$missingParams['did'] = $fDepartmentId;
}
$missingWhereSql = $missingWhere ? ('AND ' . implode(' AND ', $missingWhere)) : ''; $missingWhereSql = $missingWhere ? ('AND ' . implode(' AND ', $missingWhere)) : '';
$missingStmt = $pdo->prepare(" $missingStmt = $pdo->prepare("
@@ -125,6 +224,9 @@ if ($fType === '' || $fType === 'initial') {
$missingRows = $missingStmt->fetchAll(PDO::FETCH_ASSOC); $missingRows = $missingStmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($missingRows as $m) { foreach ($missingRows as $m) {
// Se il corso obbligatorio è assorbito da un altro già svolto, non è "Non presente"
if (isset($coveredKeys[$m['employee_id'] . ':' . $m['topic_id']])) continue;
$counters['all']++; $counters['all']++;
$counters['not_present']++; $counters['not_present']++;
if ($fStatus !== '' && $fStatus !== 'not_present') continue; if ($fStatus !== '' && $fStatus !== 'not_present') continue;
@@ -163,7 +265,8 @@ $departments = $pdo->query("
SELECT id, name, color FROM departments WHERE is_active = 1 ORDER BY sort_order, name SELECT id, name, color FROM departments WHERE is_active = 1 ORDER BY sort_order, name
")->fetchAll(PDO::FETCH_ASSOC); ")->fetchAll(PDO::FETCH_ASSOC);
function fmtDate(?string $d): string { function fmtDate(?string $d): string
{
if (!$d || $d === '0000-00-00') return '—'; if (!$d || $d === '0000-00-00') return '—';
$ts = strtotime($d); $ts = strtotime($d);
return $ts ? date('d/m/Y', $ts) : '—'; return $ts ? date('d/m/Y', $ts) : '—';
@@ -186,53 +289,279 @@ function fmtDate(?string $d): string {
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<style> <style>
body { font-size: 1.05rem; background: #f8fafc; } body {
.card { border-radius: 16px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } font-size: 1.05rem;
.back-dashboard { background: #f8fafc;
background-color: #cfe3ff !important; color: #1f2d3d !important;
border: 1px solid #bcd4f4 !important; border-radius: 10px;
font-weight: 600; padding: 10px 18px;
} }
.stat-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 20px; }
@media (max-width: 991.98px) { .stat-row { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 575.98px) { .stat-row { grid-template-columns: repeat(2, 1fr); } }
.stat-card {
border-radius: 14px; padding: 14px 16px; text-align: center;
background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,.05);
cursor: pointer; transition: transform .15s;
}
.stat-card:hover { transform: translateY(-2px); }
.stat-card.active { outline: 3px solid #0d6efd; }
.stat-card .stat-num { font-size: 1.8rem; font-weight: 700; line-height: 1; }
.stat-card .stat-label { font-size: 0.85rem; color: #64748b; margin-top: 4px; }
.stat-card.all .stat-num { color: #1f2937; }
.stat-card.compliant .stat-num { color: #16a34a; }
.stat-card.due_soon .stat-num { color: #d97706; }
.stat-card.expired .stat-num { color: #dc2626; }
.stat-card.not_present .stat-num { color: #6b7280; }
.pill { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 0.85rem; font-weight: 600; } .card {
.pill-success { background: #d1fae5; color: #065f46; } border-radius: 16px;
.pill-warning { background: #fef3c7; color: #92400e; } box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
.pill-danger { background: #fee2e2; color: #991b1b; } }
.pill-secondary { background: #e5e7eb; color: #374151; }
.pill-role { background: #fff; color: #334155; border: 1px solid #cbd5e1; } .back-dashboard {
.pill-dept-inline { padding: 2px 8px; } background-color: #cfe3ff !important;
color: #1f2d3d !important;
border: 1px solid #bcd4f4 !important;
border-radius: 10px;
font-weight: 600;
padding: 10px 18px;
}
.training-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.btn-training-action {
border-radius: 10px;
font-weight: 500;
padding: 10px 16px;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease-in-out;
color: #fff !important;
}
.btn-training-action:hover {
transform: translateY(-2px);
color: #fff !important;
}
.btn-training-topics {
background-color: #0d6efd !important;
border: 1px solid #0b5ed7 !important;
}
.btn-training-topics:hover {
background-color: #0b5ed7 !important;
}
.btn-training-calendar {
background-color: #2563eb !important;
border: 1px solid #1d4ed8 !important;
}
.btn-training-calendar:hover {
background-color: #1d4ed8 !important;
}
@media (max-width: 767.98px) {
.training-header-actions {
width: 100%;
flex-direction: column;
}
.training-header-actions .btn,
.training-header-actions a {
width: 100%;
}
}
.stat-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
@media (max-width: 991.98px) {
.stat-row {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 575.98px) {
.stat-row {
grid-template-columns: repeat(2, 1fr);
}
}
.stat-card {
border-radius: 14px;
padding: 14px 16px;
text-align: center;
background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, .05);
cursor: pointer;
transition: transform .15s, box-shadow .15s, border-color .15s;
border: 1px solid transparent;
text-decoration: none;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(0, 0, 0, .10);
text-decoration: none;
}
.stat-card.active {
outline: 3px solid #0d6efd;
outline-offset: 2px;
}
.stat-card .stat-num {
font-size: 1.8rem;
font-weight: 800;
line-height: 1;
}
.stat-card .stat-label {
font-size: 0.85rem;
margin-top: 4px;
font-weight: 700;
}
/* Totale */
.stat-card.all {
background: #f8fafc;
border-color: #cbd5e1;
}
.stat-card.all .stat-num,
.stat-card.all .stat-label {
color: #1f2937;
}
/* Conforme - verde */
.stat-card.compliant {
background: #dcfce7;
border-color: #86efac;
}
.stat-card.compliant .stat-num,
.stat-card.compliant .stat-label {
color: #166534;
}
/* Da aggiornare - arancio */
.stat-card.due_soon {
background: #ffedd5;
border-color: #fdba74;
}
.stat-card.due_soon .stat-num,
.stat-card.due_soon .stat-label {
color: #9a3412;
}
/* Scaduti - rosso */
.stat-card.expired {
background: #fee2e2;
border-color: #fca5a5;
}
.stat-card.expired .stat-num,
.stat-card.expired .stat-label {
color: #991b1b;
}
/* Non presenti - rosso diverso / bordeaux */
.stat-card.not_present {
background: #fce7f3;
border-color: #f9a8d4;
}
.stat-card.not_present .stat-num,
.stat-card.not_present .stat-label {
color: #9d174d;
}
.pill {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
}
.pill-success {
background: #d1fae5;
color: #065f46;
}
.pill-warning {
background: #fef3c7;
color: #92400e;
}
.pill-danger {
background: #fee2e2;
color: #991b1b;
}
.pill-secondary {
background: #e5e7eb;
color: #374151;
}
.pill-role {
background: #fff;
color: #334155;
border: 1px solid #cbd5e1;
}
.pill-covered {
background: #ede9fe;
color: #5b21b6;
border: 1px solid #ddd6fe;
font-size: .78rem;
margin-left: 6px;
white-space: nowrap;
}
tr.row-covered {
background-color: #faf5ff !important;
}
.pill-dept-inline {
padding: 2px 8px;
}
.tr-card { .tr-card {
border: 1px solid #e2e8f0; border-radius: 14px; border: 1px solid #e2e8f0;
padding: 14px 16px; margin-bottom: 12px; border-radius: 14px;
padding: 14px 16px;
margin-bottom: 12px;
background: #fff; background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
} }
.tr-card .name a { color: #1f2937; font-weight: 600; text-decoration: none; }
.tr-card .topic { color: #475569; } .tr-card .name a {
.tr-card .meta { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.85rem; color: #64748b; margin-top: 8px; } color: #1f2937;
.tr-card .meta b { color: #1f2937; font-weight: 600; } font-weight: 600;
text-decoration: none;
}
.tr-card .topic {
color: #475569;
}
.tr-card .meta {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
font-size: 0.85rem;
color: #64748b;
margin-top: 8px;
}
.tr-card .meta b {
color: #1f2937;
font-weight: 600;
}
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.card-header { flex-direction: column; align-items: flex-start !important; gap: .5rem; } .card-header {
.back-dashboard { width: 100%; } flex-direction: column;
align-items: flex-start !important;
gap: .5rem;
}
.back-dashboard {
width: 100%;
}
} }
</style> </style>
</head> </head>
@@ -246,11 +575,25 @@ function fmtDate(?string $d): string {
<div class="page-content"> <div class="page-content">
<div class="card p-3"> <div class="card p-3">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h5 class="mb-0">📚 Storico Formazione</h5> <h5 class="mb-0">📚 Gestione Formazione</h5>
<div class="d-flex gap-2 flex-wrap">
<div class="training-header-actions">
<button type="button" class="btn btn-primary" id="btnBulkTraining"> <button type="button" class="btn btn-primary" id="btnBulkTraining">
➕ Aggiungi sessione ➕ Aggiungi sessione
</button> </button>
<?php if (userCan('hr.training_topics.view')): ?>
<a href="training_topics.php" class="btn btn-training-action btn-training-topics">
📘 Corsi Formazione
</a>
<?php endif; ?>
<?php if (userCan('hr.trainings.view')): ?>
<a href="training_calendar.php" class="btn btn-training-action btn-training-calendar">
📅 Calendario Formazione
</a>
<?php endif; ?>
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'"> <button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
↩️ Torna alla Dashboard ↩️ Torna alla Dashboard
</button> </button>
@@ -323,7 +666,7 @@ function fmtDate(?string $d): string {
<label class="form-label small fw-semibold">Tipo</label> <label class="form-label small fw-semibold">Tipo</label>
<select name="type" class="form-select form-select-sm" onchange="this.form.submit()"> <select name="type" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">— Tutti —</option> <option value="">— Tutti —</option>
<option value="initial" <?= $fType === 'initial' ? 'selected' : '' ?>>Iniziale</option> <option value="initial" <?= $fType === 'initial' ? 'selected' : '' ?>>Iniziale</option>
<option value="refresher" <?= $fType === 'refresher' ? 'selected' : '' ?>>Aggiornamento</option> <option value="refresher" <?= $fType === 'refresher' ? 'selected' : '' ?>>Aggiornamento</option>
</select> </select>
</div> </div>
@@ -368,7 +711,7 @@ function fmtDate(?string $d): string {
$typeLbl = $r['training_type'] === 'refresher' ? 'Aggiornamento' : ($r['training_type'] === 'initial' ? 'Iniziale' : '—'); $typeLbl = $r['training_type'] === 'refresher' ? 'Aggiornamento' : ($r['training_type'] === 'initial' ? 'Iniziale' : '—');
$days = $r['_status']['days'] ?? null; $days = $r['_status']['days'] ?? null;
?> ?>
<tr> <tr class="<?= !empty($r['_covered']) ? 'row-covered' : '' ?>">
<td> <td>
<?php if (!empty($r['id'])): ?> <?php if (!empty($r['id'])): ?>
<input type="checkbox" class="form-check-input row-check" value="<?= (int)$r['id'] ?>"> <input type="checkbox" class="form-check-input row-check" value="<?= (int)$r['id'] ?>">
@@ -387,20 +730,27 @@ function fmtDate(?string $d): string {
<span class="pill pill-dept-inline" style="background:<?= htmlspecialchars($r['department_color'] ?? '#e5e7eb', ENT_QUOTES) ?>20; color:<?= htmlspecialchars($r['department_color'] ?? '#374151', ENT_QUOTES) ?>;"> <span class="pill pill-dept-inline" style="background:<?= htmlspecialchars($r['department_color'] ?? '#e5e7eb', ENT_QUOTES) ?>20; color:<?= htmlspecialchars($r['department_color'] ?? '#374151', ENT_QUOTES) ?>;">
<?= htmlspecialchars($r['department_name']) ?> <?= htmlspecialchars($r['department_name']) ?>
</span> </span>
<?php else: ?>—<?php endif; ?> <?php else: ?>—<?php endif; ?>
</td>
<td>
<?= htmlspecialchars($r['topic_name']) ?>
<?php if (!empty($r['_covered'])): ?>
<span class="pill pill-covered" title="Assorbito automaticamente dal corso <?= htmlspecialchars($r['_covered_by_name'], ENT_QUOTES) ?>">
⛓️ Coperto da <?= htmlspecialchars($r['_covered_by_name']) ?>
</span>
<?php endif; ?>
</td> </td>
<td><?= htmlspecialchars($r['topic_name']) ?></td>
<td><span class="pill pill-role"><?= $typeLbl ?></span></td> <td><span class="pill pill-role"><?= $typeLbl ?></span></td>
<td><?= fmtDate($r['completed_date']) ?></td> <td><?= fmtDate($r['completed_date']) ?></td>
<td><?= fmtDate($r['next_due_date']) ?></td> <td><?= fmtDate($r['next_due_date']) ?></td>
<td><span class="pill pill-<?= $r['_status']['class'] ?>"><?= $r['_status']['label'] ?></span></td> <td><span class="pill pill-<?= $r['_status']['class'] ?>"><?= $r['_status']['label'] ?></span></td>
<td> <td>
<?php if ($days === null): ?>— <?php if ($days === null): ?>—
<?php elseif ($days < 0): ?> <?php elseif ($days < 0): ?>
<span class="text-danger fw-semibold"><?= $days ?></span> <span class="text-danger fw-semibold"><?= $days ?></span>
<?php else: ?> <?php else: ?>
+<?= $days ?> +<?= $days ?>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
@@ -428,7 +778,14 @@ function fmtDate(?string $d): string {
</div> </div>
<span class="pill pill-<?= $r['_status']['class'] ?>"><?= $r['_status']['label'] ?></span> <span class="pill pill-<?= $r['_status']['class'] ?>"><?= $r['_status']['label'] ?></span>
</div> </div>
<div class="topic">📖 <?= htmlspecialchars($r['topic_name']) ?></div> <div class="topic">
📖 <?= htmlspecialchars($r['topic_name']) ?>
<?php if (!empty($r['_covered'])): ?>
<div class="mt-1">
<span class="pill pill-covered">⛓️ Coperto da <?= htmlspecialchars($r['_covered_by_name']) ?></span>
</div>
<?php endif; ?>
</div>
<div class="meta"> <div class="meta">
<span><b>Tipo:</b> <?= $typeLbl ?></span> <span><b>Tipo:</b> <?= $typeLbl ?></span>
<span><b>Completato:</b> <?= fmtDate($r['completed_date']) ?></span> <span><b>Completato:</b> <?= fmtDate($r['completed_date']) ?></span>
@@ -514,7 +871,9 @@ function fmtDate(?string $d): string {
<textarea id="bulkDescription" class="form-control" rows="2"></textarea> <textarea id="bulkDescription" class="form-control" rows="2"></textarea>
</div> </div>
<div class="col-12"><hr class="my-1"></div> <div class="col-12">
<hr class="my-1">
</div>
<div class="col-12"> <div class="col-12">
<label class="form-label fw-semibold">Dipendenti <span class="text-danger">*</span></label> <label class="form-label fw-semibold">Dipendenti <span class="text-danger">*</span></label>
@@ -600,7 +959,9 @@ function fmtDate(?string $d): string {
}); });
document.getElementById('bulkSelectAll').addEventListener('click', function() { document.getElementById('bulkSelectAll').addEventListener('click', function() {
var all = $emp.find('option').map(function() { return this.value; }).get(); var all = $emp.find('option').map(function() {
return this.value;
}).get();
$emp.val(all).trigger('change'); $emp.val(all).trigger('change');
}); });
document.getElementById('bulkClear').addEventListener('click', function() { document.getElementById('bulkClear').addEventListener('click', function() {
@@ -613,9 +974,18 @@ function fmtDate(?string $d): string {
var completed = document.getElementById('bulkCompletedDate').value; var completed = document.getElementById('bulkCompletedDate').value;
var emps = $emp.val() || []; var emps = $emp.val() || [];
if (!topicId) { Swal.fire('Attenzione', 'Selezionare un corso.', 'warning'); return; } if (!topicId) {
if (!completed) { Swal.fire('Attenzione', 'Indicare la data di completamento.', 'warning'); return; } Swal.fire('Attenzione', 'Selezionare un corso.', 'warning');
if (emps.length === 0) { Swal.fire('Attenzione', 'Selezionare almeno un dipendente.', 'warning'); return; } return;
}
if (!completed) {
Swal.fire('Attenzione', 'Indicare la data di completamento.', 'warning');
return;
}
if (emps.length === 0) {
Swal.fire('Attenzione', 'Selezionare almeno un dipendente.', 'warning');
return;
}
var btn = document.getElementById('bulkSaveBtn'); var btn = document.getElementById('bulkSaveBtn');
btn.disabled = true; btn.disabled = true;
@@ -630,22 +1000,39 @@ function fmtDate(?string $d): string {
fd.append('description', document.getElementById('bulkDescription').value); fd.append('description', document.getElementById('bulkDescription').value);
fd.append('update_frequency_months', document.getElementById('bulkFreq').value); fd.append('update_frequency_months', document.getElementById('bulkFreq').value);
fd.append('reminder_days', document.getElementById('bulkRem').value); fd.append('reminder_days', document.getElementById('bulkRem').value);
emps.forEach(function(id) { fd.append('employee_ids[]', id); }); emps.forEach(function(id) {
fd.append('employee_ids[]', id);
});
fetch('ajax/trainings/save_bulk_training.php', { method: 'POST', body: fd }) fetch('ajax/trainings/save_bulk_training.php', {
.then(function(r) { return r.json(); }) method: 'POST',
body: fd
})
.then(function(r) {
return r.json();
})
.then(function(data) { .then(function(data) {
if (data.success) { if (data.success) {
bulkModal.hide(); bulkModal.hide();
Swal.fire({ icon: 'success', title: 'Fatto', text: data.message, timer: 1800, showConfirmButton: false }) Swal.fire({
.then(function() { location.reload(); }); icon: 'success',
title: 'Fatto',
text: data.message,
timer: 1800,
showConfirmButton: false
})
.then(function() {
location.reload();
});
} else { } else {
btn.disabled = false; btn.innerHTML = orig; btn.disabled = false;
btn.innerHTML = orig;
Swal.fire('Errore', data.message || 'Errore.', 'error'); Swal.fire('Errore', data.message || 'Errore.', 'error');
} }
}) })
.catch(function() { .catch(function() {
btn.disabled = false; btn.innerHTML = orig; btn.disabled = false;
btn.innerHTML = orig;
Swal.fire('Errore', 'Errore di connessione.', 'error'); Swal.fire('Errore', 'Errore di connessione.', 'error');
}); });
}); });
@@ -682,14 +1069,22 @@ function fmtDate(?string $d): string {
function checkedIds() { function checkedIds() {
return Array.prototype.slice.call(document.querySelectorAll('.row-check:checked')) return Array.prototype.slice.call(document.querySelectorAll('.row-check:checked'))
.map(function(c) { return c.value; }); .map(function(c) {
return c.value;
});
} }
function refreshBulkBar() { function refreshBulkBar() {
var ids = checkedIds(); var ids = checkedIds();
var bar = document.getElementById('bulkBar'); var bar = document.getElementById('bulkBar');
document.getElementById('bulkSelCount').textContent = ids.length; document.getElementById('bulkSelCount').textContent = ids.length;
if (ids.length > 0) { bar.classList.remove('d-none'); bar.classList.add('d-flex'); } if (ids.length > 0) {
else { bar.classList.add('d-none'); bar.classList.remove('d-flex'); } bar.classList.remove('d-none');
bar.classList.add('d-flex');
} else {
bar.classList.add('d-none');
bar.classList.remove('d-flex');
}
var all = document.querySelectorAll('.row-check'); var all = document.querySelectorAll('.row-check');
if (checkAll) checkAll.checked = (all.length > 0 && ids.length === all.length); if (checkAll) checkAll.checked = (all.length > 0 && ids.length === all.length);
} }
@@ -698,11 +1093,15 @@ function fmtDate(?string $d): string {
if (e.target && e.target.classList && e.target.classList.contains('row-check')) refreshBulkBar(); if (e.target && e.target.classList && e.target.classList.contains('row-check')) refreshBulkBar();
}); });
if (checkAll) checkAll.addEventListener('change', function() { if (checkAll) checkAll.addEventListener('change', function() {
document.querySelectorAll('.row-check').forEach(function(c) { c.checked = checkAll.checked; }); document.querySelectorAll('.row-check').forEach(function(c) {
c.checked = checkAll.checked;
});
refreshBulkBar(); refreshBulkBar();
}); });
document.getElementById('btnBulkDeselect').addEventListener('click', function() { document.getElementById('btnBulkDeselect').addEventListener('click', function() {
document.querySelectorAll('.row-check').forEach(function(c) { c.checked = false; }); document.querySelectorAll('.row-check').forEach(function(c) {
c.checked = false;
});
if (checkAll) checkAll.checked = false; if (checkAll) checkAll.checked = false;
refreshBulkBar(); refreshBulkBar();
}); });
@@ -718,8 +1117,14 @@ function fmtDate(?string $d): string {
e.preventDefault(); e.preventDefault();
var ids = checkedIds(); var ids = checkedIds();
var date = document.getElementById('renewDate').value; var date = document.getElementById('renewDate').value;
if (ids.length === 0) { renewModal.hide(); return; } if (ids.length === 0) {
if (!date) { Swal.fire('Attenzione', 'Indicare la data.', 'warning'); return; } renewModal.hide();
return;
}
if (!date) {
Swal.fire('Attenzione', 'Indicare la data.', 'warning');
return;
}
var btn = document.getElementById('renewSaveBtn'); var btn = document.getElementById('renewSaveBtn');
btn.disabled = true; btn.disabled = true;
@@ -728,26 +1133,44 @@ function fmtDate(?string $d): string {
var fd = new FormData(); var fd = new FormData();
fd.append('completed_date', date); fd.append('completed_date', date);
ids.forEach(function(id) { fd.append('training_ids[]', id); }); ids.forEach(function(id) {
fd.append('training_ids[]', id);
});
fetch('ajax/trainings/bulk_update_deadline.php', { method: 'POST', body: fd }) fetch('ajax/trainings/bulk_update_deadline.php', {
.then(function(r) { return r.json(); }) method: 'POST',
body: fd
})
.then(function(r) {
return r.json();
})
.then(function(data) { .then(function(data) {
if (data.success) { if (data.success) {
renewModal.hide(); renewModal.hide();
Swal.fire({ icon: 'success', title: 'Fatto', text: data.message, timer: 1800, showConfirmButton: false }) Swal.fire({
.then(function() { location.reload(); }); icon: 'success',
title: 'Fatto',
text: data.message,
timer: 1800,
showConfirmButton: false
})
.then(function() {
location.reload();
});
} else { } else {
btn.disabled = false; btn.innerHTML = orig; btn.disabled = false;
btn.innerHTML = orig;
Swal.fire('Errore', data.message || 'Errore.', 'error'); Swal.fire('Errore', data.message || 'Errore.', 'error');
} }
}) })
.catch(function() { .catch(function() {
btn.disabled = false; btn.innerHTML = orig; btn.disabled = false;
btn.innerHTML = orig;
Swal.fire('Errore', 'Errore di connessione.', 'error'); Swal.fire('Errore', 'Errore di connessione.', 'error');
}); });
}); });
}); });
</script> </script>
</body> </body>
</html> </html>
+36
View File
@@ -0,0 +1,36 @@
<?php
include('include/headscript.php');
header('Content-Type: application/json; charset=utf-8');
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
try {
$id = (int)($_POST['id'] ?? 0);
$category = trim($_POST['category'] ?? '');
$item_name = trim($_POST['item_name'] ?? '');
$item_code = trim($_POST['item_code'] ?? '');
$allowedCats = ['PACKAGING_TYPE', 'BOX', 'PALLET', 'OTHER'];
if ($id <= 0 || $category === '' || $item_name === '' || $item_code === '') {
echo json_encode(['success' => false, 'message' => 'Dati non validi']);
exit;
}
if (!in_array($category, $allowedCats, true)) {
echo json_encode(['success' => false, 'message' => 'Categoria non valida']);
exit;
}
$stmt = $pdo->prepare("
UPDATE packaging_items
SET category = ?, item_name = ?, item_code = ?
WHERE id = ?
");
$stmt->execute([$category, $item_name, $item_code, $id]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
0
SECTION
2
HEADER
9
$INSUNITS
70
4
0
ENDSEC
0
SECTION
2
ENTITIES
0
POLYLINE
8
PROFILO
66
1
70
1
0
VERTEX
8
PROFILO
10
5.9087
20
9.0157
30
0.0
0
VERTEX
8
PROFILO
10
6.0608
20
2.8408
30
0.0
0
VERTEX
8
PROFILO
10
11.0190
20
2.8256
30
0.0
0
VERTEX
8
PROFILO
10
16.7528
20
3.2362
30
0.0
0
VERTEX
8
PROFILO
10
17.9239
20
4.4834
30
0.0
0
VERTEX
8
PROFILO
10
17.7262
20
6.6887
30
0.0
0
VERTEX
8
PROFILO
10
17.1939
20
8.1336
30
0.0
0
VERTEX
8
PROFILO
10
16.0076
20
10.2476
30
0.0
0
VERTEX
8
PROFILO
10
15.3688
20
10.9320
30
0.0
0
VERTEX
8
PROFILO
10
13.4373
20
11.0233
30
0.0
0
VERTEX
8
PROFILO
10
9.0722
20
11.4339
30
0.0
0
VERTEX
8
PROFILO
10
8.1597
20
11.2210
30
0.0
0
VERTEX
8
PROFILO
10
7.3992
20
10.7343
30
0.0
0
SEQEND
0
ENDSEC
0
EOF
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+157
View File
@@ -0,0 +1,157 @@
from flask import Flask, request, jsonify
from flask_cors import CORS
import traceback
from cad_vector_area import calculate_pdf_vector_area
from auto_contour import propose_contour_from_image_bytes
app = Flask(__name__)
CORS(app)
@app.route("/health", methods=["GET"])
def health():
return jsonify({
"success": True,
"message": "Python CAD Area service is running"
})
def get_float_or_none(name):
value = request.form.get(name, "").strip()
if value == "":
return None
try:
return float(value)
except ValueError:
return None
def get_int_or_default(name, default=1):
value = request.form.get(name, "").strip()
if value == "":
return default
try:
return int(value)
except ValueError:
return default
@app.route("/calculate", methods=["POST"])
def calculate():
try:
if "file" not in request.files:
return jsonify({
"success": False,
"message": "No PDF file received"
}), 400
uploaded_file = request.files["file"]
if uploaded_file.filename == "":
return jsonify({
"success": False,
"message": "Empty filename"
}), 400
if not uploaded_file.filename.lower().endswith(".pdf"):
return jsonify({
"success": False,
"message": "Only PDF files are allowed"
}), 400
pdf_bytes = uploaded_file.read()
scale_ratio = get_float_or_none("scale_ratio")
if scale_ratio is not None and scale_ratio <= 0:
scale_ratio = None
roi = {
"x": get_float_or_none("roi_x"),
"y": get_float_or_none("roi_y"),
"width": get_float_or_none("roi_width"),
"height": get_float_or_none("roi_height"),
"page": get_int_or_default("roi_page", 1)
}
has_roi = (
roi["x"] is not None and
roi["y"] is not None and
roi["width"] is not None and
roi["height"] is not None and
roi["width"] > 0 and
roi["height"] > 0
)
mode = request.form.get("mode", "auto_roi")
result = calculate_pdf_vector_area(
pdf_bytes=pdf_bytes,
filename=uploaded_file.filename,
scale_ratio=scale_ratio,
profile_color=None,
roi=roi if has_roi else None,
mode=mode
)
status_code = 200 if result.get("success") else 422
return jsonify(result), status_code
except Exception as e:
return jsonify({
"success": False,
"message": str(e),
"trace": traceback.format_exc()
}), 500
@app.route("/auto-contour-image", methods=["POST"])
def auto_contour_image():
try:
if "image" not in request.files:
return jsonify({
"success": False,
"message": "No image received"
}), 400
uploaded_image = request.files["image"]
image_bytes = uploaded_image.read()
max_points_raw = request.form.get("max_points", "90").strip()
try:
max_points = int(max_points_raw)
except ValueError:
max_points = 90
max_points = max(12, min(max_points, 250))
result = propose_contour_from_image_bytes(
image_bytes=image_bytes,
max_points=max_points
)
status_code = 200 if result.get("success") else 422
return jsonify(result), status_code
except Exception as e:
return jsonify({
"success": False,
"message": str(e),
"trace": traceback.format_exc()
}), 500
if __name__ == "__main__":
app.run(
host="127.0.0.1",
port=5055,
debug=True
)
+404
View File
@@ -0,0 +1,404 @@
import cv2
import numpy as np
def _normalize_contour(contour, width, height):
points = contour.reshape(-1, 2)
return [
{
"x": round(float(x) / float(width), 8),
"y": round(float(y) / float(height), 8),
}
for x, y in points
]
def _simplify_contour(contour, max_points=120):
if contour is None or len(contour) < 3:
return contour
perimeter = cv2.arcLength(contour, True)
if perimeter <= 0:
return contour
epsilon = max(0.8, perimeter * 0.0025)
simplified = cv2.approxPolyDP(contour, epsilon, True)
while len(simplified) > max_points and epsilon < perimeter * 0.06:
epsilon *= 1.25
simplified = cv2.approxPolyDP(contour, epsilon, True)
if simplified is None or len(simplified) < 3:
return contour
return simplified
def _contour_score(contour, image_area, width, height):
area = abs(cv2.contourArea(contour))
if area <= 0:
return None
x, y, w, h = cv2.boundingRect(contour)
if w < 8 or h < 8:
return None
bbox_area = w * h
if bbox_area <= 0:
return None
area_ratio = area / image_area
bbox_ratio = bbox_area / image_area
fill_ratio = area / bbox_area
aspect = max(w, h) / max(1, min(w, h))
# Too small: usually dots/noise.
if area_ratio < 0.0004:
return None
# Too large: usually background / page.
if area_ratio > 0.96 or bbox_ratio > 0.98:
return None
# Very thin: usually dimensions/text lines.
if aspect > 40:
return None
# Prefer large compact-ish shapes, but allow irregular profiles.
score = area
if 0.08 <= fill_ratio <= 0.95:
score *= 1.25
# Penalize contours glued to the border because they are often crop/background artifacts.
border_touch = (
x <= 1 or
y <= 1 or
x + w >= width - 2 or
y + h >= height - 2
)
if border_touch:
score *= 0.65
return {
"score": score,
"area": area,
"bbox": (x, y, w, h),
"area_ratio": area_ratio,
"bbox_ratio": bbox_ratio,
"fill_ratio": fill_ratio,
"aspect": aspect,
"border_touch": border_touch,
}
def _best_contour_from_mask(mask, image_area, width, height, mode_name):
contours, _hierarchy = cv2.findContours(
mask,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE
)
candidates = []
for contour in contours:
info = _contour_score(contour, image_area, width, height)
if info is None:
continue
candidates.append((info["score"], contour, info))
if not candidates:
return None, {
"mode": mode_name,
"contours_total": len(contours),
"candidates": 0,
}
candidates.sort(key=lambda item: item[0], reverse=True)
best_score, best_contour, best_info = candidates[0]
return best_contour, {
"mode": mode_name,
"contours_total": len(contours),
"candidates": len(candidates),
"selected": {
"score": round(float(best_score), 3),
"area": round(float(best_info["area"]), 3),
"bbox": {
"x": int(best_info["bbox"][0]),
"y": int(best_info["bbox"][1]),
"width": int(best_info["bbox"][2]),
"height": int(best_info["bbox"][3]),
},
"area_ratio": round(float(best_info["area_ratio"]), 5),
"bbox_ratio": round(float(best_info["bbox_ratio"]), 5),
"fill_ratio": round(float(best_info["fill_ratio"]), 5),
"aspect": round(float(best_info["aspect"]), 3),
"border_touch": bool(best_info["border_touch"]),
}
}
def _remove_small_components(mask, min_area):
num_labels, labels, stats, _centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)
output = np.zeros_like(mask)
for label in range(1, num_labels):
area = stats[label, cv2.CC_STAT_AREA]
if area >= min_area:
output[labels == label] = 255
return output
def _make_masks(image):
height, width = image.shape[:2]
image_area = width * height
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# Slight blur to reduce antialias noise.
blurred = cv2.GaussianBlur(gray, (3, 3), 0)
# Dark ink mask: lines, hatches, dots, technical strokes.
mask_dark_245 = cv2.inRange(blurred, 0, 245)
mask_dark_235 = cv2.inRange(blurred, 0, 235)
mask_dark_220 = cv2.inRange(blurred, 0, 220)
# Otsu inverse.
_t, mask_otsu = cv2.threshold(
blurred,
0,
255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)
# Adaptive threshold helps on scans with grey background.
mask_adaptive = cv2.adaptiveThreshold(
blurred,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV,
31,
7
)
base_mask = cv2.bitwise_or(mask_dark_235, mask_otsu)
base_mask = cv2.bitwise_or(base_mask, mask_adaptive)
min_component_area = max(6, int(image_area * 0.00002))
base_mask = _remove_small_components(base_mask, min_component_area)
kernel_3 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
kernel_5 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
kernel_9 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
kernel_13 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (13, 13))
kernel_21 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21))
masks = []
# Strategy 1: normal dark mask, light close.
m1 = cv2.morphologyEx(base_mask, cv2.MORPH_CLOSE, kernel_5, iterations=1)
m1 = cv2.morphologyEx(m1, cv2.MORPH_OPEN, kernel_3, iterations=1)
masks.append(("dark_close_5", m1))
# Strategy 2: stronger close for broken profile lines / dotted hatches.
m2 = cv2.morphologyEx(base_mask, cv2.MORPH_CLOSE, kernel_9, iterations=2)
m2 = cv2.morphologyEx(m2, cv2.MORPH_OPEN, kernel_3, iterations=1)
masks.append(("dark_close_9x2", m2))
# Strategy 3: very strong close, useful when profile is made of dots/hatches.
m3 = cv2.morphologyEx(base_mask, cv2.MORPH_CLOSE, kernel_13, iterations=2)
m3 = cv2.morphologyEx(m3, cv2.MORPH_OPEN, kernel_5, iterations=1)
masks.append(("dark_close_13x2", m3))
# Strategy 4: Canny edges closed.
edges = cv2.Canny(blurred, 60, 180)
e1 = cv2.dilate(edges, kernel_3, iterations=1)
e1 = cv2.morphologyEx(e1, cv2.MORPH_CLOSE, kernel_9, iterations=2)
masks.append(("canny_close_9x2", e1))
# Strategy 5: flood fill from closed boundaries.
boundary = cv2.dilate(mask_dark_245, kernel_3, iterations=1)
boundary = cv2.morphologyEx(boundary, cv2.MORPH_CLOSE, kernel_9, iterations=2)
passable = cv2.bitwise_not(boundary)
flood = passable.copy()
flood_mask = np.zeros((height + 2, width + 2), dtype=np.uint8)
for x in range(width):
if flood[0, x] > 0:
cv2.floodFill(flood, flood_mask, (x, 0), 128)
if flood[height - 1, x] > 0:
cv2.floodFill(flood, flood_mask, (x, height - 1), 128)
for y in range(height):
if flood[y, 0] > 0:
cv2.floodFill(flood, flood_mask, (0, y), 128)
if flood[y, width - 1] > 0:
cv2.floodFill(flood, flood_mask, (width - 1, y), 128)
outside = (flood == 128).astype(np.uint8) * 255
enclosed = cv2.bitwise_not(outside)
enclosed[0, :] = 0
enclosed[-1, :] = 0
enclosed[:, 0] = 0
enclosed[:, -1] = 0
enclosed = cv2.morphologyEx(enclosed, cv2.MORPH_OPEN, kernel_5, iterations=1)
masks.append(("flood_enclosed", enclosed))
# Strategy 6: if everything is sparse, glue nearby strokes aggressively.
m6 = cv2.morphologyEx(mask_dark_220, cv2.MORPH_CLOSE, kernel_21, iterations=1)
m6 = cv2.morphologyEx(m6, cv2.MORPH_OPEN, kernel_5, iterations=1)
masks.append(("aggressive_close_21", m6))
return masks, {
"gray_mean": round(float(gray.mean()), 3),
"base_mask_pixels": int((base_mask > 0).sum()),
"image_width": width,
"image_height": height,
}
def propose_contour_from_image_bytes(image_bytes, max_points=120):
"""
Receives a PNG/JPG image of the currently visible ROI canvas and returns
a proposed outer contour as normalized x/y points.
This is a proposal only. The frontend must allow editing.
"""
np_buffer = np.frombuffer(image_bytes, dtype=np.uint8)
image = cv2.imdecode(np_buffer, cv2.IMREAD_COLOR)
if image is None:
return {
"success": False,
"message": "Immagine non valida o non decodificabile."
}
height, width = image.shape[:2]
if width < 20 or height < 20:
return {
"success": False,
"message": "Immagine troppo piccola per il riconoscimento del contorno."
}
image_area = width * height
masks, base_diag = _make_masks(image)
attempts = []
best_global = None
for mode_name, mask in masks:
contour, diag = _best_contour_from_mask(
mask=mask,
image_area=image_area,
width=width,
height=height,
mode_name=mode_name
)
diag["mask_pixels"] = int((mask > 0).sum())
attempts.append(diag)
if contour is None:
continue
info = _contour_score(contour, image_area, width, height)
if info is None:
continue
score = info["score"]
if best_global is None or score > best_global["score"]:
best_global = {
"score": score,
"contour": contour,
"mode": mode_name,
"info": info,
"mask_pixels": int((mask > 0).sum()),
}
if best_global is None:
return {
"success": False,
"message": "Nessun contorno plausibile trovato. Prova una ROI più stretta o procedi manualmente.",
"diagnostics": {
**base_diag,
"attempts": attempts,
}
}
simplified = _simplify_contour(best_global["contour"], max_points=max_points)
if simplified is None or len(simplified) < 3:
return {
"success": False,
"message": "Il contorno trovato non ha abbastanza punti validi.",
"diagnostics": {
**base_diag,
"selected_mode": best_global["mode"],
"attempts": attempts,
}
}
area_px2 = float(abs(cv2.contourArea(simplified)))
x, y, w, h = cv2.boundingRect(simplified)
# Defensive check.
if area_px2 <= 0:
return {
"success": False,
"message": "Il contorno trovato ha area nulla.",
"diagnostics": {
**base_diag,
"selected_mode": best_global["mode"],
"attempts": attempts,
}
}
return {
"success": True,
"message": "Contorno proposto correttamente.",
"outer_polygon": _normalize_contour(simplified, width, height),
"holes": [],
"diagnostics": {
**base_diag,
"selected_mode": best_global["mode"],
"points_count": int(len(simplified)),
"area_px2": round(area_px2, 3),
"bbox": {
"x": int(x),
"y": int(y),
"width": int(w),
"height": int(h)
},
"selected": {
"score": round(float(best_global["score"]), 3),
"area": round(float(best_global["info"]["area"]), 3),
"area_ratio": round(float(best_global["info"]["area_ratio"]), 5),
"bbox_ratio": round(float(best_global["info"]["bbox_ratio"]), 5),
"fill_ratio": round(float(best_global["info"]["fill_ratio"]), 5),
"aspect": round(float(best_global["info"]["aspect"]), 3),
"border_touch": bool(best_global["info"]["border_touch"]),
"mask_pixels": int(best_global["mask_pixels"]),
},
"attempts": attempts,
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
header('Content-Type: application/json; charset=utf-8');
require_once(__DIR__ . '/include/headscript.php');
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$iduser = $iduserlogin ?? null;
$rawInput = file_get_contents('php://input');
$input = json_decode($rawInput, true);
if (!is_array($input)) {
throw new Exception('Payload JSON non valido.');
}
$id = (int)($input['id'] ?? 0);
if ($id <= 0) {
throw new Exception('ID non valido.');
}
$roiX = isset($input['roi_x']) ? (float)$input['roi_x'] : null;
$roiY = isset($input['roi_y']) ? (float)$input['roi_y'] : null;
$roiW = isset($input['roi_width']) ? (float)$input['roi_width'] : null;
$roiH = isset($input['roi_height']) ? (float)$input['roi_height'] : null;
$roiPage = isset($input['roi_page']) ? (int)$input['roi_page'] : 1;
$mode = $input['calculation_mode'] ?? 'auto_roi';
if ($roiX === null || $roiY === null || $roiW === null || $roiH === null) {
throw new Exception('ROI non valida.');
}
if ($roiW <= 0 || $roiH <= 0) {
throw new Exception('Dimensioni ROI non valide.');
}
if ($roiX < 0 || $roiY < 0 || $roiX > 1 || $roiY > 1 || $roiW > 1 || $roiH > 1) {
throw new Exception('Coordinate ROI fuori scala.');
}
$allowedModes = [
'auto_roi',
'stitch_contour',
'filled_union',
'closed_path'
];
if (!in_array($mode, $allowedModes, true)) {
$mode = 'auto_roi';
}
if ($iduser === null || $iduser === '') {
$stmt = $pdo->prepare("
UPDATE cad_area_jobs
SET
roi_x = ?,
roi_y = ?,
roi_width = ?,
roi_height = ?,
roi_page = ?,
calculation_mode = ?,
status = 'uploaded',
message = NULL
WHERE id = ?
");
$stmt->execute([
$roiX,
$roiY,
$roiW,
$roiH,
$roiPage,
$mode,
$id
]);
} else {
$stmt = $pdo->prepare("
UPDATE cad_area_jobs
SET
roi_x = ?,
roi_y = ?,
roi_width = ?,
roi_height = ?,
roi_page = ?,
calculation_mode = ?,
status = 'uploaded',
message = NULL
WHERE id = ?
AND iduser = ?
");
$stmt->execute([
$roiX,
$roiY,
$roiW,
$roiH,
$roiPage,
$mode,
$id,
$iduser
]);
}
if ($stmt->rowCount() === 0) {
throw new Exception('Nessun record aggiornato. Controlla ID o utente.');
}
echo json_encode([
'success' => true,
'message' => 'ROI salvata correttamente.'
]);
exit;
} catch (Throwable $e) {
error_log('CAD area save ROI error: ' . $e->getMessage());
echo json_encode([
'success' => false,
'message' => $e->getMessage()
]);
exit;
}
+776
View File
@@ -0,0 +1,776 @@
import math
import re
from collections import Counter, deque
import fitz # PyMuPDF
import numpy as np
from shapely.geometry import Polygon
from shapely.ops import unary_union
from shapely.validation import make_valid
POINT_TO_MM = 25.4 / 72.0
DEFAULT_RENDER_ZOOM = 8.0
MAX_RENDER_SIDE_PX = 3200
STITCH_TOLERANCE = 1.2
MAX_ASPECT_RATIO = 80
_SCALE_PATTERN = re.compile(
r'(?:scale|echelle|échelle|scala|masstab|escala)?\s*'
r'(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)',
re.IGNORECASE
)
def _pt(point):
return float(point.x), float(point.y)
def _dist(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
def _color_close(c1, c2, tol=0.05):
if c1 is None or c2 is None:
return False
if len(c1) == 4:
c1 = c1[:3]
if len(c2) == 4:
c2 = c2[:3]
if len(c1) != len(c2):
return False
return all(abs(float(a) - float(b)) <= tol for a, b in zip(c1, c2))
def _cubic_bezier(p0, p1, p2, p3, steps=24):
pts = []
for i in range(1, steps + 1):
t = i / steps
x = (
(1 - t) ** 3 * p0[0]
+ 3 * (1 - t) ** 2 * t * p1[0]
+ 3 * (1 - t) * t ** 2 * p2[0]
+ t ** 3 * p3[0]
)
y = (
(1 - t) ** 3 * p0[1]
+ 3 * (1 - t) ** 2 * t * p1[1]
+ 3 * (1 - t) * t ** 2 * p2[1]
+ t ** 3 * p3[1]
)
pts.append((x, y))
return pts
def _safe_polygon(points):
if len(points) < 3:
return None
try:
polygon = Polygon(points)
if not polygon.is_valid:
polygon = make_valid(polygon)
if polygon.is_empty or polygon.area <= 0:
return None
if polygon.geom_type == "MultiPolygon":
polygon = max(list(polygon.geoms), key=lambda g: g.area)
if polygon.geom_type != "Polygon":
return None
return polygon
except Exception:
return None
def _area_mm2_from_polygon(polygon, scale_ratio):
return abs(float(polygon.area)) * (POINT_TO_MM ** 2) / (scale_ratio ** 2)
def _bounds_mm_from_polygon(polygon, scale_ratio):
minx, miny, maxx, maxy = polygon.bounds
width_mm = (maxx - minx) * POINT_TO_MM / scale_ratio
height_mm = (maxy - miny) * POINT_TO_MM / scale_ratio
return round(width_mm, 3), round(height_mm, 3)
def _detect_scale_from_text(page):
raw = page.get_text("text")
if not raw:
return None
candidates = []
for match in _SCALE_PATTERN.finditer(raw):
try:
a = float(match.group(1))
b = float(match.group(2))
if b <= 0:
continue
ratio = a / b
if 0.01 <= ratio <= 100:
candidates.append(round(ratio, 4))
except Exception:
continue
if not candidates:
return None
return Counter(candidates).most_common(1)[0][0]
def _normalized_roi_to_page_rect(page_rect, roi):
if not roi:
return None
x = roi.get("x")
y = roi.get("y")
width = roi.get("width")
height = roi.get("height")
if x is None or y is None or width is None or height is None:
return None
try:
x = float(x)
y = float(y)
width = float(width)
height = float(height)
except Exception:
return None
if width <= 0 or height <= 0:
return None
x = max(0.0, min(1.0, x))
y = max(0.0, min(1.0, y))
width = max(0.0, min(1.0 - x, width))
height = max(0.0, min(1.0 - y, height))
x0 = page_rect.x0 + x * page_rect.width
y0 = page_rect.y0 + y * page_rect.height
x1 = x0 + width * page_rect.width
y1 = y0 + height * page_rect.height
return fitz.Rect(x0, y0, x1, y1)
def _drawing_intersects_rect(drawing, roi_rect):
if roi_rect is None:
return True
rect = drawing.get("rect")
if rect is None:
return False
try:
return fitz.Rect(rect).intersects(roi_rect)
except Exception:
return False
def _filter_drawings_by_roi(drawings, roi_rect):
if roi_rect is None:
return drawings
return [d for d in drawings if _drawing_intersects_rect(d, roi_rect)]
def _choose_render_zoom(rect):
zoom = DEFAULT_RENDER_ZOOM
max_side = max(rect.width, rect.height)
if max_side * zoom > MAX_RENDER_SIDE_PX:
zoom = MAX_RENDER_SIDE_PX / max_side
return max(3.0, min(DEFAULT_RENDER_ZOOM, zoom))
def _dilate(mask, iterations=1):
result = mask.astype(bool)
for _ in range(iterations):
padded = np.pad(result, 1, mode="constant", constant_values=False)
result = (
padded[1:-1, 1:-1]
| padded[:-2, 1:-1]
| padded[2:, 1:-1]
| padded[1:-1, :-2]
| padded[1:-1, 2:]
| padded[:-2, :-2]
| padded[:-2, 2:]
| padded[2:, :-2]
| padded[2:, 2:]
)
return result
def _erode(mask, iterations=1):
result = mask.astype(bool)
for _ in range(iterations):
padded = np.pad(result, 1, mode="constant", constant_values=False)
result = (
padded[1:-1, 1:-1]
& padded[:-2, 1:-1]
& padded[2:, 1:-1]
& padded[1:-1, :-2]
& padded[1:-1, 2:]
& padded[:-2, :-2]
& padded[:-2, 2:]
& padded[2:, :-2]
& padded[2:, 2:]
)
return result
def _close_mask(mask, iterations=2):
return _erode(_dilate(mask, iterations=iterations), iterations=iterations)
def _largest_component(mask):
h, w = mask.shape
visited = np.zeros_like(mask, dtype=bool)
best_pixels = []
best_count = 0
ys, xs = np.where(mask)
for start_y, start_x in zip(ys, xs):
if visited[start_y, start_x]:
continue
q = deque()
q.append((start_y, start_x))
visited[start_y, start_x] = True
pixels = []
while q:
y, x = q.popleft()
pixels.append((y, x))
for ny in (y - 1, y, y + 1):
for nx in (x - 1, x, x + 1):
if ny == y and nx == x:
continue
if ny < 0 or nx < 0 or ny >= h or nx >= w:
continue
if visited[ny, nx]:
continue
if not mask[ny, nx]:
continue
visited[ny, nx] = True
q.append((ny, nx))
if len(pixels) > best_count:
best_count = len(pixels)
best_pixels = pixels
output = np.zeros_like(mask, dtype=bool)
for y, x in best_pixels:
output[y, x] = True
return output, best_count
def _flood_fill_outside(boundary_mask):
h, w = boundary_mask.shape
outside = np.zeros_like(boundary_mask, dtype=bool)
passable = ~boundary_mask
q = deque()
for x in range(w):
if passable[0, x]:
outside[0, x] = True
q.append((0, x))
if passable[h - 1, x]:
outside[h - 1, x] = True
q.append((h - 1, x))
for y in range(h):
if passable[y, 0]:
outside[y, 0] = True
q.append((y, 0))
if passable[y, w - 1]:
outside[y, w - 1] = True
q.append((y, w - 1))
while q:
y, x = q.popleft()
for ny, nx in (
(y - 1, x),
(y + 1, x),
(y, x - 1),
(y, x + 1),
):
if ny < 0 or nx < 0 or ny >= h or nx >= w:
continue
if outside[ny, nx]:
continue
if not passable[ny, nx]:
continue
outside[ny, nx] = True
q.append((ny, nx))
return outside
def _bbox_from_mask(mask, padding=8):
ys, xs = np.where(mask)
if len(xs) == 0 or len(ys) == 0:
return None
h, w = mask.shape
x0 = max(0, int(xs.min()) - padding)
x1 = min(w - 1, int(xs.max()) + padding)
y0 = max(0, int(ys.min()) - padding)
y1 = min(h - 1, int(ys.max()) + padding)
return x0, y0, x1, y1
def _crop_mask(mask, bbox):
x0, y0, x1, y1 = bbox
return mask[y0:y1 + 1, x0:x1 + 1]
def _raster_roi_area(page, roi_rect, scale_ratio, mode="auto_roi"):
"""
Raster ROI method:
- render only the selected ROI
- detect technical ink / filled geometry
- try flood-fill to recover the enclosed section area
- fallback to filled dark pixels for filled profiles
"""
if roi_rect is None:
return None, "ROI mancante: definisci prima la sezione da misurare."
zoom = _choose_render_zoom(roi_rect)
pix = page.get_pixmap(
matrix=fitz.Matrix(zoom, zoom),
clip=roi_rect,
alpha=False
)
width = pix.width
height = pix.height
channels = pix.n
arr = np.frombuffer(pix.samples, dtype=np.uint8).reshape((height, width, channels))
if channels >= 3:
rgb = arr[:, :, :3].astype(np.int16)
else:
rgb = np.repeat(arr[:, :, :1], 3, axis=2).astype(np.int16)
brightness = rgb.mean(axis=2)
max_channel = rgb.max(axis=2)
min_channel = rgb.min(axis=2)
saturation = max_channel - min_channel
# Technical ink / profile geometry.
# Keep black, grey, colored CAD strokes, anti-aliased edges.
ink = (brightness < 245) | ((saturation > 25) & (brightness < 252))
# Remove very light background noise.
ink = _close_mask(ink, iterations=1)
ink_bbox = _bbox_from_mask(ink, padding=12)
if ink_bbox is None:
return None, "Nessuna geometria visibile trovata dentro la ROI."
cropped_ink = _crop_mask(ink, ink_bbox)
# Strengthen thin CAD lines to close small gaps.
boundary = _dilate(cropped_ink, iterations=2)
boundary = _close_mask(boundary, iterations=2)
outside = _flood_fill_outside(boundary)
filled_inside = ~outside
# Keep only the largest enclosed/filled component to avoid text or small debris.
largest_inside, largest_inside_pixels = _largest_component(filled_inside)
ink_pixels = int(cropped_ink.sum())
boundary_pixels = int(boundary.sum())
inside_pixels = int(largest_inside_pixels)
pixel_to_mm = POINT_TO_MM / zoom / scale_ratio
pixel_area_mm2 = pixel_to_mm ** 2
ink_area_mm2 = ink_pixels * pixel_area_mm2
flood_area_mm2 = inside_pixels * pixel_area_mm2
crop_h, crop_w = cropped_ink.shape
crop_area_pixels = crop_w * crop_h
flood_ratio = inside_pixels / crop_area_pixels if crop_area_pixels else 0
ink_ratio = ink_pixels / crop_area_pixels if crop_area_pixels else 0
# Decision logic:
# - If flood-fill finds a plausible closed section, use it.
# - If the ROI contains a filled/hatch mass and flood-fill is not useful, use ink area.
# - Never trust a flood area that almost fills the whole ROI crop.
selected_method = None
selected_area_mm2 = None
if mode in ("auto_roi", "stitch_contour", "closed_path"):
if flood_area_mm2 > max(ink_area_mm2 * 2.0, 5.0) and flood_ratio < 0.92:
selected_method = "raster_flood_fill"
selected_area_mm2 = flood_area_mm2
if selected_area_mm2 is None and mode in ("auto_roi", "filled_union"):
if ink_area_mm2 > 1.0:
selected_method = "raster_filled_ink"
selected_area_mm2 = ink_area_mm2
if selected_area_mm2 is None:
return None, "ROI analizzata, ma non è stata trovata un'area raster plausibile."
# Estimate width / height of detected ink bounding box.
width_mm = crop_w * pixel_to_mm
height_mm = crop_h * pixel_to_mm
diagnostics = {
"render_zoom": zoom,
"roi_rect_points": {
"x0": roi_rect.x0,
"y0": roi_rect.y0,
"x1": roi_rect.x1,
"y1": roi_rect.y1,
},
"render_width_px": width,
"render_height_px": height,
"ink_pixels": ink_pixels,
"boundary_pixels": boundary_pixels,
"inside_pixels": inside_pixels,
"ink_area_mm2": round(ink_area_mm2, 4),
"flood_area_mm2": round(flood_area_mm2, 4),
"ink_ratio": round(ink_ratio, 4),
"flood_ratio": round(flood_ratio, 4),
"selected_method": selected_method,
"crop_width_px": crop_w,
"crop_height_px": crop_h,
}
return {
"area_mm2": selected_area_mm2,
"width_mm": round(width_mm, 3),
"height_mm": round(height_mm, 3),
"method": selected_method,
"diagnostics": diagnostics,
}, None
def _extract_points_from_drawing(drawing):
points = []
source_type = "path"
for item in drawing.get("items", []):
cmd = item[0]
if cmd == "re":
rect = item[1]
source_type = "rectangle"
points = [
(float(rect.x0), float(rect.y0)),
(float(rect.x1), float(rect.y0)),
(float(rect.x1), float(rect.y1)),
(float(rect.x0), float(rect.y1)),
(float(rect.x0), float(rect.y0)),
]
return points, source_type
if cmd == "l":
p1 = _pt(item[1])
p2 = _pt(item[2])
if not points:
points.append(p1)
if _dist(points[-1], p1) > 0.01:
points.append(p1)
points.append(p2)
elif cmd == "c" and len(item) >= 5:
p0 = _pt(item[1])
c1 = _pt(item[2])
c2 = _pt(item[3])
p3 = _pt(item[4])
if not points:
points.append(p0)
elif _dist(points[-1], p0) > 0.01:
points.append(p0)
points.extend(_cubic_bezier(p0, c1, c2, p3, steps=24))
return points, source_type
def _vector_closed_path_area(drawings, scale_ratio):
candidates = []
for index, drawing in enumerate(drawings):
points, source_type = _extract_points_from_drawing(drawing)
if source_type == "rectangle":
continue
if len(points) < 6:
continue
if _dist(points[0], points[-1]) > 1.5:
continue
polygon = _safe_polygon(points)
if polygon is None:
continue
area_mm2 = _area_mm2_from_polygon(polygon, scale_ratio)
if area_mm2 < 5:
continue
width_mm, height_mm = _bounds_mm_from_polygon(polygon, scale_ratio)
min_side = min(width_mm, height_mm)
max_side = max(width_mm, height_mm)
if min_side <= 0:
continue
aspect = max_side / min_side
if aspect > MAX_ASPECT_RATIO:
continue
candidates.append({
"drawing_index": index,
"area_mm2": area_mm2,
"width_mm": width_mm,
"height_mm": height_mm,
"points_count": len(points),
"aspect_ratio": round(aspect, 3),
})
if not candidates:
return None, "Nessun path vettoriale chiuso plausibile trovato."
candidates.sort(key=lambda x: x["area_mm2"], reverse=True)
best = candidates[0]
return {
"area_mm2": best["area_mm2"],
"width_mm": best["width_mm"],
"height_mm": best["height_mm"],
"method": "vector_closed_path",
"diagnostics": {
"candidates_count": len(candidates),
"selected_candidate": best,
"candidates_preview": candidates[:20],
},
}, None
def calculate_pdf_vector_area(
pdf_bytes,
filename="uploaded.pdf",
scale_ratio=None,
profile_color=None,
roi=None,
mode="auto_roi",
):
try:
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
except Exception as exc:
return {
"success": False,
"message": f"Impossibile aprire il PDF: {exc}",
}
if len(doc) == 0:
return {
"success": False,
"message": "Il PDF non ha pagine.",
}
page_index = 0
if roi and roi.get("page"):
try:
page_index = max(0, int(roi.get("page")) - 1)
except Exception:
page_index = 0
if page_index >= len(doc):
page_index = 0
page = doc[page_index]
all_drawings = page.get_drawings()
if scale_ratio is None:
detected_scale = _detect_scale_from_text(page)
if detected_scale is not None:
scale_ratio = detected_scale
scale_source = "text_detected"
else:
scale_ratio = 1.0
scale_source = "default_1:1"
else:
try:
scale_ratio = float(scale_ratio)
except Exception:
scale_ratio = 1.0
if scale_ratio <= 0:
scale_ratio = 1.0
scale_source = "manual"
roi_rect = _normalized_roi_to_page_rect(page.rect, roi)
drawings = _filter_drawings_by_roi(all_drawings, roi_rect)
diagnostics = {
"filename": filename,
"total_pages": len(doc),
"page_index_used": page_index,
"page_width_mm": round(page.rect.width * POINT_TO_MM / scale_ratio, 3),
"page_height_mm": round(page.rect.height * POINT_TO_MM / scale_ratio, 3),
"scale_ratio": scale_ratio,
"scale_source": scale_source,
"mode": mode,
"roi_used": roi_rect is not None,
"roi": roi,
"total_drawings_page": len(all_drawings),
"drawings_inside_roi": len(drawings),
}
# First choice: ROI raster method.
# This is safer for exploded CAD linework because it measures the selected section image.
if roi_rect is not None and mode in ("auto_roi", "stitch_contour", "filled_union", "closed_path"):
raster_result, raster_error = _raster_roi_area(
page=page,
roi_rect=roi_rect,
scale_ratio=scale_ratio,
mode=mode
)
if raster_result is not None:
area_mm2 = round(float(raster_result["area_mm2"]), 4)
return {
"success": True,
"message": "Area calcolata sulla ROI selezionata.",
"area_mm2": area_mm2,
"area_cm2": round(area_mm2 / 100.0, 6),
"area_m2": round(area_mm2 / 1_000_000.0, 9),
"width_mm": raster_result["width_mm"],
"height_mm": raster_result["height_mm"],
"scale_detected": f"{scale_ratio}:1",
"scale_used": scale_ratio,
"scale_source": scale_source,
"strategy_used": raster_result["method"],
"confidence": "needs_validation",
"diagnostics": {
**diagnostics,
"raster": raster_result["diagnostics"],
},
}
diagnostics["raster_error"] = raster_error
# Fallback: closed vector path only.
vector_result, vector_error = _vector_closed_path_area(drawings, scale_ratio)
if vector_result is not None:
area_mm2 = round(float(vector_result["area_mm2"]), 4)
return {
"success": True,
"message": "Area calcolata da path vettoriale chiuso.",
"area_mm2": area_mm2,
"area_cm2": round(area_mm2 / 100.0, 6),
"area_m2": round(area_mm2 / 1_000_000.0, 9),
"width_mm": vector_result["width_mm"],
"height_mm": vector_result["height_mm"],
"scale_detected": f"{scale_ratio}:1",
"scale_used": scale_ratio,
"scale_source": scale_source,
"strategy_used": vector_result["method"],
"confidence": "needs_validation",
"diagnostics": {
**diagnostics,
"vector": vector_result["diagnostics"],
},
}
diagnostics["vector_error"] = vector_error
return {
"success": False,
"message": (
"Nessuna area affidabile trovata. "
"Definisci una ROI più stretta intorno alla sola sezione del profilo, "
"oppure verifica la scala del disegno."
),
"area_mm2": None,
"area_cm2": None,
"area_m2": None,
"scale_used": scale_ratio,
"scale_source": scale_source,
"strategy_used": None,
"confidence": "low",
"diagnostics": diagnostics,
}
Binary file not shown.
Binary file not shown.
+97
View File
@@ -0,0 +1,97 @@
const { test, expect } = require('@playwright/test')
const { CALENDAR, GLOBAL_CALENDAR, createEquipment, createMaintenance, name } = require('./helpers/fixtures')
const { sql } = require('./helpers/env')
test.describe('Calendars', () => {
let equipmentId
const title = name('Manutenzione a calendario')
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()
equipmentId = await createEquipment(page, name('Macchina calendario'))
await createMaintenance(page, equipmentId, { code: 'K', title, months: 6 })
await page.close()
// a fixed due date in the future, so the event lands in a known month
sql(`UPDATE maint_maintenances SET last_done_date = '2026-09-15', next_due_date = '2026-09-15'
WHERE equipment_id = ${equipmentId}`)
})
test('the calendar draws the events and clicking one opens the equipment page', async ({ page }) => {
await page.goto(`${CALENDAR}?_=1`)
await page.locator('.fc-view-harness').waitFor()
// page through to September 2026 (the toolbar title is localised in Italian)
await page.locator('.fc-toolbar-title').waitFor()
for (let i = 0; i < 24; i++) {
const title = await page.locator('.fc-toolbar-title').textContent()
if (/settembre 2026/i.test(title)) break
await page.locator('.fc-next-button').click()
await page.waitForTimeout(120)
}
const event = page.locator('.fc-event', { hasText: name('Macchina calendario') }).first()
await expect(event).toBeVisible()
await event.click()
await page.waitForURL(/equipment\.php\?id=\d+/)
await expect(page.getByRole('heading', { name: new RegExp(name('Macchina calendario')) })).toBeVisible()
})
test('the calendar filters re-issue the request', async ({ page }) => {
await page.goto(CALENDAR)
await page.locator('.fc-view-harness').waitFor()
const [request] = await Promise.all([
page.waitForRequest((r) => r.url().includes('get_calendar_events.php') && r.url().includes('type=extraordinary')),
page.selectOption('#filterType', 'extraordinary'),
])
expect(request.url()).toContain('type=extraordinary')
})
test('the combined calendar switches layers on and off', async ({ page }) => {
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await page.goto(GLOBAL_CALENDAR)
await page.locator('.fc-view-harness').waitFor()
const toggles = page.locator('.mnt-layer-toggle')
await expect(toggles).toHaveCount(3)
await expect(page.locator('.mnt-layer-toggle.is-on')).toHaveCount(3)
// the checkbox is hidden inside the label: click the label
const maintenanceToggle = page.locator('.mnt-layer-toggle', { hasText: 'Manutenzioni' })
await maintenanceToggle.click()
await expect(maintenanceToggle).not.toHaveClass(/is-on/)
// with every layer off the calendar stays empty, and no JS error is raised
for (const layer of ['Scadenze', 'Formazione']) {
await page.locator('.mnt-layer-toggle', { hasText: layer }).click()
}
await expect(page.locator('.fc-event')).toHaveCount(0)
expect(errors).toEqual([])
})
test('every layer brings its own events', async ({ page }) => {
const countEvents = async (layers) => {
const response = await page.request.get(
`userarea/manutenzioni/ajax/get_global_calendar_events.php?start=2026-01-01&end=2027-12-31&layers=${layers}`
)
return (await response.json()).length
}
await page.goto(GLOBAL_CALENDAR)
const all = await countEvents('deadlines,trainings,maintenances')
const onlyMaintenances = await countEvents('maintenances')
const onlyDeadlines = await countEvents('deadlines')
expect(onlyMaintenances).toBeGreaterThan(0)
expect(onlyDeadlines).toBeGreaterThan(0)
expect(all).toBeGreaterThan(onlyMaintenances)
expect(all).toBeGreaterThan(onlyDeadlines)
})
})
+243
View File
@@ -0,0 +1,243 @@
const { test, expect } = require('@playwright/test')
const {
REGISTRY, MAINTENANCES, CATEGORIES, GLOBAL_CALENDAR,
createEquipment, createMaintenance, submitAndReload, waitForModal, listItem, name,
} = require('./helpers/fixtures')
const { sql, scalar } = require('./helpers/env')
/**
* Checks the client's specification (Manutenzioni - ACS.pdf) point by point.
* The titles quote the paragraph number, so traceability stays readable without
* an external table: docs/manutenzioni-tracciabilita.md points here.
*/
test.describe('§1 Equipment registry', () => {
test('§1.2 the four categories of the spec exist and are manageable', async ({ page }) => {
await page.goto(CATEGORIES)
for (const category of ['Linea di produzione', 'Attrezzature', 'Accessori', 'Qualità']) {
await expect(page.locator('#categoryList')).toContainText(category)
}
// «not hard-coded»: the page has its own CRUD
await expect(page.locator('#btnAddCategory')).toBeVisible()
await expect(listItem(page, 'Linea di produzione').locator('.btn-edit')).toBeVisible()
})
test('§1.6 the existing production lines and tools are in the single registry', async ({ page }) => {
const lines = sql("SELECT name FROM production_lines ORDER BY line_number").split('\n').filter(Boolean)
test.skip(!lines.length, 'no production line in this database')
await page.goto(`${REGISTRY}?status=all`)
for (const line of lines) {
const row = listItem(page, line)
await expect(row, `line ${line} is not in the registry`).toBeVisible()
await expect(row).toContainText('Linea di produzione')
}
})
test('§1.5 the registry filters by production line and by department', async ({ page }) => {
const lineId = scalar('SELECT id FROM production_lines ORDER BY line_number LIMIT 1')
const lineName = scalar(`SELECT name FROM production_lines WHERE id = ${lineId}`)
await page.goto(`${REGISTRY}?line=${lineId}&status=all`)
await expect(listItem(page, lineName)).toBeVisible()
// every listed row really belongs to that line
const shown = Number(scalar(`SELECT COUNT(*) FROM inv_equipment WHERE line_id = ${lineId}`))
const rows = await page.locator('#equipmentList tbody tr, #equipmentList .mnt-item-card')
.filter({ visible: true }).count()
expect(rows).toBe(shown)
// department: a non-existent value must leave no rows behind
await page.goto(`${REGISTRY}?department=999999&status=all`)
await expect(page.locator('.empty-state')).toBeVisible()
})
test('§1.7 every registry field is saved and shown again', async ({ page }) => {
const label = name('Anagrafica completa')
await page.goto(REGISTRY)
await page.click('#btnAddEquipment')
await waitForModal(page, '#equipmentModal')
const values = {
'#eqName': label,
'#eqRegistration': 'MATR-4711',
'#eqSerial': 'SN-99-ABC',
'#eqBatch': 'LOTTO-2024-07',
'#eqManufacturer': 'Colmec',
'#eqPurchase': '2024-03-15',
'#eqCommissioning': '2024-04-01',
'#eqType': 'Estrusore',
'#eqLocation': 'Soppalco linea 2',
'#eqDescription': 'Descrizione della macchina.',
'#eqNotes': 'Nota interna di servizio.',
}
await page.selectOption('#eqCategory', { label: 'Attrezzature' })
for (const [selector, value] of Object.entries(values)) {
await page.fill(selector, value)
}
await submitAndReload(page, '#equipmentForm button[type="submit"]')
// 1. the values come back in the modal
await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`)
await listItem(page, label).locator('.btn-edit').click()
await waitForModal(page, '#equipmentModal')
for (const [selector, value] of Object.entries(values)) {
await expect(page.locator(selector), `field ${selector}`).toHaveValue(value)
}
// 2. and they are readable on the detail page, dates in Italian format
const id = scalar(`SELECT id FROM inv_equipment WHERE name = '${label}'`)
await page.goto(`userarea/manutenzioni/equipment.php?id=${id}`)
for (const text of [
'MATR-4711', 'SN-99-ABC', 'LOTTO-2024-07', 'Colmec',
'15/03/2024', '01/04/2024', 'Soppalco linea 2',
'Descrizione della macchina.', 'Nota interna di servizio.',
]) {
await expect(page.locator('.page-content'), `the detail page does not show ${text}`).toContainText(text)
}
})
test('§1.7 a decommissioned machine disappears from the planning views', async ({ page }) => {
const label = name('Da dismettere')
const title = name('Lavoro su dismessa')
const id = await createEquipment(page, label)
await createMaintenance(page, id, { code: 'D', title, months: 3 })
sql(`UPDATE maint_maintenances SET last_done_date = CURDATE(), next_due_date = DATE_ADD(CURDATE(), INTERVAL 3 MONTH)
WHERE equipment_id = ${id}`)
await page.goto(MAINTENANCES)
await expect(page.locator('#maintenanceList')).toContainText(title)
// it is taken out of service from the registry modal
await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`)
await listItem(page, label).locator('.btn-edit').click()
await waitForModal(page, '#equipmentModal')
await page.selectOption('#eqStatus', 'decommissioned')
await submitAndReload(page, '#equipmentForm button[type="submit"]')
await page.goto(MAINTENANCES)
await expect(page.locator('.mnt-card .card-body')).not.toContainText(title)
const events = await (await page.request.get(
'userarea/manutenzioni/ajax/get_calendar_events.php?start=2020-01-01&end=2030-12-31'
)).json()
expect(events.some((e) => String(e.title).includes(title))).toBe(false)
})
})
test.describe('§2 Maintenance tasks', () => {
test('§2.1 equipment without maintenance tasks stays out of the planning', async ({ page }) => {
const label = name('Senza manutenzioni')
const id = await createEquipment(page, label)
await page.goto(`userarea/manutenzioni/equipment.php?id=${id}`)
await expect(page.locator('.empty-state')).toContainText('Nessuna manutenzione definita')
await expect(page.locator('.empty-state')).toContainText('esclusa dalle pianificazioni')
await page.goto(MAINTENANCES)
await expect(page.locator('.mnt-card .card-body')).not.toContainText(label)
})
test('§2.2 one machine carries several maintenance tasks at once', async ({ page }) => {
const label = name('Con tre lavori')
const id = await createEquipment(page, label)
const titles = [name('Lavoro A'), name('Lavoro B'), name('Lavoro C')]
await createMaintenance(page, id, { code: 'A', title: titles[0], months: 1 })
await createMaintenance(page, id, { code: 'B', title: titles[1], months: 6 })
await createMaintenance(page, id, { code: 'C', title: titles[2], months: 12 })
await page.goto(`userarea/manutenzioni/equipment.php?id=${id}`)
for (const title of titles) {
await expect(page.locator('#maintenanceList')).toContainText(title)
}
await expect(page.locator('#maintenanceList .mnt-item-card')).toHaveCount(3)
// and the registry counts three of them
await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`)
await expect(listItem(page, label)).toContainText('3')
})
test('§2.6 assignee and supervisor are saved and shown on the detail page', async ({ page }) => {
const label = name('Con incaricati')
const title = name('Lavoro con incaricati')
const id = await createEquipment(page, label)
await page.goto(`userarea/manutenzioni/equipment.php?id=${id}`)
await page.click('#btnAddMaintenance')
await waitForModal(page, '#maintenanceModal')
await page.fill('#mtTitle', title)
// two different people from the employee registry
const people = await page.locator('#mtAssignee option').allTextContents()
const [assignee, supervisor] = people.filter((p) => p.trim() && p.trim() !== '—').slice(0, 2)
test.skip(!supervisor, 'at least two active employees are required')
await page.selectOption('#mtAssignee', { label: assignee })
await page.selectOption('#mtSupervisor', { label: supervisor })
await submitAndReload(page, '#maintenanceForm button[type="submit"]')
const card = page.locator(`#maintenanceList .mnt-item-card:has-text("${title}")`).first()
await expect(card).toContainText(assignee.trim())
await expect(card).toContainText(supervisor.trim())
// and they come back in the modal
await card.locator('.btn-edit-maintenance').click()
await waitForModal(page, '#maintenanceModal')
await expect(page.locator('#mtAssignee')).toHaveValue(
await page.locator('#mtAssignee option', { hasText: assignee.trim() }).getAttribute('value')
)
})
})
test.describe('§7 Combined calendar', () => {
test('§7 the three kinds of deadline have distinct colours', async ({ page }) => {
// The imported data has no frequencies, so the «maintenances» layer would be
// empty: this test creates its own due date.
const id = await createEquipment(page, name('Macchina calendario cumulativo'))
await createMaintenance(page, id, { code: 'G', title: name('Lavoro a calendario'), months: 3 })
sql(`UPDATE maint_maintenances SET last_done_date = CURDATE(), next_due_date = DATE_ADD(CURDATE(), INTERVAL 3 MONTH)
WHERE equipment_id = ${id}`)
await page.goto(GLOBAL_CALENDAR)
const events = await (await page.request.get(
'userarea/manutenzioni/ajax/get_global_calendar_events.php' +
'?start=2020-01-01&end=2030-12-31&layers=deadlines,trainings,maintenances'
)).json()
const byLayer = {}
for (const event of events) {
const layer = event.extendedProps.layer
byLayer[layer] = byLayer[layer] || new Set()
byLayer[layer].add(event.backgroundColor)
}
expect(Object.keys(byLayer).sort()).toEqual(['deadlines', 'maintenances', 'trainings'])
// no colour shared between layers (apart from the red used for «overdue»,
// which by design signals urgency on every kind)
const base = {
deadlines: '#6f42c1',
trainings: '#198754',
maintenances: '#2f7d8f',
}
for (const [layer, colour] of Object.entries(base)) {
expect([...byLayer[layer]], `layer ${layer} does not use its own colour`).toContain(colour)
}
// and the markers in the title differ per kind
// charAt would split the emoji (surrogate pairs): we need the first code point
const icons = new Set(events.map((e) => [...String(e.title).trim()][0]))
expect(icons.size).toBeGreaterThanOrEqual(3)
})
})
+168
View File
@@ -0,0 +1,168 @@
const { test, expect } = require('@playwright/test')
const {
REGISTRY, createEquipment, createMaintenance, submitAndReload, waitForModal, name, listItem
} = require('./helpers/fixtures')
const { scalar } = require('./helpers/env')
/** A valid 1×1 PNG: avoids dropping temporary files on disk. */
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
)
const PDF = Buffer.from('%PDF-1.4 prova allegato', 'utf8')
const photo = (n) => ({ name: n, mimeType: 'image/png', buffer: PNG })
const doc = (n) => ({ name: n, mimeType: 'application/pdf', buffer: PDF })
test.describe('Photos, cover image and documents', () => {
let equipmentId
const label = name('Macchina allegati')
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()
equipmentId = await createEquipment(page, label)
await page.close()
})
test('the first uploaded photo becomes the cover, in the registry too', async ({ page }) => {
await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`)
await expect(page.locator('text=Nessuna foto caricata')).toBeVisible()
const reloaded = page.waitForEvent('load')
await page.setInputFiles('#photoInput', photo('prima.png'))
await reloaded
const gallery = page.locator('.mnt-photo-grid .mnt-photo-item')
await expect(gallery).toHaveCount(1)
await expect(gallery.first()).toHaveClass(/is-cover/)
// the cover shows up in the list as well
await page.goto(`${REGISTRY}?q=${encodeURIComponent(label)}`)
await expect(listItem(page, `${label}`).locator('img.mnt-thumb')).toBeVisible()
})
test('the star moves the cover onto the second photo', async ({ page }) => {
await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`)
const reloaded = page.waitForEvent('load')
await page.setInputFiles('#photoInput', photo('seconda.png'))
await reloaded
const gallery = page.locator('.mnt-photo-grid .mnt-photo-item')
await expect(gallery).toHaveCount(2)
// the cover does not move on its own: it stays the one already set.
// The comparison is by id, not by position: created_at has second
// precision and two close uploads can tie.
const coverBefore = scalar(`SELECT cover_file_id FROM inv_equipment WHERE id = ${equipmentId}`)
await expect(page.locator('.mnt-photo-item.is-cover .btn-set-cover')).toHaveAttribute('data-id', coverBefore)
const other = page.locator(`.mnt-photo-item:not(.is-cover) .btn-set-cover`).first()
const otherId = await other.getAttribute('data-id')
const afterCover = page.waitForEvent('load')
await other.click()
await afterCover
expect(scalar(`SELECT cover_file_id FROM inv_equipment WHERE id = ${equipmentId}`)).toBe(otherId)
await expect(page.locator('.mnt-photo-item.is-cover .btn-set-cover')).toHaveAttribute('data-id', otherId)
})
test('a photo is deleted only after confirmation', async ({ page }) => {
await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`)
await page.locator('.mnt-photo-grid .mnt-photo-item').first().locator('.btn-del-photo').click()
await expect(page.locator('.swal2-title')).toContainText('Eliminare il file')
const reloaded = page.waitForEvent('load')
await page.click('.swal2-confirm')
await reloaded
await expect(page.locator('.mnt-photo-grid .mnt-photo-item')).toHaveCount(1)
})
test('documents upload and stay downloadable', async ({ page }) => {
await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`)
await expect(page.locator('text=Nessun documento caricato')).toBeVisible()
const reloaded = page.waitForEvent('load')
await page.setInputFiles('#docInput', doc('manuale-uso.pdf'))
await reloaded
const row = page.locator('.mnt-file-row', { hasText: 'manuale-uso.pdf' })
await expect(row).toBeVisible()
await expect(row.locator('a')).toHaveAttribute('href', /download_file\.php\?scope=equipment&id=\d+/)
// the protected link really answers
const href = await row.locator('a').getAttribute('href')
// href is relative to <base href="/userarea/">, not to baseURL
const response = await page.request.get('userarea/' + href)
expect(response.status()).toBe(200)
})
test('a disallowed file type is rejected with a message', async ({ page }) => {
await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`)
await page.setInputFiles('#docInput', {
name: 'script.php', mimeType: 'application/x-php', buffer: Buffer.from('<?php echo 1;'),
})
await expect(page.locator('.swal2-title')).toContainText('Errore')
await expect(page.locator('.swal2-html-container')).toContainText('non consentito')
})
})
test.describe('Attachments inside the modals', () => {
let equipmentId
const maintenanceTitle = name('Manutenzione con istruzioni')
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()
equipmentId = await createEquipment(page, name('Macchina istruzioni'))
await page.close()
})
test('instructions attached to a maintenance task are listed, reopened and deleted', async ({ page }) => {
await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`)
await page.click('#btnAddMaintenance')
await waitForModal(page, '#maintenanceModal')
await page.fill('#mtCode', 'I')
await page.fill('#mtTitle', maintenanceTitle)
await page.setInputFiles('#mtFilesInput', doc('istruzioni.pdf'))
await submitAndReload(page, '#maintenanceForm button[type="submit"]')
const card = page.locator(`.mnt-item-card:has-text("${maintenanceTitle}")`).first()
await expect(card).toContainText('1 istruzioni')
// reopening the modal, the file is listed
await card.locator('.btn-edit-maintenance').click()
await waitForModal(page, '#maintenanceModal')
const fileRow = page.locator('#mtFilesList .mnt-file-row', { hasText: 'istruzioni.pdf' })
await expect(fileRow).toBeVisible()
await fileRow.locator('.btn-del-file').click()
await expect(page.locator('#mtFilesList .mnt-file-row')).toHaveCount(0)
})
test('an attachment of an intervention shows up in the history', async ({ page }) => {
const title = name('Manutenzione con allegato')
await createMaintenance(page, equipmentId, { code: 'J', title })
await page.locator(`.mnt-item-card:has-text("${title}") .btn-register`).first().click()
await waitForModal(page, '#interventionModal')
await page.fill('#ivDate', '2026-04-18')
await page.fill('#ivNotes', 'Intervento con modulo del fornitore')
await page.setInputFiles('#ivFilesInput', doc('modulo-fornitore.pdf'))
await submitAndReload(page, '#interventionForm button[type="submit"]')
const card = page.locator(`.mnt-item-card:has-text("${title}")`).first()
await card.locator('.btn-toggle-history').click()
const entry = card.locator('.mnt-timeline-item').first()
await expect(entry).toContainText('modulo del fornitore')
await expect(entry.locator('.fa-paperclip')).toBeVisible()
})
})
+133
View File
@@ -0,0 +1,133 @@
const { test, expect } = require('@playwright/test')
const {
createEquipment, createMaintenance, submitAndReload, waitForModal, name, listItem
} = require('./helpers/fixtures')
const { scalar } = require('./helpers/env')
/**
* Modal validation and reload of the values: the fields must come back exactly
* as they were saved, and incomplete data must not get through.
*/
test.describe('Modals: validation and reload of the values', () => {
let equipmentId
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()
equipmentId = await createEquipment(page, name('Macchina moduli'))
await page.close()
})
test.beforeEach(async ({ page }) => {
await page.goto(`userarea/manutenzioni/equipment.php?id=${equipmentId}`)
})
// Title and date carry the required attribute: the browser validation stops
// the submit, so the modal stays open and no request is fired.
test('maintenance without a title: the form blocks and nothing is saved', async ({ page }) => {
const before = scalar(`SELECT COUNT(*) FROM maint_maintenances WHERE equipment_id = ${equipmentId}`)
await page.click('#btnAddMaintenance')
await waitForModal(page, '#maintenanceModal')
await page.click('#maintenanceForm button[type="submit"]')
await expect(page.locator('#maintenanceModal')).toBeVisible()
expect(await page.locator('#mtTitle').evaluate((el) => el.checkValidity())).toBe(false)
expect(scalar(`SELECT COUNT(*) FROM maint_maintenances WHERE equipment_id = ${equipmentId}`)).toBe(before)
})
test('intervention without a date: the form blocks and nothing is saved', async ({ page }) => {
const title = name('Lavoro per validazione')
await createMaintenance(page, equipmentId, { code: 'V', title })
await page.locator(`.mnt-item-card:has-text("${title}") .btn-register`).first().click()
await waitForModal(page, '#interventionModal')
await page.fill('#ivDate', '')
await page.click('#interventionForm button[type="submit"]')
await expect(page.locator('#interventionModal')).toBeVisible()
expect(await page.locator('#ivDate').evaluate((el) => el.checkValidity())).toBe(false)
expect(scalar(`SELECT COUNT(*) FROM maint_interventions
WHERE maintenance_id = (SELECT id FROM maint_maintenances
WHERE equipment_id = ${equipmentId} AND title = '${title}')`)).toBe('0')
})
test('reopening a maintenance task shows the saved values', async ({ page }) => {
const title = name('Manutenzione completa')
await page.click('#btnAddMaintenance')
await waitForModal(page, '#maintenanceModal')
await page.fill('#mtCode', 'Q')
await page.fill('#mtTitle', title)
await page.fill('#mtDescription', 'Descrizione dettagliata del controllo.')
await page.selectOption('#mtType', 'extraordinary')
await page.selectOption('#mtFrequencyUnit', 'week')
await page.fill('#mtFrequencyValue', '6')
await page.fill('#mtFrequencyNote', 'solo durante il periodo estivo')
await page.fill('#mtAlertDays', '21')
await page.check('#mtCritical')
await submitAndReload(page, '#maintenanceForm button[type="submit"]')
await page.locator(`.mnt-item-card:has-text("${title}") .btn-edit-maintenance`).first().click()
await waitForModal(page, '#maintenanceModal')
await expect(page.locator('#mtCode')).toHaveValue('Q')
await expect(page.locator('#mtTitle')).toHaveValue(title)
await expect(page.locator('#mtDescription')).toHaveValue('Descrizione dettagliata del controllo.')
await expect(page.locator('#mtType')).toHaveValue('extraordinary')
await expect(page.locator('#mtFrequencyUnit')).toHaveValue('week')
await expect(page.locator('#mtFrequencyValue')).toHaveValue('6')
await expect(page.locator('#mtFrequencyNote')).toHaveValue('solo durante il periodo estivo')
await expect(page.locator('#mtAlertDays')).toHaveValue('21')
await expect(page.locator('#mtCritical')).toBeChecked()
await expect(page.locator('#mtActive')).toBeChecked()
})
test('reopening an intervention shows the saved values', async ({ page }) => {
const title = name('Lavoro con storico')
await createMaintenance(page, equipmentId, { code: 'W', title })
await page.locator(`.mnt-item-card:has-text("${title}") .btn-register`).first().click()
await waitForModal(page, '#interventionModal')
await page.fill('#ivDate', '2026-03-12')
await page.selectOption('#ivResult', 'compliant')
await page.fill('#ivNotes', 'Sostituita guarnizione')
await page.fill('#ivMaterials', 'Guarnizione 40x60')
await page.fill('#ivOperatorName', 'Tecnico esterno Rossi')
await submitAndReload(page, '#interventionForm button[type="submit"]')
const card = page.locator(`.mnt-item-card:has-text("${title}")`).first()
await card.locator('.btn-toggle-history').click()
await card.locator('.mnt-timeline-item').first().locator('.btn-edit-intervention').click()
await waitForModal(page, '#interventionModal')
await expect(page.locator('#ivDate')).toHaveValue('2026-03-12')
await expect(page.locator('#ivResult')).toHaveValue('compliant')
await expect(page.locator('#ivNotes')).toHaveValue('Sostituita guarnizione')
await expect(page.locator('#ivMaterials')).toHaveValue('Guarnizione 40x60')
await expect(page.locator('#ivOperatorName')).toHaveValue('Tecnico esterno Rossi')
await expect(page.locator('#interventionModalTitle')).toHaveText('Modifica intervento')
})
test('equipment used in production cannot be deleted', async ({ page }) => {
// the imported Taglierina is linked to real production records
const usedId = scalar('SELECT tool_id FROM productiondata_tools LIMIT 1')
test.skip(!usedId, 'no equipment linked to production in this database')
const usedName = scalar(`SELECT name FROM inv_equipment WHERE id = ${usedId}`)
await page.goto(`userarea/manutenzioni/index.php?q=${encodeURIComponent(usedName)}&status=all`)
await listItem(page, `${usedName}`).locator('.btn-delete').first().click()
await expect(page.locator('.swal2-popup')).toBeVisible()
await page.click('.swal2-confirm')
await expect(page.locator('.swal2-title')).toContainText('Errore')
await expect(page.locator('.swal2-html-container')).toContainText('registrazioni di produzione')
expect(scalar(`SELECT COUNT(*) FROM inv_equipment WHERE id = ${usedId}`)).toBe('1')
})
})
+85
View File
@@ -0,0 +1,85 @@
const { chromium } = require('@playwright/test')
const fs = require('fs')
const path = require('path')
const { PREFIX, PASSWORD, EMAIL, EMAIL_LOW, sql, php, cleanup } = require('./helpers/env')
/**
* Prepares the environment once:
* 1. wipes any leftovers from a previous run
* 2. creates two test users — one Admin, one without the maintenance.*
* permissions — plus a test supplier
* 3. stores the cookies of both in tests/e2e/.auth/
*/
async function login(browser, baseURL, email, statePath, attempt = 1) {
const page = await browser.newPage({ baseURL })
await page.goto('login')
await page.fill('input[name="username"]', email)
await page.fill('input[name="password"]', PASSWORD)
await page.click('button[type="submit"], input[type="submit"]')
try {
await page.waitForURL(/userarea/, { timeout: 20_000 })
} catch (error) {
// The login can bounce because of the attempt limit: one more try after a
// pause, then we report the message shown on the page.
const message = (await page.locator('.alert, .invalid-feedback').allTextContents()).join(' ').trim()
await page.close()
if (attempt < 2) {
await new Promise((resolve) => setTimeout(resolve, 15_000))
return login(browser, baseURL, email, statePath, attempt + 1)
}
throw new Error(`Login failed for ${email}${message ? ' — ' + message : ''}`)
}
// the PHP session (PHPSESSID) is created on the first userarea page
const response = await page.goto('userarea/manutenzioni/index.php')
await page.context().storageState({ path: statePath })
await page.close()
return response ? response.status() : 0
}
module.exports = async (config) => {
const baseURL = config.projects[0].use.baseURL
cleanup()
const hash = php('echo password_hash("' + PASSWORD + '", PASSWORD_BCRYPT);')
// role 1 = Admin (has the maintenance.* permissions), role 4 = employee (has not)
sql(`
INSERT INTO auth_users (email, password, status, role_id, first_name, last_name, created_at, updated_at)
VALUES ('${EMAIL}', '${hash}', 'Active', 1, 'ZZ', 'E2E', NOW(), NOW()),
('${EMAIL_LOW}', '${hash}', 'Active', 4, 'ZZ', 'Limitato', NOW(), NOW());
INSERT INTO suppliers (supplier_name) VALUES ('${PREFIX}Fornitore');
`)
const authDir = path.join(__dirname, '.auth')
fs.mkdirSync(authDir, { recursive: true })
const browser = await chromium.launch()
const adminStatus = await login(browser, baseURL, EMAIL, path.join(authDir, 'state.json'))
if (adminStatus !== 200) {
await browser.close()
throw new Error(
`The registry answers ${adminStatus} to the Admin user: ` +
'check the maintenance.* permissions of role 1 (sql/4_permissions.sql).'
)
}
const lowStatus = await login(browser, baseURL, EMAIL_LOW, path.join(authDir, 'state-low.json'))
if (lowStatus !== 403) {
await browser.close()
throw new Error(
`The unprivileged user gets ${lowStatus} instead of 403: ` +
'role 4 has maintenance.* permissions it should not have.'
)
}
await browser.close()
}
+6
View File
@@ -0,0 +1,6 @@
const { cleanup } = require('./helpers/env')
/** Removes users, equipment, maintenances and attachments created by the tests. */
module.exports = async () => {
cleanup()
}

Some files were not shown because too many files have changed in this diff Show More