Merge branch 'feature/inventory-maintaince'

This commit is contained in:
2026-08-25 08:42:16 +02:00
51 changed files with 8200 additions and 48 deletions
+18 -21
View File
@@ -6,40 +6,37 @@ header('Content-Type: application/json');
require_once 'include/headscript.php';
/**
* Soft-deletes a tool: it is marked as decommissioned, not removed, because
* production records reference it (productiondata_tools.tool_id).
*
* production_tools is now the unified registry inv_equipment.
*
* Note: the page calls this endpoint with GET ?id=..., while the previous
* version only read $_POST['id'] and required a name so deletion always
* failed with "Invalid ID.". Both methods are accepted now.
*/
try {
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$id = (int)($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
$tool_type = trim($_POST['tool_type'] ?? '');
$description = trim($_POST['description'] ?? '');
$is_active = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1;
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0);
if ($id <= 0) {
echo json_encode(['success' => false, 'message' => 'Invalid ID.']);
exit;
}
if ($name === '') {
echo json_encode(['success' => false, 'message' => 'Name is required.']);
$stmt = $pdo->prepare("SELECT id FROM inv_equipment WHERE id = ?");
$stmt->execute([$id]);
if (!$stmt->fetchColumn()) {
echo json_encode(['success' => false, 'message' => 'Strumento non trovato.']);
exit;
}
$sql = "UPDATE production_tools
SET name = :name,
tool_type = :tool_type,
description = :description,
is_active = :is_active
WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
'name' => $name,
'tool_type' => $tool_type ?: null,
'description' => $description ?: null,
'is_active' => $is_active,
'id' => $id
]);
$stmt = $pdo->prepare("UPDATE inv_equipment SET status = 'decommissioned' WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
+9 -4
View File
@@ -17,7 +17,7 @@ try {
$toolType = trim($_POST['tool_type'] ?? '');
$manufacturer = trim($_POST['manufacturer'] ?? '');
$description = trim($_POST['description'] ?? '');
$isActive = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1;
$status = (string)($_POST['status'] ?? 'active');
if ($id <= 0) {
echo json_encode(['success' => false, 'message' => 'Invalid ID.']);
@@ -29,14 +29,19 @@ try {
exit;
}
$sql = "UPDATE production_tools
if (!in_array($status, ['active', 'out_of_service', 'decommissioned'], true)) {
$status = 'active';
}
// production_tools is now inv_equipment (see manutenzioni/sql)
$sql = "UPDATE inv_equipment
SET name = :name,
registration_number = :registration_number,
serial_number = :serial_number,
tool_type = :tool_type,
manufacturer = :manufacturer,
description = :description,
is_active = :is_active
status = :status
WHERE id = :id";
$stmt = $pdo->prepare($sql);
@@ -47,7 +52,7 @@ try {
'tool_type' => $toolType ?: null,
'manufacturer' => $manufacturer ?: null,
'description' => $description ?: null,
'is_active' => $isActive,
'status' => $status,
'id' => $id
]);
+60
View File
@@ -346,6 +346,66 @@
</li>
<?php endif; ?>
<?php
$canSeeMaintenance =
userCan('maintenance.equipment.view')
|| userCan('maintenance.maintenances.view')
|| userCan('maintenance.calendar.view')
|| userCan('maintenance.categories.view');
?>
<?php if ($canSeeMaintenance) : ?>
<li>
<a href="javascript:;" class="has-arrow">
<div class="parent-icon">
<i class="bx bx-wrench"></i>
</div>
<div class="menu-title">Manutenzioni</div>
</a>
<ul>
<?php if (userCan('maintenance.equipment.view')) : ?>
<li>
<a href="manutenzioni/index.php">
<i class='bx bx-radio-circle'></i>Registro Attrezzature
</a>
</li>
<?php endif; ?>
<?php if (userCan('maintenance.maintenances.view')) : ?>
<li>
<a href="manutenzioni/maintenances.php">
<i class='bx bx-radio-circle'></i>Manutenzioni
</a>
</li>
<?php endif; ?>
<?php if (userCan('maintenance.calendar.view')) : ?>
<li>
<a href="manutenzioni/calendar.php">
<i class='bx bx-radio-circle'></i>Calendario
</a>
</li>
<?php endif; ?>
<li>
<a href="manutenzioni/calendar_global.php">
<i class='bx bx-radio-circle'></i>Calendario Cumulativo
</a>
</li>
<?php if (userCan('maintenance.categories.view')) : ?>
<li>
<a href="manutenzioni/categories/index.php">
<i class='bx bx-radio-circle'></i>Categorie
</a>
</li>
<?php endif; ?>
</ul>
</li>
<?php endif; ?>
<li>
<a href="javascript:;" class="has-arrow">
<div class="parent-icon">
@@ -0,0 +1,17 @@
```bash
# 1. database — nell'ordine, tutti idempotenti
cd public/userarea/manutenzioni/sql
for f in 1_rename_production_tools 2_create_tables 3_seed_and_link 4_permissions; do
mysql -u <user> -p <database> < $f.sql
done
# 2. dati reali del cliente dagli XLS (facoltativo)
mysql -u <user> -p <database> < 5_import_from_xls.sql
# 3. cartella allegati scrivibile
chmod 755 ../attachments && chown www-data:www-data ../attachments
# 4. cron giornaliero delle notifiche
echo '0 7 * * * php /var/www/html/public/userarea/manutenzioni/cron/send_notifications.php' | crontab -
```
@@ -0,0 +1,82 @@
<?php
/**
* Auth + permission guard for the Manutenzioni ajax endpoints.
* Include at the top of every handler; it defines $currentUserId.
*
* Authorisation is enforced here, on the backend the UI only hides
* buttons, it never decides what a request is allowed to do.
*/
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
header('Content-Type: application/json; charset=utf-8');
if (empty($_SESSION['iduserlogin'])) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Non autorizzato. Effettua il login.']);
exit;
}
$currentUserId = (int)$_SESSION['iduserlogin'];
require_once __DIR__ . '/../include/functions.php';
if (!function_exists('mnt_user_can')) {
/**
* Permission check straight from the Vanguard RBAC tables
* (the Auth facade is not bootstrapped in these endpoints).
*/
function mnt_user_can(string $permission): bool
{
global $currentUserId;
static $permissions = null;
if ($permissions === null) {
$stmt = mnt_pdo()->prepare("
SELECT p.name
FROM auth_users u
INNER JOIN auth_permission_role pr ON pr.role_id = u.role_id
INNER JOIN auth_permissions p ON p.id = pr.permission_id
WHERE u.id = ?
");
$stmt->execute([$currentUserId]);
$permissions = $stmt->fetchAll(PDO::FETCH_COLUMN);
}
return in_array($permission, $permissions, true);
}
}
if (!function_exists('mnt_require_permission')) {
function mnt_require_permission(string $permission): void
{
if (!mnt_user_can($permission)) {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Permesso negato.']);
exit;
}
}
}
if (!function_exists('mnt_json_fail')) {
function mnt_json_fail(string $message, int $code = 200): void
{
if ($code !== 200) {
http_response_code($code);
}
echo json_encode(['success' => false, 'message' => $message]);
exit;
}
}
if (!function_exists('mnt_json_ok')) {
function mnt_json_ok(array $payload = []): void
{
echo json_encode(['success' => true] + $payload);
exit;
}
}
@@ -0,0 +1,31 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
// The FK is ON DELETE SET NULL, so a delete would silently orphan items:
// refuse while the category is still in use.
$stmt = $pdo->prepare("SELECT COUNT(*) FROM inv_equipment WHERE category_id = ?");
$stmt->execute([$id]);
$inUse = (int)$stmt->fetchColumn();
if ($inUse > 0) {
mnt_json_fail('Impossibile eliminare: la categoria è usata da ' . $inUse . ' attrezzature.');
}
$pdo->prepare("DELETE FROM inv_categories WHERE id = ?")->execute([$id]);
mnt_json_ok();
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,85 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("SELECT id, name FROM inv_equipment WHERE id = ?");
$stmt->execute([$id]);
$equipment = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$equipment) {
mnt_json_fail('Attrezzatura non trovata.');
}
// Legacy production records reference tools without ON DELETE CASCADE:
// refuse with a clear message instead of letting the FK throw.
$stmt = $pdo->prepare("SELECT COUNT(*) FROM productiondata_tools WHERE tool_id = ?");
$stmt->execute([$id]);
$usedInProduction = (int)$stmt->fetchColumn();
if ($usedInProduction > 0) {
mnt_json_fail(
'Impossibile eliminare: l\'attrezzatura è usata in ' . $usedInProduction .
' registrazioni di produzione. Impostala su "Dismesso" per escluderla dalle pianificazioni.'
);
}
// Collect stored files before the cascade removes their rows
$storedNames = [];
$stmt = $pdo->prepare("SELECT stored_name FROM inv_equipment_files WHERE equipment_id = ?");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
$stmt = $pdo->prepare("
SELECT f.stored_name
FROM maint_maintenance_files f
INNER JOIN maint_maintenances m ON m.id = f.maintenance_id
WHERE m.equipment_id = ?
");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
$stmt = $pdo->prepare("
SELECT f.stored_name
FROM maint_intervention_files f
INNER JOIN maint_interventions i ON i.id = f.intervention_id
WHERE i.equipment_id = ?
");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
$stmt = $pdo->prepare("SELECT signature_path FROM maint_interventions WHERE equipment_id = ? AND signature_path IS NOT NULL");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
// cover_file_id points at inv_equipment_files, which cascades from the
// equipment row: clear it first so the FK does not block the delete.
$pdo->beginTransaction();
$pdo->prepare("UPDATE inv_equipment SET cover_file_id = NULL WHERE id = ?")->execute([$id]);
$pdo->prepare("DELETE FROM inv_equipment WHERE id = ?")->execute([$id]);
$pdo->commit();
foreach (array_filter($storedNames) as $storedName) {
mnt_delete_stored_file($storedName);
}
mnt_log($pdo, 'equipment_deleted', null, null, null, $currentUserId, null, $equipment['name']);
mnt_json_ok();
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,56 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$scope = (string)($_POST['scope'] ?? 'equipment');
$id = (int)($_POST['id'] ?? 0);
$tables = [
'equipment' => 'inv_equipment_files',
'maintenance' => 'maint_maintenance_files',
'intervention' => 'maint_intervention_files',
];
if (!isset($tables[$scope])) {
mnt_json_fail('Scope non valido.');
}
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("SELECT * FROM {$tables[$scope]} WHERE id = ?");
$stmt->execute([$id]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file) {
mnt_json_fail('File non trovato.');
}
// Clear the cover reference first — the FK would block the delete
if ($scope === 'equipment') {
$pdo->prepare("UPDATE inv_equipment SET cover_file_id = NULL WHERE cover_file_id = ?")->execute([$id]);
}
$pdo->prepare("DELETE FROM {$tables[$scope]} WHERE id = ?")->execute([$id]);
mnt_delete_stored_file((string)$file['stored_name']);
mnt_log(
$pdo,
'file_deleted',
$scope === 'equipment' ? (int)$file['equipment_id'] : null,
$scope === 'maintenance' ? (int)$file['maintenance_id'] : null,
$scope === 'intervention' ? (int)$file['intervention_id'] : null,
$currentUserId,
null,
(string)$file['original_name']
);
mnt_json_ok();
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("SELECT * FROM maint_interventions WHERE id = ?");
$stmt->execute([$id]);
$intervention = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$intervention) {
mnt_json_fail('Intervento non trovato.');
}
$stmt = $pdo->prepare("SELECT stored_name FROM maint_intervention_files WHERE intervention_id = ?");
$stmt->execute([$id]);
$storedNames = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (!empty($intervention['signature_path'])) {
$storedNames[] = $intervention['signature_path'];
}
$pdo->prepare("DELETE FROM maint_interventions WHERE id = ?")->execute([$id]);
foreach (array_filter($storedNames) as $storedName) {
mnt_delete_stored_file($storedName);
}
// Removing the last completed intervention must roll the due date back
mnt_recalc_maintenance($pdo, (int)$intervention['maintenance_id']);
mnt_log(
$pdo,
'intervention_deleted',
(int)$intervention['equipment_id'],
(int)$intervention['maintenance_id'],
null,
$currentUserId,
null,
'Intervento del ' . $intervention['performed_at']
);
mnt_json_ok();
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,53 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("SELECT id, equipment_id, title FROM maint_maintenances WHERE id = ?");
$stmt->execute([$id]);
$maintenance = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$maintenance) {
mnt_json_fail('Manutenzione non trovata.');
}
// Files of the maintenance and of its interventions (both cascade in DB)
$stmt = $pdo->prepare("SELECT stored_name FROM maint_maintenance_files WHERE maintenance_id = ?");
$stmt->execute([$id]);
$storedNames = $stmt->fetchAll(PDO::FETCH_COLUMN);
$stmt = $pdo->prepare("
SELECT f.stored_name
FROM maint_intervention_files f
INNER JOIN maint_interventions i ON i.id = f.intervention_id
WHERE i.maintenance_id = ?
");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
$stmt = $pdo->prepare("SELECT signature_path FROM maint_interventions WHERE maintenance_id = ? AND signature_path IS NOT NULL");
$stmt->execute([$id]);
$storedNames = array_merge($storedNames, $stmt->fetchAll(PDO::FETCH_COLUMN));
$pdo->prepare("DELETE FROM maint_maintenances WHERE id = ?")->execute([$id]);
foreach (array_filter($storedNames) as $storedName) {
mnt_delete_stored_file($storedName);
}
mnt_log($pdo, 'maintenance_deleted', (int)$maintenance['equipment_id'], null, null, $currentUserId, null, $maintenance['title']);
mnt_json_ok();
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,72 @@
<?php
/**
* Serves an attachment. The attachments/ folder itself is closed by
* .htaccess this is the only way to read a file, and it requires a
* logged-in session.
*
* scope = equipment | maintenance | intervention | signature
*/
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['iduserlogin'])) {
http_response_code(401);
exit('Non autorizzato.');
}
require_once __DIR__ . '/../include/functions.php';
$scope = (string)($_GET['scope'] ?? 'equipment');
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
exit('ID non valido.');
}
$tables = [
'equipment' => 'inv_equipment_files',
'maintenance' => 'maint_maintenance_files',
'intervention' => 'maint_intervention_files',
];
$pdo = mnt_pdo();
if ($scope === 'signature') {
$stmt = $pdo->prepare("SELECT signature_path AS stored_name, 'firma.png' AS original_name, 'image/png' AS mime_type FROM maint_interventions WHERE id = ?");
$stmt->execute([$id]);
} elseif (isset($tables[$scope])) {
$stmt = $pdo->prepare("SELECT stored_name, original_name, mime_type FROM {$tables[$scope]} WHERE id = ?");
$stmt->execute([$id]);
} else {
http_response_code(400);
exit('Scope non valido.');
}
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file || empty($file['stored_name'])) {
http_response_code(404);
exit('File non trovato.');
}
// basename() keeps a crafted stored_name from escaping the folder
$path = __DIR__ . '/../attachments/' . basename((string)$file['stored_name']);
if (!is_file($path)) {
http_response_code(404);
exit('File non presente sul server.');
}
$mimeType = $file['mime_type'] ?: 'application/octet-stream';
$disposition = isset($_GET['download']) ? 'attachment' : 'inline';
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($path));
header('Content-Disposition: ' . $disposition . '; filename="' . rawurlencode((string)$file['original_name']) . '"');
header('X-Content-Type-Options: nosniff');
readfile($path);
@@ -0,0 +1,80 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.calendar.view');
/**
* FullCalendar feed for maintenance due dates.
* Optional filters: type=scheduled|extraordinary, category=<id>
*/
try {
$pdo = mnt_pdo();
$start = (string)($_GET['start'] ?? date('Y-m-01', strtotime('-6 months')));
$end = (string)($_GET['end'] ?? date('Y-m-t', strtotime('+12 months')));
$type = (string)($_GET['type'] ?? '');
$categoryId = (int)($_GET['category'] ?? 0);
// Le attrezzature dismesse o fuori servizio restano fuori dalle viste di
// pianificazione: è a questo che serve lo stato (capitolato §1).
$where = [
"m.is_active = 1",
"e.status = 'active'",
"m.next_due_date IS NOT NULL",
"m.next_due_date BETWEEN ? AND ?",
];
$params = [substr($start, 0, 10), substr($end, 0, 10)];
if (array_key_exists($type, mnt_intervention_types())) {
$where[] = "m.intervention_type = ?";
$params[] = $type;
}
if ($categoryId > 0) {
$where[] = "e.category_id = ?";
$params[] = $categoryId;
}
$stmt = $pdo->prepare("
SELECT m.id, m.title, m.code, m.next_due_date, m.alert_days, m.is_critical, m.intervention_type,
e.id AS equipment_id, e.name AS equipment_name,
c.color AS category_color
FROM maint_maintenances m
INNER JOIN inv_equipment e ON e.id = m.equipment_id
LEFT JOIN inv_categories c ON c.id = e.category_id
WHERE " . implode(' AND ', $where) . "
ORDER BY m.next_due_date ASC
");
$stmt->execute($params);
$events = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$state = mnt_due_state($row['next_due_date'], (int)$row['alert_days']);
// Overdue/soon override the category colour so urgency wins visually
$color = $state === 'overdue' ? '#dc3545' : ($state === 'due_soon' ? '#f0a202' : ($row['category_color'] ?: '#2f7d8f'));
$events[] = [
'id' => (int)$row['id'],
'title' => ($row['code'] ? $row['code'] . ' — ' : '') . $row['equipment_name'] . ': ' . $row['title'],
'start' => $row['next_due_date'],
'allDay' => true,
'backgroundColor' => $color,
'borderColor' => $color,
'extendedProps' => [
'equipmentId' => (int)$row['equipment_id'],
'equipmentName' => $row['equipment_name'],
'maintenanceTitle' => $row['title'],
'isCritical' => (int)$row['is_critical'] === 1,
'state' => $state,
'type' => $row['intervention_type'],
],
];
}
echo json_encode($events);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
@@ -0,0 +1,25 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.equipment.view');
try {
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = mnt_pdo()->prepare("SELECT * FROM inv_equipment WHERE id = ?");
$stmt->execute([$id]);
$equipment = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$equipment) {
mnt_json_fail('Attrezzatura non trovata.');
}
mnt_json_ok(['equipment' => $equipment]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,122 @@
<?php
require_once __DIR__ . '/auth_check.php';
/**
* Cumulative calendar feed: deadlines + trainings + maintenances.
* Each layer is returned only if the user may see that module, and only
* if the layer is requested via ?layers=deadlines,trainings,maintenances
*/
try {
$pdo = mnt_pdo();
$start = substr((string)($_GET['start'] ?? date('Y-m-01', strtotime('-6 months'))), 0, 10);
$end = substr((string)($_GET['end'] ?? date('Y-m-t', strtotime('+12 months'))), 0, 10);
$requested = array_filter(explode(',', (string)($_GET['layers'] ?? 'deadlines,trainings,maintenances')));
$wants = fn(string $layer): bool => in_array($layer, $requested, true);
$events = [];
// ---- Maintenances --------------------------------------------------
if ($wants('maintenances') && mnt_user_can('maintenance.calendar.view')) {
$stmt = $pdo->prepare("
SELECT m.id, m.title, m.code, m.next_due_date, m.alert_days, m.is_critical,
e.id AS equipment_id, e.name AS equipment_name
FROM maint_maintenances m
INNER JOIN inv_equipment e ON e.id = m.equipment_id
WHERE m.is_active = 1
AND e.status = 'active'
AND m.next_due_date BETWEEN ? AND ?
");
$stmt->execute([$start, $end]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$state = mnt_due_state($row['next_due_date'], (int)$row['alert_days']);
$color = $state === 'overdue' ? '#dc3545' : '#2f7d8f';
$events[] = [
'id' => 'maint-' . $row['id'],
'title' => '🔧 ' . $row['equipment_name'] . ': ' . $row['title'],
'start' => $row['next_due_date'],
'allDay' => true,
'backgroundColor' => $color,
'borderColor' => $color,
'extendedProps' => [
'layer' => 'maintenances',
'url' => 'manutenzioni/equipment.php?id=' . (int)$row['equipment_id'],
'state' => $state,
],
];
}
}
// ---- Deadlines (scadenzario) --------------------------------------
if ($wants('deadlines') && mnt_user_can('deadlines.view')) {
$stmt = $pdo->prepare("
SELECT d.id, d.topic, d.due_date, d.status, d.notification_days, s.name AS subject_name
FROM scad_deadlines d
LEFT JOIN scad_subjects s ON s.id = d.subject_id
WHERE d.due_date BETWEEN ? AND ?
");
$stmt->execute([$start, $end]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$isCompleted = $row['status'] === 'completed';
$state = $isCompleted ? 'ok' : mnt_due_state($row['due_date'], (int)$row['notification_days']);
$color = $isCompleted ? '#6c757d' : ($state === 'overdue' ? '#b02a37' : '#6f42c1');
$events[] = [
'id' => 'scad-' . $row['id'],
'title' => '📅 ' . $row['topic'],
'start' => $row['due_date'],
'allDay' => true,
'backgroundColor' => $color,
'borderColor' => $color,
'extendedProps' => [
'layer' => 'deadlines',
'url' => 'scadenzario/detail.php?id=' . (int)$row['id'],
'state' => $state,
],
];
}
}
// ---- Trainings (formazione) ---------------------------------------
if ($wants('trainings') && mnt_user_can('hr.trainings.view')) {
$stmt = $pdo->prepare("
SELECT t.id, t.next_due_date, t.reminder_days,
tt.name AS topic_name,
CONCAT(e.first_name, ' ', e.last_name) AS employee_name
FROM employee_trainings t
INNER JOIN training_topics tt ON tt.id = t.training_topic_id
INNER JOIN employees e ON e.id = t.employee_id
WHERE t.next_due_date IS NOT NULL AND t.next_due_date BETWEEN ? AND ?
");
$stmt->execute([$start, $end]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$state = mnt_due_state($row['next_due_date'], (int)($row['reminder_days'] ?? 0));
$color = $state === 'overdue' ? '#a94442' : '#198754';
$events[] = [
'id' => 'train-' . $row['id'],
'title' => '🎓 ' . $row['topic_name'] . ' — ' . $row['employee_name'],
'start' => $row['next_due_date'],
'allDay' => true,
'backgroundColor' => $color,
'borderColor' => $color,
'extendedProps' => [
'layer' => 'trainings',
'url' => 'trainings.php',
'state' => $state,
],
];
}
}
echo json_encode($events);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
@@ -0,0 +1,51 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.maintenances.view');
try {
$pdo = mnt_pdo();
$maintenanceId = (int)($_GET['maintenance_id'] ?? 0);
$interventionId = (int)($_GET['id'] ?? 0);
if ($interventionId > 0) {
$stmt = $pdo->prepare("SELECT * FROM maint_interventions WHERE id = ?");
$stmt->execute([$interventionId]);
$intervention = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$intervention) {
mnt_json_fail('Intervento non trovato.');
}
$stmt = $pdo->prepare("SELECT id, original_name FROM maint_intervention_files WHERE intervention_id = ?");
$stmt->execute([$interventionId]);
mnt_json_ok([
'intervention' => $intervention,
'files' => $stmt->fetchAll(PDO::FETCH_ASSOC),
]);
}
if ($maintenanceId <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("
SELECT i.*,
CONCAT(e.first_name, ' ', e.last_name) AS operator_full_name,
s.supplier_name,
(SELECT COUNT(*) FROM maint_intervention_files f WHERE f.intervention_id = i.id) AS file_count
FROM maint_interventions i
LEFT JOIN employees e ON e.id = i.operator_employee_id
LEFT JOIN suppliers s ON s.idsupplier = i.supplier_id
WHERE i.maintenance_id = ?
ORDER BY i.performed_at DESC, i.id DESC
");
$stmt->execute([$maintenanceId]);
mnt_json_ok(['interventions' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,43 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.maintenances.view');
try {
$pdo = mnt_pdo();
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("
SELECT m.*, e.name AS equipment_name
FROM maint_maintenances m
INNER JOIN inv_equipment e ON e.id = m.equipment_id
WHERE m.id = ?
");
$stmt->execute([$id]);
$maintenance = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$maintenance) {
mnt_json_fail('Manutenzione non trovata.');
}
$stmt = $pdo->prepare("
SELECT id, original_name, size
FROM maint_maintenance_files
WHERE maintenance_id = ?
ORDER BY created_at ASC
");
$stmt->execute([$id]);
mnt_json_ok([
'maintenance' => $maintenance,
'files' => $stmt->fetchAll(PDO::FETCH_ASSOC),
]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,53 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
$name = trim((string)($_POST['name'] ?? ''));
$code = trim((string)($_POST['code'] ?? ''));
$color = trim((string)($_POST['color'] ?? '#6c757d'));
$requiresLine = !empty($_POST['requires_line']) ? 1 : 0;
$sortOrder = (int)($_POST['sort_order'] ?? 999);
$isActive = !empty($_POST['is_active']) ? 1 : 0;
$description = trim((string)($_POST['description'] ?? ''));
if ($name === '') {
mnt_json_fail('Il nome è obbligatorio.');
}
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
$color = '#6c757d';
}
// name is UNIQUE — check first so the user gets a readable message
$stmt = $pdo->prepare("SELECT id FROM inv_categories WHERE name = ? AND id <> ?");
$stmt->execute([$name, $id]);
if ($stmt->fetchColumn()) {
mnt_json_fail('Esiste già una categoria con questo nome.');
}
if ($id > 0) {
$stmt = $pdo->prepare("
UPDATE inv_categories
SET name = ?, code = ?, color = ?, requires_line = ?, sort_order = ?, is_active = ?, description = ?
WHERE id = ?
");
$stmt->execute([$name, $code ?: null, $color, $requiresLine, $sortOrder, $isActive, $description ?: null, $id]);
mnt_json_ok(['id' => $id]);
}
$stmt = $pdo->prepare("
INSERT INTO inv_categories (name, code, color, requires_line, sort_order, is_active, description)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([$name, $code ?: null, $color, $requiresLine, $sortOrder, $isActive, $description ?: null]);
mnt_json_ok(['id' => (int)$pdo->lastInsertId()]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,95 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
$name = trim((string)($_POST['name'] ?? ''));
$categoryId = (int)($_POST['category_id'] ?? 0);
$lineId = (int)($_POST['line_id'] ?? 0);
$registration = trim((string)($_POST['registration_number'] ?? ''));
$serial = trim((string)($_POST['serial_number'] ?? ''));
$batchLot = trim((string)($_POST['batch_lot'] ?? ''));
$manufacturer = trim((string)($_POST['manufacturer'] ?? ''));
$purchaseDate = trim((string)($_POST['purchase_date'] ?? ''));
$commissioningDate = trim((string)($_POST['commissioning_date'] ?? ''));
$toolType = trim((string)($_POST['tool_type'] ?? ''));
$status = (string)($_POST['status'] ?? 'active');
$departmentId = (int)($_POST['department_id'] ?? 0);
$location = trim((string)($_POST['location'] ?? ''));
$description = trim((string)($_POST['description'] ?? ''));
$notes = trim((string)($_POST['notes'] ?? ''));
if ($name === '') {
mnt_json_fail('Il nome è obbligatorio.');
}
if ($categoryId <= 0) {
mnt_json_fail('La categoria è obbligatoria.');
}
if (!array_key_exists($status, mnt_equipment_statuses())) {
mnt_json_fail('Stato non valido.');
}
// A category flagged requires_line must carry a production line
$stmt = $pdo->prepare("SELECT requires_line FROM inv_categories WHERE id = ?");
$stmt->execute([$categoryId]);
$requiresLine = $stmt->fetchColumn();
if ($requiresLine === false) {
mnt_json_fail('Categoria inesistente.');
}
if ((int)$requiresLine === 1 && $lineId <= 0) {
mnt_json_fail('Per questa categoria la linea di produzione è obbligatoria.');
}
$values = [
'name' => $name,
'category_id' => $categoryId,
'line_id' => $lineId ?: null,
'registration_number' => $registration ?: null,
'serial_number' => $serial ?: null,
'batch_lot' => $batchLot ?: null,
'manufacturer' => $manufacturer ?: null,
'purchase_date' => $purchaseDate ?: null,
'commissioning_date' => $commissioningDate ?: null,
'tool_type' => $toolType ?: null,
'status' => $status,
'department_id' => $departmentId ?: null,
'location' => $location ?: null,
'description' => $description ?: null,
'notes' => $notes ?: null,
];
if ($id > 0) {
$assignments = [];
foreach (array_keys($values) as $column) {
$assignments[] = "$column = :$column";
}
$stmt = $pdo->prepare("UPDATE inv_equipment SET " . implode(', ', $assignments) . " WHERE id = :id");
$stmt->execute($values + ['id' => $id]);
mnt_log($pdo, 'equipment_updated', $id, null, null, $currentUserId, $values);
mnt_json_ok(['id' => $id]);
}
$columns = array_keys($values);
$placeholders = array_map(fn($column) => ':' . $column, $columns);
$stmt = $pdo->prepare(
"INSERT INTO inv_equipment (" . implode(', ', $columns) . ", created_by)
VALUES (" . implode(', ', $placeholders) . ", :created_by)"
);
$stmt->execute($values + ['created_by' => $currentUserId]);
$newId = (int)$pdo->lastInsertId();
mnt_log($pdo, 'equipment_created', $newId, null, null, $currentUserId, $values);
mnt_json_ok(['id' => $newId]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,154 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
/**
* Registers (or updates) one intervention against a maintenance and
* recomputes the maintenance schedule.
*
* The operator signature arrives as a data URL from the tablet canvas and
* is stored as a PNG in attachments/.
*/
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
$maintenanceId = (int)($_POST['maintenance_id'] ?? 0);
$performedAt = trim((string)($_POST['performed_at'] ?? ''));
$status = (string)($_POST['status'] ?? 'completed');
$result = trim((string)($_POST['result'] ?? ''));
$notes = trim((string)($_POST['notes'] ?? ''));
$materials = trim((string)($_POST['materials'] ?? ''));
$operatorId = (int)($_POST['operator_employee_id'] ?? 0);
$operatorName = trim((string)($_POST['operator_name'] ?? ''));
$supplierId = (int)($_POST['supplier_id'] ?? 0);
$signatureData = (string)($_POST['signature'] ?? '');
$clearSignature = !empty($_POST['clear_signature']);
if ($performedAt === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $performedAt)) {
mnt_json_fail('La data dell\'intervento è obbligatoria.');
}
$validStatuses = mnt_lookup($pdo, 'maint_status', ['planned' => '', 'in_progress' => '', 'completed' => '']);
if (!array_key_exists($status, $validStatuses)) {
mnt_json_fail('Stato intervento non valido.');
}
$validResults = mnt_lookup($pdo, 'maint_result', []);
if ($result !== '' && $validResults && !array_key_exists($result, $validResults)) {
mnt_json_fail('Esito intervento non valido.');
}
// Existing row: maintenance is taken from the record, not from the client
if ($id > 0) {
$stmt = $pdo->prepare("SELECT * FROM maint_interventions WHERE id = ?");
$stmt->execute([$id]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$existing) {
mnt_json_fail('Intervento non trovato.');
}
$maintenanceId = (int)$existing['maintenance_id'];
$equipmentId = (int)$existing['equipment_id'];
$signaturePath = $existing['signature_path'];
} else {
$stmt = $pdo->prepare("SELECT equipment_id FROM maint_maintenances WHERE id = ?");
$stmt->execute([$maintenanceId]);
$equipmentId = (int)$stmt->fetchColumn();
if (!$equipmentId) {
mnt_json_fail('Manutenzione non trovata.');
}
$signaturePath = null;
}
// Signature handling
if ($clearSignature && $signaturePath) {
mnt_delete_stored_file((string)$signaturePath);
$signaturePath = null;
}
if ($signatureData !== '' && preg_match('#^data:image/png;base64,#', $signatureData)) {
$binary = base64_decode(substr($signatureData, strlen('data:image/png;base64,')), true);
if ($binary === false || strlen($binary) > 2 * 1024 * 1024) {
mnt_json_fail('Firma non valida.');
}
$uploadDir = __DIR__ . '/../attachments/';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) {
mnt_json_fail('Cartella allegati non scrivibile.');
}
$newSignature = uniqid('sig_', true) . '.png';
$newSignature = preg_replace('/[^A-Za-z0-9._-]/', '_', $newSignature);
if (file_put_contents($uploadDir . $newSignature, $binary) === false) {
mnt_json_fail('Impossibile salvare la firma.');
}
if ($signaturePath) {
mnt_delete_stored_file((string)$signaturePath);
}
$signaturePath = $newSignature;
}
$values = [
'performed_at' => $performedAt,
'status' => $status,
'result' => $result ?: null,
'notes' => $notes ?: null,
'materials' => $materials ?: null,
'operator_employee_id' => $operatorId ?: null,
'operator_name' => $operatorName ?: null,
'supplier_id' => $supplierId ?: null,
'signature_path' => $signaturePath,
];
if ($id > 0) {
$assignments = [];
foreach (array_keys($values) as $column) {
$assignments[] = "$column = :$column";
}
$stmt = $pdo->prepare("UPDATE maint_interventions SET " . implode(', ', $assignments) . " WHERE id = :id");
$stmt->execute($values + ['id' => $id]);
$interventionId = $id;
$action = 'intervention_updated';
} else {
$values['maintenance_id'] = $maintenanceId;
$values['equipment_id'] = $equipmentId;
$values['created_by'] = $currentUserId;
$columns = array_keys($values);
$placeholders = array_map(fn($column) => ':' . $column, $columns);
$stmt = $pdo->prepare(
"INSERT INTO maint_interventions (" . implode(', ', $columns) . ")
VALUES (" . implode(', ', $placeholders) . ")"
);
$stmt->execute($values);
$interventionId = (int)$pdo->lastInsertId();
$action = 'intervention_created';
}
// Only completed interventions move the next due date; this call handles it
mnt_recalc_maintenance($pdo, $maintenanceId);
mnt_log($pdo, $action, $equipmentId, $maintenanceId, $interventionId, $currentUserId, $values);
$stmt = $pdo->prepare("SELECT last_done_date, next_due_date FROM maint_maintenances WHERE id = ?");
$stmt->execute([$maintenanceId]);
$schedule = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
mnt_json_ok(['id' => $interventionId, 'schedule' => $schedule]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,121 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$id = (int)($_POST['id'] ?? 0);
$equipmentId = (int)($_POST['equipment_id'] ?? 0);
$code = trim((string)($_POST['code'] ?? ''));
$title = trim((string)($_POST['title'] ?? ''));
$description = trim((string)($_POST['description'] ?? ''));
$interventionType = (string)($_POST['intervention_type'] ?? 'scheduled');
$executionType = (string)($_POST['execution_type'] ?? 'internal');
$isCritical = !empty($_POST['is_critical']) ? 1 : 0;
$frequencyUnit = (string)($_POST['frequency_unit'] ?? 'month');
$frequencyValue = (int)($_POST['frequency_value'] ?? 0);
$frequencyNote = trim((string)($_POST['frequency_note'] ?? ''));
$alertDays = (int)($_POST['alert_days'] ?? 7);
$assigneeId = (int)($_POST['assignee_employee_id'] ?? 0);
$supervisorId = (int)($_POST['supervisor_employee_id'] ?? 0);
$supplierId = (int)($_POST['supplier_id'] ?? 0);
$isActive = isset($_POST['is_active']) ? (int)!empty($_POST['is_active']) : 1;
$notes = trim((string)($_POST['notes'] ?? ''));
if ($title === '') {
mnt_json_fail('Il titolo è obbligatorio.');
}
if (!array_key_exists($interventionType, mnt_intervention_types())) {
mnt_json_fail('Tipo di intervento non valido.');
}
if (!array_key_exists($executionType, mnt_execution_types())) {
mnt_json_fail('Tipo di esecuzione non valido.');
}
if (!array_key_exists($frequencyUnit, mnt_frequency_units())) {
mnt_json_fail('Unità di frequenza non valida.');
}
if ($frequencyUnit !== 'on_demand' && $frequencyValue <= 0) {
mnt_json_fail('Indica ogni quanto va eseguita la manutenzione, oppure scegli "Al bisogno".');
}
if ($executionType === 'external' && $supplierId <= 0) {
mnt_json_fail('Per una manutenzione esterna indica il fornitore.');
}
if ($frequencyUnit === 'on_demand') {
$frequencyValue = null;
}
$values = [
'code' => $code ?: null,
'title' => $title,
'description' => $description ?: null,
'intervention_type' => $interventionType,
'execution_type' => $executionType,
'is_critical' => $isCritical,
'frequency_value' => $frequencyValue,
'frequency_unit' => $frequencyUnit,
'frequency_note' => $frequencyNote ?: null,
'alert_days' => max(0, $alertDays),
'assignee_employee_id' => $assigneeId ?: null,
'supervisor_employee_id' => $supervisorId ?: null,
'supplier_id' => $supplierId ?: null,
'is_active' => $isActive,
'notes' => $notes ?: null,
];
if ($id > 0) {
$stmt = $pdo->prepare("SELECT equipment_id FROM maint_maintenances WHERE id = ?");
$stmt->execute([$id]);
$equipmentId = (int)$stmt->fetchColumn();
if (!$equipmentId) {
mnt_json_fail('Manutenzione non trovata.');
}
$assignments = [];
foreach (array_keys($values) as $column) {
$assignments[] = "$column = :$column";
}
$stmt = $pdo->prepare("UPDATE maint_maintenances SET " . implode(', ', $assignments) . " WHERE id = :id");
$stmt->execute($values + ['id' => $id]);
// frequency may have changed — the due date must follow
mnt_recalc_maintenance($pdo, $id);
mnt_log($pdo, 'maintenance_updated', $equipmentId, $id, null, $currentUserId, $values);
mnt_json_ok(['id' => $id]);
}
if ($equipmentId <= 0) {
mnt_json_fail('Attrezzatura non valida.');
}
$stmt = $pdo->prepare("SELECT id FROM inv_equipment WHERE id = ?");
$stmt->execute([$equipmentId]);
if (!$stmt->fetchColumn()) {
mnt_json_fail('Attrezzatura non trovata.');
}
$values['equipment_id'] = $equipmentId;
$values['created_by'] = $currentUserId;
$columns = array_keys($values);
$placeholders = array_map(fn($column) => ':' . $column, $columns);
$stmt = $pdo->prepare(
"INSERT INTO maint_maintenances (" . implode(', ', $columns) . ")
VALUES (" . implode(', ', $placeholders) . ")"
);
$stmt->execute($values);
$newId = (int)$pdo->lastInsertId();
mnt_log($pdo, 'maintenance_created', $equipmentId, $newId, null, $currentUserId, $values);
mnt_json_ok(['id' => $newId]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,35 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
try {
$pdo = mnt_pdo();
$fileId = (int)($_POST['file_id'] ?? 0);
if ($fileId <= 0) {
mnt_json_fail('ID non valido.');
}
$stmt = $pdo->prepare("SELECT equipment_id, kind FROM inv_equipment_files WHERE id = ?");
$stmt->execute([$fileId]);
$file = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$file) {
mnt_json_fail('Immagine non trovata.');
}
if ($file['kind'] !== 'photo') {
mnt_json_fail('Solo una foto può essere usata come copertina.');
}
$pdo->prepare("UPDATE inv_equipment SET cover_file_id = ? WHERE id = ?")
->execute([$fileId, (int)$file['equipment_id']]);
mnt_log($pdo, 'cover_changed', (int)$file['equipment_id'], null, null, $currentUserId);
mnt_json_ok();
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,105 @@
<?php
require_once __DIR__ . '/auth_check.php';
mnt_require_permission('maintenance.manage');
/**
* Multi-file upload for the three attachment tables.
* scope = equipment | maintenance | intervention
* kind = file | photo (equipment only; photos feed the gallery)
*/
try {
$pdo = mnt_pdo();
$scope = (string)($_POST['scope'] ?? 'equipment');
$ownerId = (int)($_POST['owner_id'] ?? 0);
$kind = ($_POST['kind'] ?? 'file') === 'photo' ? 'photo' : 'file';
$config = [
'equipment' => ['table' => 'inv_equipment_files', 'owner' => 'equipment_id', 'parent' => 'inv_equipment'],
'maintenance' => ['table' => 'maint_maintenance_files', 'owner' => 'maintenance_id', 'parent' => 'maint_maintenances'],
'intervention' => ['table' => 'maint_intervention_files', 'owner' => 'intervention_id', 'parent' => 'maint_interventions'],
];
if (!isset($config[$scope])) {
mnt_json_fail('Scope non valido.');
}
if ($ownerId <= 0) {
mnt_json_fail('ID non valido.');
}
if (empty($_FILES['files']['name'][0])) {
mnt_json_fail('Nessun file selezionato.');
}
$target = $config[$scope];
$stmt = $pdo->prepare("SELECT id FROM {$target['parent']} WHERE id = ?");
$stmt->execute([$ownerId]);
if (!$stmt->fetchColumn()) {
mnt_json_fail('Record collegato non trovato.');
}
$columns = $scope === 'equipment'
? "({$target['owner']}, kind, original_name, stored_name, mime_type, size, uploaded_by)"
: "({$target['owner']}, original_name, stored_name, mime_type, size, uploaded_by)";
$placeholders = $scope === 'equipment' ? '(?, ?, ?, ?, ?, ?, ?)' : '(?, ?, ?, ?, ?, ?)';
$insert = $pdo->prepare("INSERT INTO {$target['table']} $columns VALUES $placeholders");
$uploaded = [];
$errors = [];
$fileCount = count($_FILES['files']['name']);
for ($i = 0; $i < $fileCount; $i++) {
$file = [
'name' => $_FILES['files']['name'][$i],
'type' => $_FILES['files']['type'][$i],
'tmp_name' => $_FILES['files']['tmp_name'][$i],
'error' => $_FILES['files']['error'][$i],
'size' => $_FILES['files']['size'][$i],
];
try {
$stored = mnt_store_upload($file, $scope);
} catch (RuntimeException $e) {
$errors[] = $file['name'] . ': ' . $e->getMessage();
continue;
}
// A file uploaded as a photo but not actually an image is kept as a file
$rowKind = ($kind === 'photo' && $stored['is_image']) ? 'photo' : 'file';
$values = $scope === 'equipment'
? [$ownerId, $rowKind, $stored['original_name'], $stored['stored_name'], $stored['mime_type'], $stored['size'], $currentUserId]
: [$ownerId, $stored['original_name'], $stored['stored_name'], $stored['mime_type'], $stored['size'], $currentUserId];
$insert->execute($values);
$uploaded[] = ['id' => (int)$pdo->lastInsertId(), 'name' => $stored['original_name']];
}
if (!$uploaded) {
mnt_json_fail($errors ? implode('; ', $errors) : 'Nessun file caricato.');
}
// The first photo of an item becomes its cover automatically
if ($scope === 'equipment' && $kind === 'photo') {
$stmt = $pdo->prepare("SELECT cover_file_id FROM inv_equipment WHERE id = ?");
$stmt->execute([$ownerId]);
if (!$stmt->fetchColumn()) {
$pdo->prepare("UPDATE inv_equipment SET cover_file_id = ? WHERE id = ?")
->execute([$uploaded[0]['id'], $ownerId]);
}
}
$equipmentId = $scope === 'equipment' ? $ownerId : null;
$maintenanceId = $scope === 'maintenance' ? $ownerId : null;
$interventionId = $scope === 'intervention' ? $ownerId : null;
mnt_log($pdo, 'file_added', $equipmentId, $maintenanceId, $interventionId, $currentUserId, null,
implode(', ', array_column($uploaded, 'name')));
mnt_json_ok(['uploaded' => $uploaded, 'errors' => $errors]);
} catch (Throwable $e) {
mnt_json_fail('Errore: ' . $e->getMessage());
}
@@ -0,0 +1,6 @@
/*!
FullCalendar Core v6.1.9
Docs & License: https://fullcalendar.io
(c) 2023 Adam Shaw
*/
!function(e){"use strict";var t={code:"it",week:{dow:1,doy:4},buttonText:{prev:"Prec",next:"Succ",today:"Oggi",year:"Anno",month:"Mese",week:"Settimana",day:"Giorno",list:"Agenda"},weekText:"Sm",allDayText:"Tutto il giorno",moreLinkText:e=>"+altri "+e,noEventsText:"Non ci sono eventi da visualizzare"};FullCalendar.globalLocales.push(t)}();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
*
!.gitignore
!.htaccess
@@ -0,0 +1 @@
Deny from all
+120
View File
@@ -0,0 +1,120 @@
<?php include(__DIR__ . '/../include/headscript.php'); ?>
<?php
require_once __DIR__ . '/include/functions.php';
$pdo = mnt_pdo();
if (!userCan('maintenance.calendar.view')) {
http_response_code(403);
exit('Permesso negato.');
}
$formData = mnt_form_data($pdo);
$MNT_TITLE = 'Calendario manutenzioni';
$MNT_PLUGINS = ['fullcalendar'];
?>
<!doctype html>
<html lang="it">
<head>
<?php include __DIR__ . '/include/page_head.php'; ?>
<style>
#mntCalendar { --fc-border-color: #e6eef0; }
#mntCalendar .fc-toolbar-title { font-size: 1.05rem; font-weight: 700; color: var(--mnt-heading); }
#mntCalendar .fc-event { cursor: pointer; font-size: 0.75rem; }
@media (max-width: 767.98px) {
#mntCalendar .fc-toolbar { flex-direction: column; gap: 0.5rem; }
}
</style>
</head>
<body>
<?php include __DIR__ . '/include/wrapper_open.php'; ?>
<?php include(__DIR__ . '/../include/navbar.php'); ?>
<?php include(__DIR__ . '/../include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="card mnt-card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<h5><i class="fa-solid fa-calendar-days me-2"></i>Calendario manutenzioni</h5>
<div class="header-actions d-flex gap-2 flex-wrap">
<a href="manutenzioni/calendar_global.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-layer-group"></i><span>Calendario cumulativo</span>
</a>
<a href="manutenzioni/maintenances.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-list"></i><span>Elenco</span>
</a>
</div>
</div>
<div class="card-body">
<div class="mnt-filter-bar">
<select class="form-select" id="filterType">
<option value="">Programmate e straordinarie</option>
<?php foreach (mnt_intervention_types() as $value => $label): ?>
<option value="<?= mnt_h($value) ?>">Solo <?= mnt_h(mb_strtolower($label)) ?></option>
<?php endforeach; ?>
</select>
<select class="form-select" id="filterCategory">
<option value="">Tutte le categorie</option>
<?php foreach ($formData['categories'] as $category): ?>
<option value="<?= (int)$category['id'] ?>"><?= mnt_h($category['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="d-flex gap-3 flex-wrap mb-3 small">
<span><span class="mnt-badge mnt-badge-overdue">Scaduta</span></span>
<span><span class="mnt-badge mnt-badge-soon">In scadenza</span></span>
<span><span class="mnt-badge mnt-badge-ok">In regola (colore della categoria)</span></span>
</div>
<div id="mntCalendar"></div>
</div>
</div>
</div>
</div>
<?php include(__DIR__ . '/../include/footer.php'); ?>
</div>
<?php include(__DIR__ . '/../jsinclude.php'); ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
const el = document.getElementById('mntCalendar');
const isMobile = window.innerWidth < 768;
const calendar = new FullCalendar.Calendar(el, {
locale: 'it',
initialView: isMobile ? 'listMonth' : 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: isMobile ? 'listMonth,dayGridMonth' : 'dayGridMonth,listMonth'
},
height: 'auto',
firstDay: 1,
events: function (info, success, failure) {
$.getJSON('manutenzioni/ajax/get_calendar_events.php', {
start: info.startStr,
end: info.endStr,
type: $('#filterType').val(),
category: $('#filterCategory').val()
}).done(success).fail(failure);
},
eventClick: function (info) {
window.location = 'manutenzioni/equipment.php?id=' + info.event.extendedProps.equipmentId;
}
});
calendar.render();
$('#filterType, #filterCategory').on('change', () => calendar.refetchEvents());
});
</script>
</body>
</html>
@@ -0,0 +1,127 @@
<?php include(__DIR__ . '/../include/headscript.php'); ?>
<?php
require_once __DIR__ . '/include/functions.php';
// Cumulative calendar: deadlines + trainings + maintenances.
// Visible to anyone who can see at least one of the three modules; each
// layer is filtered again on the backend by its own permission.
$layers = [
'deadlines' => ['label' => 'Scadenze', 'permission' => 'deadlines.view', 'color' => '#6f42c1', 'icon' => '📅'],
'trainings' => ['label' => 'Formazione', 'permission' => 'hr.trainings.view', 'color' => '#198754', 'icon' => '🎓'],
'maintenances' => ['label' => 'Manutenzioni', 'permission' => 'maintenance.calendar.view', 'color' => '#2f7d8f', 'icon' => '🔧'],
];
$availableLayers = array_filter($layers, fn($layer) => userCan($layer['permission']));
if (!$availableLayers) {
http_response_code(403);
exit('Permesso negato.');
}
$MNT_TITLE = 'Calendario cumulativo';
$MNT_PLUGINS = ['fullcalendar'];
?>
<!doctype html>
<html lang="it">
<head>
<?php include __DIR__ . '/include/page_head.php'; ?>
<style>
#mntGlobalCalendar { --fc-border-color: #e6eef0; }
#mntGlobalCalendar .fc-toolbar-title { font-size: 1.05rem; font-weight: 700; color: var(--mnt-heading); }
#mntGlobalCalendar .fc-event { cursor: pointer; font-size: 0.75rem; }
.mnt-layer-toggle { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.35rem 0.75rem; border-radius: 999px; border: 1.5px solid var(--layer-color); color: var(--layer-color); font-weight: 600; font-size: 0.8rem; cursor: pointer; user-select: none; transition: all 0.15s; }
.mnt-layer-toggle input { display: none; }
.mnt-layer-toggle.is-on { background: var(--layer-color); color: #fff; }
@media (max-width: 767.98px) {
#mntGlobalCalendar .fc-toolbar { flex-direction: column; gap: 0.5rem; }
}
</style>
</head>
<body>
<?php include __DIR__ . '/include/wrapper_open.php'; ?>
<?php include(__DIR__ . '/../include/navbar.php'); ?>
<?php include(__DIR__ . '/../include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="card mnt-card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<h5><i class="fa-solid fa-layer-group me-2"></i>Calendario cumulativo</h5>
<div class="header-actions d-flex gap-2 flex-wrap">
<?php if (userCan('maintenance.calendar.view')): ?>
<a href="manutenzioni/calendar.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-calendar-days"></i><span>Solo manutenzioni</span>
</a>
<?php endif; ?>
</div>
</div>
<div class="card-body">
<div class="d-flex gap-2 flex-wrap mb-3">
<?php foreach ($availableLayers as $key => $layer): ?>
<label class="mnt-layer-toggle is-on" style="--layer-color: <?= mnt_h($layer['color']) ?>">
<input type="checkbox" class="layer-checkbox" value="<?= mnt_h($key) ?>" checked>
<span><?= $layer['icon'] ?> <?= mnt_h($layer['label']) ?></span>
</label>
<?php endforeach; ?>
</div>
<div id="mntGlobalCalendar"></div>
</div>
</div>
</div>
</div>
<?php include(__DIR__ . '/../include/footer.php'); ?>
</div>
<?php include(__DIR__ . '/../jsinclude.php'); ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
const el = document.getElementById('mntGlobalCalendar');
const isMobile = window.innerWidth < 768;
function activeLayers() {
return $('.layer-checkbox:checked').map((i, cb) => cb.value).get().join(',');
}
const calendar = new FullCalendar.Calendar(el, {
locale: 'it',
initialView: isMobile ? 'listMonth' : 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: isMobile ? 'listMonth,dayGridMonth' : 'dayGridMonth,listMonth'
},
height: 'auto',
firstDay: 1,
events: function (info, success, failure) {
const layers = activeLayers();
if (!layers) { success([]); return; }
$.getJSON('manutenzioni/ajax/get_global_calendar_events.php', {
start: info.startStr,
end: info.endStr,
layers: layers
}).done(success).fail(failure);
},
eventClick: function (info) {
const url = info.event.extendedProps.url;
if (url) { window.location = url; }
}
});
calendar.render();
$('.layer-checkbox').on('change', function () {
$(this).closest('.mnt-layer-toggle').toggleClass('is-on', this.checked);
calendar.refetchEvents();
});
});
</script>
</body>
</html>
@@ -0,0 +1,278 @@
<?php include(__DIR__ . '/../../include/headscript.php'); ?>
<?php
require_once __DIR__ . '/../include/functions.php';
$pdo = mnt_pdo();
if (!userCan('maintenance.categories.view')) {
http_response_code(403);
exit('Permesso negato.');
}
$canManage = userCan('maintenance.manage');
$categories = $pdo->query("
SELECT c.*,
(SELECT COUNT(*) FROM inv_equipment e WHERE e.category_id = c.id) AS equipment_count
FROM inv_categories c
ORDER BY c.sort_order ASC, c.name ASC
")->fetchAll(PDO::FETCH_ASSOC);
$MNT_TITLE = 'Categorie attrezzature';
?>
<!doctype html>
<html lang="it">
<head>
<?php include __DIR__ . '/../include/page_head.php'; ?>
</head>
<body>
<?php include __DIR__ . '/../include/wrapper_open.php'; ?>
<?php include(__DIR__ . '/../../include/navbar.php'); ?>
<?php include(__DIR__ . '/../../include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb" style="background:transparent;padding:0;margin:0;font-size:0.85rem">
<li class="breadcrumb-item"><a href="manutenzioni/index.php">Registro attrezzature</a></li>
<li class="breadcrumb-item active" aria-current="page">Categorie</li>
</ol>
</nav>
<div class="card mnt-card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<h5><i class="fa-solid fa-tags me-2"></i>Categorie attrezzature</h5>
<div class="header-actions d-flex gap-2 flex-wrap">
<a href="manutenzioni/index.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-arrow-left"></i><span>Registro</span>
</a>
<?php if ($canManage): ?>
<button class="btn btn-mnt-primary d-inline-flex align-items-center gap-2" id="btnAddCategory">
<i class="fa-solid fa-plus"></i><span>Nuova categoria</span>
</button>
<?php endif; ?>
</div>
</div>
<div class="card-body">
<?php if (!$categories): ?>
<div class="empty-state">
<i class="fa-solid fa-tags"></i>
<p>Nessuna categoria definita.</p>
</div>
<?php else: ?>
<div id="categoryList">
<!-- MOBILE -->
<div class="d-md-none">
<?php foreach ($categories as $category): ?>
<div class="mnt-item-card" style="--row-color: <?= mnt_h($category['color']) ?>"
data-id="<?= (int)$category['id'] ?>"
data-json="<?= mnt_h(json_encode($category, JSON_UNESCAPED_UNICODE)) ?>">
<div class="ic-title"><?= mnt_h($category['name']) ?></div>
<div class="ic-meta">
Attrezzature: <strong><?= (int)$category['equipment_count'] ?></strong>
<?php if ((int)$category['requires_line'] === 1): ?>
· <span class="mnt-badge mnt-badge-soft">Richiede linea</span>
<?php endif; ?>
<?php if ((int)$category['is_active'] === 0): ?>
· <span class="mnt-badge mnt-badge-none">Disattivata</span>
<?php endif; ?>
</div>
<?php if ($canManage): ?>
<div class="ic-actions">
<button class="btn-action btn-action-edit btn-edit"><i class="fa-solid fa-pen"></i></button>
<button class="btn-action btn-action-delete btn-delete"
data-name="<?= mnt_h($category['name']) ?>"
data-count="<?= (int)$category['equipment_count'] ?>">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<!-- DESKTOP -->
<div class="d-none d-md-block table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th style="width:60px">Colore</th>
<th>Nome</th>
<th>Codice</th>
<th class="text-center">Richiede linea</th>
<th class="text-center">Ordine</th>
<th class="text-center">Attrezzature</th>
<th class="text-center">Stato</th>
<?php if ($canManage): ?><th class="text-center" style="width:120px">Azioni</th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($categories as $category): ?>
<tr data-id="<?= (int)$category['id'] ?>"
data-json="<?= mnt_h(json_encode($category, JSON_UNESCAPED_UNICODE)) ?>">
<td><span class="mnt-cat-dot" style="width:22px;height:22px;background: <?= mnt_h($category['color']) ?>"></span></td>
<td class="fw-semibold" style="color:var(--mnt-heading)"><?= mnt_h($category['name']) ?></td>
<td class="small text-muted"><?= mnt_h($category['code'] ?: '—') ?></td>
<td class="text-center">
<?= (int)$category['requires_line'] === 1
? '<span class="mnt-badge mnt-badge-soft">Richiede linea</span>'
: '<span class="text-muted">—</span>' ?>
</td>
<td class="text-center small"><?= (int)$category['sort_order'] ?></td>
<td class="text-center"><?= (int)$category['equipment_count'] ?></td>
<td class="text-center">
<span class="mnt-badge <?= (int)$category['is_active'] === 1 ? 'mnt-badge-ok' : 'mnt-badge-none' ?>">
<?= (int)$category['is_active'] === 1 ? 'Attiva' : 'Disattivata' ?>
</span>
</td>
<?php if ($canManage): ?>
<td class="text-center">
<div class="d-inline-flex gap-1">
<button class="btn-action btn-action-edit btn-edit" title="Modifica"><i class="fa-solid fa-pen"></i></button>
<button class="btn-action btn-action-delete btn-delete" title="Elimina"
data-name="<?= mnt_h($category['name']) ?>"
data-count="<?= (int)$category['equipment_count'] ?>">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php include(__DIR__ . '/../../include/footer.php'); ?>
</div>
<?php if ($canManage): ?>
<div class="modal fade" id="categoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="categoryModalTitle">Nuova categoria</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
</div>
<form id="categoryForm">
<div class="modal-body">
<input type="hidden" name="id" id="catId" value="">
<div class="mb-3">
<label class="form-label fw-semibold">Nome <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" id="catName" maxlength="150" required>
</div>
<div class="row g-3">
<div class="col-6">
<label class="form-label fw-semibold">Codice</label>
<input type="text" class="form-control" name="code" id="catCode" maxlength="50">
</div>
<div class="col-6">
<label class="form-label fw-semibold">Ordine</label>
<input type="number" class="form-control" name="sort_order" id="catSort" min="0" max="9999" value="999">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Colore</label>
<input type="color" class="form-control form-control-color" name="color" id="catColor" value="#6c757d">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Descrizione</label>
<textarea class="form-control" name="description" id="catDescription" rows="2"></textarea>
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="requires_line" id="catRequiresLine" value="1">
<label class="form-check-label fw-semibold" for="catRequiresLine">
Richiede una linea di produzione
</label>
<div class="form-text">Le attrezzature di questa categoria devono indicare la linea.</div>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="is_active" id="catActive" value="1" checked>
<label class="form-check-label fw-semibold" for="catActive">Attiva</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-mnt-primary">Salva</button>
</div>
</form>
</div>
</div>
</div>
<?php endif; ?>
<?php include(__DIR__ . '/../../jsinclude.php'); ?>
<script>
$(function () {
<?php if ($canManage): ?>
function openModal(data) {
const isEdit = !!data;
$('#categoryModalTitle').text(isEdit ? 'Modifica categoria' : 'Nuova categoria');
$('#catId').val(isEdit ? data.id : '');
$('#catName').val(isEdit ? data.name : '');
$('#catCode').val(isEdit ? (data.code || '') : '');
$('#catSort').val(isEdit ? data.sort_order : 999);
$('#catColor').val(isEdit ? data.color : '#6c757d');
$('#catDescription').val(isEdit ? (data.description || '') : '');
$('#catRequiresLine').prop('checked', isEdit ? Number(data.requires_line) === 1 : false);
$('#catActive').prop('checked', isEdit ? Number(data.is_active) === 1 : true);
new bootstrap.Modal('#categoryModal').show();
}
$('#btnAddCategory').on('click', () => openModal(null));
$('#categoryList').on('click', '.btn-edit', function () {
openModal($(this).closest('[data-json]').data('json'));
});
$('#categoryList').on('click', '.btn-delete', function () {
const $btn = $(this);
const count = parseInt($btn.data('count') || 0, 10);
const id = $btn.closest('[data-id]').data('id');
if (count > 0) {
Swal.fire({
icon: 'warning', title: 'Impossibile eliminare',
text: `La categoria "${$btn.data('name')}" è usata da ${count} attrezzature.`
});
return;
}
Swal.fire({
title: `Eliminare "${$btn.data('name')}"?`, icon: 'warning', showCancelButton: true,
confirmButtonText: 'Elimina', cancelButtonText: 'Annulla', confirmButtonColor: '#dc3545'
}).then(r => {
if (!r.isConfirmed) return;
$.post('manutenzioni/ajax/delete_category.php', { id: id })
.done(res => res.success ? location.reload() : Swal.fire({ icon: 'error', title: 'Errore', text: res.message }));
});
});
$('#categoryForm').on('submit', function (e) {
e.preventDefault();
if (!$('#catName').val().trim()) { Swal.fire({ icon: 'warning', title: 'Nome obbligatorio' }); return; }
$.post('manutenzioni/ajax/save_category.php', $(this).serialize())
.done(res => res.success ? location.reload() : Swal.fire({ icon: 'error', title: 'Errore', text: res.message }))
.fail(() => Swal.fire({ icon: 'error', title: 'Errore di rete' }));
});
<?php endif; ?>
});
</script>
</body>
</html>
@@ -0,0 +1,196 @@
<?php
/**
* Manutenzioni email notifications
* Run daily, e.g.: 0 7 * * * php /var/www/html/public/userarea/manutenzioni/cron/send_notifications.php
*
* Sends:
* - "advance" when next_due_date <= today + alert_days
* - "overdue" when next_due_date < today
*
* Recipients are the assignee and the supervisor of the maintenance
* (employees.auth_user_id -> auth_users.email). Deduplicated per
* maintenance + email + type + day by maint_notifications.
*/
require_once __DIR__ . '/../../class/db-functions.php';
require_once __DIR__ . '/../include/functions.php';
require_once __DIR__ . '/../../../../vendor/autoload.php';
use Dotenv\Dotenv;
use PHPMailer\PHPMailer\PHPMailer;
$dotenv = Dotenv::createImmutable(__DIR__ . '/../../../../');
$dotenv->safeLoad();
$pdo = mnt_pdo();
$today = date('Y-m-d');
$appUrl = rtrim($_ENV['APP_URL'] ?? 'http://localhost:8001', '/');
/**
* --dry-run (or MNT_MAIL_DRYRUN=1) works out the queue and prints it without
* sending anything and without writing to maint_notifications, so a run leaves
* no trace and can be repeated. Useful before a change to see what tonight
* would deliver, and it is what the test suite drives.
*
* One line per queued message, tab separated:
* QUEUE <type> <email> <maintenance_id> <label>
*/
$dryRun = in_array('--dry-run', $argv ?? [], true) || !empty($_ENV['MNT_MAIL_DRYRUN']);
if ($dryRun) {
echo "DRY-RUN — nothing is sent, nothing is recorded\n";
}
$sent = 0;
$skipped = 0;
$errors = 0;
// Only active maintenances of active equipment, with a real due date
$stmt = $pdo->prepare("
SELECT m.id, m.code, m.title, m.next_due_date, m.alert_days, m.is_critical,
e.id AS equipment_id, e.name AS equipment_name,
m.assignee_employee_id, m.supervisor_employee_id
FROM maint_maintenances m
INNER JOIN inv_equipment e ON e.id = m.equipment_id
WHERE m.is_active = 1
AND e.status = 'active'
AND m.next_due_date IS NOT NULL
AND m.next_due_date <= DATE_ADD(?, INTERVAL m.alert_days DAY)
");
$stmt->execute([$today]);
$maintenances = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!$maintenances) {
echo date('Y-m-d H:i:s') . " — Nessuna manutenzione da notificare.\n";
exit(0);
}
$getRecipient = $pdo->prepare("
SELECT e.id AS employee_id, e.first_name, e.last_name, u.email
FROM employees e
INNER JOIN auth_users u ON u.id = e.auth_user_id
WHERE e.id = ?
AND e.auth_user_id IS NOT NULL
AND u.email IS NOT NULL
AND u.email <> ''
");
$checkSent = $pdo->prepare("
SELECT COUNT(*) FROM maint_notifications
WHERE maintenance_id = ? AND email = ? AND type = ? AND sent_date = ?
");
$insertNotification = $pdo->prepare("
INSERT INTO maint_notifications (maintenance_id, employee_id, email, type, sent_date)
VALUES (?, ?, ?, ?, ?)
");
// mnt_mail_body() and mnt_notification_mail() live in include/functions.php,
// so that the preview renders the very same markup this cron sends.
foreach ($maintenances as $maintenance) {
$isOverdue = $maintenance['next_due_date'] < $today;
$type = $isOverdue ? 'overdue' : 'advance';
$daysLeft = (int)((strtotime($maintenance['next_due_date']) - strtotime($today)) / 86400);
// Assignee + supervisor, de-duplicated by employee id
$recipients = [];
foreach ([$maintenance['assignee_employee_id'], $maintenance['supervisor_employee_id']] as $employeeId) {
if (!$employeeId) {
continue;
}
$getRecipient->execute([(int)$employeeId]);
$recipient = $getRecipient->fetch(PDO::FETCH_ASSOC);
if ($recipient) {
$recipients[(int)$recipient['employee_id']] = $recipient;
}
}
if (!$recipients) {
$skipped++;
continue;
}
$label = ($maintenance['code'] ? $maintenance['code'] . ' — ' : '') . $maintenance['title'];
$detailUrl = $appUrl . '/userarea/manutenzioni/equipment.php?id=' . (int)$maintenance['equipment_id'];
foreach ($recipients as $recipient) {
$checkSent->execute([$maintenance['id'], $recipient['email'], $type, $today]);
if ((int)$checkSent->fetchColumn() > 0) {
$skipped++;
continue;
}
if ($dryRun) {
printf("QUEUE\t%s\t%s\t%d\t%s\n", $type, $recipient['email'], (int)$maintenance['id'], $label);
$sent++;
continue;
}
try {
$mail = new PHPMailer(true);
if (($_ENV['MAIL_MAILER'] ?? 'mail') === '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'] ?? '';
}
$encryption = $_ENV['MAIL_ENCRYPTION'] ?? '';
if ($encryption && $encryption !== 'null') {
$mail->SMTPSecure = $encryption;
}
}
$mail->CharSet = 'UTF-8';
$mail->isHTML(true);
$mail->setFrom(
$_ENV['MAIL_FROM_ADDRESS'] ?? 'noreply@zibogomma.it',
$_ENV['MAIL_FROM_NAME'] ?? 'Manutenzioni ZIBOGOMMA'
);
$mail->addAddress($recipient['email'], trim($recipient['first_name'] . ' ' . $recipient['last_name']));
$composed = mnt_notification_mail($maintenance, $label, $daysLeft, $detailUrl, $today);
$mail->Subject = $composed['subject'];
$mail->Body = $composed['body'];
$mail->send();
$insertNotification->execute([
$maintenance['id'],
(int)$recipient['employee_id'],
$recipient['email'],
$type,
$today,
]);
mnt_log(
$pdo,
'notification_sent',
(int)$maintenance['equipment_id'],
(int)$maintenance['id'],
null,
null,
null,
$type . ' → ' . $recipient['email']
);
$sent++;
} catch (Throwable $e) {
$errors++;
echo date('Y-m-d H:i:s') . ' — Errore invio a ' . $recipient['email'] . ': ' . $e->getMessage() . "\n";
}
}
}
echo date('Y-m-d H:i:s')
. ($dryRun ? " — In coda: $sent" : " — Inviate: $sent")
. ", saltate: $skipped, errori: $errors\n";
+560
View File
@@ -0,0 +1,560 @@
<?php include(__DIR__ . '/../include/headscript.php'); ?>
<?php
require_once __DIR__ . '/include/functions.php';
$pdo = mnt_pdo();
if (!userCan('maintenance.equipment.view')) {
http_response_code(403);
exit('Permesso negato.');
}
$canManage = userCan('maintenance.manage');
$canSeeMaintenances = userCan('maintenance.maintenances.view');
$equipmentId = isset($_GET['id']) && is_numeric($_GET['id']) ? (int)$_GET['id'] : 0;
$stmt = $pdo->prepare("
SELECT e.*,
c.name AS category_name, c.color AS category_color,
pl.name AS line_name, pl.line_number,
d.name AS department_name,
cf.stored_name AS cover_stored
FROM inv_equipment e
LEFT JOIN inv_categories c ON c.id = e.category_id
LEFT JOIN production_lines pl ON pl.id = e.line_id
LEFT JOIN departments d ON d.id = e.department_id
LEFT JOIN inv_equipment_files cf ON cf.id = e.cover_file_id
WHERE e.id = ?
");
$stmt->execute([$equipmentId]);
$equipment = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$equipment) {
http_response_code(404);
exit('Attrezzatura non trovata.');
}
// Photos and documents
$stmt = $pdo->prepare("SELECT * FROM inv_equipment_files WHERE equipment_id = ? ORDER BY created_at ASC");
$stmt->execute([$equipmentId]);
$files = $stmt->fetchAll(PDO::FETCH_ASSOC);
$photos = array_values(array_filter($files, fn($f) => $f['kind'] === 'photo'));
$documents = array_values(array_filter($files, fn($f) => $f['kind'] !== 'photo'));
// Maintenances
$maintenances = [];
$interventionsByMaintenance = [];
if ($canSeeMaintenances) {
$stmt = $pdo->prepare("
SELECT m.*,
CONCAT(a.first_name, ' ', a.last_name) AS assignee_name,
CONCAT(s.first_name, ' ', s.last_name) AS supervisor_name,
sup.supplier_name,
(SELECT COUNT(*) FROM maint_interventions i WHERE i.maintenance_id = m.id) AS intervention_count,
(SELECT COUNT(*) FROM maint_maintenance_files f WHERE f.maintenance_id = m.id) AS file_count
FROM maint_maintenances m
LEFT JOIN employees a ON a.id = m.assignee_employee_id
LEFT JOIN employees s ON s.id = m.supervisor_employee_id
LEFT JOIN suppliers sup ON sup.idsupplier = m.supplier_id
WHERE m.equipment_id = ?
ORDER BY m.is_active DESC, m.code ASC, m.title ASC
");
$stmt->execute([$equipmentId]);
$maintenances = $stmt->fetchAll(PDO::FETCH_ASSOC);
$stmt = $pdo->prepare("
SELECT i.*,
CONCAT(e.first_name, ' ', e.last_name) AS operator_full_name,
sup.supplier_name,
(SELECT COUNT(*) FROM maint_intervention_files f WHERE f.intervention_id = i.id) AS file_count
FROM maint_interventions i
LEFT JOIN employees e ON e.id = i.operator_employee_id
LEFT JOIN suppliers sup ON sup.idsupplier = i.supplier_id
WHERE i.equipment_id = ?
ORDER BY i.performed_at DESC, i.id DESC
");
$stmt->execute([$equipmentId]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $intervention) {
$interventionsByMaintenance[(int)$intervention['maintenance_id']][] = $intervention;
}
}
$formData = mnt_form_data($pdo);
$statuses = mnt_equipment_statuses();
$statusLabels = mnt_lookup($pdo, 'maint_status', ['planned' => 'Pianificato', 'in_progress' => 'In corso', 'completed' => 'Completato']);
$resultLabels = mnt_lookup($pdo, 'maint_result', []);
$typeLabels = mnt_intervention_types();
$executionLabels = mnt_execution_types();
$MNT_TITLE = $equipment['name'];
?>
<!doctype html>
<html lang="it">
<head>
<?php include __DIR__ . '/include/page_head.php'; ?>
</head>
<body>
<?php include __DIR__ . '/include/wrapper_open.php'; ?>
<?php include(__DIR__ . '/../include/navbar.php'); ?>
<?php include(__DIR__ . '/../include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb" style="background:transparent;padding:0;margin:0;font-size:0.85rem">
<li class="breadcrumb-item"><a href="manutenzioni/index.php">Registro attrezzature</a></li>
<li class="breadcrumb-item active" aria-current="page"><?= mnt_h($equipment['name']) ?></li>
</ol>
</nav>
<div class="card mnt-card mb-3">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<h5 class="d-flex align-items-center gap-2 flex-wrap">
<span class="mnt-cat-dot" style="background: <?= mnt_h($equipment['category_color'] ?? '#adb5bd') ?>"></span>
<?= mnt_h($equipment['name']) ?>
<span class="mnt-badge mnt-badge-status-<?= mnt_h($equipment['status']) ?>">
<?= mnt_h($statuses[$equipment['status']] ?? $equipment['status']) ?>
</span>
</h5>
<div class="header-actions d-flex gap-2 flex-wrap">
<a href="manutenzioni/print.php?id=<?= (int)$equipment['id'] ?>" target="_blank"
class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-print"></i><span>Scheda PDF</span>
</a>
<?php if ($canManage): ?>
<button class="btn btn-mnt-outline d-inline-flex align-items-center gap-2" id="btnEditEquipment">
<i class="fa-solid fa-pen"></i><span>Modifica</span>
</button>
<button class="btn btn-mnt-primary d-inline-flex align-items-center gap-2" id="btnAddMaintenance">
<i class="fa-solid fa-plus"></i><span>Nuova manutenzione</span>
</button>
<?php endif; ?>
</div>
</div>
</div>
<div class="row g-3">
<!-- ------------------------------------------------ left: data -->
<div class="col-12 col-lg-4">
<div class="card mnt-card mb-3">
<div class="card-header"><h5><i class="fa-solid fa-circle-info me-2"></i>Dati</h5></div>
<div class="card-body">
<?php if (!empty($equipment['cover_stored'])): ?>
<img src="manutenzioni/ajax/download_file.php?scope=equipment&id=<?= (int)$equipment['cover_file_id'] ?>"
alt="" class="w-100 mb-3" style="border-radius:.5rem;object-fit:cover;max-height:220px">
<?php endif; ?>
<dl class="mb-0">
<div class="mnt-kv"><dt>Categoria</dt><dd><?= mnt_h($equipment['category_name'] ?? '—') ?></dd></div>
<?php if ($equipment['line_name']): ?>
<div class="mnt-kv"><dt>Linea</dt><dd>Linea <?= (int)$equipment['line_number'] ?> — <?= mnt_h($equipment['line_name']) ?></dd></div>
<?php endif; ?>
<div class="mnt-kv"><dt>Matricola</dt><dd><?= mnt_h($equipment['registration_number'] ?: '—') ?></dd></div>
<div class="mnt-kv"><dt>Numero di serie</dt><dd><?= mnt_h($equipment['serial_number'] ?: '—') ?></dd></div>
<div class="mnt-kv"><dt>Lotto / partita</dt><dd><?= mnt_h($equipment['batch_lot'] ?: '—') ?></dd></div>
<div class="mnt-kv"><dt>Costruttore</dt><dd><?= mnt_h($equipment['manufacturer'] ?: '—') ?></dd></div>
<div class="mnt-kv"><dt>Tipo</dt><dd><?= mnt_h($equipment['tool_type'] ?: '—') ?></dd></div>
<div class="mnt-kv"><dt>Acquisto</dt><dd><?= mnt_format_date($equipment['purchase_date']) ?></dd></div>
<div class="mnt-kv"><dt>Messa in servizio</dt><dd><?= mnt_format_date($equipment['commissioning_date']) ?></dd></div>
<div class="mnt-kv"><dt>Reparto</dt><dd><?= mnt_h($equipment['department_name'] ?: '—') ?></dd></div>
<div class="mnt-kv"><dt>Ubicazione</dt><dd><?= mnt_h($equipment['location'] ?: '—') ?></dd></div>
</dl>
<?php if ($equipment['description']): ?>
<div class="mnt-section-title mt-3">Descrizione</div>
<div class="small"><?= nl2br(mnt_h($equipment['description'])) ?></div>
<?php endif; ?>
<?php if ($equipment['notes']): ?>
<div class="mnt-section-title mt-3">Note</div>
<div class="small"><?= nl2br(mnt_h($equipment['notes'])) ?></div>
<?php endif; ?>
</div>
</div>
<div class="card mnt-card mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<h5><i class="fa-solid fa-images me-2"></i>Foto</h5>
<?php if ($canManage): ?>
<button class="btn btn-mnt-outline btn-sm" id="btnAddPhotos">
<i class="fa-solid fa-plus"></i>
</button>
<input type="file" id="photoInput" accept="image/*" multiple hidden>
<?php endif; ?>
</div>
<div class="card-body">
<?php if (!$photos): ?>
<p class="text-muted small mb-0">Nessuna foto caricata.</p>
<?php else: ?>
<div class="mnt-photo-grid">
<?php foreach ($photos as $photo): ?>
<div class="mnt-photo-item <?= (int)$equipment['cover_file_id'] === (int)$photo['id'] ? 'is-cover' : '' ?>">
<a href="manutenzioni/ajax/download_file.php?scope=equipment&id=<?= (int)$photo['id'] ?>" target="_blank">
<img src="manutenzioni/ajax/download_file.php?scope=equipment&id=<?= (int)$photo['id'] ?>"
alt="<?= mnt_h($photo['original_name']) ?>">
</a>
<?php if ($canManage): ?>
<div class="photo-tools">
<button class="btn-set-cover" data-id="<?= (int)$photo['id'] ?>" title="Usa come copertina">
<i class="fa-solid fa-star"></i>
</button>
<button class="btn-del-photo" data-id="<?= (int)$photo['id'] ?>" title="Elimina">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<div class="card mnt-card">
<div class="card-header d-flex align-items-center justify-content-between">
<h5><i class="fa-solid fa-folder-open me-2"></i>Documenti</h5>
<?php if ($canManage): ?>
<button class="btn btn-mnt-outline btn-sm" id="btnAddDocs">
<i class="fa-solid fa-plus"></i>
</button>
<input type="file" id="docInput" multiple hidden>
<?php endif; ?>
</div>
<div class="card-body">
<?php if (!$documents): ?>
<p class="text-muted small mb-0">Nessun documento caricato.</p>
<?php else: ?>
<?php foreach ($documents as $document): ?>
<div class="mnt-file-row">
<i class="fa-solid fa-file text-muted"></i>
<a class="file-name" target="_blank"
href="manutenzioni/ajax/download_file.php?scope=equipment&id=<?= (int)$document['id'] ?>">
<?= mnt_h($document['original_name']) ?>
</a>
<?php if ($canManage): ?>
<button class="btn-action btn-action-delete btn-del-doc" data-id="<?= (int)$document['id'] ?>">
<i class="fa-solid fa-trash"></i>
</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<!-- --------------------------------------- right: maintenances -->
<div class="col-12 col-lg-8">
<div class="card mnt-card">
<div class="card-header"><h5><i class="fa-solid fa-screwdriver-wrench me-2"></i>Manutenzioni</h5></div>
<div class="card-body">
<?php if (!$canSeeMaintenances): ?>
<p class="text-muted small mb-0">Non hai i permessi per vedere le manutenzioni.</p>
<?php elseif (!$maintenances): ?>
<div class="empty-state">
<i class="fa-solid fa-screwdriver-wrench"></i>
<p>Nessuna manutenzione definita per questa attrezzatura.<br>
Un'attrezzatura senza manutenzioni è esclusa dalle pianificazioni.</p>
</div>
<?php else: ?>
<div id="maintenanceList">
<?php foreach ($maintenances as $maintenance): ?>
<?php
$state = mnt_due_state($maintenance['next_due_date'], (int)$maintenance['alert_days']);
$badge = mnt_due_badge($state, $maintenance['frequency_unit']);
$interventions = $interventionsByMaintenance[(int)$maintenance['id']] ?? [];
?>
<div class="mnt-item-card" data-id="<?= (int)$maintenance['id'] ?>"
style="--row-color: <?= $maintenance['is_critical'] ? '#dc3545' : 'var(--mnt-primary)' ?>;<?= (int)$maintenance['is_active'] === 0 ? 'opacity:.6' : '' ?>">
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
<div class="flex-grow-1">
<div class="ic-title">
<?php if ($maintenance['code']): ?>
<span class="mnt-badge mnt-badge-soft me-1"><?= mnt_h($maintenance['code']) ?></span>
<?php endif; ?>
<?= mnt_h($maintenance['title']) ?>
<?php if ($maintenance['is_critical']): ?>
<span class="mnt-badge mnt-badge-critical ms-1">Critica</span>
<?php endif; ?>
<?php if ((int)$maintenance['is_active'] === 0): ?>
<span class="mnt-badge mnt-badge-none ms-1">Disattivata</span>
<?php endif; ?>
</div>
<div class="ic-meta">
<?= mnt_h($typeLabels[$maintenance['intervention_type']] ?? '') ?> ·
<?= mnt_h($executionLabels[$maintenance['execution_type']] ?? '') ?>
<?php if ($maintenance['supplier_name']): ?>
(<?= mnt_h($maintenance['supplier_name']) ?>)
<?php endif; ?>
· <?= mnt_h(mnt_format_frequency(
$maintenance['frequency_value'] !== null ? (int)$maintenance['frequency_value'] : null,
$maintenance['frequency_unit'],
$maintenance['frequency_note']
)) ?>
</div>
<div class="ic-meta">
Ultimo: <strong><?= mnt_format_date($maintenance['last_done_date']) ?></strong> ·
Prossimo: <strong><?= mnt_format_date($maintenance['next_due_date']) ?></strong>
<span class="mnt-badge <?= $badge['class'] ?> ms-1"><?= $badge['label'] ?></span>
</div>
<?php if ($maintenance['assignee_name'] || $maintenance['supervisor_name']): ?>
<div class="ic-meta">
<?php if ($maintenance['assignee_name']): ?>
Incaricato: <strong><?= mnt_h($maintenance['assignee_name']) ?></strong>
<?php endif; ?>
<?php if ($maintenance['supervisor_name']): ?>
· Responsabile: <strong><?= mnt_h($maintenance['supervisor_name']) ?></strong>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($maintenance['description']): ?>
<div class="ic-meta"><?= nl2br(mnt_h($maintenance['description'])) ?></div>
<?php endif; ?>
<?php if ((int)$maintenance['file_count'] > 0): ?>
<div class="ic-meta"><i class="fa-solid fa-paperclip"></i> <?= (int)$maintenance['file_count'] ?> istruzioni</div>
<?php endif; ?>
</div>
<div class="d-flex gap-1 flex-shrink-0">
<?php if ($canManage): ?>
<button class="btn-action btn-action-done btn-register" title="Registra intervento">
<i class="fa-solid fa-check"></i>
</button>
<button class="btn-action btn-action-edit btn-edit-maintenance" title="Modifica">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn-action btn-action-delete btn-delete-maintenance"
title="Elimina" data-title="<?= mnt_h($maintenance['title']) ?>"
data-count="<?= (int)$maintenance['intervention_count'] ?>">
<i class="fa-solid fa-trash"></i>
</button>
<?php endif; ?>
</div>
</div>
<?php if ($interventions): ?>
<div class="mt-3">
<button class="btn btn-sm btn-light w-100 btn-toggle-history" type="button">
<i class="fa-solid fa-clock-rotate-left me-1"></i>
Storico interventi (<?= count($interventions) ?>)
</button>
<div class="mnt-timeline mt-3" style="display:none">
<?php foreach ($interventions as $intervention): ?>
<div class="mnt-timeline-item" data-intervention-id="<?= (int)$intervention['id'] ?>">
<div class="tl-date">
<?= mnt_format_date($intervention['performed_at']) ?>
· <?= mnt_h($statusLabels[$intervention['status']] ?? $intervention['status']) ?>
<?php if ($intervention['result']): ?>
· <?= mnt_h($resultLabels[$intervention['result']] ?? $intervention['result']) ?>
<?php endif; ?>
</div>
<div class="tl-body">
<?php
$operator = $intervention['operator_full_name'] ?: $intervention['operator_name'];
?>
<?php if ($operator): ?>
<strong><?= mnt_h($operator) ?></strong>
<?php endif; ?>
<?php if ($intervention['supplier_name']): ?>
<span class="text-muted">(<?= mnt_h($intervention['supplier_name']) ?>)</span>
<?php endif; ?>
<?php if ($intervention['notes']): ?>
<div><?= nl2br(mnt_h($intervention['notes'])) ?></div>
<?php endif; ?>
<?php if ($intervention['materials']): ?>
<div class="small text-muted">Materiali: <?= mnt_h($intervention['materials']) ?></div>
<?php endif; ?>
<div class="d-flex align-items-center gap-2 mt-1 flex-wrap">
<?php if ($intervention['signature_path']): ?>
<img src="manutenzioni/ajax/download_file.php?scope=signature&id=<?= (int)$intervention['id'] ?>"
alt="Firma" style="height:38px;border:1px solid #e6eef0;border-radius:.3rem;background:#fff">
<?php endif; ?>
<?php if ((int)$intervention['file_count'] > 0): ?>
<span class="small text-muted"><i class="fa-solid fa-paperclip"></i> <?= (int)$intervention['file_count'] ?></span>
<?php endif; ?>
<?php if ($canManage): ?>
<button class="btn-action btn-action-edit btn-edit-intervention" title="Modifica">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn-action btn-action-delete btn-delete-intervention" title="Elimina">
<i class="fa-solid fa-trash"></i>
</button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include(__DIR__ . '/../include/footer.php'); ?>
</div>
<?php if ($canManage): ?>
<?php include __DIR__ . '/include/equipment_modal.php'; ?>
<?php include __DIR__ . '/include/maintenance_modal.php'; ?>
<?php include __DIR__ . '/include/intervention_modal.php'; ?>
<?php endif; ?>
<?php include(__DIR__ . '/../jsinclude.php'); ?>
<script>
$(function () {
const EQUIPMENT_ID = <?= (int)$equipment['id'] ?>;
const EQUIPMENT_NAME = <?= json_encode($equipment['name'], JSON_UNESCAPED_UNICODE) ?>;
$('#maintenanceList').on('click', '.btn-toggle-history', function () {
$(this).next('.mnt-timeline').slideToggle(150);
});
<?php if ($canManage): ?>
// ---------------------------------------------------- equipment
$('#btnEditEquipment').on('click', function () {
$.getJSON('manutenzioni/ajax/get_equipment.php', { id: EQUIPMENT_ID })
.done(res => {
if (res.success) { mntOpenEquipmentModal(res.equipment); }
else { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); }
});
});
function uploadEquipmentFiles(input, kind) {
if (!input.files.length) return;
const form = new FormData();
form.append('scope', 'equipment');
form.append('owner_id', EQUIPMENT_ID);
form.append('kind', kind);
Array.from(input.files).forEach(f => form.append('files[]', f));
$.ajax({
url: 'manutenzioni/ajax/upload_file.php',
method: 'POST', data: form, processData: false, contentType: false, dataType: 'json'
}).done(res => {
if (res.success) { location.reload(); }
else { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); }
}).fail(() => Swal.fire({ icon: 'error', title: 'Errore di rete' }));
}
$('#btnAddPhotos').on('click', () => $('#photoInput').click());
$('#photoInput').on('change', function () { uploadEquipmentFiles(this, 'photo'); });
$('#btnAddDocs').on('click', () => $('#docInput').click());
$('#docInput').on('change', function () { uploadEquipmentFiles(this, 'file'); });
$('.btn-set-cover').on('click', function () {
$.post('manutenzioni/ajax/set_cover.php', { file_id: $(this).data('id') })
.done(res => res.success ? location.reload() : Swal.fire({ icon: 'error', title: 'Errore', text: res.message }));
});
$('.btn-del-photo, .btn-del-doc').on('click', function () {
const id = $(this).data('id');
Swal.fire({
title: 'Eliminare il file?', icon: 'warning', showCancelButton: true,
confirmButtonText: 'Elimina', cancelButtonText: 'Annulla', confirmButtonColor: '#dc3545'
}).then(r => {
if (!r.isConfirmed) return;
$.post('manutenzioni/ajax/delete_file.php', { scope: 'equipment', id: id })
.done(res => res.success ? location.reload() : Swal.fire({ icon: 'error', title: 'Errore', text: res.message }));
});
});
// -------------------------------------------------- maintenance
$('#btnAddMaintenance').on('click', function () {
mntOpenMaintenanceModal(null, EQUIPMENT_ID);
});
$('#maintenanceList').on('click', '.btn-edit-maintenance', function () {
const id = $(this).closest('[data-id]').data('id');
$.getJSON('manutenzioni/ajax/get_maintenance.php', { id: id })
.done(res => {
if (!res.success) { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); return; }
const data = res.maintenance;
data.files = res.files;
mntOpenMaintenanceModal(data, EQUIPMENT_ID);
});
});
$('#maintenanceList').on('click', '.btn-delete-maintenance', function () {
const $btn = $(this);
const id = $btn.closest('[data-id]').data('id');
const count = parseInt($btn.data('count') || 0, 10);
Swal.fire({
title: `Eliminare "${$btn.data('title')}"?`,
html: count > 0 ? `<p>Verranno eliminati anche <strong>${count}</strong> interventi registrati.</p>` : '',
icon: 'warning', showCancelButton: true,
confirmButtonText: 'Elimina', cancelButtonText: 'Annulla', confirmButtonColor: '#dc3545'
}).then(r => {
if (!r.isConfirmed) return;
$.post('manutenzioni/ajax/delete_maintenance.php', { id: id })
.done(res => res.success ? location.reload() : Swal.fire({ icon: 'error', title: 'Errore', text: res.message }));
});
});
// ------------------------------------------------- interventions
$('#maintenanceList').on('click', '.btn-register', function () {
const id = $(this).closest('[data-id]').data('id');
$.getJSON('manutenzioni/ajax/get_maintenance.php', { id: id })
.done(res => {
if (!res.success) { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); return; }
mntOpenInterventionModal(null, {
id: res.maintenance.id,
title: res.maintenance.title,
equipment_name: EQUIPMENT_NAME,
supplier_id: res.maintenance.supplier_id
});
});
});
$('#maintenanceList').on('click', '.btn-edit-intervention', function () {
const interventionId = $(this).closest('[data-intervention-id]').data('intervention-id');
const maintenanceId = $(this).closest('[data-id]').data('id');
$.getJSON('manutenzioni/ajax/get_interventions.php', { id: interventionId })
.done(res => {
if (!res.success) { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); return; }
const data = res.intervention;
data.files = res.files;
mntOpenInterventionModal(data, {
id: maintenanceId,
title: '',
equipment_name: EQUIPMENT_NAME
});
});
});
$('#maintenanceList').on('click', '.btn-delete-intervention', function () {
const interventionId = $(this).closest('[data-intervention-id]').data('intervention-id');
Swal.fire({
title: 'Eliminare l\'intervento?',
text: 'La data della prossima manutenzione verrà ricalcolata.',
icon: 'warning', showCancelButton: true,
confirmButtonText: 'Elimina', cancelButtonText: 'Annulla', confirmButtonColor: '#dc3545'
}).then(r => {
if (!r.isConfirmed) return;
$.post('manutenzioni/ajax/delete_intervention.php', { id: interventionId })
.done(res => res.success ? location.reload() : Swal.fire({ icon: 'error', title: 'Errore', text: res.message }));
});
});
<?php endif; ?>
});
</script>
</body>
</html>
@@ -0,0 +1,190 @@
<?php
/**
* Create/edit modal for a registry item.
* Expects $formData (mnt_form_data) in scope. Defines mntOpenEquipmentModal(data).
*/
$statuses = $statuses ?? mnt_equipment_statuses();
?>
<div class="modal fade" id="equipmentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="equipmentModalTitle">Nuova attrezzatura</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
</div>
<form id="equipmentForm">
<div class="modal-body">
<input type="hidden" name="id" id="eqId" value="">
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold">Nome <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" id="eqName" maxlength="100" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Categoria <span class="text-danger">*</span></label>
<select class="form-select" name="category_id" id="eqCategory" required>
<option value=""> seleziona </option>
<?php foreach ($formData['categories'] as $category): ?>
<option value="<?= (int)$category['id'] ?>" data-requires-line="<?= (int)$category['requires_line'] ?>">
<?= mnt_h($category['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6" id="eqLineWrap" style="display:none">
<label class="form-label fw-semibold">Linea di produzione <span class="text-danger">*</span></label>
<select class="form-select" name="line_id" id="eqLine">
<option value=""> seleziona </option>
<?php foreach ($formData['lines'] as $line): ?>
<option value="<?= (int)$line['id'] ?>">
Linea <?= (int)$line['line_number'] ?> — <?= mnt_h($line['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Matricola</label>
<input type="text" class="form-control" name="registration_number" id="eqRegistration" maxlength="100">
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Numero di serie</label>
<input type="text" class="form-control" name="serial_number" id="eqSerial" maxlength="100">
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Lotto / partita</label>
<input type="text" class="form-control" name="batch_lot" id="eqBatch" maxlength="100">
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Costruttore</label>
<input type="text" class="form-control" name="manufacturer" id="eqManufacturer" maxlength="150">
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Data di acquisto</label>
<input type="date" class="form-control" name="purchase_date" id="eqPurchase">
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Data di messa in servizio</label>
<input type="date" class="form-control" name="commissioning_date" id="eqCommissioning">
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Tipo</label>
<input type="text" class="form-control" name="tool_type" id="eqType" maxlength="50">
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Stato</label>
<select class="form-select" name="status" id="eqStatus">
<?php foreach ($statuses as $value => $label): ?>
<option value="<?= mnt_h($value) ?>"><?= mnt_h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Reparto</label>
<select class="form-select" name="department_id" id="eqDepartment">
<option value=""></option>
<?php foreach ($formData['departments'] as $department): ?>
<option value="<?= (int)$department['id'] ?>"><?= mnt_h($department['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Ubicazione</label>
<input type="text" class="form-control" name="location" id="eqLocation" maxlength="255">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Descrizione</label>
<textarea class="form-control" name="description" id="eqDescription" rows="2"></textarea>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Note</label>
<textarea class="form-control" name="notes" id="eqNotes" rows="2"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-mnt-primary">Salva</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Shows the line picker only for categories flagged requires_line
function mntSyncLineVisibility() {
const requiresLine = $('#eqCategory option:selected').data('requires-line') == 1;
$('#eqLineWrap').toggle(requiresLine);
$('#eqLine').prop('required', requiresLine);
if (!requiresLine) { $('#eqLine').val(''); }
}
function mntOpenEquipmentModal(data) {
const isEdit = !!data;
$('#equipmentModalTitle').text(isEdit ? 'Modifica attrezzatura' : 'Nuova attrezzatura');
$('#eqId').val(isEdit ? data.id : '');
$('#eqName').val(isEdit ? (data.name || '') : '');
$('#eqCategory').val(isEdit && data.category_id ? String(data.category_id) : '');
$('#eqLine').val(isEdit && data.line_id ? String(data.line_id) : '');
$('#eqRegistration').val(isEdit ? (data.registration_number || '') : '');
$('#eqSerial').val(isEdit ? (data.serial_number || '') : '');
$('#eqBatch').val(isEdit ? (data.batch_lot || '') : '');
$('#eqManufacturer').val(isEdit ? (data.manufacturer || '') : '');
$('#eqPurchase').val(isEdit ? (data.purchase_date || '') : '');
$('#eqCommissioning').val(isEdit ? (data.commissioning_date || '') : '');
$('#eqType').val(isEdit ? (data.tool_type || '') : '');
$('#eqStatus').val(isEdit ? (data.status || 'active') : 'active');
$('#eqDepartment').val(isEdit && data.department_id ? String(data.department_id) : '');
$('#eqLocation').val(isEdit ? (data.location || '') : '');
$('#eqDescription').val(isEdit ? (data.description || '') : '');
$('#eqNotes').val(isEdit ? (data.notes || '') : '');
mntSyncLineVisibility();
new bootstrap.Modal('#equipmentModal').show();
}
$(function () {
$('#eqCategory').on('change', mntSyncLineVisibility);
$('#equipmentForm').on('submit', function (e) {
e.preventDefault();
const name = $('#eqName').val().trim();
if (!name) { Swal.fire({ icon: 'warning', title: 'Il nome è obbligatorio' }); return; }
if (!$('#eqCategory').val()) { Swal.fire({ icon: 'warning', title: 'Seleziona una categoria' }); return; }
if ($('#eqLineWrap').is(':visible') && !$('#eqLine').val()) {
Swal.fire({ icon: 'warning', title: 'Seleziona la linea di produzione' });
return;
}
$.post('manutenzioni/ajax/save_equipment.php', $(this).serialize())
.done(res => {
if (res.success) {
if (window.mntAfterEquipmentSave) { window.mntAfterEquipmentSave(res); }
else { location.reload(); }
} else {
Swal.fire({ icon: 'error', title: 'Errore', text: res.message });
}
})
.fail(() => Swal.fire({ icon: 'error', title: 'Errore di rete' }));
});
});
</script>
@@ -0,0 +1,483 @@
<?php
/**
* Manutenzioni shared helpers.
*
* Included by every page and ajax endpoint of the module. Assumes a PDO
* connection is available through DBHandlerSelect (class/db-functions.php).
*/
require_once __DIR__ . '/../../class/db-functions.php';
if (!function_exists('mnt_pdo')) {
function mnt_pdo(): PDO
{
return DBHandlerSelect::getInstance()->getConnection();
}
}
if (!function_exists('mnt_h')) {
function mnt_h($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
}
/**
* Minimal HTML email body, same visual language as the scadenzario mails.
*
* Lives here rather than in cron/send_notifications.php so that the preview
* (make mail-preview-html) renders exactly what the cron sends, instead of a
* copy that drifts.
*/
if (!function_exists('mnt_mail_body')) {
function mnt_mail_body(string $heading, string $subject, string $message, string $color, string $url): string
{
$safeHeading = htmlspecialchars($heading, ENT_QUOTES, 'UTF-8');
$safeSubject = htmlspecialchars($subject, ENT_QUOTES, 'UTF-8');
return '<!doctype html><html><body style="margin:0;padding:24px;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;color:#1e3a44">'
. '<div style="max-width:560px;margin:0 auto;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,.06)">'
. '<div style="background:' . $color . ';color:#fff;padding:16px 20px;font-size:17px;font-weight:bold">' . $safeHeading . '</div>'
. '<div style="padding:20px">'
. '<p style="font-size:15px;font-weight:bold;margin:0 0 10px">' . $safeSubject . '</p>'
. '<p style="font-size:14px;line-height:1.55;margin:0 0 18px">' . $message . '</p>'
. '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" style="display:inline-block;background:#2f7d8f;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:14px">Apri la scheda</a>'
. '</div></div></body></html>';
}
}
/**
* Subject and body of a notification, for both kinds.
*
* @param array $maintenance needs equipment_name, next_due_date, is_critical
* @return array{subject:string, body:string}
*/
if (!function_exists('mnt_notification_mail')) {
function mnt_notification_mail(array $maintenance, string $label, int $daysLeft, string $detailUrl, ?string $today = null): array
{
$today = $today ?? date('Y-m-d');
$isOverdue = $maintenance['next_due_date'] < $today;
$due = date('d/m/Y', strtotime($maintenance['next_due_date']));
$criticalNote = (int)($maintenance['is_critical'] ?? 0) === 1
? '<br><strong style="color:#b02a37">Manutenzione critica.</strong>'
: '';
if ($isOverdue) {
return [
'subject' => '⚠️ Manutenzione scaduta: ' . $maintenance['equipment_name'],
'body' => mnt_mail_body(
'Manutenzione scaduta',
$maintenance['equipment_name'] . ' — ' . $label,
'Era prevista per il <strong>' . $due . '</strong>, in ritardo di <strong>'
. abs($daysLeft) . ' giorni</strong>.' . $criticalNote,
'#dc3545',
$detailUrl
),
];
}
$daysText = $daysLeft === 0 ? '<strong>oggi</strong>' : 'tra <strong>' . $daysLeft . ' giorni</strong>';
return [
'subject' => '🔧 Manutenzione in scadenza: ' . $maintenance['equipment_name'],
'body' => mnt_mail_body(
'Manutenzione in scadenza',
$maintenance['equipment_name'] . ' — ' . $label,
'È prevista per il <strong>' . $due . '</strong> (' . $daysText . ').' . $criticalNote,
'#2f7d8f',
$detailUrl
),
];
}
}
/**
* Reads ?page and ?per_page for the paged lists.
*
* per_page is a whitelist, not a free number: it lands in a LIMIT clause and
* an open value would let anyone ask for the whole table in one request.
* Returns [page, perPage] with page still unbounded above the caller clamps
* it once it knows how many rows the filters actually match.
*/
if (!function_exists('mnt_page_params')) {
function mnt_page_params(int $default = 25): array
{
$allowed = [25, 50, 100];
$perPage = isset($_GET['per_page']) ? (int)$_GET['per_page'] : $default;
if (!in_array($perPage, $allowed, true)) {
$perPage = $default;
}
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
return [max(1, $page), $perPage];
}
}
/**
* Frequency units offered in the maintenance form.
* 'on_demand' = no automatic due date (the client's "al bisogno" cases).
*/
if (!function_exists('mnt_frequency_units')) {
function mnt_frequency_units(): array
{
return [
'day' => 'Giorni',
'week' => 'Settimane',
'month' => 'Mesi',
'year' => 'Anni',
'on_demand' => 'Al bisogno (senza scadenza automatica)',
];
}
}
if (!function_exists('mnt_intervention_types')) {
function mnt_intervention_types(): array
{
return ['scheduled' => 'Programmata', 'extraordinary' => 'Straordinaria'];
}
}
if (!function_exists('mnt_execution_types')) {
function mnt_execution_types(): array
{
return ['internal' => 'Interna', 'external' => 'Esterna'];
}
}
if (!function_exists('mnt_equipment_statuses')) {
function mnt_equipment_statuses(): array
{
return [
'active' => 'Attivo',
'out_of_service' => 'Fuori servizio',
'decommissioned' => 'Dismesso',
];
}
}
/**
* Options managed through the existing lookup_values.php CRUD.
* Falls back to sensible defaults if the category has no rows yet.
*/
if (!function_exists('mnt_lookup')) {
function mnt_lookup(PDO $pdo, string $category, array $fallback = []): array
{
static $cache = [];
if (!isset($cache[$category])) {
$stmt = $pdo->prepare("
SELECT value, label
FROM ws_lookup_options
WHERE category = ? AND is_active = 1
ORDER BY sort_order ASC, label ASC
");
$stmt->execute([$category]);
$options = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$options[$row['value']] = $row['label'];
}
$cache[$category] = $options ?: $fallback;
}
return $cache[$category];
}
}
/**
* Next due date = last completed intervention + frequency.
* Returns null when the maintenance has no automatic schedule or was never done.
*/
if (!function_exists('mnt_calc_next_due')) {
function mnt_calc_next_due(?string $lastDoneDate, ?int $frequencyValue, string $frequencyUnit): ?string
{
if ($lastDoneDate === null || $frequencyUnit === 'on_demand' || !$frequencyValue) {
return null;
}
$map = ['day' => 'day', 'week' => 'week', 'month' => 'month', 'year' => 'year'];
if (!isset($map[$frequencyUnit])) {
return null;
}
$date = date_create($lastDoneDate);
if (!$date) {
return null;
}
$dayOfMonth = (int)$date->format('j');
$date->modify('+' . $frequencyValue . ' ' . $map[$frequencyUnit]);
// PHP fa traboccare i mesi corti: 31/01 +1 mese diventa 03/03.
// Per una manutenzione la data attesa è l'ultimo giorno del mese.
if (in_array($frequencyUnit, ['month', 'year'], true) && (int)$date->format('j') !== $dayOfMonth) {
$date->modify('last day of previous month');
}
return $date->format('Y-m-d');
}
}
/**
* Recompute last_done_date / next_due_date of a maintenance from its
* completed interventions. Call after any intervention insert/update/delete.
*/
if (!function_exists('mnt_recalc_maintenance')) {
function mnt_recalc_maintenance(PDO $pdo, int $maintenanceId): void
{
$stmt = $pdo->prepare("SELECT frequency_value, frequency_unit FROM maint_maintenances WHERE id = ?");
$stmt->execute([$maintenanceId]);
$maintenance = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$maintenance) {
return;
}
// Only completed interventions move the schedule; planned/in-progress do not.
$stmt = $pdo->prepare("
SELECT MAX(performed_at)
FROM maint_interventions
WHERE maintenance_id = ? AND status = 'completed'
");
$stmt->execute([$maintenanceId]);
$lastDone = $stmt->fetchColumn() ?: null;
$nextDue = mnt_calc_next_due(
$lastDone,
$maintenance['frequency_value'] !== null ? (int)$maintenance['frequency_value'] : null,
(string)$maintenance['frequency_unit']
);
$stmt = $pdo->prepare("UPDATE maint_maintenances SET last_done_date = ?, next_due_date = ? WHERE id = ?");
$stmt->execute([$lastDone, $nextDue, $maintenanceId]);
}
}
/**
* Schedule state of a maintenance, used for badges and filters.
* Returns one of: overdue | due_soon | ok | none
*/
if (!function_exists('mnt_due_state')) {
function mnt_due_state(?string $nextDueDate, int $alertDays = 0, ?string $today = null): string
{
if (empty($nextDueDate)) {
return 'none';
}
$today = $today ?: date('Y-m-d');
if ($nextDueDate < $today) {
return 'overdue';
}
$threshold = date('Y-m-d', strtotime($today . ' +' . max(0, $alertDays) . ' days'));
return $nextDueDate <= $threshold ? 'due_soon' : 'ok';
}
}
if (!function_exists('mnt_due_badge')) {
/**
* Lo stato "none" copre due casi diversi che non vanno confusi:
* una manutenzione al bisogno (per scelta senza scadenza) e una
* programmata mai eseguita (la scadenza arriverà al primo intervento).
*/
function mnt_due_badge(string $state, ?string $frequencyUnit = null): array
{
switch ($state) {
case 'overdue':
return ['label' => 'Scaduta', 'class' => 'mnt-badge-overdue'];
case 'due_soon':
return ['label' => 'In scadenza', 'class' => 'mnt-badge-soon'];
case 'ok':
return ['label' => 'In regola', 'class' => 'mnt-badge-ok'];
default:
if ($frequencyUnit !== null && $frequencyUnit !== 'on_demand') {
return ['label' => 'Mai eseguita', 'class' => 'mnt-badge-none'];
}
return ['label' => 'Al bisogno', 'class' => 'mnt-badge-none'];
}
}
}
if (!function_exists('mnt_format_date')) {
function mnt_format_date(?string $date): string
{
if (empty($date) || $date === '0000-00-00') {
return '—';
}
$ts = strtotime($date);
return $ts ? date('d/m/Y', $ts) : '—';
}
}
/**
* Human-readable frequency ("Ogni 3 mesi", "Al bisogno").
*/
if (!function_exists('mnt_format_frequency')) {
function mnt_format_frequency(?int $value, string $unit, ?string $note = null): string
{
$units = [
'day' => ['giorno', 'giorni'],
'week' => ['settimana', 'settimane'],
'month' => ['mese', 'mesi'],
'year' => ['anno', 'anni'],
];
if ($unit === 'on_demand' || !$value || !isset($units[$unit])) {
$text = 'Al bisogno';
} else {
$word = $value === 1 ? $units[$unit][0] : $units[$unit][1];
$text = 'Ogni ' . $value . ' ' . $word;
}
if (!empty($note)) {
$text .= ' — ' . $note;
}
return $text;
}
}
/**
* Audit log entry. All ids are optional so the same call works for
* equipment, maintenance and intervention events.
*/
if (!function_exists('mnt_log')) {
function mnt_log(
PDO $pdo,
string $action,
?int $equipmentId = null,
?int $maintenanceId = null,
?int $interventionId = null,
?int $userId = null,
?array $changes = null,
?string $notes = null
): void {
$stmt = $pdo->prepare("
INSERT INTO maint_histories
(equipment_id, maintenance_id, intervention_id, user_id, action, changes, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$equipmentId,
$maintenanceId,
$interventionId,
$userId,
$action,
$changes !== null ? json_encode($changes, JSON_UNESCAPED_UNICODE) : null,
$notes,
]);
}
}
/**
* Store one uploaded file into attachments/ and return its metadata.
* Throws RuntimeException on a rejected file.
*/
if (!function_exists('mnt_store_upload')) {
function mnt_store_upload(array $file, string $prefix = 'mnt'): array
{
$allowedExtensions = [
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'odt', 'ods',
'jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'bmp', 'zip',
];
$maxBytes = 20 * 1024 * 1024;
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Errore durante il caricamento del file.');
}
if (($file['size'] ?? 0) > $maxBytes) {
throw new RuntimeException('File troppo grande (massimo 20 MB).');
}
$originalName = (string)($file['name'] ?? 'file');
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions, true)) {
throw new RuntimeException('Tipo di file non consentito: .' . $extension);
}
$uploadDir = __DIR__ . '/../attachments/';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) {
throw new RuntimeException('Cartella allegati non scrivibile.');
}
$storedName = uniqid($prefix . '_', true) . '.' . $extension;
$safeStored = preg_replace('/[^A-Za-z0-9._-]/', '_', $storedName);
if (!move_uploaded_file($file['tmp_name'], $uploadDir . $safeStored)) {
throw new RuntimeException('Impossibile salvare il file.');
}
return [
'original_name' => $originalName,
'stored_name' => $safeStored,
'mime_type' => $file['type'] ?? null,
'size' => (int)($file['size'] ?? 0),
'is_image' => in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], true),
];
}
}
if (!function_exists('mnt_delete_stored_file')) {
function mnt_delete_stored_file(string $storedName): void
{
$path = __DIR__ . '/../attachments/' . basename($storedName);
if (is_file($path)) {
@unlink($path);
}
}
}
/**
* Employees / suppliers / departments pickers, shared by several forms.
*/
if (!function_exists('mnt_form_data')) {
function mnt_form_data(PDO $pdo): array
{
return [
'categories' => $pdo->query("
SELECT id, name, color, requires_line
FROM inv_categories
WHERE is_active = 1
ORDER BY sort_order ASC, name ASC
")->fetchAll(PDO::FETCH_ASSOC),
'lines' => $pdo->query("
SELECT id, line_number, name, color
FROM production_lines
ORDER BY line_number ASC
")->fetchAll(PDO::FETCH_ASSOC),
'departments' => $pdo->query("
SELECT id, name
FROM departments
WHERE is_active = 1
ORDER BY sort_order ASC, name ASC
")->fetchAll(PDO::FETCH_ASSOC),
'employees' => $pdo->query("
SELECT id, CONCAT(first_name, ' ', last_name) AS full_name
FROM employees
WHERE status = 'active'
ORDER BY first_name ASC, last_name ASC
")->fetchAll(PDO::FETCH_ASSOC),
'suppliers' => $pdo->query("
SELECT idsupplier AS id, supplier_name AS name
FROM suppliers
ORDER BY supplier_name ASC
")->fetchAll(PDO::FETCH_ASSOC),
];
}
}
@@ -0,0 +1,310 @@
<?php
/**
* Register/edit one intervention.
* Expects $formData and $pdo in scope. Defines mntOpenInterventionModal(data, maintenance).
*
* The signature is drawn on a canvas (finger on tablet, mouse on PC) and
* posted as a PNG data URL.
*/
$statusOptions = mnt_lookup($pdo, 'maint_status', [
'planned' => 'Pianificato',
'in_progress' => 'In corso',
'completed' => 'Completato',
]);
$resultOptions = mnt_lookup($pdo, 'maint_result', [
'compliant' => 'Conforme',
'not_compliant' => 'Non conforme',
'blocked' => 'Bloccato',
]);
?>
<div class="modal fade" id="interventionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="interventionModalTitle">Registra intervento</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
</div>
<form id="interventionForm">
<div class="modal-body">
<input type="hidden" name="id" id="ivId" value="">
<input type="hidden" name="maintenance_id" id="ivMaintenanceId" value="">
<input type="hidden" name="signature" id="ivSignature" value="">
<input type="hidden" name="clear_signature" id="ivClearSignature" value="">
<div class="alert alert-light border small mb-3" id="ivContext"></div>
<div class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label fw-semibold">Data <span class="text-danger">*</span></label>
<input type="date" class="form-control" name="performed_at" id="ivDate" required>
</div>
<div class="col-6 col-md-4">
<label class="form-label fw-semibold">Stato</label>
<select class="form-select" name="status" id="ivStatus">
<?php foreach ($statusOptions as $value => $label): ?>
<option value="<?= mnt_h($value) ?>" <?= $value === 'completed' ? 'selected' : '' ?>>
<?= mnt_h($label) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-6 col-md-4">
<label class="form-label fw-semibold">Esito</label>
<select class="form-select" name="result" id="ivResult">
<option value=""></option>
<?php foreach ($resultOptions as $value => $label): ?>
<option value="<?= mnt_h($value) ?>"><?= mnt_h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Operatore</label>
<select class="form-select" name="operator_employee_id" id="ivOperator">
<option value=""></option>
<?php foreach ($formData['employees'] as $employee): ?>
<option value="<?= (int)$employee['id'] ?>"><?= mnt_h($employee['full_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Operatore esterno</label>
<input type="text" class="form-control" name="operator_name" id="ivOperatorName" maxlength="191"
placeholder="Nome tecnico non presente in anagrafica">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Fornitore</label>
<select class="form-select" name="supplier_id" id="ivSupplier">
<option value=""></option>
<?php foreach ($formData['suppliers'] as $supplier): ?>
<option value="<?= (int)$supplier['id'] ?>"><?= mnt_h($supplier['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Note sull'intervento</label>
<textarea class="form-control" name="notes" id="ivNotes" rows="3"></textarea>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Materiali / parti sostituite</label>
<textarea class="form-control" name="materials" id="ivMaterials" rows="2"></textarea>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Firma operatore</label>
<div class="mnt-signature-wrap">
<canvas id="ivSignatureCanvas"></canvas>
</div>
<div class="d-flex justify-content-between align-items-center mt-1">
<span class="form-text mb-0">Firma con il dito su tablet o con il mouse.</span>
<button type="button" class="btn btn-sm btn-light" id="ivSignatureClear">Cancella firma</button>
</div>
<div id="ivExistingSignature" class="mt-2" style="display:none">
<span class="mnt-section-title">Firma registrata</span>
<img id="ivExistingSignatureImg" alt="Firma" style="max-height:90px;border:1px solid #e6eef0;border-radius:.4rem;background:#fff">
</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Allegati (modulo del fornitore, foto)</label>
<div id="ivFilesList" class="mb-2"></div>
<input type="file" class="form-control" id="ivFilesInput" multiple>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-mnt-primary">Salva intervento</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function () {
let canvas, ctx, drawing = false, hasStrokes = false;
function resizeCanvas() {
if (!canvas) return;
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
// Preserve what is already drawn across a resize
const snapshot = hasStrokes ? canvas.toDataURL() : null;
canvas.width = rect.width * ratio;
canvas.height = rect.height * ratio;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.strokeStyle = '#1e3a44';
if (snapshot) {
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height);
img.src = snapshot;
}
}
function pointerPos(event) {
const rect = canvas.getBoundingClientRect();
const source = event.touches ? event.touches[0] : event;
return { x: source.clientX - rect.left, y: source.clientY - rect.top };
}
function startDraw(event) {
event.preventDefault();
drawing = true;
hasStrokes = true;
const p = pointerPos(event);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
}
function moveDraw(event) {
if (!drawing) return;
event.preventDefault();
const p = pointerPos(event);
ctx.lineTo(p.x, p.y);
ctx.stroke();
}
function endDraw() { drawing = false; }
function clearCanvas() {
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
ctx.clearRect(0, 0, rect.width, rect.height);
hasStrokes = false;
}
window.mntOpenInterventionModal = function (data, maintenance) {
const isEdit = !!data;
$('#interventionModalTitle').text(isEdit ? 'Modifica intervento' : 'Registra intervento');
$('#ivContext').html(
'<strong>' + $('<div>').text(maintenance.equipment_name || '').html() + '</strong> — ' +
$('<div>').text(maintenance.title || '').html()
);
$('#ivId').val(isEdit ? data.id : '');
$('#ivMaintenanceId').val(maintenance.id);
$('#ivDate').val(isEdit ? data.performed_at : new Date().toISOString().slice(0, 10));
$('#ivStatus').val(isEdit ? data.status : 'completed');
$('#ivResult').val(isEdit ? (data.result || '') : '');
$('#ivOperator').val(isEdit && data.operator_employee_id ? String(data.operator_employee_id) : '');
$('#ivOperatorName').val(isEdit ? (data.operator_name || '') : '');
$('#ivSupplier').val(isEdit && data.supplier_id ? String(data.supplier_id) : (maintenance.supplier_id || ''));
$('#ivNotes').val(isEdit ? (data.notes || '') : '');
$('#ivMaterials').val(isEdit ? (data.materials || '') : '');
$('#ivSignature').val('');
$('#ivClearSignature').val('');
$('#ivFilesInput').val('');
$('#ivFilesList').empty();
if (isEdit && data.signature_path) {
$('#ivExistingSignatureImg').attr('src', 'manutenzioni/ajax/download_file.php?scope=signature&id=' + data.id);
$('#ivExistingSignature').show();
} else {
$('#ivExistingSignature').hide();
}
if (isEdit && data.files) {
data.files.forEach(f => {
$('#ivFilesList').append(
$('<div class="mnt-file-row"></div>').append(
$('<i class="fa-solid fa-paperclip text-muted"></i>'),
$('<a class="file-name" target="_blank"></a>')
.attr('href', 'manutenzioni/ajax/download_file.php?scope=intervention&id=' + f.id)
.text(f.original_name),
$('<button type="button" class="btn-action btn-action-delete btn-del-iv-file"><i class="fa-solid fa-trash"></i></button>')
.attr('data-id', f.id)
)
);
});
}
const modal = new bootstrap.Modal('#interventionModal');
modal.show();
$('#interventionModal').one('shown.bs.modal', function () {
clearCanvas();
resizeCanvas();
});
};
$(function () {
canvas = document.getElementById('ivSignatureCanvas');
if (!canvas) return;
ctx = canvas.getContext('2d');
canvas.addEventListener('mousedown', startDraw);
canvas.addEventListener('mousemove', moveDraw);
window.addEventListener('mouseup', endDraw);
canvas.addEventListener('touchstart', startDraw, { passive: false });
canvas.addEventListener('touchmove', moveDraw, { passive: false });
canvas.addEventListener('touchend', endDraw);
window.addEventListener('resize', resizeCanvas);
$('#ivSignatureClear').on('click', function () {
clearCanvas();
// Also drop a signature stored earlier
if ($('#ivExistingSignature').is(':visible')) {
$('#ivClearSignature').val('1');
$('#ivExistingSignature').hide();
}
});
$('#ivFilesList').on('click', '.btn-del-iv-file', function () {
const $row = $(this).closest('.mnt-file-row');
$.post('manutenzioni/ajax/delete_file.php', { scope: 'intervention', id: $(this).data('id') })
.done(res => {
if (res.success) { $row.remove(); }
else { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); }
});
});
$('#interventionForm').on('submit', function (e) {
e.preventDefault();
if (!$('#ivDate').val()) {
Swal.fire({ icon: 'warning', title: 'Indica la data dell\'intervento' });
return;
}
if (hasStrokes) { $('#ivSignature').val(canvas.toDataURL('image/png')); }
$.post('manutenzioni/ajax/save_intervention.php', $(this).serialize())
.done(res => {
if (!res.success) {
Swal.fire({ icon: 'error', title: 'Errore', text: res.message });
return;
}
const input = document.getElementById('ivFilesInput');
if (!input.files.length) { location.reload(); return; }
const form = new FormData();
form.append('scope', 'intervention');
form.append('owner_id', res.id);
Array.from(input.files).forEach(f => form.append('files[]', f));
$.ajax({
url: 'manutenzioni/ajax/upload_file.php',
method: 'POST', data: form, processData: false, contentType: false, dataType: 'json'
}).always(() => location.reload());
})
.fail(() => Swal.fire({ icon: 'error', title: 'Errore di rete' }));
});
});
})();
</script>
@@ -0,0 +1,259 @@
<?php
/**
* Create/edit modal for a maintenance.
* Expects $formData in scope. Defines mntOpenMaintenanceModal(data, equipmentId).
*/
?>
<div class="modal fade" id="maintenanceModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="maintenanceModalTitle">Nuova manutenzione</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
</div>
<form id="maintenanceForm">
<div class="modal-body">
<input type="hidden" name="id" id="mtId" value="">
<input type="hidden" name="equipment_id" id="mtEquipmentId" value="">
<div class="row g-3">
<div class="col-4 col-md-2">
<label class="form-label fw-semibold">Codice</label>
<input type="text" class="form-control" name="code" id="mtCode" maxlength="10" placeholder="A">
</div>
<div class="col-8 col-md-10">
<label class="form-label fw-semibold">Titolo <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="title" id="mtTitle" maxlength="500" required>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Descrizione dell'intervento</label>
<textarea class="form-control" name="description" id="mtDescription" rows="3"></textarea>
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Tipo</label>
<select class="form-select" name="intervention_type" id="mtType">
<?php foreach (mnt_intervention_types() as $value => $label): ?>
<option value="<?= mnt_h($value) ?>"><?= mnt_h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Esecuzione</label>
<select class="form-select" name="execution_type" id="mtExecution">
<?php foreach (mnt_execution_types() as $value => $label): ?>
<option value="<?= mnt_h($value) ?>"><?= mnt_h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12" id="mtSupplierWrap" style="display:none">
<label class="form-label fw-semibold">Fornitore esterno <span class="text-danger">*</span></label>
<select class="form-select" name="supplier_id" id="mtSupplier">
<option value=""> seleziona </option>
<?php foreach ($formData['suppliers'] as $supplier): ?>
<option value="<?= (int)$supplier['id'] ?>"><?= mnt_h($supplier['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<div class="mnt-section-title mb-0 mt-2">Frequenza</div>
</div>
<div class="col-6 col-md-3">
<label class="form-label fw-semibold">Ogni</label>
<input type="number" class="form-control" name="frequency_value" id="mtFrequencyValue" min="1" max="999" value="1">
</div>
<div class="col-6 col-md-5">
<label class="form-label fw-semibold">Unità</label>
<select class="form-select" name="frequency_unit" id="mtFrequencyUnit">
<?php foreach (mnt_frequency_units() as $value => $label): ?>
<option value="<?= mnt_h($value) ?>" <?= $value === 'month' ? 'selected' : '' ?>><?= mnt_h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label fw-semibold">Preavviso (giorni)</label>
<input type="number" class="form-control" name="alert_days" id="mtAlertDays" min="0" max="365" value="7">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Nota sulla frequenza</label>
<input type="text" class="form-control" name="frequency_note" id="mtFrequencyNote" maxlength="500"
placeholder="Es. prima dell'accensione, d'estate settimanale, dopo lo spegnimento">
<div class="form-text">Per i casi che non rientrano in un intervallo fisso.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Incaricato</label>
<select class="form-select" name="assignee_employee_id" id="mtAssignee">
<option value=""></option>
<?php foreach ($formData['employees'] as $employee): ?>
<option value="<?= (int)$employee['id'] ?>"><?= mnt_h($employee['full_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label fw-semibold">Responsabile</label>
<select class="form-select" name="supervisor_employee_id" id="mtSupervisor">
<option value=""></option>
<?php foreach ($formData['employees'] as $employee): ?>
<option value="<?= (int)$employee['id'] ?>"><?= mnt_h($employee['full_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Note</label>
<textarea class="form-control" name="notes" id="mtNotes" rows="2"></textarea>
</div>
<div class="col-12 d-flex flex-wrap gap-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_critical" id="mtCritical" value="1">
<label class="form-check-label fw-semibold" for="mtCritical">Manutenzione critica</label>
</div>
<div class="form-check">
<!-- Una checkbox non spuntata non viene inviata: senza questo campo
nascosto (che deve precederla) l'endpoint riceverebbe sempre
"attiva" e la manutenzione non si potrebbe mai disattivare. -->
<input type="hidden" name="is_active" value="0">
<input class="form-check-input" type="checkbox" name="is_active" id="mtActive" value="1" checked>
<label class="form-check-label fw-semibold" for="mtActive">Attiva</label>
</div>
</div>
<div class="col-12" id="mtFilesWrap">
<div class="mnt-section-title mt-2">Istruzioni allegate</div>
<div id="mtFilesList" class="mb-2"></div>
<input type="file" class="form-control" id="mtFilesInput" multiple>
<div class="form-text">I file vengono caricati dopo il salvataggio.</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-mnt-primary">Salva</button>
</div>
</form>
</div>
</div>
</div>
<script>
function mntSyncMaintenanceForm() {
const isExternal = $('#mtExecution').val() === 'external';
$('#mtSupplierWrap').toggle(isExternal);
const onDemand = $('#mtFrequencyUnit').val() === 'on_demand';
$('#mtFrequencyValue').prop('disabled', onDemand).closest('.col-6').toggle(!onDemand);
$('#mtAlertDays').prop('disabled', onDemand);
}
function mntRenderMaintenanceFiles(files) {
const $list = $('#mtFilesList').empty();
if (!files || !files.length) { return; }
files.forEach(f => {
$list.append(
$('<div class="mnt-file-row"></div>').append(
$('<i class="fa-solid fa-paperclip text-muted"></i>'),
$('<a class="file-name" target="_blank"></a>')
.attr('href', 'manutenzioni/ajax/download_file.php?scope=maintenance&id=' + f.id)
.text(f.original_name),
$('<button type="button" class="btn-action btn-action-delete btn-del-file"><i class="fa-solid fa-trash"></i></button>')
.attr('data-id', f.id)
)
);
});
}
function mntOpenMaintenanceModal(data, equipmentId) {
const isEdit = !!data;
$('#maintenanceModalTitle').text(isEdit ? 'Modifica manutenzione' : 'Nuova manutenzione');
$('#mtId').val(isEdit ? data.id : '');
$('#mtEquipmentId').val(isEdit ? data.equipment_id : (equipmentId || ''));
$('#mtCode').val(isEdit ? (data.code || '') : '');
$('#mtTitle').val(isEdit ? (data.title || '') : '');
$('#mtDescription').val(isEdit ? (data.description || '') : '');
$('#mtType').val(isEdit ? data.intervention_type : 'scheduled');
$('#mtExecution').val(isEdit ? data.execution_type : 'internal');
$('#mtSupplier').val(isEdit && data.supplier_id ? String(data.supplier_id) : '');
$('#mtFrequencyValue').val(isEdit && data.frequency_value ? data.frequency_value : 1);
$('#mtFrequencyUnit').val(isEdit ? data.frequency_unit : 'month');
$('#mtFrequencyNote').val(isEdit ? (data.frequency_note || '') : '');
$('#mtAlertDays').val(isEdit ? data.alert_days : 7);
$('#mtAssignee').val(isEdit && data.assignee_employee_id ? String(data.assignee_employee_id) : '');
$('#mtSupervisor').val(isEdit && data.supervisor_employee_id ? String(data.supervisor_employee_id) : '');
$('#mtNotes').val(isEdit ? (data.notes || '') : '');
$('#mtCritical').prop('checked', isEdit ? Number(data.is_critical) === 1 : false);
$('#mtActive').prop('checked', isEdit ? Number(data.is_active) === 1 : true);
mntRenderMaintenanceFiles(isEdit ? (data.files || []) : []);
$('#mtFilesInput').val('');
mntSyncMaintenanceForm();
new bootstrap.Modal('#maintenanceModal').show();
}
$(function () {
$('#mtExecution, #mtFrequencyUnit').on('change', mntSyncMaintenanceForm);
$('#mtFilesList').on('click', '.btn-del-file', function () {
const $row = $(this).closest('.mnt-file-row');
$.post('manutenzioni/ajax/delete_file.php', { scope: 'maintenance', id: $(this).data('id') })
.done(res => {
if (res.success) { $row.remove(); }
else { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); }
});
});
$('#maintenanceForm').on('submit', function (e) {
e.preventDefault();
if (!$('#mtTitle').val().trim()) {
Swal.fire({ icon: 'warning', title: 'Il titolo è obbligatorio' });
return;
}
if ($('#mtExecution').val() === 'external' && !$('#mtSupplier').val()) {
Swal.fire({ icon: 'warning', title: 'Seleziona il fornitore esterno' });
return;
}
// Disabled inputs are not serialised — send the unit explicitly
const payload = $(this).serializeArray();
payload.push({ name: 'frequency_unit', value: $('#mtFrequencyUnit').val() });
$.post('manutenzioni/ajax/save_maintenance.php', $.param(payload))
.done(res => {
if (!res.success) {
Swal.fire({ icon: 'error', title: 'Errore', text: res.message });
return;
}
const input = document.getElementById('mtFilesInput');
if (!input.files.length) { location.reload(); return; }
const form = new FormData();
form.append('scope', 'maintenance');
form.append('owner_id', res.id);
Array.from(input.files).forEach(f => form.append('files[]', f));
$.ajax({
url: 'manutenzioni/ajax/upload_file.php',
method: 'POST', data: form, processData: false, contentType: false, dataType: 'json'
}).always(() => location.reload());
})
.fail(() => Swal.fire({ icon: 'error', title: 'Errore di rete' }));
});
});
</script>
@@ -0,0 +1,171 @@
<?php
/**
* Shared <head> for the Manutenzioni pages.
*
* Usage (before including):
* $MNT_TITLE = 'Registro attrezzature';
* $MNT_PLUGINS = ['fullcalendar']; // opzionale
* include __DIR__ . '/include/page_head.php';
*
* Works from any depth under /userarea/ the base href is derived from
* SCRIPT_NAME, so all asset paths stay relative to /userarea/.
*/
$MNT_TITLE = $MNT_TITLE ?? 'Manutenzioni';
$MNT_PLUGINS = $MNT_PLUGINS ?? [];
$mntScriptName = $_SERVER['SCRIPT_NAME'] ?? '';
$mntMarker = '/userarea/';
$mntPos = strpos($mntScriptName, $mntMarker);
$MNT_BASE = $mntPos !== false
? substr($mntScriptName, 0, $mntPos + strlen($mntMarker))
: '/userarea/';
?>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<base href="<?= mnt_h($MNT_BASE) ?>">
<?php include(__DIR__ . '/../../cssinclude.php'); ?>
<script src="assets/js/jquery.min.js"></script>
<title><?= mnt_h($MNT_TITLE) ?> — Manutenzioni</title>
<?php if (in_array('fullcalendar', $MNT_PLUGINS, true)): ?>
<script src="manutenzioni/assets/fullcalendar.min.js"></script>
<script src="manutenzioni/assets/fullcalendar-it.js"></script>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
if (typeof window.Swal === 'undefined') {
var local = document.createElement('script');
local.src = 'manutenzioni/assets/sweetalert2.min.js';
document.head.appendChild(local);
}
});
</script>
<style>
:root {
--mnt-primary: #2f7d8f;
--mnt-primary-hover: #256575;
--mnt-heading: #1e3a44;
--mnt-card-bg: linear-gradient(135deg, #eef7f9 0%, #e4f0f3 100%);
--mnt-card-border: #d6e5e9;
}
.mnt-card { border: none; border-radius: 0.75rem; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); overflow: hidden; }
.mnt-card .card-header { background: var(--mnt-card-bg); border-bottom: 1px solid var(--mnt-card-border); padding: 1rem 1.25rem; }
.mnt-card .card-header h5 { font-weight: 700; color: var(--mnt-heading); margin: 0; font-size: 1.05rem; letter-spacing: -0.01em; }
.mnt-card .card-body { padding: 1.25rem; }
.btn-mnt-primary { background: var(--mnt-primary); border: none; color: #fff; font-weight: 600; font-size: 0.85rem; padding: 0.5rem 1rem; border-radius: 0.5rem; transition: all 0.2s; }
.btn-mnt-primary:hover { background: var(--mnt-primary-hover); color: #fff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(47, 125, 143, 0.3); }
.btn-mnt-outline { background: transparent; border: 1.5px solid var(--mnt-primary); color: var(--mnt-primary); font-weight: 600; font-size: 0.85rem; padding: 0.45rem 1rem; border-radius: 0.5rem; transition: all 0.2s; }
.btn-mnt-outline:hover { background: var(--mnt-primary); color: #fff; transform: translateY(-1px); }
.btn-action { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 0.4rem; font-size: 0.85rem; transition: all 0.15s; text-decoration: none; }
.btn-action-view { background: rgba(47, 125, 143, 0.12); color: var(--mnt-primary); }
.btn-action-view:hover { background: var(--mnt-primary); color: #fff; }
.btn-action-edit { background: rgba(13, 110, 253, 0.12); color: #0d6efd; }
.btn-action-edit:hover { background: #0d6efd; color: #fff; }
.btn-action-delete { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
.btn-action-delete:hover { background: #dc3545; color: #fff; }
.btn-action-done { background: rgba(25, 135, 84, 0.12); color: #198754; }
.btn-action-done:hover { background: #198754; color: #fff; }
/* Schedule state badges */
.mnt-badge { display: inline-block; padding: 0.22rem 0.6rem; border-radius: 999px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap; }
.mnt-badge-overdue { background: rgba(220, 53, 69, 0.14); color: #b02a37; }
.mnt-badge-soon { background: rgba(255, 193, 7, 0.2); color: #9a7000; }
.mnt-badge-ok { background: rgba(25, 135, 84, 0.14); color: #146c43; }
.mnt-badge-none { background: rgba(108, 117, 125, 0.14); color: #565e64; }
.mnt-badge-critical { background: rgba(220, 53, 69, 0.9); color: #fff; }
.mnt-badge-soft { background: rgba(47, 125, 143, 0.12); color: var(--mnt-primary); }
.mnt-badge-status-active { background: rgba(25, 135, 84, 0.14); color: #146c43; }
.mnt-badge-status-out_of_service { background: rgba(255, 193, 7, 0.2); color: #9a7000; }
.mnt-badge-status-decommissioned { background: rgba(108, 117, 125, 0.16); color: #495057; }
.mnt-cat-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.modal-content > form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.modal-content > form > .modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.mnt-title-cell { max-width: 320px; }
.mnt-equipment-cell { max-width: 240px; }
.mnt-freq-cell { max-width: 190px; }
.mnt-person-cell { max-width: 150px; }
.mnt-name-cell { max-width: 300px; }
.mnt-name-cell .mnt-title-clamp {
display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mnt-title-clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; min-width: 0; }
.mnt-equipment-cell .mnt-title-clamp { flex: 1 1 auto; }
/* Mobile-first cards; the table appears from xl (1200px) upwards */
.mnt-item-card { background: #fff; border: 1px solid var(--mnt-card-border); border-left: 5px solid var(--row-color, #e9ecef); border-radius: 0.6rem; padding: 0.85rem 0.95rem; margin-bottom: 0.6rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); }
.mnt-item-card .ic-title { font-weight: 700; color: var(--mnt-heading); font-size: 0.95rem; word-break: break-word; }
.mnt-item-card .ic-meta { font-size: 0.8rem; color: #6c757d; margin-top: 0.3rem; }
.mnt-item-card .ic-meta strong { color: var(--mnt-heading); font-weight: 600; }
.mnt-item-card .ic-actions { display: flex; gap: 0.4rem; justify-content: flex-end; margin-top: 0.6rem; }
@media (min-width: 768px) {
.d-xl-none > .mnt-item-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 1rem; }
.d-xl-none > .mnt-item-card > :not(.ic-actions) { grid-column: 1; }
.d-xl-none > .mnt-item-card > .ic-actions { grid-column: 2; grid-row: 1 / -1; margin-top: 0; }
}
.mnt-pager { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1.25rem; padding: 0.85rem 0.25rem 0.25rem; font-size: 0.85rem; }
.mnt-pager-info { color: #6c757d; }
.mnt-pager-info strong { color: var(--mnt-heading); }
.mnt-pager-per { display: flex; align-items: center; gap: 0.35rem; }
.mnt-pager-pages { display: flex; align-items: center; gap: 0.25rem; margin-left: auto; }
.mnt-pager-link { display: inline-flex; align-items: center; justify-content: center; min-width: 2rem; height: 2rem; padding: 0 0.5rem; border: 1px solid var(--mnt-card-border); border-radius: 0.45rem; background: #fff; color: var(--mnt-heading); text-decoration: none; }
.mnt-pager-link:hover { border-color: #2f7d8f; color: #2f7d8f; }
.mnt-pager-link.is-current { background: #2f7d8f; border-color: #2f7d8f; color: #fff; font-weight: 700; }
.mnt-pager-link.is-disabled { opacity: 0.4; pointer-events: none; }
.mnt-pager-gap { padding: 0 0.15rem; color: #adb5bd; }
@media (max-width: 575.98px) {
.mnt-pager-pages { margin-left: 0; width: 100%; justify-content: center; }
}
.mnt-thumb { width: 46px; height: 46px; border-radius: 0.45rem; object-fit: cover; border: 1px solid var(--mnt-card-border); background: #f6f9fa; }
.mnt-thumb-placeholder { display: inline-flex; align-items: center; justify-content: center; color: #adb5bd; font-size: 1.1rem; }
.empty-state { text-align: center; padding: 3rem 1rem; color: #6c757d; }
.empty-state i { font-size: 3rem; opacity: 0.3; margin-bottom: 1rem; display: block; }
.mnt-filter-bar { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 1rem; }
.mnt-filter-bar .form-select, .mnt-filter-bar .form-control { min-width: 150px; flex: 1 1 150px; max-width: 100%; }
.mnt-section-title { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #8a9aa0; margin-bottom: 0.5rem; }
.mnt-kv { display: flex; gap: 0.5rem; padding: 0.35rem 0; border-bottom: 1px dashed #eceff1; font-size: 0.88rem; }
.mnt-kv:last-child { border-bottom: none; }
.mnt-kv dt { flex: 0 0 42%; color: #6c757d; font-weight: 500; margin: 0; }
.mnt-kv dd { flex: 1; margin: 0; color: var(--mnt-heading); font-weight: 600; word-break: break-word; }
.mnt-photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 0.6rem; }
.mnt-photo-item { position: relative; border-radius: 0.5rem; overflow: hidden; border: 1px solid var(--mnt-card-border); aspect-ratio: 1; }
.mnt-photo-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mnt-photo-item .photo-tools { position: absolute; inset: auto 0 0 0; display: flex; gap: 0.25rem; justify-content: center; padding: 0.25rem; background: rgba(0, 0, 0, 0.45); opacity: 0; transition: opacity 0.15s; }
.mnt-photo-item:hover .photo-tools { opacity: 1; }
.mnt-photo-item .photo-tools button { border: none; background: transparent; color: #fff; font-size: 0.8rem; padding: 0.1rem 0.35rem; }
.mnt-photo-item.is-cover { border-color: var(--mnt-primary); box-shadow: 0 0 0 2px rgba(47, 125, 143, 0.25); }
.mnt-file-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0; border-bottom: 1px solid #f1f4f5; font-size: 0.88rem; }
.mnt-file-row:last-child { border-bottom: none; }
.mnt-file-row .file-name { flex: 1; word-break: break-all; }
/* Signature pad (tablet) */
.mnt-signature-wrap { border: 1px dashed var(--mnt-card-border); border-radius: 0.5rem; background: #fcfdfd; position: relative; }
.mnt-signature-wrap canvas { width: 100%; height: 160px; display: block; touch-action: none; border-radius: 0.5rem; }
.mnt-timeline { position: relative; padding-left: 1.25rem; }
.mnt-timeline::before { content: ''; position: absolute; left: 6px; top: 4px; bottom: 4px; width: 2px; background: #e6eef0; }
.mnt-timeline-item { position: relative; padding-bottom: 0.9rem; }
.mnt-timeline-item::before { content: ''; position: absolute; left: -1.25rem; top: 0.35rem; width: 10px; height: 10px; border-radius: 50%; background: var(--mnt-primary); box-shadow: 0 0 0 3px #fff; }
.mnt-timeline-item .tl-date { font-size: 0.75rem; color: #8a9aa0; font-weight: 600; }
.mnt-timeline-item .tl-body { font-size: 0.88rem; color: var(--mnt-heading); }
@media (max-width: 575.98px) {
.mnt-card .card-header { flex-direction: column; gap: 0.75rem; align-items: flex-start !important; }
.header-actions { width: 100%; }
.header-actions .btn { width: 100%; justify-content: center; }
}
</style>
@@ -0,0 +1,95 @@
<?php
/**
* Pager shared by the registry and the maintenance list.
*
* Both lists used to print every row at once 101 pieces of equipment and 126
* maintenance tasks, each rendered twice (cards + table), which on a tablet
* came out as a page some 20 000px tall.
*
* Usage (before including):
* $mntPagerUrl = 'manutenzioni/index.php'; // path, relative to <base href>
* $mntPagerPage = 3; // current page, 1-based
* $mntPagerPages = 7; // number of pages
* $mntPagerTotal = 163; // rows matching the current filters
* $mntPagerPer = 25; // rows per page
*/
$mntPagerPages = max(1, (int)($mntPagerPages ?? 1));
$mntPagerPage = min(max(1, (int)($mntPagerPage ?? 1)), $mntPagerPages);
$mntPagerTotal = (int)($mntPagerTotal ?? 0);
$mntPagerPer = max(1, (int)($mntPagerPer ?? 25));
/** Keeps the active filters, drops page when it would be redundant. */
$mntPagerLink = static function (int $page, ?int $per = null) use ($mntPagerUrl) {
$query = $_GET;
unset($query['page'], $query['per_page']);
if ($per !== null) {
$query['per_page'] = $per;
} elseif (isset($_GET['per_page'])) {
$query['per_page'] = $_GET['per_page'];
}
if ($page > 1) {
$query['page'] = $page;
}
return $mntPagerUrl . ($query ? '?' . http_build_query($query) : '');
};
$mntFirstRow = $mntPagerTotal ? (($mntPagerPage - 1) * $mntPagerPer) + 1 : 0;
$mntLastRow = min($mntPagerPage * $mntPagerPer, $mntPagerTotal);
// A window around the current page: with 40 pages, 40 links are noise.
$mntWindowFrom = max(1, $mntPagerPage - 2);
$mntWindowTo = min($mntPagerPages, $mntPagerPage + 2);
?>
<div class="mnt-pager" data-page="<?= $mntPagerPage ?>" data-pages="<?= $mntPagerPages ?>" data-total="<?= $mntPagerTotal ?>">
<div class="mnt-pager-info">
<?= $mntFirstRow ?><?= $mntLastRow ?> di <strong><?= $mntPagerTotal ?></strong>
</div>
<div class="mnt-pager-per">
<span class="text-muted">Per pagina:</span>
<?php foreach ([25, 50, 100] as $option): ?>
<?php if ($option === $mntPagerPer): ?>
<span class="mnt-pager-link is-current"><?= $option ?></span>
<?php else: ?>
<a class="mnt-pager-link" href="<?= mnt_h($mntPagerLink(1, $option)) ?>"><?= $option ?></a>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php if ($mntPagerPages > 1): ?>
<nav class="mnt-pager-pages" aria-label="Paginazione">
<?php if ($mntPagerPage > 1): ?>
<a class="mnt-pager-link btn-page-prev" rel="prev" href="<?= mnt_h($mntPagerLink($mntPagerPage - 1)) ?>" aria-label="Precedente"></a>
<?php else: ?>
<span class="mnt-pager-link is-disabled"></span>
<?php endif; ?>
<?php if ($mntWindowFrom > 1): ?>
<a class="mnt-pager-link" href="<?= mnt_h($mntPagerLink(1)) ?>">1</a>
<?php if ($mntWindowFrom > 2): ?><span class="mnt-pager-gap">…</span><?php endif; ?>
<?php endif; ?>
<?php for ($p = $mntWindowFrom; $p <= $mntWindowTo; $p++): ?>
<?php if ($p === $mntPagerPage): ?>
<span class="mnt-pager-link is-current" aria-current="page"><?= $p ?></span>
<?php else: ?>
<a class="mnt-pager-link" href="<?= mnt_h($mntPagerLink($p)) ?>"><?= $p ?></a>
<?php endif; ?>
<?php endfor; ?>
<?php if ($mntWindowTo < $mntPagerPages): ?>
<?php if ($mntWindowTo < $mntPagerPages - 1): ?><span class="mnt-pager-gap">…</span><?php endif; ?>
<a class="mnt-pager-link" href="<?= mnt_h($mntPagerLink($mntPagerPages)) ?>"><?= $mntPagerPages ?></a>
<?php endif; ?>
<?php if ($mntPagerPage < $mntPagerPages): ?>
<a class="mnt-pager-link btn-page-next" rel="next" href="<?= mnt_h($mntPagerLink($mntPagerPage + 1)) ?>" aria-label="Successiva"></a>
<?php else: ?>
<span class="mnt-pager-link is-disabled"></span>
<?php endif; ?>
</nav>
<?php endif; ?>
</div>
@@ -0,0 +1,6 @@
<div class="wrapper" id="appWrapper">
<script>
if (window.innerWidth > 1024) {
document.getElementById('appWrapper').classList.add('toggled');
}
</script>
+432
View File
@@ -0,0 +1,432 @@
<?php include(__DIR__ . '/../include/headscript.php'); ?>
<?php
require_once __DIR__ . '/include/functions.php';
$pdo = mnt_pdo();
if (!userCan('maintenance.equipment.view')) {
http_response_code(403);
exit('Permesso negato.');
}
$canManage = userCan('maintenance.manage');
// ---------------------------------------------------------------- filters
$filterSearch = trim((string)($_GET['q'] ?? ''));
$filterCategory = isset($_GET['category']) && is_numeric($_GET['category']) ? (int)$_GET['category'] : null;
$filterLine = isset($_GET['line']) && is_numeric($_GET['line']) ? (int)$_GET['line'] : null;
$filterDepartment = isset($_GET['department']) && is_numeric($_GET['department']) ? (int)$_GET['department'] : null;
$filterStatus = (string)($_GET['status'] ?? 'active');
$where = [];
$params = [];
if ($filterSearch !== '') {
$where[] = "(e.name LIKE ? OR e.serial_number LIKE ? OR e.registration_number LIKE ? OR e.manufacturer LIKE ?)";
$like = '%' . $filterSearch . '%';
array_push($params, $like, $like, $like, $like);
}
if ($filterCategory) {
$where[] = "e.category_id = ?";
$params[] = $filterCategory;
}
if ($filterLine) {
$where[] = "e.line_id = ?";
$params[] = $filterLine;
}
if ($filterDepartment) {
$where[] = "e.department_id = ?";
$params[] = $filterDepartment;
}
if ($filterStatus !== '' && $filterStatus !== 'all') {
$where[] = "e.status = ?";
$params[] = $filterStatus;
}
$sql = "
SELECT e.*,
c.name AS category_name,
c.color AS category_color,
pl.name AS line_name,
pl.line_number,
d.name AS department_name,
cf.stored_name AS cover_stored,
(SELECT COUNT(*) FROM maint_maintenances m
WHERE m.equipment_id = e.id AND m.is_active = 1) AS maintenance_count,
(SELECT MIN(m.next_due_date) FROM maint_maintenances m
WHERE m.equipment_id = e.id AND m.is_active = 1 AND m.next_due_date IS NOT NULL) AS next_due_date
FROM inv_equipment e
LEFT JOIN inv_categories c ON c.id = e.category_id
LEFT JOIN production_lines pl ON pl.id = e.line_id
LEFT JOIN departments d ON d.id = e.department_id
LEFT JOIN inv_equipment_files cf ON cf.id = e.cover_file_id
";
$whereSql = $where ? ' WHERE ' . implode(' AND ', $where) : '';
$sql .= $whereSql;
$sql .= ' ORDER BY c.sort_order ASC, e.name ASC';
// The filters only touch columns of inv_equipment, so counting needs no joins.
$countStmt = $pdo->prepare('SELECT COUNT(*) FROM inv_equipment e' . $whereSql);
$countStmt->execute($params);
$totalEquipment = (int)$countStmt->fetchColumn();
[$page, $perPage] = mnt_page_params();
$totalPages = max(1, (int)ceil($totalEquipment / $perPage));
$page = min($page, $totalPages);
// Cast to int above, so interpolating here cannot carry anything but digits;
// LIMIT/OFFSET placeholders would need PDO::ATTR_EMULATE_PREPARES off.
$sql .= sprintf(' LIMIT %d OFFSET %d', $perPage, ($page - 1) * $perPage);
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$equipment = $stmt->fetchAll(PDO::FETCH_ASSOC);
$formData = mnt_form_data($pdo);
$statuses = mnt_equipment_statuses();
$MNT_TITLE = 'Registro attrezzature';
?>
<!doctype html>
<html lang="it">
<head>
<?php include __DIR__ . '/include/page_head.php'; ?>
</head>
<body>
<?php include __DIR__ . '/include/wrapper_open.php'; ?>
<?php include(__DIR__ . '/../include/navbar.php'); ?>
<?php include(__DIR__ . '/../include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="card mnt-card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<h5><i class="fa-solid fa-boxes-stacked me-2"></i>Registro attrezzature</h5>
<div class="header-actions d-flex gap-2 flex-wrap">
<a href="manutenzioni/maintenances.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-screwdriver-wrench"></i><span>Manutenzioni</span>
</a>
<?php if ($canManage): ?>
<button class="btn btn-mnt-primary d-inline-flex align-items-center gap-2" id="btnAddEquipment">
<i class="fa-solid fa-plus"></i><span>Nuova attrezzatura</span>
</button>
<?php endif; ?>
</div>
</div>
<div class="card-body">
<form class="mnt-filter-bar" method="get" id="filterForm">
<input type="text" class="form-control" name="q" placeholder="Cerca nome, matricola, seriale…"
value="<?= mnt_h($filterSearch) ?>">
<select class="form-select" name="category">
<option value="">Tutte le categorie</option>
<?php foreach ($formData['categories'] as $category): ?>
<option value="<?= (int)$category['id'] ?>" <?= $filterCategory === (int)$category['id'] ? 'selected' : '' ?>>
<?= mnt_h($category['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select class="form-select" name="line">
<option value="">Tutte le linee</option>
<?php foreach ($formData['lines'] as $line): ?>
<option value="<?= (int)$line['id'] ?>" <?= $filterLine === (int)$line['id'] ? 'selected' : '' ?>>
Linea <?= (int)$line['line_number'] ?> — <?= mnt_h($line['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select class="form-select" name="department">
<option value="">Tutti i reparti</option>
<?php foreach ($formData['departments'] as $department): ?>
<option value="<?= (int)$department['id'] ?>" <?= $filterDepartment === (int)$department['id'] ? 'selected' : '' ?>>
<?= mnt_h($department['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select class="form-select" name="status">
<option value="all" <?= $filterStatus === 'all' ? 'selected' : '' ?>>Tutti gli stati</option>
<?php foreach ($statuses as $value => $label): ?>
<option value="<?= mnt_h($value) ?>" <?= $filterStatus === $value ? 'selected' : '' ?>>
<?= mnt_h($label) ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($perPage !== 25): ?>
<input type="hidden" name="per_page" value="<?= (int)$perPage ?>">
<?php endif; ?>
<!-- Every control applies itself, so there is no «Filtra» button to
press. This one stays only so that Enter in the search box
submits, and so the bar still works without JavaScript. -->
<button type="submit" class="visually-hidden">Filtra</button>
<a href="manutenzioni/index.php" class="btn btn-mnt-outline btn-reset-filters d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-xmark"></i><span>Azzera filtri</span>
</a>
</form>
<?php if (!$equipment): ?>
<div class="empty-state">
<i class="fa-solid fa-boxes-stacked"></i>
<p>Nessuna attrezzatura trovata con questi filtri.</p>
</div>
<?php else: ?>
<div id="equipmentList">
<!-- CARD -->
<div class="d-xl-none">
<?php foreach ($equipment as $item): ?>
<?php
$state = mnt_due_state($item['next_due_date']);
// Qui la riga aggrega piu' manutenzioni: «nessuna data» non
// significa «al bisogno», quindi l'etichetta resta neutra.
$badge = $state === 'none'
? ['label' => 'Senza scadenza', 'class' => 'mnt-badge-none']
: mnt_due_badge($state);
?>
<div class="mnt-item-card" data-id="<?= (int)$item['id'] ?>"
style="--row-color: <?= mnt_h($item['category_color'] ?? '#e9ecef') ?>">
<div class="d-flex gap-2 align-items-start">
<?php if (!empty($item['cover_stored'])): ?>
<img class="mnt-thumb" src="manutenzioni/ajax/download_file.php?scope=equipment&id=<?= (int)$item['cover_file_id'] ?>" alt="">
<?php else: ?>
<span class="mnt-thumb mnt-thumb-placeholder"><i class="fa-solid fa-gear"></i></span>
<?php endif; ?>
<div class="flex-grow-1">
<div class="ic-title">
<?= mnt_h($item['name']) ?>
<?php if ($item['status'] !== 'active'): ?>
<span class="mnt-badge mnt-badge-status-<?= mnt_h($item['status']) ?>">
<?= mnt_h($statuses[$item['status']] ?? $item['status']) ?>
</span>
<?php endif; ?>
</div>
<div class="ic-meta">
<?= mnt_h($item['category_name'] ?? 'Senza categoria') ?>
<?php if ($item['line_name']): ?>
· Linea <?= (int)$item['line_number'] ?>
<?php endif; ?>
</div>
<div class="ic-meta">
Manutenzioni: <strong><?= (int)$item["maintenance_count"] ?></strong> attive
<?php if ($item['maintenance_count'] > 0): ?>
· <span class="mnt-badge <?= $badge['class'] ?>"><?= $badge['label'] ?></span>
<?php endif; ?>
</div>
</div>
</div>
<div class="ic-actions">
<a class="btn-action btn-action-view" title="Apri scheda"
href="manutenzioni/equipment.php?id=<?= (int)$item['id'] ?>">
<i class="fa-solid fa-eye"></i>
</a>
<?php if ($canManage): ?>
<button class="btn-action btn-action-edit btn-edit" title="Modifica"><i class="fa-solid fa-pen"></i></button>
<button class="btn-action btn-action-delete btn-delete"
title="Elimina"
data-name="<?= mnt_h($item['name']) ?>"
data-maintenances="<?= (int)$item['maintenance_count'] ?>">
<i class="fa-solid fa-trash"></i>
</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- DESKTOP -->
<!-- Table only from 1200px up: below that the 8 columns force
horizontal scrolling, which is unusable on a tablet. -->
<div class="d-none d-xl-block table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th style="width:60px"></th>
<th class="mnt-name-cell">Nome</th>
<th>Categoria</th>
<th>Matricola / Seriale</th>
<th>Ubicazione</th>
<th class="text-center">Stato</th>
<th class="text-center">Manutenzioni</th>
<th class="text-center" style="width:140px">Azioni</th>
</tr>
</thead>
<tbody>
<?php foreach ($equipment as $item): ?>
<?php
$state = mnt_due_state($item['next_due_date']);
// Qui la riga aggrega piu' manutenzioni: «nessuna data» non
// significa «al bisogno», quindi l'etichetta resta neutra.
$badge = $state === 'none'
? ['label' => 'Senza scadenza', 'class' => 'mnt-badge-none']
: mnt_due_badge($state);
$statusKey = (string)$item['status'];
?>
<tr data-id="<?= (int)$item['id'] ?>">
<td>
<?php if (!empty($item['cover_stored'])): ?>
<img class="mnt-thumb" src="manutenzioni/ajax/download_file.php?scope=equipment&id=<?= (int)$item['cover_file_id'] ?>" alt="">
<?php else: ?>
<span class="mnt-thumb mnt-thumb-placeholder"><i class="fa-solid fa-gear"></i></span>
<?php endif; ?>
</td>
<td class="mnt-name-cell">
<div class="fw-semibold mnt-title-clamp" style="color:var(--mnt-heading)"
title="<?= mnt_h($item['name']) ?>"><?= mnt_h($item['name']) ?></div>
<?php if ($item['manufacturer']): ?>
<div class="small text-muted mnt-title-clamp"
title="<?= mnt_h($item['manufacturer']) ?>"><?= mnt_h($item['manufacturer']) ?></div>
<?php endif; ?>
</td>
<td>
<span class="d-inline-flex align-items-center gap-2">
<span class="mnt-cat-dot" style="background: <?= mnt_h($item['category_color'] ?? '#adb5bd') ?>"></span>
<span><?= mnt_h($item['category_name'] ?? '—') ?></span>
</span>
<?php if ($item['line_name']): ?>
<div class="small text-muted">Linea <?= (int)$item['line_number'] ?> — <?= mnt_h($item['line_name']) ?></div>
<?php endif; ?>
</td>
<td class="small">
<?= mnt_h($item['registration_number'] ?: '—') ?>
<?php if ($item['serial_number']): ?>
<div class="text-muted"><?= mnt_h($item['serial_number']) ?></div>
<?php endif; ?>
</td>
<td class="small">
<?= mnt_h($item['department_name'] ?: '—') ?>
<?php if ($item['location']): ?>
<div class="text-muted"><?= mnt_h($item['location']) ?></div>
<?php endif; ?>
</td>
<td class="text-center">
<span class="mnt-badge mnt-badge-status-<?= mnt_h($statusKey) ?>">
<?= mnt_h($statuses[$statusKey] ?? $statusKey) ?>
</span>
</td>
<td class="text-center">
<?php if ((int)$item['maintenance_count'] === 0): ?>
<span class="text-muted small"></span>
<?php else: ?>
<span class="mnt-badge <?= $badge['class'] ?>"><?= $badge['label'] ?></span>
<div class="small text-muted mt-1"><?= (int)$item['maintenance_count'] ?> attive</div>
<?php endif; ?>
</td>
<td class="text-center">
<div class="d-inline-flex gap-1">
<a class="btn-action btn-action-view" title="Apri scheda"
href="manutenzioni/equipment.php?id=<?= (int)$item['id'] ?>">
<i class="fa-solid fa-eye"></i>
</a>
<?php if ($canManage): ?>
<button class="btn-action btn-action-edit btn-edit" title="Modifica"><i class="fa-solid fa-pen"></i></button>
<button class="btn-action btn-action-delete btn-delete"
title="Elimina"
data-name="<?= mnt_h($item['name']) ?>"
data-maintenances="<?= (int)$item['maintenance_count'] ?>">
<i class="fa-solid fa-trash"></i>
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php
$mntPagerUrl = 'manutenzioni/index.php';
$mntPagerPage = $page;
$mntPagerPages = $totalPages;
$mntPagerTotal = $totalEquipment;
$mntPagerPer = $perPage;
include __DIR__ . '/include/pagination.php';
?>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php include(__DIR__ . '/../include/footer.php'); ?>
</div>
<?php if ($canManage): ?>
<?php include __DIR__ . '/include/equipment_modal.php'; ?>
<?php endif; ?>
<?php include(__DIR__ . '/../jsinclude.php'); ?>
<script>
$(function () {
$('#filterForm select').on('change', function () { $('#filterForm').submit(); });
// The search box applies itself too, so the whole bar behaves the
// same way; the pause keeps it from reloading on every keystroke.
let mntSearchTimer = null;
$('#filterForm input[name=q]').on('input', function () {
clearTimeout(mntSearchTimer);
mntSearchTimer = setTimeout(function () { $('#filterForm').submit(); }, 500);
});
// The reload lands with the box empty of focus, mid-word: put the
// caret back where it was so typing can simply continue.
const mntSearch = document.querySelector('#filterForm input[name=q]');
if (mntSearch && mntSearch.value) {
mntSearch.focus();
mntSearch.setSelectionRange(mntSearch.value.length, mntSearch.value.length);
}
<?php if ($canManage): ?>
$('#btnAddEquipment').on('click', function () { mntOpenEquipmentModal(null); });
$('#equipmentList').on('click', '.btn-edit', function () {
const id = $(this).closest('[data-id]').data('id');
$.getJSON('manutenzioni/ajax/get_equipment.php', { id: id })
.done(res => {
if (res.success) { mntOpenEquipmentModal(res.equipment); }
else { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); }
})
.fail(() => Swal.fire({ icon: 'error', title: 'Errore di rete' }));
});
$('#equipmentList').on('click', '.btn-delete', function () {
const $btn = $(this);
const id = $btn.closest('[data-id]').data('id');
const name = $btn.data('name');
const maintenances = parseInt($btn.data('maintenances') || 0, 10);
Swal.fire({
title: `Eliminare "${name}"?`,
html: maintenances > 0
? `<p>Verranno eliminate anche <strong>${maintenances}</strong> manutenzioni e tutto lo storico interventi.</p>`
: '<p>L\'operazione non è reversibile.</p>',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Elimina',
cancelButtonText: 'Annulla',
confirmButtonColor: '#dc3545'
}).then(r => {
if (!r.isConfirmed) return;
$.post('manutenzioni/ajax/delete_equipment.php', { id: id })
.done(res => {
if (res.success) { location.reload(); }
else { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); }
})
.fail(() => Swal.fire({ icon: 'error', title: 'Errore di rete' }));
});
});
<?php endif; ?>
});
</script>
</body>
</html>
@@ -0,0 +1,370 @@
<?php include(__DIR__ . '/../include/headscript.php'); ?>
<?php
require_once __DIR__ . '/include/functions.php';
$pdo = mnt_pdo();
if (!userCan('maintenance.maintenances.view')) {
http_response_code(403);
exit('Permesso negato.');
}
$canManage = userCan('maintenance.manage');
// ---------------------------------------------------------------- filters
$filterType = (string)($_GET['type'] ?? '');
$filterState = (string)($_GET['state'] ?? '');
$filterCategory = isset($_GET['category']) && is_numeric($_GET['category']) ? (int)$_GET['category'] : null;
$filterCritical = !empty($_GET['critical']);
$filterMine = !empty($_GET['mine']);
$where = ["m.is_active = 1", "e.status = 'active'"];
$params = [];
if (array_key_exists($filterType, mnt_intervention_types())) {
$where[] = "m.intervention_type = ?";
$params[] = $filterType;
}
if ($filterCategory) {
$where[] = "e.category_id = ?";
$params[] = $filterCategory;
}
if ($filterCritical) {
$where[] = "m.is_critical = 1";
}
// "Assigned to me" resolves the logged-in user to their employee record
if ($filterMine) {
$stmt = $pdo->prepare("SELECT id FROM employees WHERE auth_user_id = ? LIMIT 1");
$stmt->execute([(int)$iduserlogin]);
$myEmployeeId = (int)$stmt->fetchColumn();
if ($myEmployeeId) {
$where[] = "(m.assignee_employee_id = ? OR m.supervisor_employee_id = ?)";
$params[] = $myEmployeeId;
$params[] = $myEmployeeId;
} else {
$filterMine = false;
}
}
$stmt = $pdo->prepare("
SELECT m.*,
e.id AS equipment_id, e.name AS equipment_name,
c.name AS category_name, c.color AS category_color,
CONCAT(a.first_name, ' ', a.last_name) AS assignee_name,
CONCAT(s.first_name, ' ', s.last_name) AS supervisor_name,
sup.supplier_name
FROM maint_maintenances m
INNER JOIN inv_equipment e ON e.id = m.equipment_id
LEFT JOIN inv_categories c ON c.id = e.category_id
LEFT JOIN employees a ON a.id = m.assignee_employee_id
LEFT JOIN employees s ON s.id = m.supervisor_employee_id
LEFT JOIN suppliers sup ON sup.idsupplier = m.supplier_id
WHERE " . implode(' AND ', $where) . "
ORDER BY (m.next_due_date IS NULL) ASC, m.next_due_date ASC, e.name ASC
");
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// The schedule state depends on each maintenance's own lead time, so it is
// computed in PHP rather than in SQL.
$maintenances = [];
$counters = ['overdue' => 0, 'due_soon' => 0, 'ok' => 0, 'none' => 0];
foreach ($rows as $row) {
$state = mnt_due_state($row['next_due_date'], (int)$row['alert_days']);
$counters[$state]++;
if ($filterState !== '' && $filterState !== $state) {
continue;
}
$row['state'] = $state;
$maintenances[] = $row;
}
// Paging happens here and not in SQL: the state of each task and the four
// counter tiles are computed in PHP over the whole result set, so a LIMIT in
// the query would leave the tiles counting one page instead of everything.
[$page, $perPage] = mnt_page_params();
$totalMaintenances = count($maintenances);
$totalPages = max(1, (int)ceil($totalMaintenances / $perPage));
$page = min($page, $totalPages);
$maintenances = array_slice($maintenances, ($page - 1) * $perPage, $perPage);
$formData = mnt_form_data($pdo);
$typeLabels = mnt_intervention_types();
$executionLabels = mnt_execution_types();
$MNT_TITLE = 'Manutenzioni';
?>
<!doctype html>
<html lang="it">
<head>
<?php include __DIR__ . '/include/page_head.php'; ?>
</head>
<body>
<?php include __DIR__ . '/include/wrapper_open.php'; ?>
<?php include(__DIR__ . '/../include/navbar.php'); ?>
<?php include(__DIR__ . '/../include/topbar.php'); ?>
<div class="page-wrapper">
<div class="page-content">
<div class="card mnt-card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<h5><i class="fa-solid fa-screwdriver-wrench me-2"></i>Manutenzioni</h5>
<div class="header-actions d-flex gap-2 flex-wrap">
<a href="manutenzioni/calendar.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-calendar-days"></i><span>Calendario</span>
</a>
<a href="manutenzioni/index.php" class="btn btn-mnt-outline d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-boxes-stacked"></i><span>Registro</span>
</a>
</div>
</div>
<div class="card-body">
<div class="row g-2 mb-3">
<?php
$tiles = [
['key' => 'overdue', 'label' => 'Scadute', 'class' => 'mnt-badge-overdue'],
['key' => 'due_soon', 'label' => 'In scadenza', 'class' => 'mnt-badge-soon'],
['key' => 'ok', 'label' => 'In regola', 'class' => 'mnt-badge-ok'],
['key' => 'none', 'label' => 'Senza scadenza', 'class' => 'mnt-badge-none'],
];
?>
<?php foreach ($tiles as $tile): ?>
<div class="col-6 col-md-3">
<?php
// Percorso completo, non "?...": con <base href="/userarea/">
// un href di sola query porterebbe alla dashboard del template.
// page is dropped: changing the filter changes the result
// set, and staying on page 3 of the previous one is nonsense.
$tileQuery = $_GET;
unset($tileQuery['page']);
$tileUrl = 'manutenzioni/maintenances.php?' . http_build_query(
array_merge($tileQuery, ['state' => $filterState === $tile['key'] ? '' : $tile['key']])
);
?>
<a class="d-block text-decoration-none" href="<?= mnt_h($tileUrl) ?>">
<div class="mnt-item-card mb-0 <?= $filterState === $tile['key'] ? 'border-2' : '' ?>"
style="--row-color: <?= $tile['key'] === 'overdue' ? '#dc3545' : ($tile['key'] === 'due_soon' ? '#f0a202' : ($tile['key'] === 'ok' ? '#198754' : '#adb5bd')) ?>">
<div class="ic-title" style="font-size:1.4rem"><?= (int)$counters[$tile['key']] ?></div>
<div class="ic-meta"><?= $tile['label'] ?></div>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
<form class="mnt-filter-bar" method="get" id="filterForm">
<input type="hidden" name="state" value="<?= mnt_h($filterState) ?>">
<select class="form-select" name="type">
<option value="">Programmate e straordinarie</option>
<?php foreach ($typeLabels as $value => $label): ?>
<option value="<?= mnt_h($value) ?>" <?= $filterType === $value ? 'selected' : '' ?>>
Solo <?= mnt_h(mb_strtolower($label)) ?>
</option>
<?php endforeach; ?>
</select>
<select class="form-select" name="category">
<option value="">Tutte le categorie</option>
<?php foreach ($formData['categories'] as $category): ?>
<option value="<?= (int)$category['id'] ?>" <?= $filterCategory === (int)$category['id'] ? 'selected' : '' ?>>
<?= mnt_h($category['name']) ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-check d-flex align-items-center gap-2 ms-1">
<input class="form-check-input mt-0" type="checkbox" name="critical" value="1" id="fCritical" <?= $filterCritical ? 'checked' : '' ?>>
<label class="form-check-label" for="fCritical">Solo critiche</label>
</div>
<div class="form-check d-flex align-items-center gap-2 ms-1">
<input class="form-check-input mt-0" type="checkbox" name="mine" value="1" id="fMine" <?= $filterMine ? 'checked' : '' ?>>
<label class="form-check-label" for="fMine">Assegnate a me</label>
</div>
<?php if ($perPage !== 25): ?>
<input type="hidden" name="per_page" value="<?= (int)$perPage ?>">
<?php endif; ?>
<!-- No «Filtra» button: selects and checkboxes all submit on change,
so it never had anything left to do. Kept hidden only as the
no-JavaScript fallback. -->
<button type="submit" class="visually-hidden">Filtra</button>
<a href="manutenzioni/maintenances.php" class="btn btn-mnt-outline btn-reset-filters d-inline-flex align-items-center gap-2">
<i class="fa-solid fa-xmark"></i><span>Azzera filtri</span>
</a>
</form>
<?php if (!$maintenances): ?>
<div class="empty-state">
<i class="fa-solid fa-screwdriver-wrench"></i>
<p>Nessuna manutenzione con questi filtri.</p>
</div>
<?php else: ?>
<div id="maintenanceList">
<!-- CARD -->
<div class="d-xl-none">
<?php foreach ($maintenances as $maintenance): ?>
<?php $badge = mnt_due_badge($maintenance['state'], $maintenance['frequency_unit']); ?>
<div class="mnt-item-card" data-id="<?= (int)$maintenance['id'] ?>"
style="--row-color: <?= $maintenance['is_critical'] ? '#dc3545' : mnt_h($maintenance['category_color'] ?? '#e9ecef') ?>">
<div class="ic-title"><?= mnt_h($maintenance['equipment_name']) ?></div>
<div class="ic-meta">
<?php if ($maintenance['code']): ?><strong><?= mnt_h($maintenance['code']) ?></strong> · <?php endif; ?>
<?= mnt_h($maintenance['title']) ?>
</div>
<div class="ic-meta">
Prossimo: <strong><?= mnt_format_date($maintenance['next_due_date']) ?></strong>
<span class="mnt-badge <?= $badge['class'] ?> ms-1"><?= $badge['label'] ?></span>
</div>
<div class="ic-actions">
<a class="btn-action btn-action-view" href="manutenzioni/equipment.php?id=<?= (int)$maintenance['equipment_id'] ?>">
<i class="fa-solid fa-eye"></i>
</a>
<?php if ($canManage): ?>
<button class="btn-action btn-action-done btn-register" title="Registra intervento">
<i class="fa-solid fa-check"></i>
</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- DESKTOP -->
<!-- Table only from 1200px up: measured, the 6 columns need
~1280px to fit, so on a tablet it only scrolls sideways. -->
<div class="d-none d-xl-block table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th class="mnt-equipment-cell">Attrezzatura</th>
<th>Manutenzione</th>
<th class="mnt-freq-cell">Frequenza</th>
<th class="mnt-person-cell">Incaricato</th>
<th class="text-center">Prossima</th>
<th class="text-center" style="width:130px">Azioni</th>
</tr>
</thead>
<tbody>
<?php foreach ($maintenances as $maintenance): ?>
<?php $badge = mnt_due_badge($maintenance['state'], $maintenance['frequency_unit']); ?>
<tr data-id="<?= (int)$maintenance['id'] ?>">
<td class="mnt-equipment-cell">
<span class="d-flex align-items-start gap-2">
<span class="mnt-cat-dot mt-1" style="background: <?= mnt_h($maintenance['category_color'] ?? '#adb5bd') ?>"></span>
<span class="fw-semibold mnt-title-clamp" style="color:var(--mnt-heading)"
title="<?= mnt_h($maintenance['equipment_name']) ?>"><?= mnt_h($maintenance['equipment_name']) ?></span>
</span>
<div class="small text-muted"><?= mnt_h($maintenance['category_name'] ?? '') ?></div>
</td>
<td class="mnt-title-cell">
<?php if ($maintenance['code']): ?>
<span class="mnt-badge mnt-badge-soft me-1"><?= mnt_h($maintenance['code']) ?></span>
<?php endif; ?>
<?php if ($maintenance['is_critical']): ?>
<span class="mnt-badge mnt-badge-critical ms-1">Critica</span>
<?php endif; ?>
<div class="mnt-title-clamp" title="<?= mnt_h($maintenance['title']) ?>">
<?= mnt_h($maintenance['title']) ?>
</div>
<div class="small text-muted">
<?= mnt_h($typeLabels[$maintenance['intervention_type']] ?? '') ?>
· <?= mnt_h($executionLabels[$maintenance['execution_type']] ?? '') ?>
<?php if ($maintenance['supplier_name']): ?>
<?= mnt_h($maintenance['supplier_name']) ?>
<?php endif; ?>
</div>
</td>
<td class="small mnt-freq-cell"><div class="mnt-title-clamp"><?= mnt_h(mnt_format_frequency(
$maintenance['frequency_value'] !== null ? (int)$maintenance['frequency_value'] : null,
$maintenance['frequency_unit'],
$maintenance['frequency_note']
)) ?></div></td>
<td class="small mnt-person-cell">
<?= mnt_h($maintenance['assignee_name'] ?: '—') ?>
<?php if ($maintenance['supervisor_name']): ?>
<div class="text-muted"><?= mnt_h($maintenance['supervisor_name']) ?></div>
<?php endif; ?>
</td>
<td class="text-center">
<div><?= mnt_format_date($maintenance['next_due_date']) ?></div>
<span class="mnt-badge <?= $badge['class'] ?>"><?= $badge['label'] ?></span>
</td>
<td class="text-center">
<div class="d-inline-flex gap-1">
<a class="btn-action btn-action-view" title="Apri scheda"
href="manutenzioni/equipment.php?id=<?= (int)$maintenance['equipment_id'] ?>">
<i class="fa-solid fa-eye"></i>
</a>
<?php if ($canManage): ?>
<button class="btn-action btn-action-done btn-register" title="Registra intervento">
<i class="fa-solid fa-check"></i>
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php
$mntPagerUrl = 'manutenzioni/maintenances.php';
$mntPagerPage = $page;
$mntPagerPages = $totalPages;
$mntPagerTotal = $totalMaintenances;
$mntPagerPer = $perPage;
include __DIR__ . '/include/pagination.php';
?>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php include(__DIR__ . '/../include/footer.php'); ?>
</div>
<?php if ($canManage): ?>
<?php include __DIR__ . '/include/intervention_modal.php'; ?>
<?php endif; ?>
<?php include(__DIR__ . '/../jsinclude.php'); ?>
<script>
$(function () {
$('#filterForm select, #filterForm input[type=checkbox]').on('change', function () { $('#filterForm').submit(); });
<?php if ($canManage): ?>
$('#maintenanceList').on('click', '.btn-register', function () {
const id = $(this).closest('[data-id]').data('id');
$.getJSON('manutenzioni/ajax/get_maintenance.php', { id: id })
.done(res => {
if (!res.success) { Swal.fire({ icon: 'error', title: 'Errore', text: res.message }); return; }
mntOpenInterventionModal(null, {
id: res.maintenance.id,
title: res.maintenance.title,
equipment_name: res.maintenance.equipment_name,
supplier_id: res.maintenance.supplier_id
});
});
});
<?php endif; ?>
});
</script>
</body>
</html>
+263
View File
@@ -0,0 +1,263 @@
<?php include(__DIR__ . '/../include/headscript.php'); ?>
<?php
require_once __DIR__ . '/include/functions.php';
/**
* Printable maintenance sheet, laid out like the client's paper form
* "mod. 6.3-1": machine header, list of interventions with letter codes and
* frequency, then the log of performed interventions with operator + signature.
* Print to PDF from the browser.
*/
$pdo = mnt_pdo();
if (!userCan('maintenance.equipment.view')) {
http_response_code(403);
exit('Permesso negato.');
}
$equipmentId = isset($_GET['id']) && is_numeric($_GET['id']) ? (int)$_GET['id'] : 0;
$stmt = $pdo->prepare("
SELECT e.*, c.name AS category_name, pl.name AS line_name, pl.line_number, d.name AS department_name
FROM inv_equipment e
LEFT JOIN inv_categories c ON c.id = e.category_id
LEFT JOIN production_lines pl ON pl.id = e.line_id
LEFT JOIN departments d ON d.id = e.department_id
WHERE e.id = ?
");
$stmt->execute([$equipmentId]);
$equipment = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$equipment) {
http_response_code(404);
exit('Attrezzatura non trovata.');
}
$stmt = $pdo->prepare("
SELECT m.*, sup.supplier_name,
CONCAT(a.first_name, ' ', a.last_name) AS assignee_name
FROM maint_maintenances m
LEFT JOIN suppliers sup ON sup.idsupplier = m.supplier_id
LEFT JOIN employees a ON a.id = m.assignee_employee_id
WHERE m.equipment_id = ? AND m.is_active = 1
ORDER BY m.code ASC, m.title ASC
");
$stmt->execute([$equipmentId]);
$maintenances = $stmt->fetchAll(PDO::FETCH_ASSOC);
$stmt = $pdo->prepare("
SELECT i.*, m.code AS maintenance_code, m.title AS maintenance_title,
CONCAT(e.first_name, ' ', e.last_name) AS operator_full_name,
sup.supplier_name
FROM maint_interventions i
INNER JOIN maint_maintenances m ON m.id = i.maintenance_id
LEFT JOIN employees e ON e.id = i.operator_employee_id
LEFT JOIN suppliers sup ON sup.idsupplier = i.supplier_id
WHERE i.equipment_id = ?
ORDER BY i.performed_at DESC, i.id DESC
");
$stmt->execute([$equipmentId]);
$interventions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$statusLabels = mnt_lookup($pdo, 'maint_status', ['planned' => 'Pianificato', 'in_progress' => 'In corso', 'completed' => 'Completato']);
$resultLabels = mnt_lookup($pdo, 'maint_result', []);
$executionLabels = mnt_execution_types();
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<?php
// Must sit in <head>, before any relative URL on the page
$mntScriptName = $_SERVER['SCRIPT_NAME'] ?? '';
$mntPos = strpos($mntScriptName, '/userarea/');
$printBase = $mntPos !== false ? substr($mntScriptName, 0, $mntPos + strlen('/userarea/')) : '/userarea/';
?>
<base href="<?= mnt_h($printBase) ?>">
<title>Scheda manutenzione <?= mnt_h($equipment['name']) ?></title>
<style>
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; font-size: 11px; color: #000; margin: 0; padding: 18px; background: #fff; }
.sheet { max-width: 1000px; margin: 0 auto; }
.sheet-header { display: flex; justify-content: space-between; align-items: flex-start; border: 1px solid #000; padding: 8px 10px; }
.sheet-header h1 { font-size: 15px; margin: 0; text-transform: uppercase; letter-spacing: .02em; }
.sheet-header .form-ref { text-align: right; font-size: 10px; line-height: 1.5; }
.machine-row { border: 1px solid #000; border-top: none; padding: 8px 10px; }
.machine-row .label { font-weight: bold; text-transform: uppercase; }
.machine-meta { display: flex; flex-wrap: wrap; gap: 4px 22px; margin-top: 6px; font-size: 10.5px; }
table { width: 100%; border-collapse: collapse; margin-top: 14px; }
caption { text-align: left; font-weight: bold; text-transform: uppercase; font-size: 11.5px; padding: 6px 0; }
th, td { border: 1px solid #000; padding: 5px 6px; vertical-align: top; }
th { background: #ececec; text-transform: uppercase; font-size: 10px; text-align: left; }
.col-code { width: 60px; text-align: center; }
.col-freq { width: 190px; }
.col-date { width: 90px; }
.col-operator { width: 150px; }
.col-sign { width: 130px; }
.signature-img { max-height: 42px; max-width: 120px; display: block; }
.empty-row td { text-align: center; color: #666; font-style: italic; }
.toolbar { max-width: 1000px; margin: 0 auto 14px; display: flex; gap: 8px; justify-content: flex-end; }
.toolbar button, .toolbar a { font-family: inherit; font-size: 12px; padding: 7px 14px; border-radius: 5px; border: 1px solid #2f7d8f; background: #2f7d8f; color: #fff; cursor: pointer; text-decoration: none; }
.toolbar a { background: #fff; color: #2f7d8f; }
.footnote { margin-top: 14px; font-size: 9.5px; color: #444; }
@media print {
body { padding: 0; }
.toolbar { display: none; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; page-break-after: auto; }
thead { display: table-header-group; }
}
</style>
</head>
<body>
<div class="toolbar">
<a href="manutenzioni/equipment.php?id=<?= (int)$equipment['id'] ?>"> Torna alla scheda</a>
<button type="button" onclick="window.print()">Stampa / Salva PDF</button>
</div>
<div class="sheet">
<div class="sheet-header">
<h1>Scheda manutenzione</h1>
<div class="form-ref">
ZIBOGOMMA<br>
mod. 6.3-1<br>
Stampato il <?= date('d/m/Y') ?>
</div>
</div>
<div class="machine-row">
<span class="label">Macchina:</span>
<strong><?= mnt_h($equipment['name']) ?></strong>
<?php if ($equipment['manufacturer']): ?> — <?= mnt_h($equipment['manufacturer']) ?><?php endif; ?>
<?php if ($equipment['registration_number']): ?> — matricola <?= mnt_h($equipment['registration_number']) ?><?php endif; ?>
<div class="machine-meta">
<span><strong>Categoria:</strong> <?= mnt_h($equipment['category_name'] ?: '—') ?></span>
<?php if ($equipment['line_name']): ?>
<span><strong>Linea:</strong> <?= (int)$equipment['line_number'] ?> — <?= mnt_h($equipment['line_name']) ?></span>
<?php endif; ?>
<span><strong>Numero di serie:</strong> <?= mnt_h($equipment['serial_number'] ?: '—') ?></span>
<span><strong>Lotto:</strong> <?= mnt_h($equipment['batch_lot'] ?: '—') ?></span>
<span><strong>Messa in servizio:</strong> <?= mnt_format_date($equipment['commissioning_date']) ?></span>
<span><strong>Reparto:</strong> <?= mnt_h($equipment['department_name'] ?: '—') ?></span>
<span><strong>Ubicazione:</strong> <?= mnt_h($equipment['location'] ?: '—') ?></span>
</div>
</div>
<table>
<caption>Interventi previsti</caption>
<thead>
<tr>
<th class="col-code">Tipo</th>
<th>Intervento</th>
<th class="col-freq">Frequenza</th>
<th class="col-operator">Esecuzione</th>
</tr>
</thead>
<tbody>
<?php if (!$maintenances): ?>
<tr class="empty-row">
<td colspan="4">Nessuna manutenzione attiva definita.</td>
</tr>
<?php else: ?>
<?php foreach ($maintenances as $maintenance): ?>
<tr>
<td class="col-code"><strong><?= mnt_h($maintenance['code'] ?: '—') ?></strong></td>
<td>
<strong><?= mnt_h($maintenance['title']) ?></strong>
<?php if ($maintenance['is_critical']): ?> (critica)<?php endif; ?>
<?php if ($maintenance['description']): ?>
<div><?= nl2br(mnt_h($maintenance['description'])) ?></div>
<?php endif; ?>
</td>
<td><?= mnt_h(mnt_format_frequency(
$maintenance['frequency_value'] !== null ? (int)$maintenance['frequency_value'] : null,
$maintenance['frequency_unit'],
$maintenance['frequency_note']
)) ?></td>
<td>
<?= mnt_h($executionLabels[$maintenance['execution_type']] ?? '') ?>
<?php if ($maintenance['supplier_name']): ?>
<div><?= mnt_h($maintenance['supplier_name']) ?></div>
<?php elseif ($maintenance['assignee_name']): ?>
<div><?= mnt_h($maintenance['assignee_name']) ?></div>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<table>
<caption>Registro interventi eseguiti</caption>
<thead>
<tr>
<th class="col-date">Data</th>
<th class="col-code">Tipo</th>
<th>Intervento / note</th>
<th class="col-operator">Operatore</th>
<th class="col-sign">Firma</th>
</tr>
</thead>
<tbody>
<?php if (!$interventions): ?>
<tr class="empty-row">
<td colspan="5">Nessun intervento registrato.</td>
</tr>
<?php else: ?>
<?php foreach ($interventions as $intervention): ?>
<tr>
<td><?= mnt_format_date($intervention['performed_at']) ?></td>
<td class="col-code"><?= mnt_h($intervention['maintenance_code'] ?: '—') ?></td>
<td>
<strong><?= mnt_h($intervention['maintenance_title']) ?></strong>
<div>
<?= mnt_h($statusLabels[$intervention['status']] ?? $intervention['status']) ?>
<?php if ($intervention['result']): ?>
<?= mnt_h($resultLabels[$intervention['result']] ?? $intervention['result']) ?>
<?php endif; ?>
</div>
<?php if ($intervention['notes']): ?>
<div><?= nl2br(mnt_h($intervention['notes'])) ?></div>
<?php endif; ?>
<?php if ($intervention['materials']): ?>
<div>Materiali: <?= mnt_h($intervention['materials']) ?></div>
<?php endif; ?>
</td>
<td>
<?= mnt_h($intervention['operator_full_name'] ?: ($intervention['operator_name'] ?: '—')) ?>
<?php if ($intervention['supplier_name']): ?>
<div><?= mnt_h($intervention['supplier_name']) ?></div>
<?php endif; ?>
</td>
<td>
<?php if ($intervention['signature_path']): ?>
<img class="signature-img" alt="Firma"
src="manutenzioni/ajax/download_file.php?scope=signature&id=<?= (int)$intervention['id'] ?>">
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<div class="footnote">
Documento generato automaticamente dal portale ZIBO registro attrezzature e manutenzioni.
</div>
</div>
</body>
</html>
@@ -0,0 +1,59 @@
DELIMITER $$
DROP PROCEDURE IF EXISTS manutenzioni_migrate_step1 $$
CREATE PROCEDURE manutenzioni_migrate_step1()
BEGIN
DECLARE v_db VARCHAR(64);
SET v_db = DATABASE();
-- 1. Rename production_tools -> inv_equipment
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = v_db AND table_name = 'production_tools')
AND NOT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = v_db AND table_name = 'inv_equipment') THEN
RENAME TABLE production_tools TO inv_equipment;
END IF;
-- 2. Normalise charset/collation (was utf8mb4_general_ci; the rest of
-- the new modules are utf8mb4_unicode_ci — mixing them breaks joins
-- on string columns with "Illegal mix of collations")
ALTER TABLE inv_equipment CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 3. Registry columns (all nullable: existing rows stay valid)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = v_db AND table_name = 'inv_equipment'
AND column_name = 'category_id') THEN
ALTER TABLE inv_equipment
ADD COLUMN category_id INT UNSIGNED NULL AFTER name,
ADD COLUMN line_id INT NULL AFTER category_id,
ADD COLUMN batch_lot VARCHAR(100) NULL AFTER serial_number,
ADD COLUMN purchase_date DATE NULL AFTER batch_lot,
ADD COLUMN commissioning_date DATE NULL AFTER purchase_date,
ADD COLUMN department_id INT UNSIGNED NULL AFTER manufacturer,
ADD COLUMN location VARCHAR(255) NULL AFTER department_id,
ADD COLUMN notes TEXT NULL AFTER description,
ADD COLUMN cover_file_id INT UNSIGNED NULL AFTER notes,
ADD COLUMN created_by INT UNSIGNED NULL AFTER cover_file_id,
ADD KEY idx_inv_equipment_category (category_id),
ADD KEY idx_inv_equipment_line (line_id),
ADD KEY idx_inv_equipment_department (department_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = v_db AND table_name = 'inv_equipment'
AND column_name = 'status') THEN
ALTER TABLE inv_equipment
ADD COLUMN status ENUM('active','decommissioned','out_of_service') NULL AFTER location;
UPDATE inv_equipment SET status = IF(is_active = 1, 'active', 'decommissioned');
ALTER TABLE inv_equipment
MODIFY COLUMN status ENUM('active','decommissioned','out_of_service')
NOT NULL DEFAULT 'active';
ALTER TABLE inv_equipment DROP COLUMN is_active;
ALTER TABLE inv_equipment ADD KEY idx_inv_equipment_status (status);
END IF;
END $$
DELIMITER ;
CALL manutenzioni_migrate_step1();
DROP PROCEDURE manutenzioni_migrate_step1;
@@ -0,0 +1,171 @@
CREATE TABLE IF NOT EXISTS inv_categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(150) NOT NULL,
code VARCHAR(50) NULL,
description TEXT NULL,
color VARCHAR(20) NOT NULL DEFAULT '#6c757d',
-- when 1, the equipment form requires a production line
requires_line TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT UNSIGNED NOT NULL DEFAULT 999,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_inv_categories_name (name),
KEY idx_inv_categories_active (is_active, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS inv_equipment_files (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
equipment_id INT NOT NULL,
kind VARCHAR(10) NOT NULL DEFAULT 'file',
original_name VARCHAR(500) NOT NULL,
stored_name VARCHAR(500) NOT NULL,
mime_type VARCHAR(100) NULL,
size INT UNSIGNED NULL,
uploaded_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_inv_files_equipment (equipment_id, kind),
CONSTRAINT fk_inv_files_equipment FOREIGN KEY (equipment_id)
REFERENCES inv_equipment (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS maint_maintenances (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
equipment_id INT NOT NULL,
-- letter code as used on the paper form (A, B, C, ...)
code VARCHAR(10) NULL,
title VARCHAR(500) NOT NULL,
description TEXT NULL,
intervention_type VARCHAR(20) NOT NULL DEFAULT 'scheduled',
execution_type VARCHAR(20) NOT NULL DEFAULT 'internal',
is_critical TINYINT(1) NOT NULL DEFAULT 0,
-- frequency: NULL value + unit 'on_demand' means "no automatic due date"
frequency_value SMALLINT UNSIGNED NULL,
frequency_unit VARCHAR(20) NOT NULL DEFAULT 'month',
-- free text for the irregular cases in the client's sheets
-- ("prima dell'accensione / d'estate settimanale / dopo lo spegnimento")
frequency_note VARCHAR(500) NULL,
alert_days SMALLINT UNSIGNED NOT NULL DEFAULT 7,
assignee_employee_id INT UNSIGNED NULL,
supervisor_employee_id INT UNSIGNED NULL,
supplier_id INT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
-- denormalised, recomputed on every intervention change
last_done_date DATE NULL,
next_due_date DATE NULL,
notes TEXT NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_maint_equipment (equipment_id),
KEY idx_maint_due (next_due_date),
KEY idx_maint_type (intervention_type, is_active),
CONSTRAINT fk_maint_equipment FOREIGN KEY (equipment_id)
REFERENCES inv_equipment (id) ON DELETE CASCADE,
CONSTRAINT fk_maint_assignee FOREIGN KEY (assignee_employee_id)
REFERENCES employees (id) ON DELETE SET NULL,
CONSTRAINT fk_maint_supervisor FOREIGN KEY (supervisor_employee_id)
REFERENCES employees (id) ON DELETE SET NULL,
CONSTRAINT fk_maint_supplier FOREIGN KEY (supplier_id)
REFERENCES suppliers (idsupplier) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS maint_maintenance_files (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
maintenance_id INT UNSIGNED NOT NULL,
original_name VARCHAR(500) NOT NULL,
stored_name VARCHAR(500) NOT NULL,
mime_type VARCHAR(100) NULL,
size INT UNSIGNED NULL,
uploaded_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_mmf_maintenance (maintenance_id),
CONSTRAINT fk_mmf_maintenance FOREIGN KEY (maintenance_id)
REFERENCES maint_maintenances (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS maint_interventions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
maintenance_id INT UNSIGNED NOT NULL,
-- denormalised so the equipment history is a single-table scan
equipment_id INT NOT NULL,
performed_at DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'completed',
result VARCHAR(20) NULL,
notes TEXT NULL,
materials TEXT NULL,
operator_employee_id INT UNSIGNED NULL,
-- free text operator for external technicians not present in employees
operator_name VARCHAR(191) NULL,
signature_path VARCHAR(255) NULL,
supplier_id INT NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_int_maintenance (maintenance_id, performed_at),
KEY idx_int_equipment (equipment_id, performed_at),
KEY idx_int_status (status),
CONSTRAINT fk_int_maintenance FOREIGN KEY (maintenance_id)
REFERENCES maint_maintenances (id) ON DELETE CASCADE,
CONSTRAINT fk_int_equipment FOREIGN KEY (equipment_id)
REFERENCES inv_equipment (id) ON DELETE CASCADE,
CONSTRAINT fk_int_operator FOREIGN KEY (operator_employee_id)
REFERENCES employees (id) ON DELETE SET NULL,
CONSTRAINT fk_int_supplier FOREIGN KEY (supplier_id)
REFERENCES suppliers (idsupplier) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS maint_intervention_files (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
intervention_id INT UNSIGNED NOT NULL,
original_name VARCHAR(500) NOT NULL,
stored_name VARCHAR(500) NOT NULL,
mime_type VARCHAR(100) NULL,
size INT UNSIGNED NULL,
uploaded_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_mif_intervention (intervention_id),
CONSTRAINT fk_mif_intervention FOREIGN KEY (intervention_id)
REFERENCES maint_interventions (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS maint_histories (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
equipment_id INT NULL,
maintenance_id INT UNSIGNED NULL,
intervention_id INT UNSIGNED NULL,
user_id INT UNSIGNED NULL,
action VARCHAR(50) NOT NULL,
changes TEXT NULL,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_hist_equipment (equipment_id, created_at),
KEY idx_hist_maintenance (maintenance_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS maint_notifications (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
maintenance_id INT UNSIGNED NOT NULL,
employee_id INT UNSIGNED NULL,
email VARCHAR(191) NOT NULL,
type VARCHAR(20) NOT NULL,
sent_date DATE NOT NULL,
sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_maint_notif (maintenance_id, email, type, sent_date),
CONSTRAINT fk_notif_maintenance FOREIGN KEY (maintenance_id)
REFERENCES maint_maintenances (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO ws_lookup_options (category, value, label, sort_order, is_default, is_active) VALUES
('maint_status', 'planned', 'Pianificato', 10, 0, 1),
('maint_status', 'in_progress', 'In corso', 20, 0, 1),
('maint_status', 'completed', 'Completato', 30, 1, 1),
('maint_result', 'compliant', 'Conforme', 10, 1, 1),
('maint_result', 'not_compliant','Non conforme', 20, 0, 1),
('maint_result', 'blocked', 'Bloccato', 30, 0, 1),
('maint_result', 'partial', 'Parziale', 40, 0, 1),
('equipment_status', 'active', 'Attivo', 10, 1, 1),
('equipment_status', 'out_of_service', 'Fuori servizio', 20, 0, 1),
('equipment_status', 'decommissioned', 'Dismesso', 30, 0, 1);
@@ -0,0 +1,68 @@
INSERT IGNORE INTO inv_categories (name, code, color, requires_line, sort_order) VALUES
('Linea di produzione', 'line', '#dc2626', 1, 10),
('Attrezzature', 'equipment', '#0d6efd', 0, 20),
('Accessori', 'accessories', '#20c997', 0, 30),
('Qualità', 'quality', '#6f42c1', 0, 40);
UPDATE inv_equipment
SET category_id = (SELECT id FROM inv_categories WHERE code = 'equipment')
WHERE category_id IS NULL AND line_id IS NULL;
INSERT INTO inv_equipment (name, category_id, line_id, status, description, created_at, updated_at)
SELECT
pl.name,
(SELECT id FROM inv_categories WHERE code = 'line'),
pl.id,
IF(pl.status = 'active', 'active', 'out_of_service'),
CONCAT_WS(' ', NULLIF(pl.brand, ''), NULLIF(pl.model, '')),
NOW(), NOW()
FROM production_lines pl
WHERE NOT EXISTS (
SELECT 1 FROM inv_equipment e WHERE e.line_id = pl.id
);
DELIMITER $$
DROP PROCEDURE IF EXISTS manutenzioni_migrate_step3 $$
CREATE PROCEDURE manutenzioni_migrate_step3()
BEGIN
DECLARE v_db VARCHAR(64);
SET v_db = DATABASE();
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = v_db AND table_name = 'inv_equipment'
AND constraint_name = 'fk_inv_equipment_category') THEN
ALTER TABLE inv_equipment
ADD CONSTRAINT fk_inv_equipment_category FOREIGN KEY (category_id)
REFERENCES inv_categories (id) ON DELETE SET NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = v_db AND table_name = 'inv_equipment'
AND constraint_name = 'fk_inv_equipment_line') THEN
ALTER TABLE inv_equipment
ADD CONSTRAINT fk_inv_equipment_line FOREIGN KEY (line_id)
REFERENCES production_lines (id) ON DELETE SET NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = v_db AND table_name = 'inv_equipment'
AND constraint_name = 'fk_inv_equipment_department') THEN
ALTER TABLE inv_equipment
ADD CONSTRAINT fk_inv_equipment_department FOREIGN KEY (department_id)
REFERENCES departments (id) ON DELETE SET NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = v_db AND table_name = 'inv_equipment'
AND constraint_name = 'fk_inv_equipment_cover') THEN
ALTER TABLE inv_equipment
ADD CONSTRAINT fk_inv_equipment_cover FOREIGN KEY (cover_file_id)
REFERENCES inv_equipment_files (id) ON DELETE SET NULL;
END IF;
END $$
DELIMITER ;
CALL manutenzioni_migrate_step3();
DROP PROCEDURE manutenzioni_migrate_step3;
@@ -0,0 +1,19 @@
INSERT IGNORE INTO auth_permissions
(name, display_name, description, removable, created_at, updated_at)
VALUES
('maintenance.equipment.view', 'View Equipment Registry', 'Can view the equipment registry.', 1, NOW(), NOW()),
('maintenance.maintenances.view','View Maintenances', 'Can view maintenances and the intervention log.', 1, NOW(), NOW()),
('maintenance.calendar.view', 'View Maintenance Calendar','Can view the maintenance calendar.', 1, NOW(), NOW()),
('maintenance.categories.view', 'View Equipment Categories','Can view equipment categories.', 1, NOW(), NOW()),
('maintenance.manage', 'Manage Maintenance', 'Can create/edit equipment, maintenances and interventions.', 1, NOW(), NOW());
INSERT IGNORE INTO auth_permission_role (permission_id, role_id)
SELECT p.id, 1
FROM auth_permissions p
WHERE p.name IN (
'maintenance.equipment.view',
'maintenance.maintenances.view',
'maintenance.calendar.view',
'maintenance.categories.view',
'maintenance.manage'
);
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -288,7 +288,7 @@ if (!empty($_GET['ajax'])) {
(
SELECT GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR ' | ')
FROM productiondata_tools pt
JOIN production_tools t ON t.id = pt.tool_id
JOIN inv_equipment t ON t.id = pt.tool_id
WHERE pt.productiondata_id = p.id
) AS tools_list,
(
@@ -350,7 +350,7 @@ if (!empty($_GET['ajax'])) {
(
SELECT GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR ' | ')
FROM productiondata_tools pt
JOIN production_tools t ON t.id = pt.tool_id
JOIN inv_equipment t ON t.id = pt.tool_id
WHERE pt.productiondata_id = p.id
) AS tools_list,
(
+28 -11
View File
@@ -127,7 +127,15 @@
$db = DBHandlerSelect::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->query("SELECT * FROM production_tools ORDER BY id ASC");
// production_tools is now the unified registry inv_equipment;
// this page keeps showing the "Attrezzature" slice of it.
$stmt = $pdo->query("
SELECT e.*
FROM inv_equipment e
LEFT JOIN inv_categories c ON c.id = e.category_id
WHERE c.code = 'equipment' OR e.category_id IS NULL
ORDER BY e.id ASC
");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)):
?>
<tr>
@@ -139,10 +147,17 @@
<td><?= nl2br(htmlspecialchars($row['description'] ?? '')) ?></td>
<td>
<?php if ((int)$row['is_active'] === 1): ?>
<?php
$statusLabels = [
'active' => 'Attivo',
'out_of_service' => 'Fuori servizio',
'decommissioned' => 'Dismesso',
];
?>
<?php if ($row['status'] === 'active'): ?>
<span class="badge-active">Attivo</span>
<?php else: ?>
<span class="badge-inactive">Non attivo</span>
<span class="badge-inactive"><?= htmlspecialchars($statusLabels[$row['status']] ?? $row['status']) ?></span>
<?php endif; ?>
</td>
@@ -156,7 +171,7 @@
data-type="<?= htmlspecialchars($row['tool_type'] ?? '', ENT_QUOTES) ?>"
data-manufacturer="<?= htmlspecialchars($row['manufacturer'] ?? '', ENT_QUOTES) ?>"
data-desc="<?= htmlspecialchars($row['description'] ?? '', ENT_QUOTES) ?>"
data-active="<?= (int)$row['is_active'] ?>">
data-status="<?= htmlspecialchars($row['status'], ENT_QUOTES) ?>">
<i class="fas fa-edit"></i>
</button>
@@ -214,9 +229,10 @@
<textarea name="description" class="form-control mb-3"></textarea>
<label class="fw-semibold">Stato</label>
<select name="is_active" class="form-control">
<option value="1">Attivo</option>
<option value="0">Non attivo</option>
<select name="status" class="form-control">
<option value="active">Attivo</option>
<option value="out_of_service">Fuori servizio</option>
<option value="decommissioned">Dismesso</option>
</select>
</div>
@@ -265,9 +281,10 @@
<textarea id="edit_description" name="description" class="form-control mb-3"></textarea>
<label class="fw-semibold">Stato</label>
<select id="edit_is_active" name="is_active" class="form-control">
<option value="1">Attivo</option>
<option value="0">Non attivo</option>
<select id="edit_status" name="status" class="form-control">
<option value="active">Attivo</option>
<option value="out_of_service">Fuori servizio</option>
<option value="decommissioned">Dismesso</option>
</select>
</div>
@@ -327,7 +344,7 @@
$("#edit_tool_type").val($(this).data("type"));
$("#edit_manufacturer").val($(this).data("manufacturer"));
$("#edit_description").val($(this).data("desc"));
$("#edit_is_active").val($(this).data("active"));
$("#edit_status").val($(this).data("status"));
$("#editToolModal").modal("show");
});
+13 -6
View File
@@ -16,17 +16,24 @@ try {
$toolType = trim($_POST['tool_type'] ?? '');
$manufacturer = trim($_POST['manufacturer'] ?? '');
$description = trim($_POST['description'] ?? '');
$isActive = isset($_POST['is_active']) ? (int)$_POST['is_active'] : 1;
$status = (string)($_POST['status'] ?? 'active');
if ($name === '') {
echo json_encode(['success' => false, 'message' => 'Name is required.']);
exit;
}
$sql = "INSERT INTO production_tools
(name, registration_number, serial_number, tool_type, manufacturer, description, is_active)
VALUES
(:name, :registration_number, :serial_number, :tool_type, :manufacturer, :description, :is_active)";
if (!in_array($status, ['active', 'out_of_service', 'decommissioned'], true)) {
$status = 'active';
}
// production_tools is now inv_equipment; tools created here land in the
// "Attrezzature" category of the registry.
$sql = "INSERT INTO inv_equipment
(name, registration_number, serial_number, tool_type, manufacturer, description, status, category_id)
VALUES
(:name, :registration_number, :serial_number, :tool_type, :manufacturer, :description, :status,
(SELECT id FROM inv_categories WHERE code = 'equipment'))";
$stmt = $pdo->prepare($sql);
$stmt->execute([
@@ -36,7 +43,7 @@ try {
'tool_type' => $toolType ?: null,
'manufacturer' => $manufacturer ?: null,
'description' => $description ?: null,
'is_active' => $isActive
'status' => $status
]);
echo json_encode(['success' => true]);
+4 -4
View File
@@ -104,7 +104,7 @@ $skills = $pdo->query("
pt.registration_number
FROM skills s
LEFT JOIN production_lines pl ON s.production_line_id = pl.id
LEFT JOIN production_tools pt ON s.tool_id = pt.id
LEFT JOIN inv_equipment pt ON s.tool_id = pt.id
ORDER BY s.ordinamento ASC, s.id
")->fetchAll(PDO::FETCH_ASSOC);
@@ -113,9 +113,9 @@ $lines = $pdo->query("SELECT id, name FROM production_lines ORDER BY line_number
// Attrezzature per tendina
$tools = $pdo->query("
SELECT id, name, registration_number
FROM production_tools
WHERE is_active = 1
SELECT id, name, registration_number
FROM inv_equipment
WHERE status = 'active'
ORDER BY name
")->fetchAll(PDO::FETCH_ASSOC);
?>