Compare commits
34 Commits
d155d1cbab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 27cbc9f449 | |||
| 4c09a0dcb4 | |||
| 8bb23ee563 | |||
| 20571c9e4b | |||
| fdde16b113 | |||
| 33b627f328 | |||
| d96b4be9e0 | |||
| 088e518db1 | |||
| 789c547bc7 | |||
| e5bf546ae7 | |||
| 6dd13e5d7d | |||
| b1f2bb60e3 | |||
| f7e97f55e9 | |||
| 70b712ff3b | |||
| fdc3af01f3 | |||
| 3d54140280 | |||
| bfdbbbfc8f | |||
| 40a5771a4b | |||
| 9f5a585717 | |||
| 9ec5419a86 | |||
| c05091e020 | |||
| 0b470f290e | |||
| e74870c8d3 | |||
| 9001eff317 | |||
| 7cbd74111d | |||
| 650676037a | |||
| 2fc34c3cf4 | |||
| 955a7ed9e9 | |||
| cb221a8039 | |||
| ece1beb87f | |||
| e6a805f1f7 | |||
| fe84d446e7 | |||
| 2ddf575191 | |||
| d73a8bb8d3 |
+4
-2
@@ -31,6 +31,8 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
|
||||
MANAGER_USER_ID=
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
@@ -55,5 +57,5 @@ AZURE_REDIRECT_URI=https://your-app.com/auth/azure/callback
|
||||
AZURE_TENANT_ID=
|
||||
|
||||
MICROSOFT_CLIENT_ID=your_client_id_here
|
||||
MICROSOFT_CLIENT_SECRET=your_client_secret_here
|
||||
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback"
|
||||
MICROSOFT_CLIENT_SECRET=your_client_secret_here
|
||||
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback"
|
||||
|
||||
@@ -46,6 +46,7 @@ public/userarea/last_url.txt
|
||||
public/userarea/class/curl_auth_debug.log
|
||||
public/userarea/class/curl_request_debug.log
|
||||
|
||||
public/userarea/uploads/cad_area/originals/*
|
||||
# Ignora tutti i log
|
||||
*.log
|
||||
|
||||
@@ -66,3 +67,6 @@ public/userarea/logsapi/commessaweb_customfields_763.json
|
||||
public/userarea/logsapi/commessaweb_invia_762.json
|
||||
public/userarea/logsapi/commessaweb_invia_763.json
|
||||
public/userarea/logsapi/last_auth_url.txt
|
||||
|
||||
# User uploaded files
|
||||
/public/userarea/files/
|
||||
@@ -111,6 +111,14 @@ class LoginController extends Controller
|
||||
return redirect()->to('userarea/production_dashboard.php');
|
||||
} elseif ($user->hasRole('User')) {
|
||||
return redirect()->to('userarea/production_dashboard.php');
|
||||
} elseif ($user->hasRole('HR')) {
|
||||
return redirect()->to('userarea/production_dashboard.php');
|
||||
} elseif ($user->hasRole('SuperUser')) {
|
||||
return redirect()->to('userarea/production_dashboard.php');
|
||||
} elseif ($user->hasRole('Management')) {
|
||||
return redirect()->to('userarea/production_dashboard.php');
|
||||
} elseif ($user->hasRole('Quality')) {
|
||||
return redirect()->to('userarea/production_dashboard.php');
|
||||
}
|
||||
|
||||
// Se il ruolo non è specificato, reindirizza alla home predefinita
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"phpmailer/phpmailer": "^6.9",
|
||||
"phpoffice/phpspreadsheet": "^4.1",
|
||||
"proengsoft/laravel-jsvalidation": "^4.0.0",
|
||||
"robmorgan/phinx": "^0.16.11",
|
||||
"socialiteproviders/microsoft": "^4.7",
|
||||
"spatie/laravel-query-builder": "^5.0",
|
||||
"vanguardapp/activity-log": "^6.0",
|
||||
|
||||
Generated
+646
-2
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "9c4f1e3bc3ee2180211c055e70635aef",
|
||||
"content-hash": "076e7721d08cfea8b06ce75dd8c6c576",
|
||||
"packages": [
|
||||
{
|
||||
"name": "akaunting/laravel-setting",
|
||||
@@ -251,6 +251,330 @@
|
||||
],
|
||||
"time": "2023-11-29T23:19:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "cakephp/chronos",
|
||||
"version": "3.5.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cakephp/chronos.git",
|
||||
"reference": "e6e777b534244911566face8a5dbdbd7f7bda5a6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/cakephp/chronos/zipball/e6e777b534244911566face8a5dbdbd7f7bda5a6",
|
||||
"reference": "e6e777b534244911566face8a5dbdbd7f7bda5a6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"psr/clock": "^1.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/clock-implementation": "1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"cakephp/cakephp-codesniffer": "^5.0",
|
||||
"phpunit/phpunit": "^10.5.58 || ^11.5.3 || ^12.1.3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Cake\\Chronos\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Brian Nesbitt",
|
||||
"email": "brian@nesbot.com",
|
||||
"homepage": "http://nesbot.com"
|
||||
},
|
||||
{
|
||||
"name": "The CakePHP Team",
|
||||
"homepage": "https://cakephp.org"
|
||||
}
|
||||
],
|
||||
"description": "A simple API extension for DateTime.",
|
||||
"homepage": "https://cakephp.org",
|
||||
"keywords": [
|
||||
"date",
|
||||
"datetime",
|
||||
"time"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/cakephp/chronos/issues",
|
||||
"source": "https://github.com/cakephp/chronos"
|
||||
},
|
||||
"time": "2026-04-10T02:50:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "cakephp/core",
|
||||
"version": "5.3.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cakephp/core.git",
|
||||
"reference": "eb012517900ed288f580aa3487e9a09f28ea85f9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/cakephp/core/zipball/eb012517900ed288f580aa3487e9a09f28ea85f9",
|
||||
"reference": "eb012517900ed288f580aa3487e9a09f28ea85f9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"cakephp/utility": "^5.3.0",
|
||||
"league/container": "^5.1",
|
||||
"php": ">=8.2",
|
||||
"psr/container": "^1.1 || ^2.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/container-implementation": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"cakephp/cache": "To use Configure::store() and restore().",
|
||||
"cakephp/event": "To use PluginApplicationInterface or plugin applications.",
|
||||
"league/container": "To use Container and ServiceProvider classes"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-5.next": "5.4.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Cake\\Core\\": "."
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "CakePHP Community",
|
||||
"homepage": "https://github.com/cakephp/core/graphs/contributors"
|
||||
}
|
||||
],
|
||||
"description": "CakePHP Framework Core classes",
|
||||
"homepage": "https://cakephp.org",
|
||||
"keywords": [
|
||||
"cakephp",
|
||||
"core",
|
||||
"framework"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://stackoverflow.com/tags/cakephp",
|
||||
"irc": "irc://irc.freenode.org/cakephp",
|
||||
"issues": "https://github.com/cakephp/cakephp/issues",
|
||||
"source": "https://github.com/cakephp/core"
|
||||
},
|
||||
"time": "2026-03-31T06:25:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "cakephp/database",
|
||||
"version": "5.3.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cakephp/database.git",
|
||||
"reference": "cf94dcb57c54a1a308fd866b038cd6995910e36e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/cakephp/database/zipball/cf94dcb57c54a1a308fd866b038cd6995910e36e",
|
||||
"reference": "cf94dcb57c54a1a308fd866b038cd6995910e36e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"cakephp/chronos": "^3.3",
|
||||
"cakephp/core": "^5.3.0",
|
||||
"cakephp/datasource": "^5.3.0",
|
||||
"php": ">=8.2",
|
||||
"psr/log": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"cakephp/i18n": "^5.3.0",
|
||||
"cakephp/log": "^5.3.0"
|
||||
},
|
||||
"suggest": {
|
||||
"cakephp/i18n": "If you are using locale-aware datetime formats.",
|
||||
"cakephp/log": "If you want to use query logging without providing a logger yourself."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-5.next": "5.4.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Cake\\Database\\": "."
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "CakePHP Community",
|
||||
"homepage": "https://github.com/cakephp/database/graphs/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Flexible and powerful Database abstraction library with a familiar PDO-like API",
|
||||
"homepage": "https://cakephp.org",
|
||||
"keywords": [
|
||||
"abstraction",
|
||||
"cakephp",
|
||||
"database",
|
||||
"database abstraction",
|
||||
"pdo"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://stackoverflow.com/tags/cakephp",
|
||||
"irc": "irc://irc.freenode.org/cakephp",
|
||||
"issues": "https://github.com/cakephp/cakephp/issues",
|
||||
"source": "https://github.com/cakephp/database"
|
||||
},
|
||||
"time": "2026-03-31T06:25:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "cakephp/datasource",
|
||||
"version": "5.3.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cakephp/datasource.git",
|
||||
"reference": "512464eb27b19316b515ec338089b83822c9ab5a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/cakephp/datasource/zipball/512464eb27b19316b515ec338089b83822c9ab5a",
|
||||
"reference": "512464eb27b19316b515ec338089b83822c9ab5a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"cakephp/core": "^5.3.0",
|
||||
"php": ">=8.2",
|
||||
"psr/simple-cache": "^2.0 || ^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"cakephp/cache": "^5.3.0",
|
||||
"cakephp/collection": "^5.3.0",
|
||||
"cakephp/utility": "^5.3.0"
|
||||
},
|
||||
"suggest": {
|
||||
"cakephp/cache": "If you decide to use Query caching.",
|
||||
"cakephp/collection": "If you decide to use ResultSetInterface.",
|
||||
"cakephp/utility": "If you decide to use EntityTrait."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-5.next": "5.4.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Cake\\Datasource\\": "."
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "CakePHP Community",
|
||||
"homepage": "https://github.com/cakephp/datasource/graphs/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides connection managing and traits for Entities and Queries that can be reused for different datastores",
|
||||
"homepage": "https://cakephp.org",
|
||||
"keywords": [
|
||||
"cakephp",
|
||||
"connection management",
|
||||
"datasource",
|
||||
"entity",
|
||||
"query"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://stackoverflow.com/tags/cakephp",
|
||||
"irc": "irc://irc.freenode.org/cakephp",
|
||||
"issues": "https://github.com/cakephp/cakephp/issues",
|
||||
"source": "https://github.com/cakephp/datasource"
|
||||
},
|
||||
"time": "2026-04-04T08:08:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "cakephp/utility",
|
||||
"version": "5.3.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cakephp/utility.git",
|
||||
"reference": "4ac9826fe5faa1505ec5aa3c171d6b58b6ab4e99"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/cakephp/utility/zipball/4ac9826fe5faa1505ec5aa3c171d6b58b6ab4e99",
|
||||
"reference": "4ac9826fe5faa1505ec5aa3c171d6b58b6ab4e99",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"cakephp/core": "^5.3.0",
|
||||
"php": ">=8.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-intl": "To use Text::transliterate() or Text::slug()",
|
||||
"lib-ICU": "To use Text::transliterate() or Text::slug()"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-5.next": "5.4.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Cake\\Utility\\": "."
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "CakePHP Community",
|
||||
"homepage": "https://github.com/cakephp/utility/graphs/contributors"
|
||||
}
|
||||
],
|
||||
"description": "CakePHP Utility classes such as Inflector, String, Hash, and Security",
|
||||
"homepage": "https://cakephp.org",
|
||||
"keywords": [
|
||||
"cakephp",
|
||||
"hash",
|
||||
"inflector",
|
||||
"security",
|
||||
"string",
|
||||
"utility"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://stackoverflow.com/tags/cakephp",
|
||||
"irc": "irc://irc.freenode.org/cakephp",
|
||||
"issues": "https://github.com/cakephp/cakephp/issues",
|
||||
"source": "https://github.com/cakephp/utility"
|
||||
},
|
||||
"time": "2026-03-09T09:38:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "carbonphp/carbon-doctrine-types",
|
||||
"version": "3.2.0",
|
||||
@@ -2627,6 +2951,90 @@
|
||||
],
|
||||
"time": "2022-12-11T20:36:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/container",
|
||||
"version": "5.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/container.git",
|
||||
"reference": "58accbc032f0090a9bd08326f93062c5a658b2c5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/container/zipball/58accbc032f0090a9bd08326f93062c5a658b2c5",
|
||||
"reference": "58accbc032f0090a9bd08326f93062c5a658b2c5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"psr/container": "^2.0.2",
|
||||
"psr/event-dispatcher": "^1.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/container-implementation": "^1.0"
|
||||
},
|
||||
"replace": {
|
||||
"orno/di": "~2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"nette/php-generator": "^4.1",
|
||||
"nikic/php-parser": "^5.0",
|
||||
"phpstan/phpstan": "^2.1.11",
|
||||
"phpunit/phpunit": "^10.5.45|^11.5.15|^12.0",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"scrutinizer/ocular": "^1.9",
|
||||
"squizlabs/php_codesniffer": "^3.9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-1.x": "1.x-dev",
|
||||
"dev-2.x": "2.x-dev",
|
||||
"dev-3.x": "3.x-dev",
|
||||
"dev-4.x": "4.x-dev",
|
||||
"dev-5.x": "5.x-dev",
|
||||
"dev-master": "5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Container\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Phil Bennett",
|
||||
"email": "mail@philbennett.co.uk",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A fast and intuitive dependency injection container.",
|
||||
"homepage": "https://github.com/thephpleague/container",
|
||||
"keywords": [
|
||||
"container",
|
||||
"dependency",
|
||||
"di",
|
||||
"injection",
|
||||
"league",
|
||||
"provider",
|
||||
"service"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/container/issues",
|
||||
"source": "https://github.com/thephpleague/container/tree/5.2.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/philipobenito",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-19T18:52:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem",
|
||||
"version": "3.28.0",
|
||||
@@ -4980,6 +5388,93 @@
|
||||
],
|
||||
"time": "2024-04-27T21:32:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "robmorgan/phinx",
|
||||
"version": "0.16.11",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cakephp/phinx.git",
|
||||
"reference": "a03014fea316ba021fc0776982e5bed2d10228d4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/cakephp/phinx/zipball/a03014fea316ba021fc0776982e5bed2d10228d4",
|
||||
"reference": "a03014fea316ba021fc0776982e5bed2d10228d4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"cakephp/database": "^5.0.2",
|
||||
"composer-runtime-api": "^2.0",
|
||||
"php-64bit": ">=8.1",
|
||||
"psr/container": "^1.1|^2.0",
|
||||
"symfony/config": "^4.0|^5.0|^6.0|^7.0|^8.0",
|
||||
"symfony/console": "^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"cakephp/cakephp-codesniffer": "^5.0",
|
||||
"cakephp/i18n": "^5.0",
|
||||
"ext-json": "*",
|
||||
"ext-pdo": "*",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"symfony/yaml": "^4.0|^5.0|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-json": "Install if using JSON configuration format",
|
||||
"ext-pdo": "PDO extension is needed",
|
||||
"symfony/yaml": "Install if using YAML configuration format"
|
||||
},
|
||||
"bin": [
|
||||
"bin/phinx"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Phinx\\": "src/Phinx/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Rob Morgan",
|
||||
"email": "robbym@gmail.com",
|
||||
"homepage": "https://robmorgan.id.au",
|
||||
"role": "Lead Developer"
|
||||
},
|
||||
{
|
||||
"name": "Woody Gilk",
|
||||
"email": "woody.gilk@gmail.com",
|
||||
"homepage": "https://shadowhand.me",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Richard Quadling",
|
||||
"email": "rquadling@gmail.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "CakePHP Community",
|
||||
"homepage": "https://github.com/cakephp/phinx/graphs/contributors",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Phinx makes it ridiculously easy to manage the database migrations for your PHP app.",
|
||||
"homepage": "https://phinx.org",
|
||||
"keywords": [
|
||||
"database",
|
||||
"database migrations",
|
||||
"db",
|
||||
"migrations",
|
||||
"phinx"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/cakephp/phinx/issues",
|
||||
"source": "https://github.com/cakephp/phinx/tree/0.16.11"
|
||||
},
|
||||
"time": "2026-03-15T00:04:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "socialiteproviders/manager",
|
||||
"version": "v4.8.1",
|
||||
@@ -5312,6 +5807,85 @@
|
||||
],
|
||||
"time": "2024-05-31T14:57:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/config",
|
||||
"version": "v7.4.10",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/config.git",
|
||||
"reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/config/zipball/d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57",
|
||||
"reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/filesystem": "^7.1|^8.0",
|
||||
"symfony/polyfill-ctype": "~1.8"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/finder": "<6.4",
|
||||
"symfony/service-contracts": "<2.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/finder": "^6.4|^7.0|^8.0",
|
||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/yaml": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Config\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/config/tree/v7.4.10"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-03T14:20:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v7.1.3",
|
||||
@@ -5768,6 +6342,76 @@
|
||||
],
|
||||
"time": "2024-04-18T09:32:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v7.4.11",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/filesystem.git",
|
||||
"reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50",
|
||||
"reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/process": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Filesystem\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides basic utilities for the filesystem",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/filesystem/tree/v7.4.11"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-11T16:38:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v7.1.3",
|
||||
@@ -11355,6 +11999,6 @@
|
||||
"php": "^8.2.0",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class BaselineExistingDatabase extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* Change Method.
|
||||
*
|
||||
* Write your reversible migrations using this method.
|
||||
*
|
||||
* More information on writing migrations is available here:
|
||||
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
|
||||
*
|
||||
* Remember to call "create()" or "update()" and NOT "save()" when working
|
||||
* with the Table class.
|
||||
*/
|
||||
public function change(): void
|
||||
{
|
||||
// Baseline migration.
|
||||
// Existing database structure starts being tracked from this point.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreatePhinxTestTable extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* Change Method.
|
||||
*
|
||||
* Write your reversible migrations using this method.
|
||||
*
|
||||
* More information on writing migrations is available here:
|
||||
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
|
||||
*
|
||||
* Remember to call "create()" or "update()" and NOT "save()" when working
|
||||
* with the Table class.
|
||||
*/
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('phinx_test_table');
|
||||
|
||||
$table
|
||||
->addColumn('name', 'string', [
|
||||
'limit' => 100,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('created_at', 'timestamp', [
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
'null' => false,
|
||||
])
|
||||
->create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddFunctionsToScadDeadlines extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$this->table('scad_functions', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'encoding' => 'utf8mb4',
|
||||
])
|
||||
->addColumn('id', 'integer', [
|
||||
'identity' => true,
|
||||
'signed' => false,
|
||||
])
|
||||
->addColumn('name', 'string', [
|
||||
'limit' => 255,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('description', 'text', [
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('status', 'string', [
|
||||
'limit' => 20,
|
||||
'null' => false,
|
||||
'default' => 'active',
|
||||
])
|
||||
->addColumn('created_at', 'timestamp', [
|
||||
'null' => false,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addColumn('updated_at', 'timestamp', [
|
||||
'null' => false,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
'update' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addIndex(['name'], [
|
||||
'unique' => true,
|
||||
'name' => 'uniq_scad_functions_name',
|
||||
])
|
||||
->create();
|
||||
|
||||
$this->table('scad_deadlines')
|
||||
->addColumn('function_id', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => true,
|
||||
'after' => 'subject_id',
|
||||
])
|
||||
->addIndex(['function_id'], [
|
||||
'name' => 'idx_scad_deadlines_function_id',
|
||||
])
|
||||
->addForeignKey('function_id', 'scad_functions', 'id', [
|
||||
'delete' => 'SET_NULL',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_scad_deadlines_function',
|
||||
])
|
||||
->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateJobSubRolesTable extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
if (!$this->hasTable('job_roles')) {
|
||||
$rolesTable = $this->table('job_roles', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'encoding' => 'utf8mb4',
|
||||
]);
|
||||
|
||||
$rolesTable
|
||||
->addColumn('id', 'integer', [
|
||||
'identity' => true,
|
||||
'signed' => false,
|
||||
])
|
||||
->addColumn('name', 'string', [
|
||||
'limit' => 255,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('description', 'text', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
])
|
||||
->addColumn('sort_order', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
'default' => 999,
|
||||
])
|
||||
->addColumn('is_active', 'boolean', [
|
||||
'null' => false,
|
||||
'default' => 1,
|
||||
])
|
||||
->addColumn('created_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addColumn('updated_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
'update' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addIndex(['is_active'], [
|
||||
'name' => 'idx_job_roles_is_active',
|
||||
])
|
||||
->addIndex(['sort_order'], [
|
||||
'name' => 'idx_job_roles_sort_order',
|
||||
])
|
||||
->create();
|
||||
}
|
||||
|
||||
if (!$this->hasTable('job_sub_roles')) {
|
||||
$table = $this->table('job_sub_roles', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'encoding' => 'utf8mb4',
|
||||
]);
|
||||
|
||||
$table
|
||||
->addColumn('id', 'integer', [
|
||||
'identity' => true,
|
||||
'signed' => false,
|
||||
])
|
||||
->addColumn('job_role_id', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('name', 'string', [
|
||||
'limit' => 255,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('description', 'text', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
])
|
||||
->addColumn('sort_order', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
'default' => 999,
|
||||
])
|
||||
->addColumn('is_active', 'boolean', [
|
||||
'null' => false,
|
||||
'default' => 1,
|
||||
])
|
||||
->addColumn('created_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addColumn('updated_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
'update' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addIndex(['job_role_id'], [
|
||||
'name' => 'idx_job_sub_roles_job_role_id',
|
||||
])
|
||||
->addIndex(['is_active'], [
|
||||
'name' => 'idx_job_sub_roles_is_active',
|
||||
])
|
||||
->addIndex(['sort_order'], [
|
||||
'name' => 'idx_job_sub_roles_sort_order',
|
||||
])
|
||||
->addForeignKey(
|
||||
'job_role_id',
|
||||
'job_roles',
|
||||
'id',
|
||||
[
|
||||
'delete' => 'CASCADE',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_job_sub_roles_job_role',
|
||||
]
|
||||
)
|
||||
->create();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreatePpeItemsTable extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('ppe_items', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'encoding' => 'utf8mb4',
|
||||
]);
|
||||
|
||||
$table
|
||||
->addColumn('id', 'integer', [
|
||||
'identity' => true,
|
||||
'signed' => false,
|
||||
])
|
||||
->addColumn('name', 'string', [
|
||||
'limit' => 255,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('description', 'text', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
])
|
||||
->addColumn('category', 'string', [
|
||||
'limit' => 100,
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'comment' => 'PPE category, for example Head, Hands, Eyes, Feet, Respiratory',
|
||||
])
|
||||
->addColumn('photo', 'string', [
|
||||
'limit' => 255,
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'comment' => 'PPE image path or filename',
|
||||
])
|
||||
->addColumn('standard_reference', 'string', [
|
||||
'limit' => 255,
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'comment' => 'Reference standard, for example EN ISO 20345',
|
||||
])
|
||||
->addColumn('validity_months', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'comment' => 'Default validity in months after assignment',
|
||||
])
|
||||
->addColumn('sort_order', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
'default' => 999,
|
||||
])
|
||||
->addColumn('is_active', 'boolean', [
|
||||
'null' => false,
|
||||
'default' => 1,
|
||||
])
|
||||
->addColumn('created_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addColumn('updated_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
'update' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addIndex(['category'], [
|
||||
'name' => 'idx_ppe_items_category',
|
||||
])
|
||||
->addIndex(['is_active'], [
|
||||
'name' => 'idx_ppe_items_is_active',
|
||||
])
|
||||
->addIndex(['sort_order'], [
|
||||
'name' => 'idx_ppe_items_sort_order',
|
||||
])
|
||||
->create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateEmployeePpeItemsTable extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('employee_ppe_items', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'encoding' => 'utf8mb4',
|
||||
]);
|
||||
|
||||
$table
|
||||
->addColumn('id', 'integer', [
|
||||
'identity' => true,
|
||||
'signed' => false,
|
||||
])
|
||||
->addColumn('employee_id', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('ppe_item_id', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('assigned_date', 'date', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
])
|
||||
->addColumn('expiry_date', 'date', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
])
|
||||
->addColumn('quantity', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
'default' => 1,
|
||||
])
|
||||
->addColumn('status', 'enum', [
|
||||
'values' => [
|
||||
'assigned',
|
||||
'returned',
|
||||
'expired',
|
||||
'lost',
|
||||
'damaged',
|
||||
],
|
||||
'null' => false,
|
||||
'default' => 'assigned',
|
||||
])
|
||||
->addColumn('notes', 'text', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
])
|
||||
->addColumn('created_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addColumn('updated_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
'update' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addIndex(['employee_id'], [
|
||||
'name' => 'idx_employee_ppe_items_employee_id',
|
||||
])
|
||||
->addIndex(['ppe_item_id'], [
|
||||
'name' => 'idx_employee_ppe_items_ppe_item_id',
|
||||
])
|
||||
->addIndex(['status'], [
|
||||
'name' => 'idx_employee_ppe_items_status',
|
||||
])
|
||||
->addIndex(['expiry_date'], [
|
||||
'name' => 'idx_employee_ppe_items_expiry_date',
|
||||
])
|
||||
->addForeignKey(
|
||||
'employee_id',
|
||||
'employees',
|
||||
'id',
|
||||
[
|
||||
'delete' => 'CASCADE',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_employee_ppe_items_employee',
|
||||
]
|
||||
)
|
||||
->addForeignKey(
|
||||
'ppe_item_id',
|
||||
'ppe_items',
|
||||
'id',
|
||||
[
|
||||
'delete' => 'RESTRICT',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_employee_ppe_items_ppe_item',
|
||||
]
|
||||
)
|
||||
->create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateJobSubRolePpeItemsTable extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('job_sub_role_ppe_items', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'encoding' => 'utf8mb4',
|
||||
]);
|
||||
|
||||
$table
|
||||
->addColumn('id', 'integer', [
|
||||
'identity' => true,
|
||||
'signed' => false,
|
||||
])
|
||||
->addColumn('job_sub_role_id', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('ppe_item_id', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('requirement_type', 'enum', [
|
||||
'values' => [
|
||||
'mandatory',
|
||||
'recommended',
|
||||
'optional',
|
||||
],
|
||||
'null' => false,
|
||||
'default' => 'mandatory',
|
||||
'comment' => 'Defines if the PPE is mandatory, recommended or optional for the sub role',
|
||||
])
|
||||
->addColumn('notes', 'text', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
])
|
||||
->addColumn('sort_order', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
'default' => 999,
|
||||
])
|
||||
->addColumn('is_active', 'boolean', [
|
||||
'null' => false,
|
||||
'default' => 1,
|
||||
])
|
||||
->addColumn('created_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addColumn('updated_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
'update' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addIndex(['job_sub_role_id'], [
|
||||
'name' => 'idx_job_sub_role_ppe_items_sub_role_id',
|
||||
])
|
||||
->addIndex(['ppe_item_id'], [
|
||||
'name' => 'idx_job_sub_role_ppe_items_ppe_item_id',
|
||||
])
|
||||
->addIndex(['requirement_type'], [
|
||||
'name' => 'idx_job_sub_role_ppe_items_requirement_type',
|
||||
])
|
||||
->addIndex(['is_active'], [
|
||||
'name' => 'idx_job_sub_role_ppe_items_is_active',
|
||||
])
|
||||
->addIndex(['job_sub_role_id', 'ppe_item_id'], [
|
||||
'unique' => true,
|
||||
'name' => 'uq_job_sub_role_ppe_item',
|
||||
])
|
||||
->addForeignKey(
|
||||
'job_sub_role_id',
|
||||
'job_sub_roles',
|
||||
'id',
|
||||
[
|
||||
'delete' => 'CASCADE',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_job_sub_role_ppe_items_sub_role',
|
||||
]
|
||||
)
|
||||
->addForeignKey(
|
||||
'ppe_item_id',
|
||||
'ppe_items',
|
||||
'id',
|
||||
[
|
||||
'delete' => 'CASCADE',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_job_sub_role_ppe_items_ppe_item',
|
||||
]
|
||||
)
|
||||
->create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddJobSubRoleIdToEmployeesTable extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!$this->hasTable('employees')) {
|
||||
throw new RuntimeException('Table employees does not exist.');
|
||||
}
|
||||
|
||||
$table = $this->table('employees');
|
||||
|
||||
if (!$table->hasColumn('job_role_id')) {
|
||||
$table
|
||||
->addColumn('job_role_id', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => true,
|
||||
'after' => 'department_id',
|
||||
])
|
||||
->addIndex(['job_role_id'], [
|
||||
'name' => 'idx_employees_job_role_id',
|
||||
])
|
||||
->addForeignKey(
|
||||
'job_role_id',
|
||||
'job_roles',
|
||||
'id',
|
||||
[
|
||||
'delete' => 'SET_NULL',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_employees_job_role',
|
||||
]
|
||||
)
|
||||
->update();
|
||||
}
|
||||
|
||||
$table = $this->table('employees');
|
||||
|
||||
if (!$table->hasColumn('job_sub_role_id')) {
|
||||
$afterColumn = $table->hasColumn('job_role_id') ? 'job_role_id' : 'department_id';
|
||||
|
||||
$table
|
||||
->addColumn('job_sub_role_id', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => true,
|
||||
'after' => $afterColumn,
|
||||
])
|
||||
->addIndex(['job_sub_role_id'], [
|
||||
'name' => 'idx_employees_job_sub_role_id',
|
||||
])
|
||||
->addForeignKey(
|
||||
'job_sub_role_id',
|
||||
'job_sub_roles',
|
||||
'id',
|
||||
[
|
||||
'delete' => 'SET_NULL',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_employees_job_sub_role',
|
||||
]
|
||||
)
|
||||
->update();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (!$this->hasTable('employees')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $this->table('employees');
|
||||
|
||||
if ($table->hasForeignKey('job_sub_role_id')) {
|
||||
$table->dropForeignKey('job_sub_role_id')->update();
|
||||
}
|
||||
|
||||
if ($table->hasForeignKey('job_role_id')) {
|
||||
$table->dropForeignKey('job_role_id')->update();
|
||||
}
|
||||
|
||||
$table = $this->table('employees');
|
||||
|
||||
if ($table->hasIndexByName('idx_employees_job_sub_role_id')) {
|
||||
$table->removeIndexByName('idx_employees_job_sub_role_id')->update();
|
||||
}
|
||||
|
||||
if ($table->hasIndexByName('idx_employees_job_role_id')) {
|
||||
$table->removeIndexByName('idx_employees_job_role_id')->update();
|
||||
}
|
||||
|
||||
$table = $this->table('employees');
|
||||
|
||||
if ($table->hasColumn('job_sub_role_id')) {
|
||||
$table->removeColumn('job_sub_role_id')->update();
|
||||
}
|
||||
|
||||
if ($table->hasColumn('job_role_id')) {
|
||||
$table->removeColumn('job_role_id')->update();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddDeliveryFieldsToEmployeePpeItemsTable extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('employee_ppe_items');
|
||||
|
||||
$table
|
||||
->addColumn('delivered_by', 'string', [
|
||||
'limit' => 255,
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'after' => 'expiry_date',
|
||||
])
|
||||
->addColumn('created_by', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'after' => 'notes',
|
||||
])
|
||||
->addIndex(['created_by'], [
|
||||
'name' => 'idx_employee_ppe_items_created_by',
|
||||
])
|
||||
->addForeignKey(
|
||||
'created_by',
|
||||
'auth_users',
|
||||
'id',
|
||||
[
|
||||
'delete' => 'SET_NULL',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_employee_ppe_items_created_by',
|
||||
]
|
||||
)
|
||||
->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateEmployeeJobSubRolesTable extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!$this->hasTable('employee_job_sub_roles')) {
|
||||
$table = $this->table('employee_job_sub_roles', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'signed' => false,
|
||||
'collation' => 'utf8mb4_general_ci',
|
||||
'encoding' => 'utf8mb4',
|
||||
]);
|
||||
|
||||
$table
|
||||
->addColumn('id', 'integer', [
|
||||
'identity' => true,
|
||||
'signed' => false,
|
||||
])
|
||||
->addColumn('employee_id', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('job_sub_role_id', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('is_primary', 'boolean', [
|
||||
'null' => false,
|
||||
'default' => false,
|
||||
])
|
||||
->addColumn('created_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addIndex(['employee_id', 'job_sub_role_id'], [
|
||||
'unique' => true,
|
||||
'name' => 'uq_employee_subrole',
|
||||
])
|
||||
->addIndex(['employee_id'], [
|
||||
'name' => 'idx_employee_job_sub_roles_employee',
|
||||
])
|
||||
->addIndex(['job_sub_role_id'], [
|
||||
'name' => 'idx_employee_job_sub_roles_subrole',
|
||||
])
|
||||
->addForeignKey(
|
||||
'employee_id',
|
||||
'employees',
|
||||
'id',
|
||||
[
|
||||
'delete' => 'CASCADE',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_employee_job_sub_roles_employee',
|
||||
]
|
||||
)
|
||||
->addForeignKey(
|
||||
'job_sub_role_id',
|
||||
'job_sub_roles',
|
||||
'id',
|
||||
[
|
||||
'delete' => 'CASCADE',
|
||||
'update' => 'CASCADE',
|
||||
'constraint' => 'fk_employee_job_sub_roles_subrole',
|
||||
]
|
||||
)
|
||||
->create();
|
||||
}
|
||||
|
||||
// Import existing single sub-role assignments from employees.job_sub_role_id
|
||||
// into the new bridge table.
|
||||
$this->execute("
|
||||
INSERT IGNORE INTO employee_job_sub_roles
|
||||
(employee_id, job_sub_role_id, is_primary, created_at)
|
||||
SELECT
|
||||
e.id,
|
||||
e.job_sub_role_id,
|
||||
1,
|
||||
NOW()
|
||||
FROM employees e
|
||||
WHERE e.job_sub_role_id IS NOT NULL
|
||||
AND e.job_sub_role_id > 0
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if ($this->hasTable('employee_job_sub_roles')) {
|
||||
$this->table('employee_job_sub_roles')->drop()->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateCompanyFunctionsTable extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!$this->hasTable('company_functions')) {
|
||||
$table = $this->table('company_functions', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'signed' => false,
|
||||
'collation' => 'utf8mb4_general_ci',
|
||||
'encoding' => 'utf8mb4',
|
||||
]);
|
||||
|
||||
$table
|
||||
->addColumn('id', 'integer', [
|
||||
'identity' => true,
|
||||
'signed' => false,
|
||||
])
|
||||
->addColumn('function_name', 'string', [
|
||||
'limit' => 150,
|
||||
'null' => false,
|
||||
'comment' => 'Function name, for example RSPP, Medico del lavoro, RLS',
|
||||
])
|
||||
->addColumn('person_full_name', 'string', [
|
||||
'limit' => 200,
|
||||
'null' => false,
|
||||
'comment' => 'Full name and surname of the person assigned to the function',
|
||||
])
|
||||
->addColumn('phone', 'string', [
|
||||
'limit' => 80,
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('email', 'string', [
|
||||
'limit' => 190,
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('notes', 'text', [
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('sort_order', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
'default' => 0,
|
||||
])
|
||||
->addColumn('is_active', 'boolean', [
|
||||
'null' => false,
|
||||
'default' => true,
|
||||
])
|
||||
->addColumn('created_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addColumn('updated_at', 'timestamp', [
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
'update' => 'CURRENT_TIMESTAMP',
|
||||
])
|
||||
->addIndex(['function_name'], [
|
||||
'name' => 'idx_company_functions_function_name',
|
||||
])
|
||||
->addIndex(['person_full_name'], [
|
||||
'name' => 'idx_company_functions_person_full_name',
|
||||
])
|
||||
->addIndex(['email'], [
|
||||
'name' => 'idx_company_functions_email',
|
||||
])
|
||||
->addIndex(['is_active', 'sort_order'], [
|
||||
'name' => 'idx_company_functions_active_sort',
|
||||
])
|
||||
->create();
|
||||
}
|
||||
|
||||
$this->execute("
|
||||
INSERT INTO company_functions
|
||||
(function_name, person_full_name, phone, email, notes, sort_order, is_active, created_at, updated_at)
|
||||
VALUES
|
||||
('RSPP', '', NULL, NULL, NULL, 10, 1, NOW(), NOW()),
|
||||
('Medico del lavoro', '', NULL, NULL, NULL, 20, 1, NOW(), NOW()),
|
||||
('RLS', '', NULL, NULL, NULL, 30, 1, NOW(), NOW())
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if ($this->hasTable('company_functions')) {
|
||||
$this->table('company_functions')->drop()->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AlterScadFunctionsAddContactFields extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!$this->hasTable('scad_functions')) {
|
||||
throw new RuntimeException('Table scad_functions does not exist.');
|
||||
}
|
||||
|
||||
$table = $this->table('scad_functions');
|
||||
|
||||
if (!$table->hasColumn('person_full_name')) {
|
||||
$table->addColumn('person_full_name', 'string', [
|
||||
'limit' => 200,
|
||||
'null' => true,
|
||||
'after' => 'description',
|
||||
'comment' => 'Full name and surname of the person assigned to the function',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('phone')) {
|
||||
$table->addColumn('phone', 'string', [
|
||||
'limit' => 80,
|
||||
'null' => true,
|
||||
'after' => 'person_full_name',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('email')) {
|
||||
$table->addColumn('email', 'string', [
|
||||
'limit' => 190,
|
||||
'null' => true,
|
||||
'after' => 'phone',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('notes')) {
|
||||
$table->addColumn('notes', 'text', [
|
||||
'null' => true,
|
||||
'after' => 'email',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('sort_order')) {
|
||||
$table->addColumn('sort_order', 'integer', [
|
||||
'signed' => false,
|
||||
'null' => false,
|
||||
'default' => 0,
|
||||
'after' => 'status',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasIndexByName('idx_scad_functions_name')) {
|
||||
$table->addIndex(['name'], [
|
||||
'name' => 'idx_scad_functions_name',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasIndexByName('idx_scad_functions_person_full_name')) {
|
||||
$table->addIndex(['person_full_name'], [
|
||||
'name' => 'idx_scad_functions_person_full_name',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasIndexByName('idx_scad_functions_email')) {
|
||||
$table->addIndex(['email'], [
|
||||
'name' => 'idx_scad_functions_email',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasIndexByName('idx_scad_functions_status_sort')) {
|
||||
$table->addIndex(['status', 'sort_order'], [
|
||||
'name' => 'idx_scad_functions_status_sort',
|
||||
]);
|
||||
}
|
||||
|
||||
$table->update();
|
||||
|
||||
// Set a default order for existing rows without changing their names.
|
||||
$this->execute("
|
||||
UPDATE scad_functions
|
||||
SET sort_order = id * 10
|
||||
WHERE sort_order = 0
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (!$this->hasTable('scad_functions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $this->table('scad_functions');
|
||||
|
||||
if ($table->hasIndexByName('idx_scad_functions_status_sort')) {
|
||||
$table->removeIndexByName('idx_scad_functions_status_sort');
|
||||
}
|
||||
|
||||
if ($table->hasIndexByName('idx_scad_functions_email')) {
|
||||
$table->removeIndexByName('idx_scad_functions_email');
|
||||
}
|
||||
|
||||
if ($table->hasIndexByName('idx_scad_functions_person_full_name')) {
|
||||
$table->removeIndexByName('idx_scad_functions_person_full_name');
|
||||
}
|
||||
|
||||
if ($table->hasIndexByName('idx_scad_functions_name')) {
|
||||
$table->removeIndexByName('idx_scad_functions_name');
|
||||
}
|
||||
|
||||
if ($table->hasColumn('sort_order')) {
|
||||
$table->removeColumn('sort_order');
|
||||
}
|
||||
|
||||
if ($table->hasColumn('notes')) {
|
||||
$table->removeColumn('notes');
|
||||
}
|
||||
|
||||
if ($table->hasColumn('email')) {
|
||||
$table->removeColumn('email');
|
||||
}
|
||||
|
||||
if ($table->hasColumn('phone')) {
|
||||
$table->removeColumn('phone');
|
||||
}
|
||||
|
||||
if ($table->hasColumn('person_full_name')) {
|
||||
$table->removeColumn('person_full_name');
|
||||
}
|
||||
|
||||
$table->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateCadAreaJobsTable extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('cad_area_jobs');
|
||||
|
||||
$table
|
||||
->addColumn('iduser', 'integer', [
|
||||
'null' => true,
|
||||
'signed' => false,
|
||||
'limit' => 10,
|
||||
])
|
||||
->addColumn('original_filename', 'string', [
|
||||
'limit' => 255,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('stored_filename', 'string', [
|
||||
'limit' => 255,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('file_path', 'string', [
|
||||
'limit' => 500,
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('file_url', 'string', [
|
||||
'limit' => 500,
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('file_size', 'integer', [
|
||||
'null' => true,
|
||||
'signed' => false,
|
||||
])
|
||||
->addColumn('status', 'enum', [
|
||||
'values' => [
|
||||
'uploaded',
|
||||
'processing',
|
||||
'completed',
|
||||
'error',
|
||||
],
|
||||
'default' => 'uploaded',
|
||||
'null' => false,
|
||||
])
|
||||
->addColumn('area_mm2', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('area_cm2', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('area_m2', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 9,
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('scale_detected', 'string', [
|
||||
'limit' => 50,
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('confidence', 'string', [
|
||||
'limit' => 50,
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('message', 'text', [
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('python_response', 'text', [
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('created_at', 'timestamp', [
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
'null' => true,
|
||||
])
|
||||
->addColumn('updated_at', 'timestamp', [
|
||||
'default' => 'CURRENT_TIMESTAMP',
|
||||
'update' => 'CURRENT_TIMESTAMP',
|
||||
'null' => true,
|
||||
])
|
||||
->addIndex(['iduser'], [
|
||||
'name' => 'idx_cad_area_jobs_iduser',
|
||||
])
|
||||
->addIndex(['status'], [
|
||||
'name' => 'idx_cad_area_jobs_status',
|
||||
])
|
||||
->create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddNotifyFunctionToScadDeadlines extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (!$this->hasTable('scad_deadlines')) {
|
||||
throw new RuntimeException('Table scad_deadlines does not exist.');
|
||||
}
|
||||
|
||||
$table = $this->table('scad_deadlines');
|
||||
|
||||
if (!$table->hasColumn('notify_function')) {
|
||||
$table
|
||||
->addColumn('notify_function', 'boolean', [
|
||||
'null' => false,
|
||||
'default' => false,
|
||||
'after' => 'function_id',
|
||||
'comment' => 'Send deadline reminder also to the linked function email',
|
||||
])
|
||||
->update();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (!$this->hasTable('scad_deadlines')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $this->table('scad_deadlines');
|
||||
|
||||
if ($table->hasColumn('notify_function')) {
|
||||
$table
|
||||
->removeColumn('notify_function')
|
||||
->update();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddRoiFieldsToCadAreaJobsTable extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('cad_area_jobs');
|
||||
|
||||
if (!$table->hasColumn('roi_x')) {
|
||||
$table->addColumn('roi_x', 'decimal', [
|
||||
'precision' => 12,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
'after' => 'file_size',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('roi_y')) {
|
||||
$table->addColumn('roi_y', 'decimal', [
|
||||
'precision' => 12,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
'after' => 'roi_x',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('roi_width')) {
|
||||
$table->addColumn('roi_width', 'decimal', [
|
||||
'precision' => 12,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
'after' => 'roi_y',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('roi_height')) {
|
||||
$table->addColumn('roi_height', 'decimal', [
|
||||
'precision' => 12,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
'after' => 'roi_width',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('roi_page')) {
|
||||
$table->addColumn('roi_page', 'integer', [
|
||||
'null' => true,
|
||||
'default' => 1,
|
||||
'after' => 'roi_height',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('calculation_mode')) {
|
||||
$table->addColumn('calculation_mode', 'string', [
|
||||
'limit' => 50,
|
||||
'null' => true,
|
||||
'default' => 'auto_roi',
|
||||
'after' => 'roi_page',
|
||||
]);
|
||||
}
|
||||
|
||||
$table->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddResultDetailFieldsToCadAreaJobsTable extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('cad_area_jobs');
|
||||
|
||||
if (!$table->hasColumn('width_mm')) {
|
||||
$table->addColumn('width_mm', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('height_mm')) {
|
||||
$table->addColumn('height_mm', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('scale_used')) {
|
||||
$table->addColumn('scale_used', 'decimal', [
|
||||
'precision' => 12,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('strategy_used')) {
|
||||
$table->addColumn('strategy_used', 'string', [
|
||||
'limit' => 100,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$table->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddManualTracingFieldsToCadAreaJobsTable extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('cad_area_jobs');
|
||||
|
||||
if (!$table->hasColumn('width_mm')) {
|
||||
$table->addColumn('width_mm', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('height_mm')) {
|
||||
$table->addColumn('height_mm', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('scale_used')) {
|
||||
$table->addColumn('scale_used', 'decimal', [
|
||||
'precision' => 12,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('strategy_used')) {
|
||||
$table->addColumn('strategy_used', 'string', [
|
||||
'limit' => 100,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_calibration_px')) {
|
||||
$table->addColumn('manual_calibration_px', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_calibration_mm')) {
|
||||
$table->addColumn('manual_calibration_mm', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_mm_per_px')) {
|
||||
$table->addColumn('manual_mm_per_px', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 10,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_polygon_json')) {
|
||||
$table->addColumn('manual_polygon_json', 'text', [
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_area_mm2')) {
|
||||
$table->addColumn('manual_area_mm2', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_area_cm2')) {
|
||||
$table->addColumn('manual_area_cm2', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_width_mm')) {
|
||||
$table->addColumn('manual_width_mm', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_height_mm')) {
|
||||
$table->addColumn('manual_height_mm', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_status')) {
|
||||
$table->addColumn('manual_status', 'string', [
|
||||
'limit' => 50,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$table->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddManualHoleFieldsToCadAreaJobsTable extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('cad_area_jobs');
|
||||
|
||||
if (!$table->hasColumn('manual_outer_area_mm2')) {
|
||||
$table->addColumn('manual_outer_area_mm2', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_holes_area_mm2')) {
|
||||
$table->addColumn('manual_holes_area_mm2', 'decimal', [
|
||||
'precision' => 18,
|
||||
'scale' => 6,
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('manual_holes_json')) {
|
||||
$table->addColumn('manual_holes_json', 'text', [
|
||||
'null' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$table->update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
if (file_exists(__DIR__ . '/.env')) {
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
||||
$dotenv->safeLoad();
|
||||
}
|
||||
|
||||
return [
|
||||
'paths' => [
|
||||
'migrations' => __DIR__ . '/db/migrations',
|
||||
'seeds' => __DIR__ . '/db/seeds',
|
||||
],
|
||||
|
||||
'environments' => [
|
||||
'default_migration_table' => 'phinxlog',
|
||||
'default_environment' => 'development',
|
||||
|
||||
'development' => [
|
||||
'adapter' => $_ENV['DB_CONNECTION'] ?? 'mysql',
|
||||
'host' => $_ENV['DB_HOST'] ?? 'localhost',
|
||||
'name' => $_ENV['DB_DATABASE'] ?? '',
|
||||
'user' => $_ENV['DB_USERNAME'] ?? '',
|
||||
'pass' => $_ENV['DB_PASSWORD'] ?? '',
|
||||
'port' => $_ENV['DB_PORT'] ?? 3306,
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
],
|
||||
],
|
||||
|
||||
'version_order' => 'creation',
|
||||
];
|
||||
@@ -1,26 +1,38 @@
|
||||
<?php
|
||||
require_once(__DIR__ . '/../hr_auth_check.php');
|
||||
include('../../include/headscript.php');
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'message' => 'Metodo non consentito.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['success' => false, 'message' => 'ID DPI non valido.']);
|
||||
exit;
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("DELETE FROM employee_ppe WHERE id = :id");
|
||||
$stmt->execute(['id' => $id]);
|
||||
echo json_encode(['success' => true]);
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'ID DPI non valido.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE employee_ppe_items
|
||||
SET status = 'returned',
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$id]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'DPI rimosso correttamente.'
|
||||
]);
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
/**
|
||||
* Bulk-assign a single DPI (PPE) item to several employees at once:
|
||||
* one employee_ppe row per selected employee, all sharing the same
|
||||
* item name / delivery date / delivered-by / notes.
|
||||
* Mirrors ajax/trainings/save_bulk_training.php. HR-only.
|
||||
*/
|
||||
require_once(__DIR__ . '/../hr_auth_check.php');
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'message' => 'Metodo non consentito.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// $pdo and $currentUserId from hr_auth_check.php
|
||||
|
||||
$itemName = trim($_POST['item_name'] ?? '');
|
||||
$deliveryDate = trim($_POST['delivery_date'] ?? '');
|
||||
$deliveredBy = trim($_POST['delivered_by'] ?? '');
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
$employeeIds = $_POST['employee_ids'] ?? [];
|
||||
|
||||
if (!is_array($employeeIds)) {
|
||||
$employeeIds = [];
|
||||
}
|
||||
$employeeIds = array_values(array_unique(array_filter(array_map('intval', $employeeIds), fn($v) => $v > 0)));
|
||||
|
||||
if ($itemName === '') {
|
||||
echo json_encode(['success' => false, 'message' => 'Il nome del DPI è obbligatorio.']);
|
||||
exit;
|
||||
}
|
||||
if ($deliveryDate !== '' && !DateTime::createFromFormat('Y-m-d', $deliveryDate)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Data di consegna non valida.']);
|
||||
exit;
|
||||
}
|
||||
if (empty($employeeIds)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Selezionare almeno un dipendente.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$deliveryDate = $deliveryDate === '' ? null : $deliveryDate;
|
||||
$deliveredBy = $deliveredBy !== '' ? $deliveredBy : null;
|
||||
$notes = $notes !== '' ? $notes : null;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Only insert for employees that actually exist
|
||||
$checkEmp = $pdo->prepare("SELECT id FROM employees WHERE id = :id");
|
||||
|
||||
$ins = $pdo->prepare("
|
||||
INSERT INTO employee_ppe
|
||||
(employee_id, item_name, delivery_date, delivered_by, notes, created_by, created_at, updated_at)
|
||||
VALUES
|
||||
(:employee_id, :item_name, :delivery_date, :delivered_by, :notes, :created_by, NOW(), NOW())
|
||||
");
|
||||
|
||||
$created = 0;
|
||||
foreach ($employeeIds as $eid) {
|
||||
$checkEmp->execute(['id' => $eid]);
|
||||
if (!$checkEmp->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
$ins->execute([
|
||||
'employee_id' => $eid,
|
||||
'item_name' => $itemName,
|
||||
'delivery_date' => $deliveryDate,
|
||||
'delivered_by' => $deliveredBy,
|
||||
'notes' => $notes,
|
||||
'created_by' => $currentUserId,
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'created' => $created,
|
||||
'message' => 'DPI assegnato a ' . $created . ' dipendent' . ($created === 1 ? 'e' : 'i') . '.',
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -1,82 +1,153 @@
|
||||
<?php
|
||||
require_once(__DIR__ . '/../hr_auth_check.php');
|
||||
include('../../include/headscript.php');
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'message' => 'Metodo non consentito.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$employeeId = (int)($_POST['employee_id'] ?? 0);
|
||||
$itemName = trim($_POST['item_name'] ?? '');
|
||||
$deliveryDate = trim($_POST['delivery_date'] ?? '');
|
||||
$deliveredBy = trim($_POST['delivered_by'] ?? '');
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
|
||||
if ($employeeId <= 0) {
|
||||
echo json_encode(['success' => false, 'message' => 'ID dipendente non valido.']);
|
||||
exit;
|
||||
}
|
||||
if ($itemName === '') {
|
||||
echo json_encode(['success' => false, 'message' => 'Il nome del DPI è obbligatorio.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$deliveryDate = $deliveryDate === '' ? null : $deliveryDate;
|
||||
$deliveredBy = $deliveredBy !== '' ? $deliveredBy : null;
|
||||
$notes = $notes !== '' ? $notes : null;
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
if ($id > 0) {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE employee_ppe
|
||||
SET item_name = :item_name,
|
||||
delivery_date = :delivery_date,
|
||||
delivered_by = :delivered_by,
|
||||
notes = :notes,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id AND employee_id = :eid
|
||||
");
|
||||
$stmt->execute([
|
||||
'item_name' => $itemName,
|
||||
'delivery_date' => $deliveryDate,
|
||||
'delivered_by' => $deliveredBy,
|
||||
'notes' => $notes,
|
||||
'id' => $id,
|
||||
'eid' => $employeeId,
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
$id = isset($_POST['id']) && $_POST['id'] !== '' ? (int)$_POST['id'] : null;
|
||||
$employeeId = (int)($_POST['employee_id'] ?? 0);
|
||||
$ppeItemId = (int)($_POST['ppe_item_id'] ?? 0);
|
||||
$assignedDate = trim($_POST['assigned_date'] ?? '');
|
||||
$expiryDate = trim($_POST['expiry_date'] ?? '');
|
||||
$deliveredBy = trim($_POST['delivered_by'] ?? '');
|
||||
$status = trim($_POST['status'] ?? 'assigned');
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
|
||||
$allowedStatuses = [
|
||||
'assigned',
|
||||
'returned',
|
||||
'expired',
|
||||
'lost',
|
||||
'damaged',
|
||||
];
|
||||
|
||||
if ($employeeId <= 0) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Dipendente non valido.'
|
||||
]);
|
||||
echo json_encode(['success' => true, 'id' => $id]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$check = $pdo->prepare("SELECT COUNT(*) FROM employees WHERE id = :id");
|
||||
$check->execute(['id' => $employeeId]);
|
||||
if ((int)$check->fetchColumn() === 0) {
|
||||
echo json_encode(['success' => false, 'message' => 'Dipendente non trovato.']);
|
||||
if ($ppeItemId <= 0) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Selezionare un DPI.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!in_array($status, $allowedStatuses, true)) {
|
||||
$status = 'assigned';
|
||||
}
|
||||
|
||||
$checkEmployee = $pdo->prepare("SELECT id FROM employees WHERE id = ? LIMIT 1");
|
||||
$checkEmployee->execute([$employeeId]);
|
||||
|
||||
if (!$checkEmployee->fetchColumn()) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'Dipendente non trovato.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$checkPpe = $pdo->prepare("SELECT id FROM ppe_items WHERE id = ? LIMIT 1");
|
||||
$checkPpe->execute([$ppeItemId]);
|
||||
|
||||
if (!$checkPpe->fetchColumn()) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => 'DPI non trovato.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE employee_ppe_items
|
||||
SET ppe_item_id = :ppe_item_id,
|
||||
assigned_date = :assigned_date,
|
||||
expiry_date = :expiry_date,
|
||||
delivered_by = :delivered_by,
|
||||
status = :status,
|
||||
notes = :notes,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id
|
||||
AND employee_id = :employee_id
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
'ppe_item_id' => $ppeItemId,
|
||||
'assigned_date' => $assignedDate !== '' ? $assignedDate : null,
|
||||
'expiry_date' => $expiryDate !== '' ? $expiryDate : null,
|
||||
'delivered_by' => $deliveredBy !== '' ? $deliveredBy : null,
|
||||
'status' => $status,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
'id' => $id,
|
||||
'employee_id' => $employeeId,
|
||||
]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'DPI aggiornato.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO employee_ppe
|
||||
(employee_id, item_name, delivery_date, delivered_by, notes, created_by, created_at, updated_at)
|
||||
INSERT INTO employee_ppe_items
|
||||
(
|
||||
employee_id,
|
||||
ppe_item_id,
|
||||
assigned_date,
|
||||
expiry_date,
|
||||
delivered_by,
|
||||
quantity,
|
||||
status,
|
||||
notes,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(:employee_id, :item_name, :delivery_date, :delivered_by, :notes, :created_by, NOW(), NOW())
|
||||
(
|
||||
:employee_id,
|
||||
:ppe_item_id,
|
||||
:assigned_date,
|
||||
:expiry_date,
|
||||
:delivered_by,
|
||||
1,
|
||||
:status,
|
||||
:notes,
|
||||
:created_by,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
'employee_id' => $employeeId,
|
||||
'item_name' => $itemName,
|
||||
'delivery_date' => $deliveryDate,
|
||||
'delivered_by' => $deliveredBy,
|
||||
'notes' => $notes,
|
||||
'created_by' => $currentUserId,
|
||||
'employee_id' => $employeeId,
|
||||
'ppe_item_id' => $ppeItemId,
|
||||
'assigned_date' => $assignedDate !== '' ? $assignedDate : null,
|
||||
'expiry_date' => $expiryDate !== '' ? $expiryDate : null,
|
||||
'delivered_by' => $deliveredBy !== '' ? $deliveredBy : null,
|
||||
'status' => $status,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
'created_by' => isset($iduserlogin) ? (int)$iduserlogin : null,
|
||||
]);
|
||||
|
||||
echo json_encode(['success' => true, 'id' => (int)$pdo->lastInsertId()]);
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'DPI assegnato.'
|
||||
]);
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* Bulk "renew": set a common completed_date on the selected training records
|
||||
*/
|
||||
require_once(__DIR__ . '/../hr_auth_check.php');
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'message' => 'Metodo non consentito.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// $pdo and $currentUserId from hr_auth_check.php
|
||||
|
||||
$completedDate = trim($_POST['completed_date'] ?? '');
|
||||
$ids = $_POST['training_ids'] ?? [];
|
||||
|
||||
if (!is_array($ids)) {
|
||||
$ids = [];
|
||||
}
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0)));
|
||||
|
||||
if ($completedDate === '' || !DateTime::createFromFormat('Y-m-d', $completedDate)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Indicare una data valida.']);
|
||||
exit;
|
||||
}
|
||||
if (empty($ids)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Selezionare almeno un record.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Load each record with its topic default frequency
|
||||
$rowStmt = $pdo->prepare("
|
||||
SELECT et.id, et.employee_id, et.completed_date, et.next_due_date,
|
||||
et.update_frequency_months, tt.default_frequency_months
|
||||
FROM employee_trainings et
|
||||
JOIN training_topics tt ON tt.id = et.training_topic_id
|
||||
WHERE et.id = :id
|
||||
");
|
||||
$upd = $pdo->prepare("
|
||||
UPDATE employee_trainings
|
||||
SET completed_date = :cd, next_due_date = :nd, updated_at = NOW()
|
||||
WHERE id = :id
|
||||
");
|
||||
$logStmt = $pdo->prepare("
|
||||
INSERT INTO employee_training_log
|
||||
(employee_id, training_id, action, field, old_value, new_value, changed_by, changed_at)
|
||||
VALUES
|
||||
(:eid, :tid, 'updated', :field, :old_v, :new_v, :cb, NOW())
|
||||
");
|
||||
|
||||
$updated = 0;
|
||||
foreach ($ids as $id) {
|
||||
$rowStmt->execute(['id' => $id]);
|
||||
$row = $rowStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Effective frequency: per-record override, else topic default
|
||||
$effFreq = $row['update_frequency_months'] !== null
|
||||
? (int)$row['update_frequency_months']
|
||||
: ($row['default_frequency_months'] !== null ? (int)$row['default_frequency_months'] : null);
|
||||
|
||||
$nextDue = null;
|
||||
if ($effFreq !== null && $effFreq > 0) {
|
||||
$d = DateTime::createFromFormat('Y-m-d', $completedDate);
|
||||
if ($d) {
|
||||
$d->modify('+' . $effFreq . ' months');
|
||||
$nextDue = $d->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
$upd->execute(['cd' => $completedDate, 'nd' => $nextDue, 'id' => $id]);
|
||||
|
||||
if ((string)$row['completed_date'] !== (string)$completedDate) {
|
||||
$logStmt->execute([
|
||||
'eid' => $row['employee_id'], 'tid' => $id, 'field' => 'completed_date',
|
||||
'old_v' => $row['completed_date'], 'new_v' => $completedDate, 'cb' => $currentUserId,
|
||||
]);
|
||||
}
|
||||
if ((string)$row['next_due_date'] !== (string)$nextDue) {
|
||||
$logStmt->execute([
|
||||
'eid' => $row['employee_id'], 'tid' => $id, 'field' => 'next_due_date',
|
||||
'old_v' => $row['next_due_date'], 'new_v' => $nextDue, 'cb' => $currentUserId,
|
||||
]);
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'updated' => $updated,
|
||||
'message' => $updated . ' record aggiornat' . ($updated === 1 ? 'o' : 'i') . '.',
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
/**
|
||||
* Calendar events for the training calendar (training_calendar.php).
|
||||
* Returns FullCalendar event objects for the *current* training record per
|
||||
* (employee, topic) that has a next_due_date, colored by computed status.
|
||||
* HR-only.
|
||||
*/
|
||||
require_once(__DIR__ . '/../hr_auth_check.php');
|
||||
header('Content-Type: application/json');
|
||||
|
||||
try {
|
||||
// $pdo and $currentUserId provided by hr_auth_check.php
|
||||
|
||||
$start = $_GET['start'] ?? null;
|
||||
$end = $_GET['end'] ?? null;
|
||||
$fStatus = isset($_GET['status']) ? trim($_GET['status']) : '';
|
||||
$fDept = isset($_GET['department_id']) && $_GET['department_id'] !== '' ? (int)$_GET['department_id'] : 0;
|
||||
$fTopic = isset($_GET['topic_id']) && $_GET['topic_id'] !== '' ? (int)$_GET['topic_id'] : 0;
|
||||
$fEmp = isset($_GET['employee_id']) && $_GET['employee_id'] !== '' ? (int)$_GET['employee_id'] : 0;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
// Deadlines only (one-time trainings have no next_due_date)
|
||||
$where[] = "et.next_due_date IS NOT NULL";
|
||||
|
||||
// Only the most recent record per (employee, topic)
|
||||
$where[] = "NOT EXISTS (
|
||||
SELECT 1 FROM employee_trainings et2
|
||||
WHERE et2.employee_id = et.employee_id
|
||||
AND et2.training_topic_id = et.training_topic_id
|
||||
AND (et2.completed_date > et.completed_date
|
||||
OR (et2.completed_date = et.completed_date AND et2.id > et.id))
|
||||
)";
|
||||
|
||||
if ($start && $end) {
|
||||
$where[] = "et.next_due_date >= :start AND et.next_due_date <= :end";
|
||||
$params['start'] = $start;
|
||||
$params['end'] = $end;
|
||||
}
|
||||
if ($fDept > 0) { $where[] = "e.department_id = :did"; $params['did'] = $fDept; }
|
||||
if ($fTopic > 0) { $where[] = "et.training_topic_id = :tid"; $params['tid'] = $fTopic; }
|
||||
if ($fEmp > 0) { $where[] = "et.employee_id = :eid"; $params['eid'] = $fEmp; }
|
||||
|
||||
$whereSql = 'WHERE ' . implode(' AND ', $where);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT et.id, et.employee_id, et.next_due_date, et.reminder_days,
|
||||
tt.name AS topic_name, tt.default_reminder_days AS topic_default_rem,
|
||||
e.first_name, e.last_name
|
||||
FROM employee_trainings et
|
||||
JOIN training_topics tt ON tt.id = et.training_topic_id
|
||||
JOIN employees e ON e.id = et.employee_id
|
||||
$whereSql
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$today = new DateTime('today');
|
||||
$events = [];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$rem = $r['reminder_days'] !== null
|
||||
? (int)$r['reminder_days']
|
||||
: ($r['topic_default_rem'] !== null ? (int)$r['topic_default_rem'] : 30);
|
||||
|
||||
$due = DateTime::createFromFormat('Y-m-d', $r['next_due_date']);
|
||||
if (!$due) continue;
|
||||
$daysLeft = (int)$today->diff($due)->format('%r%a');
|
||||
|
||||
if ($daysLeft < 0) { $code = 'expired'; $color = '#dc3545'; }
|
||||
elseif ($daysLeft <= $rem){ $code = 'due_soon'; $color = '#e8930c'; }
|
||||
else { $code = 'compliant'; $color = '#198754'; }
|
||||
|
||||
if ($fStatus !== '' && $fStatus !== $code) continue;
|
||||
|
||||
$name = trim($r['first_name'] . ' ' . $r['last_name']);
|
||||
$events[] = [
|
||||
'id' => (int)$r['id'],
|
||||
'title' => $name . ' — ' . $r['topic_name'],
|
||||
'start' => $r['next_due_date'],
|
||||
'allDay' => true,
|
||||
'backgroundColor' => $color,
|
||||
'borderColor' => $color,
|
||||
'url' => 'employee-profile.php?id=' . (int)$r['employee_id'] . '#tab-training',
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode($events);
|
||||
} catch (Exception $e) {
|
||||
echo json_encode([]);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* Bulk-create training records: one employee_trainings row per selected employee,
|
||||
* all sharing the same course + parameters (a single training "session").
|
||||
* Mirrors the next_due_date logic of ajax/employee_profile/save_training.php.
|
||||
* HR-only.
|
||||
*/
|
||||
require_once(__DIR__ . '/../hr_auth_check.php');
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'message' => 'Metodo non consentito.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// $pdo and $currentUserId from hr_auth_check.php
|
||||
|
||||
$topicId = (int)($_POST['training_topic_id'] ?? 0);
|
||||
$completedDate = trim($_POST['completed_date'] ?? '');
|
||||
$deliveredBy = trim($_POST['delivered_by'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$trainingType = trim($_POST['training_type'] ?? 'initial');
|
||||
$freqRaw = $_POST['update_frequency_months'] ?? '';
|
||||
$remRaw = $_POST['reminder_days'] ?? '';
|
||||
$employeeIds = $_POST['employee_ids'] ?? [];
|
||||
|
||||
if (!is_array($employeeIds)) {
|
||||
$employeeIds = [];
|
||||
}
|
||||
$employeeIds = array_values(array_unique(array_filter(array_map('intval', $employeeIds), fn($v) => $v > 0)));
|
||||
|
||||
if ($topicId <= 0) {
|
||||
echo json_encode(['success' => false, 'message' => 'Selezionare un corso.']);
|
||||
exit;
|
||||
}
|
||||
if ($completedDate === '' || !DateTime::createFromFormat('Y-m-d', $completedDate)) {
|
||||
echo json_encode(['success' => false, 'message' => 'La data di completamento è obbligatoria.']);
|
||||
exit;
|
||||
}
|
||||
if (empty($employeeIds)) {
|
||||
echo json_encode(['success' => false, 'message' => 'Selezionare almeno un dipendente.']);
|
||||
exit;
|
||||
}
|
||||
if (!in_array($trainingType, ['initial', 'refresher'], true)) {
|
||||
$trainingType = 'initial';
|
||||
}
|
||||
|
||||
$topicStmt = $pdo->prepare("SELECT default_frequency_months, default_reminder_days FROM training_topics WHERE id = :id");
|
||||
$topicStmt->execute(['id' => $topicId]);
|
||||
$topic = $topicStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$topic) {
|
||||
echo json_encode(['success' => false, 'message' => 'Corso non trovato.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$freq = ($freqRaw === '' || $freqRaw === null) ? null : max(0, (int)$freqRaw);
|
||||
$rem = ($remRaw === '' || $remRaw === null) ? null : max(0, (int)$remRaw);
|
||||
|
||||
/* Effective frequency → next_due_date (same for every employee: same date + same frequency) */
|
||||
$effFreq = $freq !== null ? $freq : ($topic['default_frequency_months'] !== null ? (int)$topic['default_frequency_months'] : null);
|
||||
$nextDue = null;
|
||||
if ($effFreq !== null && $effFreq > 0) {
|
||||
$d = DateTime::createFromFormat('Y-m-d', $completedDate);
|
||||
if ($d) {
|
||||
$d->modify('+' . (int)$effFreq . ' months');
|
||||
$nextDue = $d->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
$deliveredBy = $deliveredBy !== '' ? $deliveredBy : null;
|
||||
$description = $description !== '' ? $description : null;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Only insert for employees that actually exist
|
||||
$checkEmp = $pdo->prepare("SELECT id FROM employees WHERE id = :id");
|
||||
|
||||
$ins = $pdo->prepare("
|
||||
INSERT INTO employee_trainings
|
||||
(employee_id, training_topic_id, completed_date,
|
||||
delivered_by, description,
|
||||
training_type, update_frequency_months, reminder_days, next_due_date,
|
||||
created_by, created_at, updated_at)
|
||||
VALUES
|
||||
(:eid, :tid, :completed_date,
|
||||
:delivered_by, :description,
|
||||
:training_type, :freq, :rem, :next_due,
|
||||
:cb, NOW(), NOW())
|
||||
");
|
||||
$logStmt = $pdo->prepare("
|
||||
INSERT INTO employee_training_log
|
||||
(employee_id, training_id, action, field, old_value, new_value, changed_by, changed_at)
|
||||
VALUES
|
||||
(:eid, :tid, 'created', NULL, NULL, NULL, :cb, NOW())
|
||||
");
|
||||
|
||||
$created = 0;
|
||||
foreach ($employeeIds as $eid) {
|
||||
$checkEmp->execute(['id' => $eid]);
|
||||
if (!$checkEmp->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
$ins->execute([
|
||||
'eid' => $eid,
|
||||
'tid' => $topicId,
|
||||
'completed_date' => $completedDate,
|
||||
'delivered_by' => $deliveredBy,
|
||||
'description' => $description,
|
||||
'training_type' => $trainingType,
|
||||
'freq' => $freq,
|
||||
'rem' => $rem,
|
||||
'next_due' => $nextDue,
|
||||
'cb' => $currentUserId,
|
||||
]);
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
$logStmt->execute(['eid' => $eid, 'tid' => $newId, 'cb' => $currentUserId]);
|
||||
$created++;
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'created' => $created,
|
||||
'message' => $created . ' formazion' . ($created === 1 ? 'e registrata' : 'i registrate') . '.',
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
require_once(__DIR__ . '/include/headscript.php');
|
||||
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$iduser = $iduserlogin ?? null;
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
throw new Exception('ID non valido.');
|
||||
}
|
||||
|
||||
if ($iduser === null) {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM cad_area_jobs
|
||||
WHERE id = :id
|
||||
LIMIT 1
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
':id' => $id
|
||||
]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM cad_area_jobs
|
||||
WHERE id = :id
|
||||
AND iduser = :iduser
|
||||
LIMIT 1
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':iduser' => $iduser
|
||||
]);
|
||||
}
|
||||
|
||||
$job = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$job) {
|
||||
throw new Exception('Record non trovato.');
|
||||
}
|
||||
|
||||
if (!empty($job['file_path']) && file_exists($job['file_path'])) {
|
||||
unlink($job['file_path']);
|
||||
}
|
||||
|
||||
$stmtDelete = $pdo->prepare("
|
||||
DELETE FROM cad_area_jobs
|
||||
WHERE id = :id
|
||||
");
|
||||
$stmtDelete->execute([':id' => $id]);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('CAD area delete error: ' . $e->getMessage());
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once(__DIR__ . '/include/headscript.php');
|
||||
|
||||
function jsonResponse(array $data): void
|
||||
{
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
function updateJobProcessing(PDO $pdo, int $id): void
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE cad_area_jobs
|
||||
SET
|
||||
status = 'processing',
|
||||
message = 'Elaborazione in corso...',
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([$id]);
|
||||
}
|
||||
|
||||
function updateJobError(PDO $pdo, int $id, string $message, ?array $pythonResponse = null): void
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE cad_area_jobs
|
||||
SET
|
||||
status = 'error',
|
||||
message = ?,
|
||||
python_response = ?,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$message,
|
||||
$pythonResponse ? json_encode($pythonResponse) : null,
|
||||
$id
|
||||
]);
|
||||
}
|
||||
|
||||
function updateJobCompleted(PDO $pdo, int $id, array $response): void
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE cad_area_jobs
|
||||
SET
|
||||
status = 'completed',
|
||||
message = ?,
|
||||
area_mm2 = ?,
|
||||
area_cm2 = ?,
|
||||
area_m2 = ?,
|
||||
width_mm = ?,
|
||||
height_mm = ?,
|
||||
scale_detected = ?,
|
||||
scale_used = ?,
|
||||
confidence = ?,
|
||||
python_response = ?,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$response['message'] ?? 'Area calcolata correttamente.',
|
||||
$response['area_mm2'] ?? null,
|
||||
$response['area_cm2'] ?? null,
|
||||
$response['area_m2'] ?? null,
|
||||
$response['width_mm'] ?? null,
|
||||
$response['height_mm'] ?? null,
|
||||
$response['scale_detected'] ?? null,
|
||||
$response['scale_used'] ?? null,
|
||||
$response['confidence'] ?? null,
|
||||
json_encode($response),
|
||||
$id
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeCalculationMode(?string $mode): string
|
||||
{
|
||||
$allowed = [
|
||||
'auto_roi',
|
||||
'stitch_contour',
|
||||
'filled_union',
|
||||
'closed_path'
|
||||
];
|
||||
|
||||
if (!$mode || !in_array($mode, $allowed, true)) {
|
||||
return 'auto_roi';
|
||||
}
|
||||
|
||||
return $mode;
|
||||
}
|
||||
|
||||
function hasValidRoi(array $job): bool
|
||||
{
|
||||
return (
|
||||
array_key_exists('roi_x', $job) &&
|
||||
array_key_exists('roi_y', $job) &&
|
||||
array_key_exists('roi_width', $job) &&
|
||||
array_key_exists('roi_height', $job) &&
|
||||
$job['roi_x'] !== null &&
|
||||
$job['roi_y'] !== null &&
|
||||
$job['roi_width'] !== null &&
|
||||
$job['roi_height'] !== null &&
|
||||
(float)$job['roi_width'] > 0 &&
|
||||
(float)$job['roi_height'] > 0
|
||||
);
|
||||
}
|
||||
|
||||
function callPythonAreaService(string $url, array $job): array
|
||||
{
|
||||
$filePath = $job['file_path'] ?? '';
|
||||
$originalFilename = $job['original_filename'] ?? basename($filePath);
|
||||
|
||||
if (!$filePath || !file_exists($filePath)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'File PDF non trovato sul server: ' . $filePath
|
||||
];
|
||||
}
|
||||
|
||||
$mode = normalizeCalculationMode($job['calculation_mode'] ?? 'auto_roi');
|
||||
|
||||
$scaleRatio = $job['scale_used'] ?? null;
|
||||
|
||||
if ($scaleRatio === null || $scaleRatio === '' || (float)$scaleRatio <= 0) {
|
||||
$scaleRatio = '1';
|
||||
}
|
||||
|
||||
$curlFile = new CURLFile(
|
||||
$filePath,
|
||||
'application/pdf',
|
||||
$originalFilename
|
||||
);
|
||||
|
||||
$postFields = [
|
||||
'file' => $curlFile,
|
||||
'mode' => $mode,
|
||||
'scale_ratio' => (string)$scaleRatio,
|
||||
'roi_x' => (string)$job['roi_x'],
|
||||
'roi_y' => (string)$job['roi_y'],
|
||||
'roi_width' => (string)$job['roi_width'],
|
||||
'roi_height' => (string)$job['roi_height'],
|
||||
'roi_page' => (string)($job['roi_page'] ?? 1)
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $postFields,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 180,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json'
|
||||
]
|
||||
]);
|
||||
|
||||
$rawResponse = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($rawResponse === false) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Errore cURL verso Python: ' . $curlError
|
||||
];
|
||||
}
|
||||
|
||||
$decoded = json_decode($rawResponse, true);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Risposta Python non JSON valida.',
|
||||
'http_code' => $httpCode,
|
||||
'raw_response' => $rawResponse
|
||||
];
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $decoded['message'] ?? ('Servizio Python HTTP ' . $httpCode),
|
||||
'http_code' => $httpCode,
|
||||
'python_response' => $decoded,
|
||||
'raw_response' => $rawResponse
|
||||
];
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$iduser = $iduserlogin ?? null;
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!is_array($input)) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Payload JSON non valido.'
|
||||
]);
|
||||
}
|
||||
|
||||
$ids = $input['ids'] ?? [];
|
||||
|
||||
if (!is_array($ids) || count($ids) === 0) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Nessun ID ricevuto.'
|
||||
]);
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
$ids = array_filter($ids, fn($id) => $id > 0);
|
||||
|
||||
if (count($ids) === 0) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Nessun ID valido ricevuto.'
|
||||
]);
|
||||
}
|
||||
|
||||
$pythonServiceUrl = 'http://127.0.0.1:5055/calculate';
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if ($iduser === null || $iduser === '') {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM cad_area_jobs
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
");
|
||||
|
||||
$stmt->execute([$id]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT *
|
||||
FROM cad_area_jobs
|
||||
WHERE id = ?
|
||||
AND iduser = ?
|
||||
LIMIT 1
|
||||
");
|
||||
|
||||
$stmt->execute([$id, $iduser]);
|
||||
}
|
||||
|
||||
$job = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$job) {
|
||||
$results[] = [
|
||||
'id' => $id,
|
||||
'success' => false,
|
||||
'message' => 'Record non trovato.'
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasValidRoi($job)) {
|
||||
$message = 'Prima devi definire la sezione da misurare tramite il pulsante Sezione.';
|
||||
|
||||
updateJobError($pdo, $id, $message, [
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
'job_roi_debug' => [
|
||||
'roi_x' => $job['roi_x'] ?? null,
|
||||
'roi_y' => $job['roi_y'] ?? null,
|
||||
'roi_width' => $job['roi_width'] ?? null,
|
||||
'roi_height' => $job['roi_height'] ?? null,
|
||||
'roi_page' => $job['roi_page'] ?? null,
|
||||
'calculation_mode' => $job['calculation_mode'] ?? null
|
||||
]
|
||||
]);
|
||||
|
||||
$results[] = [
|
||||
'id' => $id,
|
||||
'success' => false,
|
||||
'message' => $message
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
updateJobProcessing($pdo, $id);
|
||||
|
||||
$pythonResponse = callPythonAreaService($pythonServiceUrl, $job);
|
||||
|
||||
if (!($pythonResponse['success'] ?? false)) {
|
||||
$message = $pythonResponse['message'] ?? 'Errore durante il calcolo Python.';
|
||||
|
||||
updateJobError($pdo, $id, $message, $pythonResponse);
|
||||
|
||||
$results[] = [
|
||||
'id' => $id,
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
'python_response' => $pythonResponse
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
updateJobCompleted($pdo, $id, $pythonResponse);
|
||||
|
||||
$results[] = [
|
||||
'id' => $id,
|
||||
'success' => true,
|
||||
'message' => $pythonResponse['message'] ?? 'Area calcolata.',
|
||||
'area_mm2' => $pythonResponse['area_mm2'] ?? null,
|
||||
'area_cm2' => $pythonResponse['area_cm2'] ?? null
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'results' => $results
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('CAD area process error: ' . $e->getMessage());
|
||||
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once(__DIR__ . '/include/headscript.php');
|
||||
|
||||
function jsonResponse(array $data): void
|
||||
{
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$iduser = $iduserlogin ?? null;
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$input = json_decode($rawInput, true);
|
||||
|
||||
if (!is_array($input)) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Payload JSON non valido.'
|
||||
]);
|
||||
}
|
||||
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'ID non valido.'
|
||||
]);
|
||||
}
|
||||
|
||||
$areaMm2 = isset($input['area_mm2']) ? (float)$input['area_mm2'] : 0;
|
||||
$areaCm2 = isset($input['area_cm2']) ? (float)$input['area_cm2'] : 0;
|
||||
|
||||
$outerAreaMm2 = isset($input['manual_outer_area_mm2']) ? (float)$input['manual_outer_area_mm2'] : $areaMm2;
|
||||
$holesAreaMm2 = isset($input['manual_holes_area_mm2']) ? (float)$input['manual_holes_area_mm2'] : 0;
|
||||
|
||||
$widthMm = isset($input['width_mm']) ? (float)$input['width_mm'] : null;
|
||||
$heightMm = isset($input['height_mm']) ? (float)$input['height_mm'] : null;
|
||||
|
||||
$calibrationPx = isset($input['manual_calibration_px']) ? (float)$input['manual_calibration_px'] : 0;
|
||||
$calibrationMm = isset($input['manual_calibration_mm']) ? (float)$input['manual_calibration_mm'] : 0;
|
||||
$mmPerPx = isset($input['manual_mm_per_px']) ? (float)$input['manual_mm_per_px'] : 0;
|
||||
|
||||
$outerPolygon = $input['manual_polygon'] ?? null;
|
||||
$holes = $input['manual_holes'] ?? [];
|
||||
$roi = $input['roi'] ?? null;
|
||||
|
||||
if ($areaMm2 <= 0) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Area finale non valida.'
|
||||
]);
|
||||
}
|
||||
|
||||
if ($outerAreaMm2 <= 0) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Area esterna non valida.'
|
||||
]);
|
||||
}
|
||||
|
||||
if ($holesAreaMm2 < 0) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Area fori non valida.'
|
||||
]);
|
||||
}
|
||||
|
||||
if ($calibrationPx <= 0 || $calibrationMm <= 0 || $mmPerPx <= 0) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Calibrazione non valida.'
|
||||
]);
|
||||
}
|
||||
|
||||
if (!is_array($outerPolygon) || count($outerPolygon) < 3) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Poligono esterno non valido. Servono almeno 3 punti.'
|
||||
]);
|
||||
}
|
||||
|
||||
if (!is_array($holes)) {
|
||||
$holes = [];
|
||||
}
|
||||
|
||||
$manualPolygonJson = json_encode([
|
||||
'outer_polygon' => $outerPolygon,
|
||||
'holes' => $holes,
|
||||
'roi' => $roi,
|
||||
'calibration' => $input['calibration'] ?? null,
|
||||
'canvas' => $input['canvas'] ?? null,
|
||||
'areas' => [
|
||||
'outer_area_mm2' => $outerAreaMm2,
|
||||
'holes_area_mm2' => $holesAreaMm2,
|
||||
'final_area_mm2' => $areaMm2,
|
||||
'final_area_cm2' => $areaCm2
|
||||
]
|
||||
]);
|
||||
|
||||
$manualHolesJson = json_encode($holes);
|
||||
|
||||
$roiX = null;
|
||||
$roiY = null;
|
||||
$roiW = null;
|
||||
$roiH = null;
|
||||
|
||||
if (is_array($roi)) {
|
||||
$roiX = isset($roi['x']) ? (float)$roi['x'] : null;
|
||||
$roiY = isset($roi['y']) ? (float)$roi['y'] : null;
|
||||
$roiW = isset($roi['width']) ? (float)$roi['width'] : null;
|
||||
$roiH = isset($roi['height']) ? (float)$roi['height'] : null;
|
||||
}
|
||||
|
||||
if ($iduser === null || $iduser === '') {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE cad_area_jobs
|
||||
SET
|
||||
roi_x = COALESCE(?, roi_x),
|
||||
roi_y = COALESCE(?, roi_y),
|
||||
roi_width = COALESCE(?, roi_width),
|
||||
roi_height = COALESCE(?, roi_height),
|
||||
roi_page = 1,
|
||||
|
||||
status = 'completed',
|
||||
message = 'Area calcolata tramite tracciamento manuale calibrato.',
|
||||
|
||||
area_mm2 = ?,
|
||||
area_cm2 = ?,
|
||||
area_m2 = ?,
|
||||
|
||||
manual_area_mm2 = ?,
|
||||
manual_area_cm2 = ?,
|
||||
manual_outer_area_mm2 = ?,
|
||||
manual_holes_area_mm2 = ?,
|
||||
manual_width_mm = ?,
|
||||
manual_height_mm = ?,
|
||||
|
||||
width_mm = ?,
|
||||
height_mm = ?,
|
||||
|
||||
manual_calibration_px = ?,
|
||||
manual_calibration_mm = ?,
|
||||
manual_mm_per_px = ?,
|
||||
manual_polygon_json = ?,
|
||||
manual_holes_json = ?,
|
||||
manual_status = 'completed',
|
||||
|
||||
scale_used = ?,
|
||||
scale_detected = ?,
|
||||
confidence = 'manual_validated',
|
||||
strategy_used = 'manual_tracing_with_exclusions',
|
||||
|
||||
python_response = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$roiX,
|
||||
$roiY,
|
||||
$roiW,
|
||||
$roiH,
|
||||
|
||||
$areaMm2,
|
||||
$areaCm2,
|
||||
$areaMm2 / 1000000,
|
||||
|
||||
$areaMm2,
|
||||
$areaCm2,
|
||||
$outerAreaMm2,
|
||||
$holesAreaMm2,
|
||||
$widthMm,
|
||||
$heightMm,
|
||||
|
||||
$widthMm,
|
||||
$heightMm,
|
||||
|
||||
$calibrationPx,
|
||||
$calibrationMm,
|
||||
$mmPerPx,
|
||||
$manualPolygonJson,
|
||||
$manualHolesJson,
|
||||
|
||||
$mmPerPx,
|
||||
'manual',
|
||||
|
||||
$id
|
||||
]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE cad_area_jobs
|
||||
SET
|
||||
roi_x = COALESCE(?, roi_x),
|
||||
roi_y = COALESCE(?, roi_y),
|
||||
roi_width = COALESCE(?, roi_width),
|
||||
roi_height = COALESCE(?, roi_height),
|
||||
roi_page = 1,
|
||||
|
||||
status = 'completed',
|
||||
message = 'Area calcolata tramite tracciamento manuale calibrato.',
|
||||
|
||||
area_mm2 = ?,
|
||||
area_cm2 = ?,
|
||||
area_m2 = ?,
|
||||
|
||||
manual_area_mm2 = ?,
|
||||
manual_area_cm2 = ?,
|
||||
manual_outer_area_mm2 = ?,
|
||||
manual_holes_area_mm2 = ?,
|
||||
manual_width_mm = ?,
|
||||
manual_height_mm = ?,
|
||||
|
||||
width_mm = ?,
|
||||
height_mm = ?,
|
||||
|
||||
manual_calibration_px = ?,
|
||||
manual_calibration_mm = ?,
|
||||
manual_mm_per_px = ?,
|
||||
manual_polygon_json = ?,
|
||||
manual_holes_json = ?,
|
||||
manual_status = 'completed',
|
||||
|
||||
scale_used = ?,
|
||||
scale_detected = ?,
|
||||
confidence = 'manual_validated',
|
||||
strategy_used = 'manual_tracing_with_exclusions',
|
||||
|
||||
python_response = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
AND iduser = ?
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$roiX,
|
||||
$roiY,
|
||||
$roiW,
|
||||
$roiH,
|
||||
|
||||
$areaMm2,
|
||||
$areaCm2,
|
||||
$areaMm2 / 1000000,
|
||||
|
||||
$areaMm2,
|
||||
$areaCm2,
|
||||
$outerAreaMm2,
|
||||
$holesAreaMm2,
|
||||
$widthMm,
|
||||
$heightMm,
|
||||
|
||||
$widthMm,
|
||||
$heightMm,
|
||||
|
||||
$calibrationPx,
|
||||
$calibrationMm,
|
||||
$mmPerPx,
|
||||
$manualPolygonJson,
|
||||
$manualHolesJson,
|
||||
|
||||
$mmPerPx,
|
||||
'manual',
|
||||
|
||||
$id,
|
||||
$iduser
|
||||
]);
|
||||
}
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Nessun record aggiornato. Controlla ID o utente.'
|
||||
]);
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Area manuale salvata correttamente.',
|
||||
'area_mm2' => $areaMm2,
|
||||
'area_cm2' => $areaCm2,
|
||||
'outer_area_mm2' => $outerAreaMm2,
|
||||
'holes_area_mm2' => $holesAreaMm2
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('CAD manual area save error: ' . $e->getMessage());
|
||||
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once(__DIR__ . '/include/headscript.php');
|
||||
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$iduser = $iduserlogin ?? null;
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$input = json_decode($rawInput, true);
|
||||
|
||||
if (!is_array($input)) {
|
||||
throw new Exception('Payload JSON non valido.');
|
||||
}
|
||||
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
throw new Exception('ID non valido.');
|
||||
}
|
||||
|
||||
$roiX = isset($input['roi_x']) ? (float)$input['roi_x'] : null;
|
||||
$roiY = isset($input['roi_y']) ? (float)$input['roi_y'] : null;
|
||||
$roiW = isset($input['roi_width']) ? (float)$input['roi_width'] : null;
|
||||
$roiH = isset($input['roi_height']) ? (float)$input['roi_height'] : null;
|
||||
$roiPage = isset($input['roi_page']) ? (int)$input['roi_page'] : 1;
|
||||
$mode = $input['calculation_mode'] ?? 'auto_roi';
|
||||
|
||||
if ($roiX === null || $roiY === null || $roiW === null || $roiH === null) {
|
||||
throw new Exception('ROI non valida.');
|
||||
}
|
||||
|
||||
if ($roiW <= 0 || $roiH <= 0) {
|
||||
throw new Exception('Dimensioni ROI non valide.');
|
||||
}
|
||||
|
||||
if ($roiX < 0 || $roiY < 0 || $roiX > 1 || $roiY > 1 || $roiW > 1 || $roiH > 1) {
|
||||
throw new Exception('Coordinate ROI fuori scala.');
|
||||
}
|
||||
|
||||
$allowedModes = [
|
||||
'auto_roi',
|
||||
'stitch_contour',
|
||||
'filled_union',
|
||||
'closed_path'
|
||||
];
|
||||
|
||||
if (!in_array($mode, $allowedModes, true)) {
|
||||
$mode = 'auto_roi';
|
||||
}
|
||||
|
||||
if ($iduser === null || $iduser === '') {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE cad_area_jobs
|
||||
SET
|
||||
roi_x = ?,
|
||||
roi_y = ?,
|
||||
roi_width = ?,
|
||||
roi_height = ?,
|
||||
roi_page = ?,
|
||||
calculation_mode = ?,
|
||||
status = 'uploaded',
|
||||
message = NULL
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$roiX,
|
||||
$roiY,
|
||||
$roiW,
|
||||
$roiH,
|
||||
$roiPage,
|
||||
$mode,
|
||||
$id
|
||||
]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE cad_area_jobs
|
||||
SET
|
||||
roi_x = ?,
|
||||
roi_y = ?,
|
||||
roi_width = ?,
|
||||
roi_height = ?,
|
||||
roi_page = ?,
|
||||
calculation_mode = ?,
|
||||
status = 'uploaded',
|
||||
message = NULL
|
||||
WHERE id = ?
|
||||
AND iduser = ?
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$roiX,
|
||||
$roiY,
|
||||
$roiW,
|
||||
$roiH,
|
||||
$roiPage,
|
||||
$mode,
|
||||
$id,
|
||||
$iduser
|
||||
]);
|
||||
}
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
throw new Exception('Nessun record aggiornato. Controlla ID o utente.');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'ROI salvata correttamente.'
|
||||
]);
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
error_log('CAD area save ROI error: ' . $e->getMessage());
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
|
||||
require_once(__DIR__ . '/include/headscript.php');
|
||||
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$iduser = $iduserlogin ?? null;
|
||||
|
||||
$uploadDir = __DIR__ . '/uploads/cad_area/originals/';
|
||||
$publicBaseUrl = 'uploads/cad_area/originals/';
|
||||
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
if (empty($_FILES['pdf_files'])) {
|
||||
throw new Exception('Nessun file ricevuto.');
|
||||
}
|
||||
|
||||
$files = $_FILES['pdf_files'];
|
||||
$insertedIds = [];
|
||||
|
||||
for ($i = 0; $i < count($files['name']); $i++) {
|
||||
if ($files['error'][$i] !== UPLOAD_ERR_OK) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalName = $files['name'][$i];
|
||||
$tmpName = $files['tmp_name'][$i];
|
||||
$size = (int)$files['size'][$i];
|
||||
|
||||
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
|
||||
if ($extension !== 'pdf') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($size > 25 * 1024 * 1024) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$safeBaseName = preg_replace('/[^a-zA-Z0-9_\-]/', '_', pathinfo($originalName, PATHINFO_FILENAME));
|
||||
$storedName = date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '_' . $safeBaseName . '.pdf';
|
||||
|
||||
$targetPath = $uploadDir . $storedName;
|
||||
|
||||
if (!move_uploaded_file($tmpName, $targetPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativeUrl = $publicBaseUrl . $storedName;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO cad_area_jobs
|
||||
(
|
||||
iduser,
|
||||
original_filename,
|
||||
stored_filename,
|
||||
file_path,
|
||||
file_url,
|
||||
file_size,
|
||||
status
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
:iduser,
|
||||
:original_filename,
|
||||
:stored_filename,
|
||||
:file_path,
|
||||
:file_url,
|
||||
:file_size,
|
||||
'uploaded'
|
||||
)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
':iduser' => $iduser,
|
||||
':original_filename' => $originalName,
|
||||
':stored_filename' => $storedName,
|
||||
':file_path' => $targetPath,
|
||||
':file_url' => $relativeUrl,
|
||||
':file_size' => $size
|
||||
]);
|
||||
|
||||
$insertedIds[] = (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
if (empty($insertedIds)) {
|
||||
throw new Exception('Nessun PDF valido caricato.');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ids' => $insertedIds
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('CAD area upload error: ' . $e->getMessage());
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
<?php
|
||||
ini_set('display_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
include('include/headscript.php');
|
||||
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
function jsonResponse(array $data): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
function normalizeNullableInt($value): ?int
|
||||
{
|
||||
return (isset($value) && $value !== '') ? (int)$value : null;
|
||||
}
|
||||
|
||||
function normalizeBoolValue($value): int
|
||||
{
|
||||
return ((string)$value === '0') ? 0 : 1;
|
||||
}
|
||||
|
||||
function cleanString(?string $value): string
|
||||
{
|
||||
return trim((string)$value);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
AJAX HANDLERS
|
||||
========================================== */
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ajax']) && $_POST['ajax'] == '1') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
try {
|
||||
if ($action === 'add') {
|
||||
$functionName = cleanString($_POST['function_name'] ?? '');
|
||||
$personFullName = cleanString($_POST['person_full_name'] ?? '');
|
||||
$phone = cleanString($_POST['phone'] ?? '');
|
||||
$email = cleanString($_POST['email'] ?? '');
|
||||
$notes = cleanString($_POST['notes'] ?? '');
|
||||
$sortOrder = normalizeNullableInt($_POST['sort_order'] ?? '0') ?? 0;
|
||||
$isActive = normalizeBoolValue($_POST['is_active'] ?? '1');
|
||||
|
||||
if ($functionName === '') {
|
||||
jsonResponse(['success' => false, 'message' => 'Il nome funzione è obbligatorio.']);
|
||||
}
|
||||
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Email non valida.']);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("\n INSERT INTO company_functions\n (function_name, person_full_name, phone, email, notes, sort_order, is_active, created_at, updated_at)\n VALUES\n (:function_name, :person_full_name, :phone, :email, :notes, :sort_order, :is_active, NOW(), NOW())\n ");
|
||||
|
||||
$stmt->execute([
|
||||
'function_name' => $functionName,
|
||||
'person_full_name' => $personFullName !== '' ? $personFullName : '',
|
||||
'phone' => $phone !== '' ? $phone : null,
|
||||
'email' => $email !== '' ? $email : null,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
'sort_order' => $sortOrder,
|
||||
'is_active' => $isActive,
|
||||
]);
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'Funzione salvata correttamente.']);
|
||||
}
|
||||
|
||||
if ($action === 'edit') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$functionName = cleanString($_POST['function_name'] ?? '');
|
||||
$personFullName = cleanString($_POST['person_full_name'] ?? '');
|
||||
$phone = cleanString($_POST['phone'] ?? '');
|
||||
$email = cleanString($_POST['email'] ?? '');
|
||||
$notes = cleanString($_POST['notes'] ?? '');
|
||||
$sortOrder = normalizeNullableInt($_POST['sort_order'] ?? '0') ?? 0;
|
||||
$isActive = normalizeBoolValue($_POST['is_active'] ?? '1');
|
||||
|
||||
if ($id <= 0) {
|
||||
jsonResponse(['success' => false, 'message' => 'ID funzione non valido.']);
|
||||
}
|
||||
|
||||
if ($functionName === '') {
|
||||
jsonResponse(['success' => false, 'message' => 'Il nome funzione è obbligatorio.']);
|
||||
}
|
||||
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Email non valida.']);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("\n UPDATE company_functions\n SET function_name = :function_name,\n person_full_name = :person_full_name,\n phone = :phone,\n email = :email,\n notes = :notes,\n sort_order = :sort_order,\n is_active = :is_active,\n updated_at = NOW()\n WHERE id = :id\n ");
|
||||
|
||||
$stmt->execute([
|
||||
'function_name' => $functionName,
|
||||
'person_full_name' => $personFullName !== '' ? $personFullName : '',
|
||||
'phone' => $phone !== '' ? $phone : null,
|
||||
'email' => $email !== '' ? $email : null,
|
||||
'notes' => $notes !== '' ? $notes : null,
|
||||
'sort_order' => $sortOrder,
|
||||
'is_active' => $isActive,
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'Funzione aggiornata correttamente.']);
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
jsonResponse(['success' => false, 'message' => 'ID funzione non valido.']);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("DELETE FROM company_functions WHERE id = :id");
|
||||
$stmt->execute(['id' => $id]);
|
||||
|
||||
jsonResponse(['success' => true, 'message' => 'Funzione cancellata correttamente.']);
|
||||
}
|
||||
|
||||
jsonResponse(['success' => false, 'message' => 'Azione non riconosciuta.']);
|
||||
} catch (Exception $e) {
|
||||
jsonResponse(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
PAGE DATA
|
||||
========================================== */
|
||||
$stmtFunctions = $pdo->query("\n SELECT id, function_name, person_full_name, phone, email, notes, sort_order, is_active, created_at, updated_at\n FROM company_functions\n ORDER BY is_active DESC, sort_order ASC, function_name ASC, person_full_name ASC\n");
|
||||
$functions = $stmtFunctions->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||||
<?php include('cssinclude.php'); ?>
|
||||
<title>Funzioni Aziendali - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
|
||||
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-size: 1.05rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.back-dashboard {
|
||||
background-color: #cfe3ff !important;
|
||||
color: #1f2d3d !important;
|
||||
border: 1px solid #bcd4f4 !important;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
padding: 10px 18px;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.back-dashboard:hover {
|
||||
background-color: #b9d3ff !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-main-action,
|
||||
.btn-add {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-main-action:hover,
|
||||
.btn-add:hover {
|
||||
background-color: #0b5ed7;
|
||||
color: #fff;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.table thead {
|
||||
background-color: #cfe3ff;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
|
||||
#tabCompanyFunctions thead th {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.function-name {
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.person-name {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.contact-line {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.notes-small {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
max-width: 420px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-status.active {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-status.inactive {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.back-dashboard,
|
||||
.btn-main-action {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper" id="appWrapper">
|
||||
<?php include('include/navbar.php'); ?>
|
||||
<?php include('include/topbar.php'); ?>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="page-content">
|
||||
<div class="card p-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h5 class="mb-0">Funzioni Aziendali</h5>
|
||||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
|
||||
↩️ Torna alla Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div>
|
||||
<h6 class="fw-semibold mb-1">Elenco Funzioni</h6>
|
||||
<div class="text-muted small">Gestione di RSPP, medico del lavoro, RLS e altre funzioni aziendali.</div>
|
||||
</div>
|
||||
<button class="btn btn-main-action" data-bs-toggle="modal" data-bs-target="#companyFunctionModal" onclick="openCompanyFunctionModal()">
|
||||
➕ Aggiungi Funzione
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="tabCompanyFunctions" class="table table-striped align-middle text-center" style="width:100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Funzione</th>
|
||||
<th>Nominativo</th>
|
||||
<th>Contatti</th>
|
||||
<th>Note</th>
|
||||
<th>Ordine</th>
|
||||
<th>Stato</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($functions as $row): ?>
|
||||
<?php
|
||||
$id = (int)$row['id'];
|
||||
$functionName = (string)($row['function_name'] ?? '');
|
||||
$personFullName = (string)($row['person_full_name'] ?? '');
|
||||
$phone = (string)($row['phone'] ?? '');
|
||||
$email = (string)($row['email'] ?? '');
|
||||
$notes = (string)($row['notes'] ?? '');
|
||||
$sortOrder = (int)($row['sort_order'] ?? 0);
|
||||
$isActive = (int)($row['is_active'] ?? 1);
|
||||
?>
|
||||
<tr>
|
||||
<td class="text-start">
|
||||
<div class="function-name"><?= htmlspecialchars($functionName, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
</td>
|
||||
<td class="text-start">
|
||||
<?php if ($personFullName !== ''): ?>
|
||||
<div class="person-name"><?= htmlspecialchars($personFullName, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php else: ?>
|
||||
<span class="empty-text">Da definire</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-start">
|
||||
<?php if ($phone !== ''): ?>
|
||||
<a class="contact-line" href="tel:<?= htmlspecialchars($phone, ENT_QUOTES, 'UTF-8') ?>">
|
||||
📞 <?= htmlspecialchars($phone, ENT_QUOTES, 'UTF-8') ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($email !== ''): ?>
|
||||
<a class="contact-line" href="mailto:<?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?>">
|
||||
✉️ <?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($phone === '' && $email === ''): ?>
|
||||
<span class="empty-text">Nessun contatto</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-start">
|
||||
<?php if ($notes !== ''): ?>
|
||||
<div class="notes-small" title="<?= htmlspecialchars($notes, ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= htmlspecialchars($notes, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="empty-text">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= $sortOrder ?></td>
|
||||
<td>
|
||||
<?php if ($isActive === 1): ?>
|
||||
<span class="badge-status active">Attiva</span>
|
||||
<?php else: ?>
|
||||
<span class="badge-status inactive">Non attiva</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary edit-function mb-1"
|
||||
data-id="<?= $id ?>"
|
||||
data-function_name="<?= htmlspecialchars($functionName, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-person_full_name="<?= htmlspecialchars($personFullName, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-phone="<?= htmlspecialchars($phone, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-email="<?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-notes="<?= htmlspecialchars($notes, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-sort_order="<?= $sortOrder ?>"
|
||||
data-is_active="<?= $isActive ?>">
|
||||
✏️ Modifica
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-danger delete-function mb-1"
|
||||
data-id="<?= $id ?>"
|
||||
data-name="<?= htmlspecialchars($functionName, ENT_QUOTES, 'UTF-8') ?>">
|
||||
🗑️ Cancella
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<!-- MODALE ADD / EDIT FUNZIONE -->
|
||||
<div class="modal fade" id="companyFunctionModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header" style="background-color:#cfe3ff;">
|
||||
<h5 class="modal-title" id="companyFunctionModalTitle">Aggiungi Funzione</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<form id="companyFunctionForm">
|
||||
<input type="hidden" id="functionId">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Nome funzione <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="functionName" placeholder="Es. RSPP, Medico del lavoro, RLS" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Nome e Cognome persona</label>
|
||||
<input type="text" class="form-control" id="personFullName" placeholder="Es. Mario Rossi">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">Telefono</label>
|
||||
<input type="text" class="form-control" id="phone" placeholder="Es. +39 333 1234567">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">Email</label>
|
||||
<input type="email" class="form-control" id="email" placeholder="nome@azienda.it">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">Ordine</label>
|
||||
<input type="number" class="form-control" id="sortOrder" value="0" min="0" step="1">
|
||||
<small class="text-muted">Serve solo per ordinare la visualizzazione.</small>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">Stato</label>
|
||||
<select class="form-select" id="isActive">
|
||||
<option value="1">Attiva</option>
|
||||
<option value="0">Non attiva</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Note</label>
|
||||
<textarea class="form-control" id="notes" rows="3" placeholder="Note interne, riferimenti, disponibilità, ecc."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button type="submit" class="btn btn-add">💾 Salva</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include('jsinclude.php'); ?>
|
||||
|
||||
<script>
|
||||
function escapeHtml(value) {
|
||||
return $('<div>').text(value || '').html();
|
||||
}
|
||||
|
||||
function openCompanyFunctionModal() {
|
||||
$('#functionId').val('');
|
||||
$('#functionName').val('');
|
||||
$('#personFullName').val('');
|
||||
$('#phone').val('');
|
||||
$('#email').val('');
|
||||
$('#notes').val('');
|
||||
$('#sortOrder').val('0');
|
||||
$('#isActive').val('1');
|
||||
$('#companyFunctionModalTitle').text('Aggiungi Funzione');
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
$('#tabCompanyFunctions').DataTable({
|
||||
order: [
|
||||
[5, 'asc'],
|
||||
[4, 'asc'],
|
||||
[0, 'asc']
|
||||
],
|
||||
pageLength: 25,
|
||||
language: {
|
||||
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json',
|
||||
emptyTable: 'Nessuna funzione presente'
|
||||
},
|
||||
columnDefs: [{
|
||||
targets: -1,
|
||||
orderable: false,
|
||||
searchable: false
|
||||
}]
|
||||
});
|
||||
|
||||
$(document).on('click', '.edit-function', function() {
|
||||
const btn = $(this);
|
||||
|
||||
$('#functionId').val(btn.data('id'));
|
||||
$('#functionName').val(btn.data('function_name'));
|
||||
$('#personFullName').val(btn.data('person_full_name'));
|
||||
$('#phone').val(btn.data('phone'));
|
||||
$('#email').val(btn.data('email'));
|
||||
$('#notes').val(btn.data('notes'));
|
||||
$('#sortOrder').val(btn.data('sort_order'));
|
||||
$('#isActive').val(String(btn.data('is_active')));
|
||||
$('#companyFunctionModalTitle').text('Modifica Funzione');
|
||||
|
||||
$('#companyFunctionModal').modal('show');
|
||||
});
|
||||
|
||||
$('#companyFunctionForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const id = $('#functionId').val();
|
||||
const payload = new URLSearchParams();
|
||||
payload.append('ajax', '1');
|
||||
payload.append('action', id ? 'edit' : 'add');
|
||||
payload.append('id', id);
|
||||
payload.append('function_name', $('#functionName').val().trim());
|
||||
payload.append('person_full_name', $('#personFullName').val().trim());
|
||||
payload.append('phone', $('#phone').val().trim());
|
||||
payload.append('email', $('#email').val().trim());
|
||||
payload.append('notes', $('#notes').val().trim());
|
||||
payload.append('sort_order', $('#sortOrder').val() || '0');
|
||||
payload.append('is_active', $('#isActive').val());
|
||||
|
||||
fetch('', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: payload.toString()
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Salvato!',
|
||||
text: data.message || 'Operazione completata.',
|
||||
confirmButtonColor: '#3085d6'
|
||||
}).then(() => location.reload());
|
||||
} else {
|
||||
Swal.fire('Errore', data.message || 'Impossibile salvare.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
Swal.fire('Errore', 'Errore di comunicazione.', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.delete-function', function() {
|
||||
const id = $(this).data('id');
|
||||
const name = $(this).data('name');
|
||||
|
||||
Swal.fire({
|
||||
title: 'Confermi la cancellazione?',
|
||||
text: name ? ('Funzione: ' + name) : 'La funzione verrà cancellata.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#d33',
|
||||
cancelButtonColor: '#6c757d',
|
||||
confirmButtonText: 'Sì, cancella',
|
||||
cancelButtonText: 'Annulla'
|
||||
}).then((result) => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
const payload = new URLSearchParams();
|
||||
payload.append('ajax', '1');
|
||||
payload.append('action', 'delete');
|
||||
payload.append('id', id);
|
||||
|
||||
fetch('', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: payload.toString()
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Cancellato!',
|
||||
text: data.message || 'Funzione cancellata.',
|
||||
confirmButtonColor: '#3085d6'
|
||||
}).then(() => location.reload());
|
||||
} else {
|
||||
Swal.fire('Errore', data.message || 'Impossibile cancellare.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
Swal.fire('Errore', 'Errore di comunicazione.', 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -47,7 +47,8 @@ $sent = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
|
||||
/* Candidate trainings (with optional override reminder + topic default) */
|
||||
/* Candidate trainings (with optional override reminder + topic default).
|
||||
Only the most recent record per (employee, topic) — older history rows skipped. */
|
||||
$stmt = $pdo->query("
|
||||
SELECT et.id, et.employee_id, et.completed_date, et.next_due_date,
|
||||
et.reminder_days, et.delivered_by,
|
||||
@@ -60,6 +61,13 @@ $stmt = $pdo->query("
|
||||
JOIN employees e ON e.id = et.employee_id
|
||||
LEFT JOIN auth_users au ON au.id = e.auth_user_id
|
||||
WHERE et.next_due_date IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_trainings et2
|
||||
WHERE et2.employee_id = et.employee_id
|
||||
AND et2.training_topic_id = et.training_topic_id
|
||||
AND (et2.completed_date > et.completed_date
|
||||
OR (et2.completed_date = et.completed_date AND et2.id > et.id))
|
||||
)
|
||||
");
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
|
||||
+1978
-773
File diff suppressed because it is too large
Load Diff
+1580
-445
File diff suppressed because it is too large
Load Diff
@@ -59,5 +59,5 @@ $photousername = basename($avatar);
|
||||
require_once(__DIR__ . '/../../languages/en/general.php');
|
||||
|
||||
//include("generalsettings.php");
|
||||
|
||||
require_once __DIR__ . '/permissions_helper.php';
|
||||
?>
|
||||
|
||||
+328
-106
@@ -6,166 +6,388 @@
|
||||
<div>
|
||||
<h4 class="logo-text"><?= htmlspecialchars('ZIBOGOMMA', ENT_QUOTES, 'UTF-8'); ?></h4>
|
||||
</div>
|
||||
<div class="toggle-icon ms-auto"><i class='bx bx-arrow-back'></i>
|
||||
<div class="toggle-icon ms-auto">
|
||||
<i class='bx bx-arrow-back'></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--navigation-->
|
||||
<ul class="metismenu" id="menu">
|
||||
<!-- Production: Admin / User / Superuser / employee-hr / manager -->
|
||||
<?php if (
|
||||
Auth::user()->hasRole('Admin')
|
||||
|| Auth::user()->hasRole('User')
|
||||
|| Auth::user()->hasRole('Superuser')
|
||||
|| Auth::user()->hasRole('employee-hr')
|
||||
|| Auth::user()->hasRole('manager')
|
||||
) : ?>
|
||||
|
||||
<?php if (userCan('production.dashboard.view')) : ?>
|
||||
<li>
|
||||
<a href="production_dashboard.php">
|
||||
<div class="parent-icon"><i class="bx bx-home-alt"></i>
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-home-alt"></i>
|
||||
</div>
|
||||
<div class="menu-title">Dashboard</div>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php
|
||||
$canSeeProgramming =
|
||||
userCan('production.programming.view')
|
||||
|| userCan('templates.dashboard.view')
|
||||
|| userCan('templates.create.view');
|
||||
?>
|
||||
|
||||
<?php if ($canSeeProgramming) : ?>
|
||||
<li>
|
||||
<a href="javascript:;" class="has-arrow">
|
||||
<div class="parent-icon"><i class="bx bx-category"></i>
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-category"></i>
|
||||
</div>
|
||||
<div class="menu-title">Programmazione</div>
|
||||
</a>
|
||||
<ul>
|
||||
<li> <a href="templates_dashboard.php"><i class='bx bx-radio-circle'></i><?= htmlspecialchars($dashtemplate, ENT_QUOTES, 'UTF-8'); ?></a>
|
||||
</li>
|
||||
<li> <a href="insert_template_xls.php"><i class='bx bx-radio-circle'></i><?= htmlspecialchars($insertnewtemplatexls, ENT_QUOTES, 'UTF-8'); ?></a>
|
||||
</li>
|
||||
|
||||
<ul>
|
||||
<?php if (userCan('templates.dashboard.view')) : ?>
|
||||
<li>
|
||||
<a href="templates_dashboard.php">
|
||||
<i class='bx bx-radio-circle'></i>
|
||||
<?= htmlspecialchars($dashtemplate, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('templates.create.view')) : ?>
|
||||
<li>
|
||||
<a href="insert_template_xls.php">
|
||||
<i class='bx bx-radio-circle'></i>
|
||||
<?= htmlspecialchars($insertnewtemplatexls, ENT_QUOTES, 'UTF-8'); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('production.programming.view')) : ?>
|
||||
<li>
|
||||
<a href="produzione_programmazione_drag.php">
|
||||
<i class='bx bx-radio-circle'></i>
|
||||
Programmazione Produzione
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php
|
||||
$canSeeFunctions =
|
||||
userCan('masterdata.mescole.view')
|
||||
|| userCan('masterdata.matrici.view')
|
||||
|| userCan('masterdata.linee.view')
|
||||
|| userCan('masterdata.packaging.view')
|
||||
|| userCan('masterdata.suppliers.view')
|
||||
|| userCan('masterdata.lookup.view')
|
||||
|| userCan('masterdata.worksheets.view');
|
||||
?>
|
||||
|
||||
<?php if ($canSeeFunctions) : ?>
|
||||
<li>
|
||||
<a href="javascript:;" class="has-arrow">
|
||||
<div class="parent-icon"><i class="bx bx-category"></i>
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-category"></i>
|
||||
</div>
|
||||
<div class="menu-title">Funzioni</div>
|
||||
</a>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="mescole.php"><i class='bx bx-radio-circle'></i>Mescole</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="matrici.php"><i class='bx bx-radio-circle'></i>Matrici</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="linee.php"><i class='bx bx-radio-circle'></i>Linee di produzione</a>
|
||||
</li>
|
||||
<?php if (userCan('masterdata.mescole.view')) : ?>
|
||||
<li>
|
||||
<a href="mescole.php">
|
||||
<i class='bx bx-radio-circle'></i>Mescole
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('masterdata.matrici.view')) : ?>
|
||||
<li>
|
||||
<a href="matrici.php">
|
||||
<i class='bx bx-radio-circle'></i>Matrici
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('masterdata.linee.view')) : ?>
|
||||
<li>
|
||||
<a href="linee.php">
|
||||
<i class='bx bx-radio-circle'></i>Linee di produzione
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('masterdata.packaging.view')) : ?>
|
||||
<li>
|
||||
<a href="packaging_items.php">
|
||||
<i class='bx bx-radio-circle'></i>Imballaggi
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('masterdata.suppliers.view')) : ?>
|
||||
<li>
|
||||
<a href="suppliers.php">
|
||||
<i class='bx bx-radio-circle'></i>Suppliers
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('masterdata.lookup.view')) : ?>
|
||||
<li>
|
||||
<a href="lookup_values.php">
|
||||
<i class='bx bx-radio-circle'></i>Setup
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('masterdata.worksheets.view')) : ?>
|
||||
<li>
|
||||
<a href="worksheets.php">
|
||||
<i class='bx bx-radio-circle'></i>Fogli di lavoro
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Personale: only Admin / Superuser / employee-hr / manager (not regular User) -->
|
||||
<?php if (
|
||||
Auth::user()->hasRole('Admin')
|
||||
|| Auth::user()->hasRole('Superuser')
|
||||
|| Auth::user()->hasRole('employee-hr')
|
||||
|| Auth::user()->hasRole('manager')
|
||||
) : ?>
|
||||
|
||||
<?php
|
||||
$canSeeProduction =
|
||||
userCan('production.line_view.view')
|
||||
|| userCan('production.stats.view')
|
||||
|| userCan('production.manager.view')
|
||||
|| userCan('production.manager_stats.view')
|
||||
|| userCan('warehouse.dashboard.view');
|
||||
?>
|
||||
|
||||
<?php if ($canSeeProduction) : ?>
|
||||
<li>
|
||||
<a href="javascript:;" class="has-arrow">
|
||||
<div class="parent-icon"><i class="bx bx-group"></i>
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-line-chart"></i>
|
||||
</div>
|
||||
<div class="menu-title">Produzione</div>
|
||||
</a>
|
||||
|
||||
<ul>
|
||||
<?php if (userCan('production.line_view.view')) : ?>
|
||||
<li>
|
||||
<a href="production_line_view2.php">
|
||||
<i class='bx bx-radio-circle'></i>Line View
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('production.stats.view')) : ?>
|
||||
<li>
|
||||
<a href="production_stats.php">
|
||||
<i class='bx bx-radio-circle'></i>Statistiche
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('production.manager.view')) : ?>
|
||||
<li>
|
||||
<a href="manager_produzione.php">
|
||||
<i class='bx bx-radio-circle'></i>Manager
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('production.manager_stats.view')) : ?>
|
||||
<li>
|
||||
<a href="manager_stats.php">
|
||||
<i class='bx bx-radio-circle'></i>Manager Stats
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('warehouse.dashboard.view')) : ?>
|
||||
<li>
|
||||
<a href="warehouse_dashboard.php">
|
||||
<i class='bx bx-radio-circle'></i>Magazzino
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php
|
||||
$canSeeServices =
|
||||
userCan('services.status.view')
|
||||
|| userCan('services.pause_reasons.view')
|
||||
|| userCan('services.tools.view');
|
||||
?>
|
||||
|
||||
<?php if ($canSeeServices) : ?>
|
||||
<li>
|
||||
<a href="javascript:;" class="has-arrow">
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-wrench"></i>
|
||||
</div>
|
||||
<div class="menu-title">Servizi</div>
|
||||
</a>
|
||||
|
||||
<ul>
|
||||
<?php if (userCan('services.status.view')) : ?>
|
||||
<li>
|
||||
<a href="production_status.php">
|
||||
<i class='bx bx-radio-circle'></i>Status
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('services.pause_reasons.view')) : ?>
|
||||
<li>
|
||||
<a href="production_pause_reasons.php">
|
||||
<i class='bx bx-radio-circle'></i>Cause di Pausa
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('services.tools.view')) : ?>
|
||||
<li>
|
||||
<a href="production_tools.php">
|
||||
<i class='bx bx-radio-circle'></i>Attrezzature
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php
|
||||
$canSeeHr =
|
||||
userCan('hr.employees.view')
|
||||
|| userCan('hr.departments.view')
|
||||
|| userCan('hr.job_roles.view')
|
||||
|| userCan('hr.training_topics.view')
|
||||
|| userCan('hr.trainings.view')
|
||||
|| userCan('hr.skills.view');
|
||||
?>
|
||||
|
||||
<?php if ($canSeeHr) : ?>
|
||||
<li>
|
||||
<a href="javascript:;" class="has-arrow">
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-group"></i>
|
||||
</div>
|
||||
<div class="menu-title">Personale</div>
|
||||
</a>
|
||||
|
||||
<ul>
|
||||
<?php if (userCan('hr.employees.view')) : ?>
|
||||
<li>
|
||||
<a href="employees.php">
|
||||
<i class='bx bx-radio-circle'></i>Dipendenti
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="job-roles.php">
|
||||
<i class='bx bx-radio-circle'></i>Mansioni
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="ppe-items.php">
|
||||
<i class='bx bx-radio-circle'></i>DPI
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('hr.departments.view')) : ?>
|
||||
<li>
|
||||
<a href="departments.php">
|
||||
<i class='bx bx-radio-circle'></i>Reparti
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
|
||||
<?php if (userCan('hr.trainings.view')) : ?>
|
||||
<li>
|
||||
<a href="trainings.php">
|
||||
<i class='bx bx-radio-circle'></i>Gestione Formazione
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('hr.skills.view')) : ?>
|
||||
<li>
|
||||
<a href="skills.php">
|
||||
<i class='bx bx-radio-circle'></i>Skills
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php if (userCan('deadlines.view')) : ?>
|
||||
<li>
|
||||
<a href="javascript:;" class="has-arrow">
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-calendar-check"></i>
|
||||
</div>
|
||||
<div class="menu-title">Scadenzario</div>
|
||||
</a>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="employees.php"><i class='bx bx-radio-circle'></i>Dipendenti</a>
|
||||
<a href="scadenzario/index.php">
|
||||
<i class='bx bx-radio-circle'></i>Lista Scadenze
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="scadenzario/calendar.php">
|
||||
<i class='bx bx-radio-circle'></i>Calendario
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="departments.php"><i class='bx bx-radio-circle'></i>Reparti</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="job_roles.php"><i class='bx bx-radio-circle'></i>Mansioni</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="training_topics.php"><i class='bx bx-radio-circle'></i>Corsi di Formazione</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="trainings.php"><i class='bx bx-radio-circle'></i>Storico Formazione</a>
|
||||
<a href="scadenzario/functions/index.php">
|
||||
<i class='bx bx-radio-circle'></i>Funzioni Aziendali
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Scadenzario: Admin / User / Superuser / employee-hr / manager -->
|
||||
<?php if (
|
||||
Auth::user()->hasRole('Admin')
|
||||
|| Auth::user()->hasRole('User')
|
||||
|| Auth::user()->hasRole('Superuser')
|
||||
|| Auth::user()->hasRole('employee-hr')
|
||||
|| Auth::user()->hasRole('manager')
|
||||
) : ?>
|
||||
<li>
|
||||
<a href="javascript:;" class="has-arrow">
|
||||
<div class="parent-icon"><i class="bx bx-calendar-check"></i>
|
||||
</div>
|
||||
<div class="menu-title">Scadenzario</div>
|
||||
</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="scadenzario/index.php"><i class='bx bx-radio-circle'></i>Lista Scadenze</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="scadenzario/calendar.php"><i class='bx bx-radio-circle'></i>Calendario</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="menu-label">Others</li>
|
||||
<li class="menu-label">Others</li>
|
||||
|
||||
<li>
|
||||
<a href="https://helpdesk.cesoft.io" target="_blank">
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-support"></i>
|
||||
</div>
|
||||
<div class="menu-title">Support</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<a href="https://helpdesk.cesoft.io" target="_blank">
|
||||
<div class="parent-icon"><i class="bx bx-support"></i>
|
||||
</div>
|
||||
<div class="menu-title">Support</div>
|
||||
</a>
|
||||
</li>
|
||||
<?php
|
||||
endif; ?>
|
||||
<!-- admin, superuser menù -->
|
||||
<?php if ((Auth::user()->hasRole('Admin')) || (Auth::user()->hasRole('Superuser'))) : ?>
|
||||
<?php
|
||||
endif; ?>
|
||||
<!-- admin menù -->
|
||||
<?php if (Auth::user()->hasRole('Admin')) : ?>
|
||||
<?php if (userCan('users.manage')) : ?>
|
||||
<li class="menu-label">Admin Menù</li>
|
||||
|
||||
<li>
|
||||
<a href="../" target="_blank">
|
||||
<div class="parent-icon"><i class="bx bx-support"></i>
|
||||
<div class="parent-icon">
|
||||
<i class="bx bx-user-circle"></i>
|
||||
</div>
|
||||
<div class="menu-title">User Management</div>
|
||||
</a>
|
||||
</li>
|
||||
<!-- <li>
|
||||
<a href="template/index.html" target="_blank">
|
||||
<div class="parent-icon"><i class="bx bx-support"></i>
|
||||
</div>
|
||||
<div class="menu-title">Template</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://codervent.com/rocker/documentation/index.html" target="_blank">
|
||||
<div class="parent-icon"><i class="bx bx-folder"></i>
|
||||
</div>
|
||||
<div class="menu-title">Documentation</div>
|
||||
</a>
|
||||
</li> -->
|
||||
<?php
|
||||
endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
</ul>
|
||||
<!--end navigation-->
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
if (!function_exists('userCan')) {
|
||||
/**
|
||||
* Check if current user has a Vanguard permission.
|
||||
* Uses Vanguard native method if available, otherwise falls back to DB check.
|
||||
*/
|
||||
function userCan($permissionName)
|
||||
{
|
||||
global $kindofrole;
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vanguard / Laravel-style methods, depending on installed version/customization.
|
||||
if (method_exists($user, 'hasPermission')) {
|
||||
return $user->hasPermission($permissionName);
|
||||
}
|
||||
|
||||
if (method_exists($user, 'hasPermissionTo')) {
|
||||
return $user->hasPermissionTo($permissionName);
|
||||
}
|
||||
|
||||
if (method_exists($user, 'can')) {
|
||||
return $user->can($permissionName);
|
||||
}
|
||||
|
||||
// Fallback: direct DB check using existing Vanguard tables.
|
||||
static $permissions = null;
|
||||
|
||||
if ($permissions === null) {
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT p.name
|
||||
FROM auth_permissions p
|
||||
INNER JOIN auth_permission_role pr ON pr.permission_id = p.id
|
||||
WHERE pr.role_id = ?
|
||||
");
|
||||
$stmt->execute([(int)$kindofrole]);
|
||||
|
||||
$permissions = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
return in_array($permissionName, $permissions, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('visibleButtons')) {
|
||||
/**
|
||||
* Filter visible buttons.
|
||||
*/
|
||||
function visibleButtons(array $buttons)
|
||||
{
|
||||
return array_values(array_filter($buttons, function ($button) {
|
||||
return empty($button['permission']) || userCan($button['permission']);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item d-flex align-items-center" href="../users">
|
||||
<a class="dropdown-item d-flex align-items-center" href="user_settings.php">
|
||||
<i class="bx bx-user fs-5"></i><span>Utente</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -117,4 +117,4 @@
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
@@ -19,6 +19,7 @@ if (!$__trWidgetHr) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Only the most recent record per (employee, topic) — older history rows ignored. */
|
||||
$__trRows = $pdo->query("
|
||||
SELECT et.id,
|
||||
et.next_due_date,
|
||||
@@ -27,6 +28,13 @@ $__trRows = $pdo->query("
|
||||
FROM employee_trainings et
|
||||
JOIN training_topics tt ON tt.id = et.training_topic_id
|
||||
WHERE et.next_due_date IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_trainings et2
|
||||
WHERE et2.employee_id = et.employee_id
|
||||
AND et2.training_topic_id = et.training_topic_id
|
||||
AND (et2.completed_date > et.completed_date
|
||||
OR (et2.completed_date = et.completed_date AND et2.id > et.id))
|
||||
)
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$__expiredCount = 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,205 @@
|
||||
<?php include('include/headscript.php'); ?>
|
||||
<?php
|
||||
$dashboardSections = [
|
||||
[
|
||||
'id' => 'secOperativo',
|
||||
'title' => 'Operativo',
|
||||
'subtitle' => 'Azioni principali di produzione e attività in scadenza',
|
||||
'icon' => '🚀',
|
||||
'open' => true,
|
||||
'buttons' => [
|
||||
[
|
||||
'label' => 'Programmazione',
|
||||
'icon' => '🗓️',
|
||||
'class' => 'btn-programmazione',
|
||||
'url' => 'produzione_programmazione_drag.php',
|
||||
'permission' => 'production.programming.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Line View',
|
||||
'icon' => '⚙️',
|
||||
'class' => 'btn-status',
|
||||
'url' => 'production_line_view2.php',
|
||||
'permission' => 'production.line_view.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Statistiche',
|
||||
'icon' => '📈',
|
||||
'class' => 'btn-statistiche',
|
||||
'url' => 'production_stats.php',
|
||||
'permission' => 'production.stats.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Manager',
|
||||
'icon' => '👔',
|
||||
'class' => 'btn-manager',
|
||||
'url' => 'manager_produzione.php',
|
||||
'permission' => 'production.manager.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Manager Stats',
|
||||
'icon' => '📊',
|
||||
'class' => 'btn-manager-stats',
|
||||
'url' => 'manager_stats.php',
|
||||
'permission' => 'production.manager_stats.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Magazzino',
|
||||
'icon' => '📦',
|
||||
'class' => 'btn-magazzino',
|
||||
'url' => 'warehouse_dashboard.php',
|
||||
'permission' => 'warehouse.dashboard.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Smart-Alert',
|
||||
'icon' => '⏰',
|
||||
'class' => 'btn-scadenziario',
|
||||
'url' => 'scadenzario/index.php',
|
||||
'permission' => 'deadlines.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'secAnagrafiche',
|
||||
'title' => 'Anagrafiche',
|
||||
'subtitle' => 'Dati di base e setup di produzione',
|
||||
'icon' => '🗂️',
|
||||
'open' => false,
|
||||
'buttons' => [
|
||||
[
|
||||
'label' => 'Mescole',
|
||||
'icon' => '⚗️',
|
||||
'class' => 'btn-mescole',
|
||||
'url' => 'mescole.php',
|
||||
'permission' => 'masterdata.mescole.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Elenco Profili',
|
||||
'icon' => '🧩',
|
||||
'class' => 'btn-matrici',
|
||||
'url' => 'matrici.php',
|
||||
'permission' => 'masterdata.matrici.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Linee Produzione',
|
||||
'icon' => '🏭',
|
||||
'class' => 'btn-linee',
|
||||
'url' => 'linee.php',
|
||||
'permission' => 'masterdata.linee.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Imballaggi',
|
||||
'icon' => '📦',
|
||||
'class' => 'btn-setup',
|
||||
'url' => 'packaging_items.php',
|
||||
'permission' => 'masterdata.packaging.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Suppliers',
|
||||
'icon' => '🏷️',
|
||||
'class' => 'btn-setup',
|
||||
'url' => 'suppliers.php',
|
||||
'permission' => 'masterdata.suppliers.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Setup',
|
||||
'icon' => '⚙️',
|
||||
'class' => 'btn-setup',
|
||||
'url' => 'lookup_values.php',
|
||||
'permission' => 'masterdata.lookup.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Fogli di lavoro',
|
||||
'icon' => '🗒️',
|
||||
'class' => 'btn-setup',
|
||||
'url' => 'worksheets.php',
|
||||
'permission' => 'masterdata.worksheets.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'secServizi',
|
||||
'title' => 'Servizi',
|
||||
'subtitle' => 'Status, cause pausa, attrezzature',
|
||||
'icon' => '🧰',
|
||||
'open' => false,
|
||||
'buttons' => [
|
||||
[
|
||||
'label' => 'Status',
|
||||
'icon' => '📋',
|
||||
'class' => 'btn-setup',
|
||||
'url' => 'production_status.php',
|
||||
'permission' => 'services.status.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Cause di Pausa',
|
||||
'icon' => '🛑',
|
||||
'class' => 'btn-problem',
|
||||
'url' => 'production_pause_reasons.php',
|
||||
'permission' => 'services.pause_reasons.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Attrezzature',
|
||||
'icon' => '🛠️',
|
||||
'class' => 'btn-tools',
|
||||
'url' => 'production_tools.php',
|
||||
'permission' => 'services.tools.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'secPersonale',
|
||||
'title' => 'Personale',
|
||||
'subtitle' => 'Dipendenti, formazione, skill',
|
||||
'icon' => '👥',
|
||||
'open' => false,
|
||||
'buttons' => [
|
||||
[
|
||||
'label' => 'Dipendenti',
|
||||
'icon' => '👥',
|
||||
'class' => 'btn-employees',
|
||||
'url' => 'employees.php',
|
||||
'permission' => 'hr.employees.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Mansioni',
|
||||
'icon' => '🧩',
|
||||
'class' => 'btn-setup',
|
||||
'url' => 'job-roles.php',
|
||||
'permission' => 'hr.employees.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Departments',
|
||||
'icon' => '🏢',
|
||||
'class' => 'btn-departments',
|
||||
'url' => 'departments.php',
|
||||
'permission' => 'hr.departments.view',
|
||||
],
|
||||
[
|
||||
'label' => 'DPI',
|
||||
'icon' => '🦺',
|
||||
'class' => 'btn-setup',
|
||||
'url' => 'ppe-items.php',
|
||||
'permission' => 'hr.employees.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Gestione Formazione',
|
||||
'icon' => '🎓',
|
||||
'class' => 'btn-setup',
|
||||
'url' => 'trainings.php',
|
||||
'permission' => 'hr.trainings.view',
|
||||
],
|
||||
[
|
||||
'label' => 'Skills',
|
||||
'icon' => '🧠',
|
||||
'class' => 'btn-setup',
|
||||
'url' => 'skills.php',
|
||||
'permission' => 'hr.skills.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
@@ -7,7 +208,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||||
<?php include('cssinclude.php'); ?>
|
||||
<title>Dashboard Produzione - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
<title>Dashboard <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
|
||||
<!-- Bootstrap + jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
@@ -314,52 +515,104 @@
|
||||
margin-bottom: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
.my-deadlines-widgets:empty { display: none; }
|
||||
|
||||
.my-deadlines-widgets:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Each widget wraps itself in .my-deadlines-widgets; collapse the nested
|
||||
wrapper so all cards flow into the outer flex (single row). */
|
||||
.my-deadlines-widgets .my-deadlines-widgets {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-radius: 0.6rem;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.my-deadlines-widgets .mdw { flex: 1 1 calc(50% - 0.375rem); }
|
||||
.my-deadlines-widgets .mdw {
|
||||
flex: 1 1 calc(50% - 0.375rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.my-deadlines-widgets .mdw { flex: 1 1 100%; }
|
||||
.my-deadlines-widgets .mdw {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
.my-deadlines-widgets .mdw:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); color: #fff; }
|
||||
.my-deadlines-widgets .mdw-red { background: linear-gradient(135deg, #dc3545 0%, #b02a37 100%); }
|
||||
.my-deadlines-widgets .mdw-orange { background: linear-gradient(135deg, #e8930c 0%, #c77a00 100%); }
|
||||
.my-deadlines-widgets .mdw-gray { background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); }
|
||||
|
||||
.my-deadlines-widgets .mdw:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-red {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #b02a37 100%);
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-orange {
|
||||
background: linear-gradient(135deg, #e8930c 0%, #c77a00 100%);
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-gray {
|
||||
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-icon {
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255,255,255,0.22); font-size: 1.05rem; flex-shrink: 0;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
font-size: 1.05rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.my-deadlines-widgets .mdw-body { flex: 1; line-height: 1.2; min-width: 0; }
|
||||
.my-deadlines-widgets .mdw-count { font-size: 1.5rem; font-weight: 700; }
|
||||
|
||||
.my-deadlines-widgets .mdw-body {
|
||||
flex: 1;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-count {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-label {
|
||||
font-size: 0.78rem; opacity: 0.95;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.95;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-arrow {
|
||||
opacity: 0.7;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.my-deadlines-widgets .mdw-arrow { opacity: 0.7; font-size: 0.85rem; flex-shrink: 0; }
|
||||
</style>
|
||||
<div class="my-deadlines-widgets">
|
||||
<?php include(__DIR__ . '/scadenzario/include/my_deadlines_widget.php'); ?>
|
||||
<?php include(__DIR__ . '/include/training_widget.php'); ?>
|
||||
</div>
|
||||
|
||||
<h3 class="dashboard-title">Dashboard Produzione</h3>
|
||||
<h3 class="dashboard-title">Dashboard</h3>
|
||||
|
||||
<!-- ===== STATISTICHE PRINCIPALI ===== -->
|
||||
<div class="stats-row">
|
||||
@@ -396,188 +649,71 @@
|
||||
<!-- ===== SEZIONI COLLASSABILI ===== -->
|
||||
<div class="sections-wrap" id="prodAccordion">
|
||||
|
||||
<!-- OPERATIVO -->
|
||||
<div class="section-card">
|
||||
<button type="button" class="section-header" data-bs-toggle="collapse" data-bs-target="#secOperativo" aria-expanded="true" aria-controls="secOperativo">
|
||||
<div class="section-left">
|
||||
<div class="section-icon">🚀</div>
|
||||
<div style="min-width:0;">
|
||||
<p class="section-title">Operativo</p>
|
||||
<p class="section-subtitle">Azioni principali di produzione e attività in scadenza</p>
|
||||
<?php
|
||||
$hasVisibleSections = false;
|
||||
|
||||
foreach ($dashboardSections as $section):
|
||||
$buttons = visibleButtons($section['buttons']);
|
||||
|
||||
// If no visible buttons are available, do not show the section.
|
||||
if (empty($buttons)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasVisibleSections = true;
|
||||
|
||||
$sectionId = htmlspecialchars($section['id'], ENT_QUOTES, 'UTF-8');
|
||||
$isOpen = !empty($section['open']);
|
||||
?>
|
||||
|
||||
<div class="section-card">
|
||||
<button type="button"
|
||||
class="section-header"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#<?= $sectionId ?>"
|
||||
aria-expanded="<?= $isOpen ? 'true' : 'false' ?>"
|
||||
aria-controls="<?= $sectionId ?>">
|
||||
<div class="section-left">
|
||||
<div class="section-icon"><?= htmlspecialchars($section['icon'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div style="min-width:0;">
|
||||
<p class="section-title"><?= htmlspecialchars($section['title'], ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<p class="section-subtitle"><?= htmlspecialchars($section['subtitle'], ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chev">⌄</div>
|
||||
</button>
|
||||
<div class="chev">⌄</div>
|
||||
</button>
|
||||
|
||||
<div id="secOperativo" class="collapse show" data-bs-parent="#prodAccordion">
|
||||
<div class="section-body">
|
||||
<div class="dashboard-grid">
|
||||
<button class="dash-btn btn-programmazione" onclick="location.href='produzione_programmazione_drag.php'">
|
||||
<div class="dash-icon">🗓️</div>
|
||||
<div>Programmazione</div>
|
||||
</button>
|
||||
<div id="<?= $sectionId ?>"
|
||||
class="collapse <?= $isOpen ? 'show' : '' ?>"
|
||||
data-bs-parent="#prodAccordion">
|
||||
<div class="section-body">
|
||||
<div class="dashboard-grid">
|
||||
|
||||
<?php foreach ($buttons as $button): ?>
|
||||
<button class="dash-btn <?= htmlspecialchars($button['class'], ENT_QUOTES, 'UTF-8') ?>"
|
||||
onclick="location.href='<?= htmlspecialchars($button['url'], ENT_QUOTES, 'UTF-8') ?>'">
|
||||
<div class="dash-icon"><?= htmlspecialchars($button['icon'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div><?= htmlspecialchars($button['label'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
|
||||
|
||||
<button class="dash-btn btn-status" onclick="location.href='production_line_view2.php'">
|
||||
<div class="dash-icon">⚙️</div>
|
||||
<div>Line View</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-statistiche" onclick="location.href='production_stats.php'">
|
||||
<div class="dash-icon">📈</div>
|
||||
<div>Statistiche</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-manager" onclick="location.href='manager_produzione.php'">
|
||||
<div class="dash-icon">👔</div>
|
||||
<div>Manager</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-manager-stats" onclick="location.href='manager_stats.php'">
|
||||
<div class="dash-icon">📊</div>
|
||||
<div>Manager Stats</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-magazzino" onclick="location.href='warehouse_dashboard.php'">
|
||||
<div class="dash-icon">📦</div>
|
||||
<div>Magazzino</div>
|
||||
|
||||
</button>
|
||||
<button class="dash-btn btn-scadenziario" onclick="location.href='scadenzario/index.php'">
|
||||
<div class="dash-icon">⏰</div>
|
||||
<div>Scadenziario</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ANAGRAFICHE -->
|
||||
<div class="section-card">
|
||||
<button type="button" class="section-header" data-bs-toggle="collapse" data-bs-target="#secAnagrafiche" aria-expanded="false" aria-controls="secAnagrafiche">
|
||||
<div class="section-left">
|
||||
<div class="section-icon">🗂️</div>
|
||||
<div style="min-width:0;">
|
||||
<p class="section-title">Anagrafiche</p>
|
||||
<p class="section-subtitle">Dati di base e setup di produzione</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chev">⌄</div>
|
||||
</button>
|
||||
<div id="secAnagrafiche" class="collapse" data-bs-parent="#prodAccordion">
|
||||
<div class="section-body">
|
||||
<div class="dashboard-grid">
|
||||
<button class="dash-btn btn-mescole" onclick="location.href='mescole.php'">
|
||||
<div class="dash-icon">⚗️</div>
|
||||
<div>Mescole</div>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<button class="dash-btn btn-matrici" onclick="location.href='matrici.php'">
|
||||
<div class="dash-icon">🧩</div>
|
||||
<div>Elenco Profili</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-linee" onclick="location.href='linee.php'">
|
||||
<div class="dash-icon">🏭</div>
|
||||
<div>Linee Produzione</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-setup" onclick="location.href='packaging_items.php'">
|
||||
<div class="dash-icon">📦</div>
|
||||
<div>Imballaggi</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-setup" onclick="location.href='suppliers.php'">
|
||||
<div class="dash-icon">🏷️</div>
|
||||
<div>Suppliers</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-setup" onclick="location.href='lookup_values.php'">
|
||||
<div class="dash-icon">⚙️</div>
|
||||
<div>Setup</div>
|
||||
</button>
|
||||
<button class="dash-btn btn-setup" onclick="location.href='worksheets.php'">
|
||||
<div class="dash-icon">🗒️</div>
|
||||
<div>Fogli di lavoro</div>
|
||||
</button>
|
||||
</div>
|
||||
<?php if (!$hasVisibleSections): ?>
|
||||
<div class="section-card">
|
||||
<div class="section-body text-center">
|
||||
Nessuna sezione disponibile per il tuo profilo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- QUALITÀ / SERVIZI -->
|
||||
<div class="section-card">
|
||||
<button type="button" class="section-header" data-bs-toggle="collapse" data-bs-target="#secServizi" aria-expanded="false" aria-controls="secServizi">
|
||||
<div class="section-left">
|
||||
<div class="section-icon">🧰</div>
|
||||
<div style="min-width:0;">
|
||||
<p class="section-title">Servizi</p>
|
||||
<p class="section-subtitle">Status, cause pausa, attrezzature</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chev">⌄</div>
|
||||
</button>
|
||||
|
||||
<div id="secServizi" class="collapse" data-bs-parent="#prodAccordion">
|
||||
<div class="section-body">
|
||||
<div class="dashboard-grid">
|
||||
<button class="dash-btn btn-setup" onclick="location.href='production_status.php'">
|
||||
<div class="dash-icon">📋</div>
|
||||
<div>Status</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-problem" onclick="location.href='production_pause_reasons.php'">
|
||||
<div class="dash-icon">🛑</div>
|
||||
<div>Cause di Pausa</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-tools" onclick="location.href='production_tools.php'">
|
||||
<div class="dash-icon">🛠️</div>
|
||||
<div>Attrezzature</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PERSONALE -->
|
||||
<div class="section-card">
|
||||
<button type="button" class="section-header" data-bs-toggle="collapse" data-bs-target="#secPersonale" aria-expanded="false" aria-controls="secPersonale">
|
||||
<div class="section-left">
|
||||
<div class="section-icon">👥</div>
|
||||
<div style="min-width:0;">
|
||||
<p class="section-title">Personale</p>
|
||||
<p class="section-subtitle">Dipendenti, skill</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chev">⌄</div>
|
||||
</button>
|
||||
|
||||
<div id="secPersonale" class="collapse" data-bs-parent="#prodAccordion">
|
||||
<div class="section-body">
|
||||
<div class="dashboard-grid">
|
||||
|
||||
<button class="dash-btn btn-employees" onclick="location.href='employees.php'">
|
||||
<div class="dash-icon">👥</div>
|
||||
<div>Employees</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-departments" onclick="location.href='departments.php'">
|
||||
<div class="dash-icon">🏢</div>
|
||||
<div>Departments</div>
|
||||
</button>
|
||||
|
||||
<button class="dash-btn btn-setup" onclick="location.href='skills.php'">
|
||||
<div class="dash-icon">🧠</div>
|
||||
<div>Skills</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /sections-wrap -->
|
||||
</div>
|
||||
<!-- /sections-wrap -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
include('../../include/headscript.php');
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
function jsonResponse(array $data): void
|
||||
{
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
function normalizeNullableInt($value): ?int
|
||||
{
|
||||
return (isset($value) && $value !== '') ? (int)$value : null;
|
||||
}
|
||||
|
||||
try {
|
||||
$isHrManager = Auth::user()->hasRole('Admin')
|
||||
|| Auth::user()->hasRole('Superuser')
|
||||
|| Auth::user()->hasRole('employee-hr')
|
||||
|| Auth::user()->hasRole('manager');
|
||||
|
||||
if (!$isHrManager) {
|
||||
jsonResponse(['success' => false, 'message' => 'Non autorizzato.']);
|
||||
}
|
||||
|
||||
$employeeId = (int)($_POST['employee_id'] ?? 0);
|
||||
$firstName = trim($_POST['first_name'] ?? '');
|
||||
$lastName = trim($_POST['last_name'] ?? '');
|
||||
$employeeCode = trim($_POST['employee_code'] ?? '');
|
||||
$hireDate = trim($_POST['hire_date'] ?? '');
|
||||
$address = trim($_POST['address'] ?? '');
|
||||
$phone = trim($_POST['phone'] ?? '');
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$departmentId = normalizeNullableInt($_POST['department_id'] ?? '');
|
||||
$status = trim($_POST['status'] ?? 'active');
|
||||
$authUserId = normalizeNullableInt($_POST['auth_user_id'] ?? '');
|
||||
$roleId = normalizeNullableInt($_POST['role_id'] ?? '');
|
||||
|
||||
$jobSubRoleIds = $_POST['job_sub_role_ids'] ?? [];
|
||||
if (!is_array($jobSubRoleIds)) {
|
||||
$jobSubRoleIds = [$jobSubRoleIds];
|
||||
}
|
||||
|
||||
$jobSubRoleIds = array_values(array_unique(array_filter(array_map('intval', $jobSubRoleIds))));
|
||||
|
||||
if ($employeeId <= 0) {
|
||||
jsonResponse(['success' => false, 'message' => 'ID dipendente non valido.']);
|
||||
}
|
||||
|
||||
if ($firstName === '' || $lastName === '') {
|
||||
jsonResponse(['success' => false, 'message' => 'Nome e cognome sono obbligatori.']);
|
||||
}
|
||||
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
jsonResponse(['success' => false, 'message' => 'Email non valida.']);
|
||||
}
|
||||
|
||||
if (!in_array($status, ['active', 'inactive', 'suspended'], true)) {
|
||||
$status = 'active';
|
||||
}
|
||||
|
||||
$stmtEmployee = $pdo->prepare('SELECT id FROM employees WHERE id = ? LIMIT 1');
|
||||
$stmtEmployee->execute([$employeeId]);
|
||||
if (!$stmtEmployee->fetchColumn()) {
|
||||
jsonResponse(['success' => false, 'message' => 'Dipendente non trovato.']);
|
||||
}
|
||||
|
||||
$primaryJobRoleId = null;
|
||||
$primaryJobSubRoleId = null;
|
||||
|
||||
if ($jobSubRoleIds) {
|
||||
$placeholders = implode(',', array_fill(0, count($jobSubRoleIds), '?'));
|
||||
$stmtSubRoles = $pdo->prepare("\n SELECT id, job_role_id\n FROM job_sub_roles\n WHERE id IN ($placeholders)\n AND is_active = 1\n ");
|
||||
$stmtSubRoles->execute($jobSubRoleIds);
|
||||
$validRows = $stmtSubRoles->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$validMap = [];
|
||||
foreach ($validRows as $row) {
|
||||
$validMap[(int)$row['id']] = (int)$row['job_role_id'];
|
||||
}
|
||||
|
||||
$jobSubRoleIds = array_values(array_filter($jobSubRoleIds, static function ($id) use ($validMap) {
|
||||
return isset($validMap[(int)$id]);
|
||||
}));
|
||||
|
||||
if ($jobSubRoleIds) {
|
||||
$primaryJobSubRoleId = (int)$jobSubRoleIds[0];
|
||||
$primaryJobRoleId = $validMap[$primaryJobSubRoleId] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$stmt = $pdo->prepare("\n UPDATE employees\n SET first_name = :first_name,\n last_name = :last_name,\n employee_code = :employee_code,\n hire_date = :hire_date,\n address = :address,\n phone = :phone,\n email = :email,\n department_id = :department_id,\n job_role_id = :job_role_id,\n job_sub_role_id = :job_sub_role_id,\n status = :status,\n auth_user_id = :auth_user_id,\n updated_at = NOW()\n WHERE id = :employee_id\n ");
|
||||
$stmt->execute([
|
||||
'first_name' => $firstName,
|
||||
'last_name' => $lastName,
|
||||
'employee_code' => $employeeCode !== '' ? $employeeCode : null,
|
||||
'hire_date' => $hireDate !== '' ? $hireDate : null,
|
||||
'address' => $address !== '' ? $address : null,
|
||||
'phone' => $phone !== '' ? $phone : null,
|
||||
'email' => $email !== '' ? $email : null,
|
||||
'department_id' => $departmentId,
|
||||
'job_role_id' => $primaryJobRoleId,
|
||||
'job_sub_role_id' => $primaryJobSubRoleId,
|
||||
'status' => $status,
|
||||
'auth_user_id' => $authUserId,
|
||||
'employee_id' => $employeeId,
|
||||
]);
|
||||
|
||||
$stmtDelete = $pdo->prepare('DELETE FROM employee_job_sub_roles WHERE employee_id = ?');
|
||||
$stmtDelete->execute([$employeeId]);
|
||||
|
||||
if ($jobSubRoleIds) {
|
||||
$stmtInsert = $pdo->prepare("\n INSERT INTO employee_job_sub_roles\n (employee_id, job_sub_role_id, is_primary, created_at)\n VALUES\n (:employee_id, :job_sub_role_id, :is_primary, NOW())\n ");
|
||||
|
||||
foreach ($jobSubRoleIds as $index => $jobSubRoleId) {
|
||||
$stmtInsert->execute([
|
||||
'employee_id' => $employeeId,
|
||||
'job_sub_role_id' => (int)$jobSubRoleId,
|
||||
'is_primary' => $index === 0 ? 1 : 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($authUserId !== null && $roleId !== null) {
|
||||
$checkRole = $pdo->prepare('SELECT COUNT(*) FROM auth_roles WHERE id = ?');
|
||||
$checkRole->execute([$roleId]);
|
||||
|
||||
if ((int)$checkRole->fetchColumn() > 0) {
|
||||
$stmtRole = $pdo->prepare('UPDATE auth_users SET role_id = :role_id, updated_at = NOW() WHERE id = :auth_user_id');
|
||||
$stmtRole->execute([
|
||||
'role_id' => $roleId,
|
||||
'auth_user_id' => $authUserId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
jsonResponse(['success' => true]);
|
||||
} catch (Throwable $e) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
@@ -4,12 +4,19 @@ header('Content-Type: application/json');
|
||||
require_once(__DIR__ . '/../../class/db-functions.php');
|
||||
|
||||
try {
|
||||
if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
|
||||
$rawId = $_POST['id'] ?? $_GET['id'] ?? null;
|
||||
if ($rawId === null || !is_numeric($rawId)) {
|
||||
echo json_encode(['success' => false, 'message' => 'ID non valido.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)$_GET['id'];
|
||||
$id = (int)$rawId;
|
||||
|
||||
// Whether to create the next (recurring) deadline. Absent or '1' => create; '0' => complete only.
|
||||
$createNext = ($_POST['create_next'] ?? '1') !== '0';
|
||||
|
||||
// Whether to carry the attachment links over to the new deadline. Default ON ("default all activate").
|
||||
$copyAttachments = ($_POST['copy_attachments'] ?? '1') !== '0';
|
||||
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
@@ -34,11 +41,13 @@ try {
|
||||
->execute([$id, $currentUserId]);
|
||||
|
||||
$newId = null;
|
||||
$newDueDate = null;
|
||||
|
||||
// If recurring, create next deadline
|
||||
if ($deadline['recurrence_type'] !== 'once') {
|
||||
// If recurring AND the user asked for it, create the next deadline
|
||||
if ($deadline['recurrence_type'] !== 'once' && $createNext) {
|
||||
$dueDate = new DateTime($deadline['due_date']);
|
||||
$checkDate = $deadline['check_date'] ? new DateTime($deadline['check_date']) : null;
|
||||
$documentDate = $deadline['document_date'] ? new DateTime($deadline['document_date']) : null;
|
||||
|
||||
switch ($deadline['recurrence_type']) {
|
||||
case 'monthly': $interval = new DateInterval('P1M'); break;
|
||||
@@ -57,23 +66,25 @@ try {
|
||||
if ($interval) {
|
||||
$dueDate->add($interval);
|
||||
if ($checkDate) $checkDate->add($interval);
|
||||
if ($documentDate) $documentDate->add($interval);
|
||||
|
||||
$ins = $pdo->prepare("
|
||||
INSERT INTO scad_deadlines
|
||||
(subject_id, topic, law_regulation, recurrence_type, due_date, check_date,
|
||||
(subject_id, function_id, topic, law_regulation, recurrence_type, due_date, check_date,
|
||||
document_date, notification_days, storage_location, notes, created_by, departments)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$ins->execute([
|
||||
$deadline['subject_id'], $deadline['topic'], $deadline['law_regulation'],
|
||||
$deadline['subject_id'], $deadline['function_id'], $deadline['topic'], $deadline['law_regulation'],
|
||||
$deadline['recurrence_type'], $dueDate->format('Y-m-d'),
|
||||
$checkDate ? $checkDate->format('Y-m-d') : null,
|
||||
$deadline['document_date'],
|
||||
$documentDate ? $documentDate->format('Y-m-d') : null,
|
||||
$deadline['notification_days'], $deadline['storage_location'],
|
||||
$deadline['notes'], $deadline['created_by'], $deadline['departments']
|
||||
]);
|
||||
|
||||
$newId = $pdo->lastInsertId();
|
||||
$newDueDate = $dueDate;
|
||||
|
||||
// Copy employee assignments
|
||||
$empStmt = $pdo->prepare("SELECT employee_id FROM scad_deadline_employee WHERE deadline_id = ?");
|
||||
@@ -87,6 +98,31 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Carry forward ALL attachment links from the source deadline (shared physical file, same stored_name).
|
||||
// Individual links can later be removed on the new deadline without deleting the file.
|
||||
if ($copyAttachments) {
|
||||
$attSel = $pdo->prepare("
|
||||
SELECT original_name, stored_name, mime_type, size
|
||||
FROM scad_deadline_attachments
|
||||
WHERE deadline_id = ?
|
||||
");
|
||||
$attSel->execute([$id]);
|
||||
$attRows = $attSel->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($attRows) {
|
||||
$attIns = $pdo->prepare("
|
||||
INSERT INTO scad_deadline_attachments
|
||||
(deadline_id, original_name, stored_name, mime_type, size, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$attHist = $pdo->prepare("INSERT INTO scad_deadline_histories (deadline_id, user_id, action, notes) VALUES (?, ?, 'attachment_linked', ?)");
|
||||
foreach ($attRows as $a) {
|
||||
$attIns->execute([$newId, $a['original_name'], $a['stored_name'], $a['mime_type'], $a['size'], $currentUserId]);
|
||||
$attHist->execute([$newId, $currentUserId, $a['original_name']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// History for new
|
||||
$pdo->prepare("INSERT INTO scad_deadline_histories (deadline_id, user_id, action, notes) VALUES (?, ?, 'created', ?)")
|
||||
->execute([$newId, $currentUserId, 'Creata automaticamente dalla scadenza #' . $id]);
|
||||
@@ -97,7 +133,7 @@ try {
|
||||
|
||||
$msg = 'Scadenza completata con successo.';
|
||||
if ($newId) {
|
||||
$msg .= ' Nuova scadenza creata con data ' . $dueDate->format('d/m/Y') . '.';
|
||||
$msg .= ' Nuova scadenza creata con data ' . $newDueDate->format('d/m/Y') . '.';
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => $msg, 'new_id' => $newId]);
|
||||
|
||||
@@ -23,20 +23,32 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Delete file
|
||||
$filePath = __DIR__ . '/../attachments/' . $att['stored_name'];
|
||||
if (file_exists($filePath)) {
|
||||
unlink($filePath);
|
||||
}
|
||||
|
||||
// Delete DB record
|
||||
// Remove this link (DB record) first
|
||||
$pdo->prepare("DELETE FROM scad_deadline_attachments WHERE id = ?")->execute([$id]);
|
||||
|
||||
// History
|
||||
$pdo->prepare("INSERT INTO scad_deadline_histories (deadline_id, user_id, action, notes) VALUES (?, ?, 'attachment_removed', ?)")
|
||||
->execute([$att['deadline_id'], $currentUserId, $att['original_name']]);
|
||||
// The same physical file may be shared with other deadlines (carried forward on completion).
|
||||
// Only unlink it when no other link references the same stored file.
|
||||
$refStmt = $pdo->prepare("SELECT COUNT(*) FROM scad_deadline_attachments WHERE stored_name = ?");
|
||||
$refStmt->execute([$att['stored_name']]);
|
||||
$stillReferenced = (int)$refStmt->fetchColumn() > 0;
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Allegato eliminato.']);
|
||||
if ($stillReferenced) {
|
||||
$action = 'attachment_unlinked';
|
||||
$message = 'Collegamento rimosso. Il file è conservato (usato da un\'altra scadenza).';
|
||||
} else {
|
||||
$filePath = __DIR__ . '/../attachments/' . $att['stored_name'];
|
||||
if (file_exists($filePath)) {
|
||||
unlink($filePath);
|
||||
}
|
||||
$action = 'attachment_removed';
|
||||
$message = 'Allegato eliminato.';
|
||||
}
|
||||
|
||||
// History
|
||||
$pdo->prepare("INSERT INTO scad_deadline_histories (deadline_id, user_id, action, notes) VALUES (?, ?, ?, ?)")
|
||||
->execute([$att['deadline_id'], $currentUserId, $action, $att['original_name']]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => $message]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'message' => 'Errore: ' . $e->getMessage()]);
|
||||
|
||||
@@ -13,10 +13,29 @@ try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
// Collect the physical files referenced by this deadline before the FK cascade removes its links
|
||||
$attStmt = $pdo->prepare("SELECT DISTINCT stored_name FROM scad_deadline_attachments WHERE deadline_id = ?");
|
||||
$attStmt->execute([$id]);
|
||||
$storedNames = $attStmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
// Deleting the deadline cascades to its attachment/employee/history rows (FK ON DELETE CASCADE)
|
||||
$stmt = $pdo->prepare("DELETE FROM scad_deadlines WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// Unlink physical files no longer referenced by any other deadline (shared-file safe)
|
||||
if (!empty($storedNames)) {
|
||||
$refStmt = $pdo->prepare("SELECT COUNT(*) FROM scad_deadline_attachments WHERE stored_name = ?");
|
||||
foreach ($storedNames as $storedName) {
|
||||
$refStmt->execute([$storedName]);
|
||||
if ((int)$refStmt->fetchColumn() === 0) {
|
||||
$filePath = __DIR__ . '/../attachments/' . $storedName;
|
||||
if (file_exists($filePath)) {
|
||||
unlink($filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
echo json_encode(['success' => true, 'message' => 'Scadenza eliminata con successo.']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'message' => 'Scadenza non trovata.']);
|
||||
|
||||
@@ -9,6 +9,8 @@ try {
|
||||
|
||||
$id = isset($_POST['id']) && is_numeric($_POST['id']) ? (int)$_POST['id'] : null;
|
||||
$subject_id = isset($_POST['subject_id']) && is_numeric($_POST['subject_id']) && (int)$_POST['subject_id'] > 0 ? (int)$_POST['subject_id'] : null;
|
||||
$function_id = isset($_POST['function_id']) && is_numeric($_POST['function_id']) && (int)$_POST['function_id'] > 0 ? (int)$_POST['function_id'] : null;
|
||||
$notify_function = isset($_POST['notify_function']) && (int)$_POST['notify_function'] === 1 ? 1 : 0;
|
||||
$topic = trim($_POST['topic'] ?? '');
|
||||
$law_regulation = trim($_POST['law_regulation'] ?? '') ?: null;
|
||||
$recurrence_type = $_POST['recurrence_type'] ?? 'once';
|
||||
@@ -51,16 +53,27 @@ try {
|
||||
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE scad_deadlines SET
|
||||
subject_id = ?, topic = ?, law_regulation = ?, recurrence_type = ?,
|
||||
UPDATE scad_deadlines SET
|
||||
subject_id = ?, function_id = ?, notify_function = ?, topic = ?, law_regulation = ?, recurrence_type = ?,
|
||||
due_date = ?, check_date = ?, document_date = ?, notification_days = ?,
|
||||
storage_location = ?, notes = ?, departments = ?
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([
|
||||
$subject_id, $topic, $law_regulation, $recurrence_type,
|
||||
$due_date, $check_date, $document_date, $notification_days,
|
||||
$storage_location, $notes, $departmentsStr, $id
|
||||
$subject_id,
|
||||
$function_id,
|
||||
$notify_function,
|
||||
$topic,
|
||||
$law_regulation,
|
||||
$recurrence_type,
|
||||
$due_date,
|
||||
$check_date,
|
||||
$document_date,
|
||||
$notification_days,
|
||||
$storage_location,
|
||||
$notes,
|
||||
$departmentsStr,
|
||||
$id
|
||||
]);
|
||||
|
||||
// Re-link employees
|
||||
@@ -75,14 +88,25 @@ try {
|
||||
// INSERT
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO scad_deadlines
|
||||
(subject_id, topic, law_regulation, recurrence_type, due_date, check_date,
|
||||
document_date, notification_days, storage_location, notes, created_by, departments)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(subject_id, function_id, notify_function, topic, law_regulation, recurrence_type, due_date, check_date,
|
||||
document_date, notification_days, storage_location, notes, created_by, departments)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$stmt->execute([
|
||||
$subject_id, $topic, $law_regulation, $recurrence_type,
|
||||
$due_date, $check_date, $document_date, $notification_days,
|
||||
$storage_location, $notes, $currentUserId, $departmentsStr
|
||||
$subject_id,
|
||||
$function_id,
|
||||
$notify_function,
|
||||
$topic,
|
||||
$law_regulation,
|
||||
$recurrence_type,
|
||||
$due_date,
|
||||
$check_date,
|
||||
$document_date,
|
||||
$notification_days,
|
||||
$storage_location,
|
||||
$notes,
|
||||
$currentUserId,
|
||||
$departmentsStr
|
||||
]);
|
||||
|
||||
$deadlineId = $pdo->lastInsertId();
|
||||
@@ -107,7 +131,6 @@ try {
|
||||
'message' => $id ? 'Scadenza aggiornata con successo.' : 'Scadenza creata con successo.',
|
||||
'id' => $deadlineId
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
if (isset($pdo) && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Scadenzario — Email notification cron script
|
||||
* Run daily: 0 7 * * * php /var/www/html/public/userarea/scadenzario/cron/send_notifications.php
|
||||
@@ -25,15 +26,36 @@ $pdo = $db->getConnection();
|
||||
$today = date('Y-m-d');
|
||||
$appUrl = rtrim($_ENV['APP_URL'] ?? 'http://localhost:8001', '/');
|
||||
|
||||
// Manager email for Cc — taken from MANAGER_USER_ID → auth_users.email
|
||||
$managerCcEmail = null;
|
||||
if (!empty($_ENV['MANAGER_USER_ID']) && is_numeric($_ENV['MANAGER_USER_ID'])) {
|
||||
$mgrStmt = $pdo->prepare("SELECT email FROM auth_users WHERE id = ?");
|
||||
$mgrStmt->execute([(int)$_ENV['MANAGER_USER_ID']]);
|
||||
$mgrEmail = $mgrStmt->fetchColumn();
|
||||
if (!empty($mgrEmail)) {
|
||||
$managerCcEmail = $mgrEmail;
|
||||
}
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
|
||||
// Get active deadlines that are approaching or overdue
|
||||
$stmt = $pdo->query("
|
||||
SELECT d.id, d.topic, s.name AS subject_name, d.due_date, d.notification_days
|
||||
SELECT
|
||||
d.id,
|
||||
d.topic,
|
||||
s.name AS subject_name,
|
||||
d.due_date,
|
||||
d.notification_days,
|
||||
d.notify_function,
|
||||
f.email AS function_email,
|
||||
f.person_full_name AS function_person,
|
||||
f.name AS function_name
|
||||
FROM scad_deadlines d
|
||||
LEFT JOIN scad_subjects s ON s.id = d.subject_id
|
||||
LEFT JOIN scad_functions f ON f.id = d.function_id
|
||||
WHERE d.status = 'active'
|
||||
AND d.due_date <= DATE_ADD(CURDATE(), INTERVAL d.notification_days DAY)
|
||||
");
|
||||
@@ -90,20 +112,28 @@ foreach ($deadlines as $dl) {
|
||||
$type = $isOverdue ? 'overdue' : 'approaching';
|
||||
$daysLeft = (int)((strtotime($dl['due_date']) - strtotime($today)) / 86400);
|
||||
|
||||
// Collect all recipients (direct + department)
|
||||
// Collect all recipients (direct + department + optional function email)
|
||||
$recipients = [];
|
||||
$functionRecipient = null;
|
||||
|
||||
$getRecipients->execute([$dl['id']]);
|
||||
foreach ($getRecipients->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$recipients[$r['employee_id']] = $r;
|
||||
}
|
||||
|
||||
$getDeptRecipients->execute([$dl['id']]);
|
||||
foreach ($getDeptRecipients->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$recipients[$r['employee_id']] = $r;
|
||||
// Optional: also notify the linked function email if enabled on the deadline.
|
||||
if (
|
||||
!empty($dl['notify_function'])
|
||||
&& !empty($dl['function_email'])
|
||||
&& filter_var($dl['function_email'], FILTER_VALIDATE_EMAIL)
|
||||
) {
|
||||
$functionRecipient = [
|
||||
'email' => $dl['function_email'],
|
||||
'name' => trim(($dl['function_person'] ?? '') !== '' ? $dl['function_person'] : ($dl['function_name'] ?? 'Funzione')),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($recipients)) {
|
||||
if (empty($recipients) && empty($functionRecipient)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -143,6 +173,11 @@ foreach ($deadlines as $dl) {
|
||||
);
|
||||
$mail->addAddress($emp['email'], trim($emp['first_name'] . ' ' . $emp['last_name']));
|
||||
|
||||
// Cc the manager (unless they are the direct recipient)
|
||||
if ($managerCcEmail && strcasecmp($managerCcEmail, $emp['email']) !== 0) {
|
||||
$mail->addCC($managerCcEmail);
|
||||
}
|
||||
|
||||
$detailUrl = $appUrl . '/userarea/scadenzario/detail.php?id=' . $dl['id'];
|
||||
$topicText = (!empty($dl['subject_name']) ? $dl['subject_name'] . ' — ' : '') . $dl['topic'];
|
||||
|
||||
@@ -177,15 +212,99 @@ foreach ($deadlines as $dl) {
|
||||
$sent++;
|
||||
|
||||
echo date('H:i:s') . " ✓ {$type} → {$emp['email']} — {$dl['topic']}\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
$errors++;
|
||||
echo date('H:i:s') . " ✗ Errore {$emp['email']}: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
// Send notification to function email if enabled.
|
||||
// It is tracked with employee_id = 0 to avoid duplicate daily sends.
|
||||
if ($functionRecipient) {
|
||||
$functionEmployeeId = 0;
|
||||
|
||||
$checkSent->execute([$dl['id'], $functionEmployeeId, $type]);
|
||||
if ($checkSent->fetchColumn() > 0) {
|
||||
$skipped++;
|
||||
} else {
|
||||
try {
|
||||
$mail = new PHPMailer(true);
|
||||
|
||||
$mailer = $_ENV['MAIL_MAILER'] ?? 'mail';
|
||||
if ($mailer === 'smtp') {
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $_ENV['MAIL_HOST'] ?? 'localhost';
|
||||
$mail->Port = (int)($_ENV['MAIL_PORT'] ?? 587);
|
||||
|
||||
if (!empty($_ENV['MAIL_USERNAME']) && $_ENV['MAIL_USERNAME'] !== 'null') {
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = $_ENV['MAIL_USERNAME'];
|
||||
$mail->Password = $_ENV['MAIL_PASSWORD'] ?? '';
|
||||
}
|
||||
|
||||
$enc = $_ENV['MAIL_ENCRYPTION'] ?? '';
|
||||
if ($enc && $enc !== 'null') {
|
||||
$mail->SMTPSecure = $enc;
|
||||
}
|
||||
}
|
||||
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->setFrom(
|
||||
$_ENV['MAIL_FROM_ADDRESS'] ?? 'noreply@zibogomma.it',
|
||||
$_ENV['MAIL_FROM_NAME'] ?? 'Scadenzario ZIBOGOMMA'
|
||||
);
|
||||
|
||||
$mail->addAddress($functionRecipient['email'], $functionRecipient['name']);
|
||||
|
||||
if ($managerCcEmail && strcasecmp($managerCcEmail, $functionRecipient['email']) !== 0) {
|
||||
$mail->addCC($managerCcEmail);
|
||||
}
|
||||
|
||||
$detailUrl = $appUrl . '/userarea/scadenzario/detail.php?id=' . $dl['id'];
|
||||
$topicText = (!empty($dl['subject_name']) ? $dl['subject_name'] . ' — ' : '') . $dl['topic'];
|
||||
|
||||
if ($isOverdue) {
|
||||
$mail->Subject = '⚠️ Scadenza superata: ' . $dl['topic'];
|
||||
$mail->Body = buildHtml(
|
||||
'Scadenza superata',
|
||||
$topicText,
|
||||
'La scadenza era prevista per il <strong>' . date('d/m/Y', strtotime($dl['due_date'])) . '</strong> ed è stata superata da <strong>' . abs($daysLeft) . ' giorni</strong>.',
|
||||
'#dc3545',
|
||||
$detailUrl
|
||||
);
|
||||
} else {
|
||||
$mail->Subject = '📅 Scadenza in arrivo: ' . $dl['topic'];
|
||||
$daysText = $daysLeft === 0 ? 'oggi' : 'tra <strong>' . $daysLeft . ' giorni</strong>';
|
||||
$mail->Body = buildHtml(
|
||||
'Scadenza in arrivo',
|
||||
$topicText,
|
||||
'La scadenza è prevista per il <strong>' . date('d/m/Y', strtotime($dl['due_date'])) . '</strong> (' . $daysText . ').',
|
||||
'#e8930c',
|
||||
$detailUrl
|
||||
);
|
||||
}
|
||||
|
||||
$mail->isHTML(true);
|
||||
$mail->AltBody = strip_tags(str_replace('<br>', "\n", $mail->Body));
|
||||
|
||||
$mail->send();
|
||||
|
||||
$insertNotif->execute([$dl['id'], $functionEmployeeId, $type]);
|
||||
$sent++;
|
||||
|
||||
echo date('H:i:s') . " ✓ {$type} → funzione {$functionRecipient['email']} — {$dl['topic']}\n";
|
||||
} catch (Exception $e) {
|
||||
$errors++;
|
||||
echo date('H:i:s') . " ✗ Errore funzione {$functionRecipient['email']}: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
// History (one per deadline, not per recipient)
|
||||
$recipientNames = implode(', ', array_map(fn($r) => trim($r['first_name'] . ' ' . $r['last_name']), $recipients));
|
||||
|
||||
if ($functionRecipient) {
|
||||
$recipientNames .= ($recipientNames !== '' ? ', ' : '') . 'Funzione: ' . $functionRecipient['name'] . ' <' . $functionRecipient['email'] . '>';
|
||||
}
|
||||
|
||||
$insertHistory->execute([$dl['id'], "Notifica {$type} inviata a: {$recipientNames}"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,9 +66,9 @@ if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
|
||||
}
|
||||
|
||||
$recurrenceLabels = ['once' => 'Una tantum', 'monthly' => 'Mensile', 'quarterly' => 'Trimestrale', 'semiannual' => 'Semestrale', 'annual' => 'Annuale', 'biennial' => 'Biennale', 'triennial' => 'Triennale', 'quadriennial' => 'Quadriennale', 'quinquennial' => 'Quinquennale', 'decennial' => 'Decennale', 'quindecennial' => 'Quindicennale'];
|
||||
$actionLabels = ['created' => 'Creata', 'updated' => 'Modificata', 'completed' => 'Completata', 'attachment_added' => 'Allegato aggiunto', 'attachment_removed' => 'Allegato rimosso', 'notification_sent' => 'Notifica inviata'];
|
||||
$actionColors = ['created' => '#198754', 'updated' => '#5a8fd8', 'completed' => '#6f42c1', 'attachment_added' => '#e8930c', 'attachment_removed' => '#e8930c', 'notification_sent' => '#adb5bd'];
|
||||
$actionIcons = ['created' => 'fa-plus', 'updated' => 'fa-pen', 'completed' => 'fa-check', 'attachment_added' => 'fa-paperclip', 'attachment_removed' => 'fa-trash', 'notification_sent' => 'fa-bell'];
|
||||
$actionLabels = ['created' => 'Creata', 'updated' => 'Modificata', 'completed' => 'Completata', 'attachment_added' => 'Allegato aggiunto', 'attachment_removed' => 'Allegato rimosso', 'attachment_linked' => 'Allegato collegato', 'attachment_unlinked' => 'Collegamento rimosso', 'notification_sent' => 'Notifica inviata'];
|
||||
$actionColors = ['created' => '#198754', 'updated' => '#5a8fd8', 'completed' => '#6f42c1', 'attachment_added' => '#e8930c', 'attachment_removed' => '#e8930c', 'attachment_linked' => '#0dcaf0', 'attachment_unlinked' => '#adb5bd', 'notification_sent' => '#adb5bd'];
|
||||
$actionIcons = ['created' => 'fa-plus', 'updated' => 'fa-pen', 'completed' => 'fa-check', 'attachment_added' => 'fa-paperclip', 'attachment_removed' => 'fa-trash', 'attachment_linked' => 'fa-link', 'attachment_unlinked' => 'fa-link-slash', 'notification_sent' => 'fa-bell'];
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -85,6 +85,14 @@ if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
|
||||
<base href="<?= $baseHref ?>">
|
||||
<?php include('../cssinclude.php'); ?>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/i18n/it.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/it.js"></script>
|
||||
<?php include __DIR__ . '/include/deadline_modal_css.php'; ?>
|
||||
<title><?= $deadline ? htmlspecialchars($deadline['topic'], ENT_QUOTES, 'UTF-8') . ' — ' : '' ?>Scadenzario</title>
|
||||
<script>
|
||||
if (window.innerWidth > 1024) document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -755,52 +763,114 @@ if (!isset($_GET['id']) || !is_numeric($_GET['id'])) {
|
||||
</div>
|
||||
<?php include('../include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<?php if ($deadline && !$isCompleted): ?>
|
||||
<?php require __DIR__ . '/include/deadline_form_data.php'; ?>
|
||||
<?php include __DIR__ . '/include/deadline_modal.php'; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include('../jsinclude.php'); ?>
|
||||
<?php if ($deadline && !$isCompleted): ?>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Used by the shared modal JS to auto-open edit on "#edit"
|
||||
window.SCAD_DETAIL_ID = <?= (int)$deadline['id'] ?>;
|
||||
|
||||
$('#btnModifica').on('click', function() {
|
||||
window.location.href = 'scadenzario/index.php?edit=<?= (int)$deadline['id'] ?>';
|
||||
window.openDeadlineEdit(<?= (int)$deadline['id'] ?>);
|
||||
});
|
||||
|
||||
function detailSubmitComplete(createNext, copyAttachments) {
|
||||
var fd = new FormData();
|
||||
fd.append('id', '<?= (int)$deadline['id'] ?>');
|
||||
fd.append('create_next', createNext ? '1' : '0');
|
||||
fd.append('copy_attachments', copyAttachments ? '1' : '0');
|
||||
|
||||
fetch('scadenzario/ajax/complete_deadline.php', {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
})
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Completata',
|
||||
text: data.message,
|
||||
timer: 1800,
|
||||
showConfirmButton: false
|
||||
})
|
||||
.then(function() {
|
||||
if (data.new_id) {
|
||||
window.location.href = 'scadenzario/detail.php?id=' + data.new_id + '#edit';
|
||||
} else {
|
||||
window.location.href = 'scadenzario/index.php';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Swal.fire('Errore', data.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
$('#btnCompleta').on('click', function() {
|
||||
var recurrence = <?= json_encode($deadline['recurrence_type'] ?? 'once') ?>;
|
||||
var attCount = <?= count($attachments) ?>;
|
||||
|
||||
if (recurrence === 'once') {
|
||||
Swal.fire({
|
||||
title: 'Completare la scadenza?',
|
||||
text: 'La scadenza verrà contrassegnata come completata.',
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#198754',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonText: 'Completa',
|
||||
reverseButtons: true
|
||||
}).then(function(result) {
|
||||
if (result.isConfirmed) {
|
||||
detailSubmitComplete(false, false);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var attCheckbox = attCount > 0 ?
|
||||
'<div class="form-check d-flex align-items-center justify-content-center gap-2 mt-3">' +
|
||||
'<input class="form-check-input" type="checkbox" id="swCopyAtt" checked>' +
|
||||
'<label class="form-check-label" for="swCopyAtt">Copia gli allegati (' + attCount + ') sulla nuova scadenza</label>' +
|
||||
'</div>' :
|
||||
'';
|
||||
|
||||
Swal.fire({
|
||||
title: 'Completare la scadenza?',
|
||||
html: 'Vuoi creare automaticamente la prossima scadenza ricorrente?' + attCheckbox,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
showDenyButton: true,
|
||||
confirmButtonColor: '#198754',
|
||||
denyButtonColor: '#6c757d',
|
||||
confirmButtonText: 'Completa e crea la prossima',
|
||||
denyButtonText: 'Completa senza nuova',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonText: 'Completa'
|
||||
reverseButtons: true
|
||||
}).then(function(result) {
|
||||
if (result.isConfirmed) {
|
||||
fetch('scadenzario/ajax/complete_deadline.php?id=<?= (int)$deadline['id'] ?>')
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Completata',
|
||||
text: data.message,
|
||||
timer: 2500,
|
||||
showConfirmButton: false
|
||||
})
|
||||
.then(function() {
|
||||
window.location.href = 'scadenzario/index.php';
|
||||
});
|
||||
} else {
|
||||
Swal.fire('Errore', data.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
});
|
||||
var copy = attCount > 0 ? document.getElementById('swCopyAtt').checked : false;
|
||||
detailSubmitComplete(true, copy);
|
||||
} else if (result.isDenied) {
|
||||
detailSubmitComplete(false, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php include __DIR__ . '/include/deadline_modal_js.php'; ?>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
require_once(__DIR__ . '/../../ajax/auth_check.php');
|
||||
header('Content-Type: application/json');
|
||||
require_once(__DIR__ . '/../../../class/db-functions.php');
|
||||
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$id = isset($_POST['id']) && is_numeric($_POST['id']) ? (int)$_POST['id'] : 0;
|
||||
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['success' => false, 'message' => 'ID non valido.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM scad_deadlines WHERE function_id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$inUse = (int)$stmt->fetchColumn();
|
||||
|
||||
if ($inUse > 0) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => "Impossibile eliminare: la funzione è utilizzata in $inUse scadenz" . ($inUse === 1 ? 'a' : 'e') . '.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo->prepare("DELETE FROM scad_functions WHERE id = ?")->execute([$id]);
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Funzione eliminata.']);
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'message' => 'Errore: ' . $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
require_once(__DIR__ . '/../../ajax/auth_check.php');
|
||||
header('Content-Type: application/json');
|
||||
require_once(__DIR__ . '/../../../class/db-functions.php');
|
||||
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$id = isset($_POST['id']) && is_numeric($_POST['id']) ? (int)$_POST['id'] : null;
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '') ?: null;
|
||||
|
||||
if ($name === '') {
|
||||
echo json_encode(['success' => false, 'message' => 'Il nome è obbligatorio.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (mb_strlen($name) > 255) {
|
||||
echo json_encode(['success' => false, 'message' => 'Il nome supera 255 caratteri.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare("SELECT id FROM scad_functions WHERE name = ? AND id <> ?");
|
||||
$stmt->execute([$name, $id]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("SELECT id FROM scad_functions WHERE name = ?");
|
||||
$stmt->execute([$name]);
|
||||
}
|
||||
|
||||
if ($stmt->fetch()) {
|
||||
echo json_encode(['success' => false, 'message' => 'Esiste già una funzione con questo nome.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($id) {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE scad_functions
|
||||
SET name = ?, description = ?
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$name, $description, $id]);
|
||||
$savedId = $id;
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO scad_functions (name, description, status)
|
||||
VALUES (?, ?, 'active')
|
||||
");
|
||||
$stmt->execute([$name, $description]);
|
||||
$savedId = (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => $id ? 'Funzione aggiornata.' : 'Funzione creata.',
|
||||
'id' => $savedId,
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['success' => false, 'message' => 'Errore: ' . $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
<?php include('../../include/headscript.php'); ?>
|
||||
<?php
|
||||
ini_set('display_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
function scadJsonResponse(array $data): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
function scadNullableString($value): ?string
|
||||
{
|
||||
$value = trim((string)($value ?? ''));
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
function scadNormalizeStatus(string $status): string
|
||||
{
|
||||
return in_array($status, ['active', 'inactive'], true) ? $status : 'active';
|
||||
}
|
||||
|
||||
function scadValidateEmail($email): ?string
|
||||
{
|
||||
$email = scadNullableString($email);
|
||||
|
||||
if ($email !== null && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new Exception('Email non valida.');
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ajax']) && $_POST['ajax'] === '1') {
|
||||
try {
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'save') {
|
||||
$id = isset($_POST['id']) && $_POST['id'] !== '' ? (int)$_POST['id'] : 0;
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$description = scadNullableString($_POST['description'] ?? null);
|
||||
$personFullName = scadNullableString($_POST['person_full_name'] ?? null);
|
||||
$phone = scadNullableString($_POST['phone'] ?? null);
|
||||
$email = scadValidateEmail($_POST['email'] ?? null);
|
||||
$notes = scadNullableString($_POST['notes'] ?? null);
|
||||
$sortOrder = isset($_POST['sort_order']) && $_POST['sort_order'] !== '' ? (int)$_POST['sort_order'] : 0;
|
||||
$status = scadNormalizeStatus(trim($_POST['status'] ?? 'active'));
|
||||
|
||||
if ($name === '') {
|
||||
scadJsonResponse(['success' => false, 'message' => 'Il nome funzione è obbligatorio.']);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$stmt = $pdo->prepare("\n UPDATE scad_functions\n SET name = :name,\n description = :description,\n person_full_name = :person_full_name,\n phone = :phone,\n email = :email,\n notes = :notes,\n sort_order = :sort_order,\n status = :status,\n updated_at = NOW()\n WHERE id = :id\n ");
|
||||
$stmt->execute([
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'person_full_name' => $personFullName,
|
||||
'phone' => $phone,
|
||||
'email' => $email,
|
||||
'notes' => $notes,
|
||||
'sort_order' => $sortOrder,
|
||||
'status' => $status,
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
scadJsonResponse(['success' => true, 'message' => 'Funzione aggiornata correttamente.']);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("\n INSERT INTO scad_functions\n (name, description, person_full_name, phone, email, notes, sort_order, status, created_at, updated_at)\n VALUES\n (:name, :description, :person_full_name, :phone, :email, :notes, :sort_order, :status, NOW(), NOW())\n ");
|
||||
$stmt->execute([
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'person_full_name' => $personFullName,
|
||||
'phone' => $phone,
|
||||
'email' => $email,
|
||||
'notes' => $notes,
|
||||
'sort_order' => $sortOrder,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
scadJsonResponse(['success' => true, 'message' => 'Funzione creata correttamente.']);
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
scadJsonResponse(['success' => false, 'message' => 'ID funzione non valido.']);
|
||||
}
|
||||
|
||||
$stmtUse = $pdo->prepare('SELECT COUNT(*) FROM scad_deadlines WHERE function_id = ?');
|
||||
$stmtUse->execute([$id]);
|
||||
$inUse = (int)$stmtUse->fetchColumn();
|
||||
|
||||
if ($inUse > 0) {
|
||||
scadJsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Impossibile eliminare: la funzione è utilizzata in ' . $inUse . ' scadenza/e.'
|
||||
]);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM scad_functions WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
|
||||
scadJsonResponse(['success' => true, 'message' => 'Funzione eliminata correttamente.']);
|
||||
}
|
||||
|
||||
scadJsonResponse(['success' => false, 'message' => 'Azione non riconosciuta.']);
|
||||
} catch (Exception $e) {
|
||||
scadJsonResponse(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$functions = $pdo->query("\n SELECT f.*,\n (SELECT COUNT(*) FROM scad_deadlines d WHERE d.function_id = f.id) AS deadline_count,\n (SELECT COUNT(*) FROM scad_deadlines d WHERE d.function_id = f.id AND d.status <> 'completed') AS open_count\n FROM scad_functions f\n ORDER BY COALESCE(f.sort_order, 0) ASC, f.name ASC\n")->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<?php
|
||||
$scriptDir = dirname($_SERVER['SCRIPT_NAME']);
|
||||
$baseHref = dirname(dirname($scriptDir)) . '/';
|
||||
?>
|
||||
<base href="<?= $baseHref ?>">
|
||||
<?php include('../../cssinclude.php'); ?>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<title>Scadenzario - Funzioni</title>
|
||||
<script>
|
||||
if (window.innerWidth > 1024) {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const wrapper = document.getElementById('appWrapper');
|
||||
if (wrapper) wrapper.classList.add('toggled');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--scad-primary: #5a8fd8;
|
||||
--scad-primary-hover: #4578c0;
|
||||
--scad-heading: #2c3e6b;
|
||||
--scad-card-bg: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 100%);
|
||||
--scad-card-border: #dde4f0;
|
||||
--scad-muted: #6c757d;
|
||||
}
|
||||
|
||||
.scad-card {
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scad-card .card-header {
|
||||
background: var(--scad-card-bg);
|
||||
border-bottom: 1px solid var(--scad-card-border);
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.scad-card .card-header h5 {
|
||||
font-weight: 700;
|
||||
color: var(--scad-heading);
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.scad-card .card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.btn-scad-primary {
|
||||
background: var(--scad-primary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-scad-primary:hover {
|
||||
background: var(--scad-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-scad-outline {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--scad-primary);
|
||||
color: var(--scad-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.45rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-scad-outline:hover {
|
||||
background: var(--scad-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-action-edit {
|
||||
background: rgba(90, 143, 216, 0.12);
|
||||
color: var(--scad-primary);
|
||||
}
|
||||
|
||||
.btn-action-edit:hover {
|
||||
background: var(--scad-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-action-delete {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.btn-action-delete:hover {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.function-table th {
|
||||
font-size: 0.82rem;
|
||||
color: var(--scad-heading);
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--scad-card-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.function-table td {
|
||||
font-size: 0.9rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.function-name {
|
||||
font-weight: 700;
|
||||
color: var(--scad-heading);
|
||||
}
|
||||
|
||||
.function-description,
|
||||
.function-notes {
|
||||
color: var(--scad-muted);
|
||||
font-size: 0.82rem;
|
||||
margin-top: 2px;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-line {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.contact-line a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.badge-function-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-function-status.active {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-function-status.inactive {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.function-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--scad-card-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.function-card .fc-name {
|
||||
font-weight: 700;
|
||||
color: var(--scad-heading);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.function-card .fc-meta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--scad-muted);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.function-card .fc-stats {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--scad-muted);
|
||||
margin: 0.5rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.function-card .fc-stats strong {
|
||||
color: var(--scad-heading);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--scad-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
opacity: 0.3;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: var(--scad-card-bg);
|
||||
border-bottom: 1px solid var(--scad-card-border);
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.scad-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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper" id="appWrapper">
|
||||
<?php include('../../include/navbar.php'); ?>
|
||||
<?php include('../../include/topbar.php'); ?>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="page-content">
|
||||
|
||||
<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="scadenzario/index.php">Scadenzario</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Funzioni</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card scad-card">
|
||||
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<h5><i class="fa-solid fa-briefcase me-2"></i>Funzioni</h5>
|
||||
|
||||
<div class="header-actions d-flex gap-2 flex-wrap">
|
||||
<a href="scadenzario/index.php" class="btn btn-scad-outline d-inline-flex align-items-center gap-2">
|
||||
<i class="fa-solid fa-arrow-left"></i><span>Scadenzario</span>
|
||||
</a>
|
||||
|
||||
<button class="btn btn-scad-primary d-inline-flex align-items-center gap-2" id="btnAddFunction">
|
||||
<i class="fa-solid fa-plus"></i><span>Nuova Funzione</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<?php if (count($functions) === 0): ?>
|
||||
<div class="empty-state">
|
||||
<i class="fa-solid fa-briefcase"></i>
|
||||
<p>Nessuna funzione definita.<br>Clicca <strong>"Nuova Funzione"</strong> per aggiungere la prima.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
|
||||
<div id="functionsList">
|
||||
<div class="d-md-none">
|
||||
<?php foreach ($functions as $f): ?>
|
||||
<?php
|
||||
$status = $f['status'] === 'inactive' ? 'inactive' : 'active';
|
||||
$statusLabel = $status === 'active' ? 'Attiva' : 'Non attiva';
|
||||
?>
|
||||
<div class="function-card"
|
||||
data-id="<?= (int)$f['id'] ?>"
|
||||
data-name="<?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-description="<?= htmlspecialchars($f['description'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-person-full-name="<?= htmlspecialchars($f['person_full_name'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-phone="<?= htmlspecialchars($f['phone'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-email="<?= htmlspecialchars($f['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-notes="<?= htmlspecialchars($f['notes'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-sort-order="<?= (int)($f['sort_order'] ?? 0) ?>"
|
||||
data-status="<?= htmlspecialchars($status, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-in-use="<?= (int)$f['deadline_count'] ?>">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<div class="fc-name"><?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<span class="badge-function-status <?= $status ?>"><?= $statusLabel ?></span>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($f['description'])): ?>
|
||||
<div class="fc-meta"><?= htmlspecialchars($f['description'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($f['person_full_name'])): ?>
|
||||
<div class="fc-meta"><strong>Referente:</strong> <?= htmlspecialchars($f['person_full_name'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($f['phone']) || !empty($f['email'])): ?>
|
||||
<div class="fc-meta">
|
||||
<?php if (!empty($f['phone'])): ?>📞 <?= htmlspecialchars($f['phone'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
|
||||
<?php if (!empty($f['email'])): ?><?= !empty($f['phone']) ? '<br>' : '' ?>✉️ <?= htmlspecialchars($f['email'], ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="fc-stats">
|
||||
<span>Scadenze: <strong><?= (int)$f['deadline_count'] ?></strong></span>
|
||||
<span>Aperte: <strong><?= (int)$f['open_count'] ?></strong></span>
|
||||
<span>Ordine: <strong><?= (int)($f['sort_order'] ?? 0) ?></strong></span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
<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">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="d-none d-md-block">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 function-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:70px" class="text-center">Ord.</th>
|
||||
<th>Funzione</th>
|
||||
<th style="width:220px">Referente</th>
|
||||
<th style="width:230px">Contatti</th>
|
||||
<th style="width:120px" class="text-center">Stato</th>
|
||||
<th style="width:120px" class="text-center">Scadenze</th>
|
||||
<th style="width:120px" class="text-center">Aperte</th>
|
||||
<th style="width:120px" class="text-center">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($functions as $f): ?>
|
||||
<?php
|
||||
$status = $f['status'] === 'inactive' ? 'inactive' : 'active';
|
||||
$statusLabel = $status === 'active' ? 'Attiva' : 'Non attiva';
|
||||
?>
|
||||
<tr data-id="<?= (int)$f['id'] ?>"
|
||||
data-name="<?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-description="<?= htmlspecialchars($f['description'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-person-full-name="<?= htmlspecialchars($f['person_full_name'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-phone="<?= htmlspecialchars($f['phone'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-email="<?= htmlspecialchars($f['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-notes="<?= htmlspecialchars($f['notes'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-sort-order="<?= (int)($f['sort_order'] ?? 0) ?>"
|
||||
data-status="<?= htmlspecialchars($status, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-in-use="<?= (int)$f['deadline_count'] ?>">
|
||||
|
||||
<td class="text-center"><?= (int)($f['sort_order'] ?? 0) ?></td>
|
||||
<td>
|
||||
<div class="function-name"><?= htmlspecialchars($f['name'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php if (!empty($f['description'])): ?>
|
||||
<div class="function-description" title="<?= htmlspecialchars($f['description'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= htmlspecialchars($f['description'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($f['notes'])): ?>
|
||||
<div class="function-notes" title="<?= htmlspecialchars($f['notes'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
Note: <?= htmlspecialchars($f['notes'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= !empty($f['person_full_name']) ? htmlspecialchars($f['person_full_name'], ENT_QUOTES, 'UTF-8') : '<span class="text-muted">—</span>' ?></td>
|
||||
<td>
|
||||
<?php if (!empty($f['phone'])): ?>
|
||||
<div class="contact-line">📞 <a href="tel:<?= htmlspecialchars($f['phone'], ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($f['phone'], ENT_QUOTES, 'UTF-8') ?></a></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($f['email'])): ?>
|
||||
<div class="contact-line">✉️ <a href="mailto:<?= htmlspecialchars($f['email'], ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($f['email'], ENT_QUOTES, 'UTF-8') ?></a></div>
|
||||
<?php endif; ?>
|
||||
<?php if (empty($f['phone']) && empty($f['email'])): ?>
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center"><span class="badge-function-status <?= $status ?>"><?= $statusLabel ?></span></td>
|
||||
<td class="text-center"><?= (int)$f['deadline_count'] ?></td>
|
||||
<td class="text-center"><?= (int)$f['open_count'] ?></td>
|
||||
<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">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include('../../include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="functionModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="functionModalTitle">Nuova Funzione</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||||
</div>
|
||||
|
||||
<form id="functionForm">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="functionId" name="id" value="">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8 mb-3">
|
||||
<label for="functionName" class="form-label fw-semibold">Nome funzione <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="functionName" name="name" maxlength="255" required placeholder="Es. RSPP, Medico del lavoro, RLS">
|
||||
</div>
|
||||
<div class="col-12 col-md-4 mb-3">
|
||||
<label for="functionSortOrder" class="form-label fw-semibold">Ordine</label>
|
||||
<input type="number" class="form-control" id="functionSortOrder" name="sort_order" min="0" step="1" value="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="functionDescription" class="form-label fw-semibold">Descrizione</label>
|
||||
<textarea class="form-control" id="functionDescription" name="description" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="functionPersonFullName" class="form-label fw-semibold">Nome e cognome referente</label>
|
||||
<input type="text" class="form-control" id="functionPersonFullName" name="person_full_name" maxlength="200" placeholder="Es. Mario Rossi">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<label for="functionPhone" class="form-label fw-semibold">Telefono</label>
|
||||
<input type="text" class="form-control" id="functionPhone" name="phone" maxlength="80">
|
||||
</div>
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<label for="functionEmail" class="form-label fw-semibold">Email</label>
|
||||
<input type="email" class="form-control" id="functionEmail" name="email" maxlength="190">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<label for="functionStatus" class="form-label fw-semibold">Stato</label>
|
||||
<select class="form-select" id="functionStatus" name="status">
|
||||
<option value="active">Attiva</option>
|
||||
<option value="inactive">Non attiva</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="functionNotes" class="form-label fw-semibold">Note operative</label>
|
||||
<textarea class="form-control" id="functionNotes" name="notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button>
|
||||
<button type="submit" class="btn btn-scad-primary" id="functionSaveBtn">Salva</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include('../../jsinclude.php'); ?>
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value).replace(/[&<>'"]/g, function(c) {
|
||||
return ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"'
|
||||
})[c];
|
||||
});
|
||||
}
|
||||
|
||||
function getRowData($row) {
|
||||
return {
|
||||
id: $row.data('id') || '',
|
||||
name: $row.data('name') || '',
|
||||
description: $row.data('description') || '',
|
||||
person_full_name: $row.data('person-full-name') || '',
|
||||
phone: $row.data('phone') || '',
|
||||
email: $row.data('email') || '',
|
||||
notes: $row.data('notes') || '',
|
||||
sort_order: $row.data('sort-order') || 0,
|
||||
status: $row.data('status') || 'active',
|
||||
in_use: parseInt($row.data('in-use') || 0, 10)
|
||||
};
|
||||
}
|
||||
|
||||
function openModal(data) {
|
||||
const isEdit = !!data;
|
||||
|
||||
$('#functionModalTitle').text(isEdit ? 'Modifica Funzione' : 'Nuova Funzione');
|
||||
$('#functionId').val(isEdit ? data.id : '');
|
||||
$('#functionName').val(isEdit ? data.name : '');
|
||||
$('#functionDescription').val(isEdit ? data.description : '');
|
||||
$('#functionPersonFullName').val(isEdit ? data.person_full_name : '');
|
||||
$('#functionPhone').val(isEdit ? data.phone : '');
|
||||
$('#functionEmail').val(isEdit ? data.email : '');
|
||||
$('#functionNotes').val(isEdit ? data.notes : '');
|
||||
$('#functionSortOrder').val(isEdit ? data.sort_order : 0);
|
||||
$('#functionStatus').val(isEdit ? data.status : 'active');
|
||||
$('#functionSaveBtn').prop('disabled', false).html('Salva');
|
||||
|
||||
new bootstrap.Modal('#functionModal').show();
|
||||
}
|
||||
|
||||
$('#btnAddFunction').on('click', function() {
|
||||
openModal(null);
|
||||
});
|
||||
|
||||
$('#functionsList').on('click', '.btn-edit', function() {
|
||||
const $row = $(this).closest('[data-id]');
|
||||
openModal(getRowData($row));
|
||||
});
|
||||
|
||||
$('#functionsList').on('click', '.btn-delete', function() {
|
||||
const $row = $(this).closest('[data-id]');
|
||||
const data = getRowData($row);
|
||||
|
||||
if (data.in_use > 0) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Impossibile eliminare',
|
||||
text: 'La funzione "' + data.name + '" è utilizzata in ' + data.in_use + ' scadenza/e.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
title: 'Eliminare "' + data.name + '"?',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Elimina',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonColor: '#dc3545'
|
||||
}).then(function(result) {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
$.post(window.location.href.split('#')[0], {
|
||||
ajax: '1',
|
||||
action: 'delete',
|
||||
id: data.id
|
||||
})
|
||||
.done(function(res) {
|
||||
if (res.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Errore',
|
||||
text: res.message || 'Impossibile eliminare.'
|
||||
});
|
||||
}
|
||||
})
|
||||
.fail(function() {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Errore di rete'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$('#functionForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const $btn = $('#functionSaveBtn');
|
||||
const payload = {
|
||||
ajax: '1',
|
||||
action: 'save',
|
||||
id: $('#functionId').val(),
|
||||
name: $('#functionName').val().trim(),
|
||||
description: $('#functionDescription').val().trim(),
|
||||
person_full_name: $('#functionPersonFullName').val().trim(),
|
||||
phone: $('#functionPhone').val().trim(),
|
||||
email: $('#functionEmail').val().trim(),
|
||||
notes: $('#functionNotes').val().trim(),
|
||||
sort_order: $('#functionSortOrder').val(),
|
||||
status: $('#functionStatus').val()
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Nome obbligatorio'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Salvataggio...');
|
||||
|
||||
$.post(window.location.href.split('#')[0], payload)
|
||||
.done(function(res) {
|
||||
if (res.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
$btn.prop('disabled', false).html('Salva');
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Errore',
|
||||
text: res.message || 'Impossibile salvare.'
|
||||
});
|
||||
}
|
||||
})
|
||||
.fail(function() {
|
||||
$btn.prop('disabled', false).html('Salva');
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Errore di rete'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared data for the deadline modal form (used by index.php and detail.php).
|
||||
* Populates $employees, $departments, $subjects. Safe to include more than once.
|
||||
*/
|
||||
if (!isset($pdo) || !$pdo) {
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
}
|
||||
|
||||
if (!isset($employees)) {
|
||||
$employees = $pdo->query("
|
||||
SELECT e.id, e.first_name, e.last_name, e.department_id, dep.name AS department_name
|
||||
FROM employees e
|
||||
LEFT JOIN departments dep ON dep.id = e.department_id
|
||||
WHERE e.status = 'active'
|
||||
ORDER BY e.first_name, e.last_name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
if (!isset($departments)) {
|
||||
$departments = $pdo->query("
|
||||
SELECT id, name, code, color
|
||||
FROM departments
|
||||
WHERE is_active = 1
|
||||
ORDER BY sort_order ASC, name ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
if (!isset($subjects)) {
|
||||
$subjects = $pdo->query("SELECT id, name, color FROM scad_subjects ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
if (!isset($functions)) {
|
||||
$functions = $pdo->query("
|
||||
SELECT id, name
|
||||
FROM scad_functions
|
||||
WHERE status = 'active'
|
||||
ORDER BY name ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared "Nuova/Modifica Scadenza" modal markup (used by index.php and detail.php).
|
||||
* Requires $subjects, $departments, $employees in scope (see deadline_form_data.php).
|
||||
* The accompanying JS lives in deadline_modal_js.php.
|
||||
*/
|
||||
?>
|
||||
<!-- Deadline Modal -->
|
||||
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-fullscreen-sm-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modalTitle">Nuova Scadenza</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||||
</div>
|
||||
<form id="deadlineForm">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="dlId" name="id" value="">
|
||||
|
||||
<!-- Group 1: Informazioni principali -->
|
||||
<div class="form-section-title">Informazioni principali</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="dlSubject" class="form-label fw-semibold">Argomento</label>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select" id="dlSubject" name="subject_id" style="flex:1">
|
||||
<option value="">— Nessuno —</option>
|
||||
<?php foreach ($subjects as $s): ?>
|
||||
<option value="<?= (int)$s['id'] ?>" data-color="<?= htmlspecialchars($s['color'], ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($s['name'], ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<a href="scadenzario/subjects/index.php" target="_blank" class="btn btn-scad-outline" title="Gestisci argomenti">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="dlFunction" class="form-label fw-semibold">Funzione</label>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select" id="dlFunction" name="function_id" style="flex:1">
|
||||
<option value="">— Nessuna —</option>
|
||||
<?php foreach ($functions as $fn): ?>
|
||||
<option value="<?= (int)$fn['id'] ?>">
|
||||
<?= htmlspecialchars($fn['name'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<a href="scadenzario/functions/index.php" target="_blank" class="btn btn-scad-outline" title="Gestisci funzioni">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" id="notify_function" name="notify_function" value="1">
|
||||
<label class="form-check-label" for="notify_function">
|
||||
Invia promemoria anche alla funzione selezionata
|
||||
</label>
|
||||
<div class="form-text">
|
||||
Se attivo, la mail giornaliera verrà inviata anche all’email collegata alla funzione.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="dlLaw" class="form-label fw-semibold">Legge / Articolo</label>
|
||||
<input type="text" class="form-control" id="dlLaw" name="law_regulation" maxlength="500" placeholder="es. D.Lgs. 81/2008, D.M. 10.03.1998...">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="dlTopic" class="form-label fw-semibold">Dettaglio <span class="text-danger">*</span></label>
|
||||
<textarea class="form-control" id="dlTopic" name="topic" required maxlength="500" rows="2" placeholder="es. Verifica estintori, Autorizzazione trasporto rifiuti..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 2: Date e frequenza -->
|
||||
<div class="form-section-title">Date e frequenza</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="dlRecurrence" class="form-label fw-semibold">Periodicità</label>
|
||||
<select class="form-select" id="dlRecurrence" name="recurrence_type">
|
||||
<option value="once">Una tantum</option>
|
||||
<option value="monthly">Mensile</option>
|
||||
<option value="quarterly">Trimestrale</option>
|
||||
<option value="semiannual">Semestrale</option>
|
||||
<option value="annual">Annuale</option>
|
||||
<option value="biennial">Biennale</option>
|
||||
<option value="triennial">Triennale</option>
|
||||
<option value="quadriennial">Quadriennale</option>
|
||||
<option value="quinquennial">Quinquennale</option>
|
||||
<option value="decennial">Decennale</option>
|
||||
<option value="quindecennial">Quindicennale</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="dlDocDate" class="form-label fw-semibold">Data documento</label>
|
||||
<input type="text" class="form-control js-date-it" id="dlDocDate" name="document_date" placeholder="gg/mm/aaaa" autocomplete="off">
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="dlDueDate" class="form-label fw-semibold">Data scadenza <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control js-date-it" id="dlDueDate" name="due_date" placeholder="gg/mm/aaaa" autocomplete="off" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="dlCheckDate" class="form-label fw-semibold">Data ultimo controllo</label>
|
||||
<input type="text" class="form-control js-date-it" id="dlCheckDate" name="check_date" placeholder="gg/mm/aaaa" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 3: Responsabili -->
|
||||
<div class="form-section-title">Esecutore</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<label for="dlDepartments" class="form-label fw-semibold">Reparti</label>
|
||||
<select class="form-select" id="dlDepartments" name="department_names[]" multiple>
|
||||
<?php foreach ($departments as $dept): ?>
|
||||
<option value="<?= htmlspecialchars($dept['name'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= htmlspecialchars($dept['name'], ENT_QUOTES, 'UTF-8') ?>
|
||||
<?= !empty($dept['code']) ? ' (' . htmlspecialchars($dept['code'], ENT_QUOTES, 'UTF-8') . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text">Tutto il reparto sarà responsabile</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="dlEmployees" class="form-label fw-semibold">Singoli responsabili</label>
|
||||
<select class="form-select" id="dlEmployees" name="employee_ids[]" multiple>
|
||||
<?php foreach ($employees as $emp): ?>
|
||||
<option value="<?= (int)$emp['id'] ?>">
|
||||
<?= htmlspecialchars($emp['first_name'] . ' ' . $emp['last_name'], ENT_QUOTES, 'UTF-8') ?><?php if (!empty($emp['department_name'])): ?> (<?= htmlspecialchars($emp['department_name'], ENT_QUOTES, 'UTF-8') ?>)<?php endif; ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 4: Dettagli aggiuntivi -->
|
||||
<div class="form-section-title">Dettagli aggiuntivi</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="dlNotifDays" class="form-label fw-semibold">Giorni preavviso</label>
|
||||
<input type="number" class="form-control" id="dlNotifDays" name="notification_days" value="7" min="1" max="365">
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<label for="dlStorage" class="form-label fw-semibold">Luogo archiviazione</label>
|
||||
<input type="text" class="form-control" id="dlStorage" name="storage_location" maxlength="500" placeholder="es. Armadio A3, Server/Documenti/Sicurezza...">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="dlNotes" class="form-label fw-semibold">Note</label>
|
||||
<textarea class="form-control" id="dlNotes" name="notes" rows="3" placeholder="es. Scadenza 09/06/2026, Attività in appalto a Ditta specializzata..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 5: Allegati -->
|
||||
<div class="form-section-title mt-4">Allegati</div>
|
||||
<div id="attachmentsList" class="mb-3"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label for="dlFiles" class="form-label fw-semibold">Carica file</label>
|
||||
<input type="file" class="form-control" id="dlFiles" multiple>
|
||||
<div class="form-text">Puoi selezionare più file contemporaneamente</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-scad-primary">
|
||||
<i class="fa-solid fa-check me-1"></i> Salva
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared styles for the deadline modal (deadline_modal.php).
|
||||
* Relies on the --scad-* CSS variables defined on each page's :root.
|
||||
*/
|
||||
?>
|
||||
<style>
|
||||
.form-section-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--scad-heading);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 2px solid #e8eeff;
|
||||
}
|
||||
|
||||
#deadlineModal.modal {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
#deadlineModal .modal-content,
|
||||
#deadlineModal .modal-body,
|
||||
#deadlineModal .modal-footer {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
#deadlineModal .modal-header {
|
||||
background: var(--scad-card-bg);
|
||||
border-bottom: 1px solid var(--scad-card-border);
|
||||
}
|
||||
|
||||
#deadlineModal .modal-title {
|
||||
font-weight: 700;
|
||||
color: var(--scad-heading);
|
||||
}
|
||||
|
||||
/* Attachment list in modal */
|
||||
.att-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 0.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.att-item .att-name {
|
||||
color: var(--scad-heading);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.att-item .att-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.att-item .att-actions a,
|
||||
.att-item .att-actions button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.3rem;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.att-item .att-download {
|
||||
background: #eef3ff;
|
||||
color: var(--scad-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.att-item .att-download:hover {
|
||||
background: var(--scad-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.att-item .att-remove {
|
||||
background: #fff0f0;
|
||||
color: var(--scad-red, #dc3545);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.att-item .att-remove:hover {
|
||||
background: var(--scad-red, #dc3545);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Self-contained JS for the deadline modal (deadline_modal.php).
|
||||
* Requires jQuery, Bootstrap, flatpickr, select2 and SweetAlert2 to be loaded first.
|
||||
*
|
||||
* Exposes:
|
||||
* window.openDeadlineCreate() — open the modal empty (new deadline)
|
||||
* window.openDeadlineEdit(id) — fetch a deadline and open the modal in edit mode
|
||||
*
|
||||
* Auto-open on load:
|
||||
* #edit=<id> → opens edit for that id
|
||||
* #edit → opens edit for window.SCAD_DETAIL_ID (used by detail.php)
|
||||
*/
|
||||
?>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
|
||||
// --- Flatpickr date fields (visible dd/mm/yyyy, submitted yyyy-mm-dd) ---
|
||||
var fpOptsDate = {
|
||||
dateFormat: 'Y-m-d',
|
||||
altInput: true,
|
||||
altFormat: 'd/m/Y',
|
||||
locale: 'it',
|
||||
allowInput: true
|
||||
};
|
||||
var fpDocDate = flatpickr('#dlDocDate', fpOptsDate);
|
||||
var fpDueDate = flatpickr('#dlDueDate', fpOptsDate);
|
||||
var fpCheckDate = flatpickr('#dlCheckDate', fpOptsDate);
|
||||
|
||||
// --- Select2 ---
|
||||
var s2Opts = {
|
||||
theme: 'bootstrap-5',
|
||||
allowClear: true,
|
||||
dropdownParent: $('#deadlineModal .modal-body'),
|
||||
language: 'it',
|
||||
width: '100%'
|
||||
};
|
||||
$('#dlSubject').select2($.extend({}, s2Opts, {
|
||||
placeholder: 'Seleziona argomento...'
|
||||
}));
|
||||
$('#dlDepartments').select2($.extend({}, s2Opts, {
|
||||
placeholder: 'Seleziona reparti...'
|
||||
}));
|
||||
$('#dlEmployees').select2($.extend({}, s2Opts, {
|
||||
placeholder: 'Seleziona persone...'
|
||||
}));
|
||||
$('#dlFunction').select2($.extend({}, s2Opts, {
|
||||
placeholder: 'Seleziona funzione...'
|
||||
}));
|
||||
|
||||
// --- Auto-calc due_date from document_date + recurrence ---
|
||||
var RECURRENCE_OFFSETS = {
|
||||
monthly: {
|
||||
months: 1
|
||||
},
|
||||
quarterly: {
|
||||
months: 3
|
||||
},
|
||||
semiannual: {
|
||||
months: 6
|
||||
},
|
||||
annual: {
|
||||
years: 1
|
||||
},
|
||||
biennial: {
|
||||
years: 2
|
||||
},
|
||||
triennial: {
|
||||
years: 3
|
||||
},
|
||||
quadriennial: {
|
||||
years: 4
|
||||
},
|
||||
quinquennial: {
|
||||
years: 5
|
||||
},
|
||||
decennial: {
|
||||
years: 10
|
||||
},
|
||||
quindecennial: {
|
||||
years: 15
|
||||
}
|
||||
};
|
||||
|
||||
function computeDueDate() {
|
||||
var docVal = document.getElementById('dlDocDate').value;
|
||||
var recurrence = document.getElementById('dlRecurrence').value;
|
||||
var offset = RECURRENCE_OFFSETS[recurrence];
|
||||
if (!docVal || !offset) return;
|
||||
var d = new Date(docVal + 'T00:00:00');
|
||||
if (isNaN(d.getTime())) return;
|
||||
if (offset.months) d.setMonth(d.getMonth() + offset.months);
|
||||
if (offset.years) d.setFullYear(d.getFullYear() + offset.years);
|
||||
var iso = d.getFullYear() + '-' +
|
||||
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(d.getDate()).padStart(2, '0');
|
||||
fpDueDate.setDate(iso, true, 'Y-m-d');
|
||||
}
|
||||
$('#dlDocDate, #dlRecurrence').on('change', computeDueDate);
|
||||
|
||||
// --- Modal instance ---
|
||||
var modal = new bootstrap.Modal(document.getElementById('deadlineModal'));
|
||||
var form = document.getElementById('deadlineForm');
|
||||
|
||||
// --- Render attachments list ---
|
||||
function renderAttachments(attachments) {
|
||||
var container = document.getElementById('attachmentsList');
|
||||
if (!attachments || attachments.length === 0) {
|
||||
container.innerHTML = '<div class="text-muted" style="font-size:0.85rem">Nessun allegato</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = attachments.map(function(a) {
|
||||
return '<div class="att-item" data-att-id="' + a.id + '">' +
|
||||
'<span class="att-name"><i class="fa-solid fa-paperclip me-1"></i>' + $('<span>').text(a.original_name).html() + '</span>' +
|
||||
'<span class="att-actions">' +
|
||||
'<a href="scadenzario/ajax/download_attachment.php?id=' + a.id + '" class="att-download" title="Scarica"><i class="fa-solid fa-download"></i></a>' +
|
||||
'<button type="button" class="att-remove" title="Elimina" data-att-id="' + a.id + '"><i class="fa-solid fa-xmark"></i></button>' +
|
||||
'</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// --- Open modal (create) ---
|
||||
window.openDeadlineCreate = function() {
|
||||
form.reset();
|
||||
document.getElementById('dlId').value = '';
|
||||
document.getElementById('dlNotifDays').value = '7';
|
||||
document.getElementById('modalTitle').textContent = 'Nuova Scadenza';
|
||||
document.getElementById('dlFiles').value = '';
|
||||
fpDocDate.clear();
|
||||
fpDueDate.clear();
|
||||
fpCheckDate.clear();
|
||||
$('#dlSubject').val('').trigger('change');
|
||||
$('#dlDepartments').val(null).trigger('change');
|
||||
$('#dlEmployees').val(null).trigger('change');
|
||||
$('#dlFunction').val('').trigger('change');
|
||||
$('#notify_function').prop('checked', false);
|
||||
renderAttachments([]);
|
||||
modal.show();
|
||||
};
|
||||
|
||||
// --- Open modal (edit) ---
|
||||
window.openDeadlineEdit = function(id) {
|
||||
fetch('scadenzario/ajax/get_deadline.php?id=' + id)
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (!data.success) {
|
||||
Swal.fire('Errore', data.message, 'error');
|
||||
return;
|
||||
}
|
||||
var d = data.data;
|
||||
document.getElementById('dlId').value = d.id;
|
||||
$('#dlSubject').val(d.subject_id || '').trigger('change');
|
||||
document.getElementById('dlTopic').value = d.topic || '';
|
||||
$('#dlFunction').val(d.function_id || '').trigger('change');
|
||||
$('#notify_function').prop('checked', Number(d.notify_function) === 1);
|
||||
document.getElementById('dlLaw').value = d.law_regulation || '';
|
||||
document.getElementById('dlRecurrence').value = d.recurrence_type || 'once';
|
||||
fpDocDate.setDate(d.document_date || null, false, 'Y-m-d');
|
||||
fpDueDate.setDate(d.due_date || null, false, 'Y-m-d');
|
||||
fpCheckDate.setDate(d.check_date || null, false, 'Y-m-d');
|
||||
document.getElementById('dlNotifDays').value = d.notification_days || 7;
|
||||
document.getElementById('dlStorage').value = d.storage_location || '';
|
||||
document.getElementById('dlNotes').value = d.notes || '';
|
||||
document.getElementById('dlFiles').value = '';
|
||||
document.getElementById('modalTitle').textContent = 'Modifica Scadenza';
|
||||
$('#dlDepartments').val(d.department_names || []).trigger('change');
|
||||
if (Array.isArray(d.employee_ids)) {
|
||||
$('#dlEmployees').val(d.employee_ids.map(String)).trigger('change');
|
||||
} else {
|
||||
$('#dlEmployees').val(null).trigger('change');
|
||||
}
|
||||
renderAttachments(d.attachments || []);
|
||||
modal.show();
|
||||
})
|
||||
.catch(function() {
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
// --- Save ---
|
||||
var isSaving = false;
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
if (isSaving) return;
|
||||
isSaving = true;
|
||||
var saveBtn = form.querySelector('[type="submit"]');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin me-1"></i> Salvataggio...';
|
||||
var formData = new FormData(form);
|
||||
|
||||
fetch('scadenzario/ajax/save_deadline.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
var deadlineId = data.id;
|
||||
var fileInput = document.getElementById('dlFiles');
|
||||
if (fileInput.files.length > 0) {
|
||||
var fileData = new FormData();
|
||||
fileData.append('deadline_id', deadlineId);
|
||||
for (var i = 0; i < fileInput.files.length; i++) {
|
||||
fileData.append('files[]', fileInput.files[i]);
|
||||
}
|
||||
return fetch('scadenzario/ajax/upload_attachment.php', {
|
||||
method: 'POST',
|
||||
body: fileData
|
||||
})
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(upData) {
|
||||
modal.hide();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Salvato',
|
||||
text: data.message + ' ' + upData.message,
|
||||
timer: 2000,
|
||||
showConfirmButton: false
|
||||
})
|
||||
.then(function() {
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
modal.hide();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Salvato',
|
||||
text: data.message,
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
})
|
||||
.then(function() {
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
Swal.fire('Errore', data.message, 'error');
|
||||
isSaving = false;
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="fa-solid fa-check me-1"></i> Salva';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
isSaving = false;
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="fa-solid fa-check me-1"></i> Salva';
|
||||
});
|
||||
});
|
||||
|
||||
// --- Delete attachment ---
|
||||
$(document).on('click', '.att-remove', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = $(this);
|
||||
var attId = btn.data('att-id');
|
||||
Swal.fire({
|
||||
title: 'Rimuovere l\'allegato?',
|
||||
text: 'Il collegamento verrà rimosso da questa scadenza. Il file resta disponibile se è usato da altre scadenze.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#dc3545',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonText: 'Rimuovi',
|
||||
reverseButtons: true
|
||||
}).then(function(result) {
|
||||
if (result.isConfirmed) {
|
||||
fetch('scadenzario/ajax/delete_attachment.php?id=' + attId)
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
btn.closest('.att-item').remove();
|
||||
if ($('#attachmentsList .att-item').length === 0) {
|
||||
renderAttachments([]);
|
||||
}
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Fatto',
|
||||
text: data.message,
|
||||
timer: 1800,
|
||||
showConfirmButton: false
|
||||
});
|
||||
} else {
|
||||
Swal.fire('Errore', data.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Auto-open from hash (#edit=ID or #edit for the current detail page) ---
|
||||
var hash = window.location.hash;
|
||||
var hashMatch = hash.match(/^#edit=(\d+)$/);
|
||||
var autoEditId = hashMatch ? hashMatch[1] :
|
||||
(hash === '#edit' && window.SCAD_DETAIL_ID ? window.SCAD_DETAIL_ID : null);
|
||||
if (autoEditId) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
window.openDeadlineEdit(autoEditId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Renders two status banners for the current user:
|
||||
* - red -> overdue deadlines (scaduta)
|
||||
@@ -44,63 +45,90 @@ if (!$_emp || ($_overdue === 0 && $_approaching === 0)) {
|
||||
?>
|
||||
<style>
|
||||
.my-deadlines-widgets {
|
||||
display: flex; flex-wrap: wrap; gap: 0.75rem;
|
||||
margin-bottom: 1rem; width: 100%;
|
||||
}
|
||||
.my-deadlines-widgets:empty { display: none; }
|
||||
/* When two widget containers are nested inside an outer .my-deadlines-widgets
|
||||
(e.g. on the production dashboard), let their children flow into the outer flex. */
|
||||
.my-deadlines-widgets .my-deadlines-widgets {
|
||||
display: contents;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw {
|
||||
flex: 1 1 0; min-width: 0;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.8rem 0.9rem; border-radius: 0.6rem;
|
||||
text-decoration: none; color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
flex: 1 1 260px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 0.6rem;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.my-deadlines-widgets .mdw:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); color: #fff; }
|
||||
.my-deadlines-widgets .mdw-red { background: linear-gradient(135deg, #dc3545 0%, #b02a37 100%); }
|
||||
.my-deadlines-widgets .mdw-orange { background: linear-gradient(135deg, #e8930c 0%, #c77a00 100%); }
|
||||
.my-deadlines-widgets .mdw-gray { background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); }
|
||||
|
||||
.my-deadlines-widgets .mdw:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-red {
|
||||
background: linear-gradient(135deg, #dc3545 0%, #b02a37 100%);
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-orange {
|
||||
background: linear-gradient(135deg, #e8930c 0%, #c77a00 100%);
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-icon {
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255,255,255,0.22); font-size: 1.05rem; flex-shrink: 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.my-deadlines-widgets .mdw-body { flex: 1; line-height: 1.2; min-width: 0; }
|
||||
.my-deadlines-widgets .mdw-count { font-size: 1.5rem; font-weight: 700; }
|
||||
.my-deadlines-widgets .mdw-label { font-size: 0.78rem; opacity: 0.95;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.my-deadlines-widgets .mdw-arrow { opacity: 0.7; font-size: 0.85rem; flex-shrink: 0; }
|
||||
@media (max-width: 991.98px) {
|
||||
.my-deadlines-widgets .mdw { flex: 1 1 calc(50% - 0.375rem); }
|
||||
|
||||
.my-deadlines-widgets .mdw-body {
|
||||
flex: 1;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@media (max-width: 575.98px) {
|
||||
.my-deadlines-widgets .mdw { flex: 1 1 100%; }
|
||||
|
||||
.my-deadlines-widgets .mdw-count {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-label {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.my-deadlines-widgets .mdw-arrow {
|
||||
opacity: 0.7;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
<div class="my-deadlines-widgets">
|
||||
<?php if ($_overdue > 0): ?>
|
||||
<a class="mdw mdw-red" href="scadenzario/index.php?filter_my=1&filter_status=scaduta">
|
||||
<span class="mdw-icon"><i class="fa-solid fa-triangle-exclamation"></i></span>
|
||||
<span class="mdw-body">
|
||||
<span class="mdw-count"><?= $_overdue ?></span>
|
||||
<span class="mdw-label d-block">Scadenz<?= $_overdue === 1 ? 'a' : 'e' ?> scadut<?= $_overdue === 1 ? 'a' : 'e' ?> — <?= $_dept !== '' ? htmlspecialchars($_dept, ENT_QUOTES, 'UTF-8') : 'personali' ?></span>
|
||||
</span>
|
||||
<span class="mdw-arrow"><i class="fa-solid fa-arrow-right"></i></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($_approaching > 0): ?>
|
||||
<a class="mdw mdw-orange" href="scadenzario/index.php?filter_my=1&filter_status=in-scadenza">
|
||||
<span class="mdw-icon"><i class="fa-solid fa-clock"></i></span>
|
||||
<span class="mdw-body">
|
||||
<span class="mdw-count"><?= $_approaching ?></span>
|
||||
<span class="mdw-label d-block">In scadenza a breve — <?= $_dept !== '' ? htmlspecialchars($_dept, ENT_QUOTES, 'UTF-8') : 'personali' ?></span>
|
||||
</span>
|
||||
<span class="mdw-arrow"><i class="fa-solid fa-arrow-right"></i></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($_overdue > 0): ?>
|
||||
<a class="mdw mdw-red" href="scadenzario/index.php?filter_my=1&filter_status=scaduta">
|
||||
<span class="mdw-icon"><i class="fa-solid fa-triangle-exclamation"></i></span>
|
||||
<span class="mdw-body">
|
||||
<span class="mdw-count"><?= $_overdue ?></span>
|
||||
<span class="mdw-label d-block">Task<?= $_overdue === 1 ? '' : 's' ?> scadut<?= $_overdue === 1 ? 'o' : 'i' ?> — <?= $_dept !== '' ? htmlspecialchars($_dept, ENT_QUOTES, 'UTF-8') : 'personali' ?></span>
|
||||
</span>
|
||||
<span class="mdw-arrow"><i class="fa-solid fa-arrow-right"></i></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($_approaching > 0): ?>
|
||||
<a class="mdw mdw-orange" href="scadenzario/index.php?filter_my=1&filter_status=in-scadenza">
|
||||
<span class="mdw-icon"><i class="fa-solid fa-clock"></i></span>
|
||||
<span class="mdw-body">
|
||||
<span class="mdw-count"><?= $_approaching ?></span>
|
||||
<span class="mdw-label d-block">In scadenza a breve — <?= $_dept !== '' ? htmlspecialchars($_dept, ENT_QUOTES, 'UTF-8') : 'personali' ?></span>
|
||||
</span>
|
||||
<span class="mdw-arrow"><i class="fa-solid fa-arrow-right"></i></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -37,12 +37,14 @@ $sql = "
|
||||
SELECT d.*,
|
||||
s.name AS subject_name,
|
||||
s.color AS subject_color,
|
||||
f.name AS function_name,
|
||||
GROUP_CONCAT(DISTINCT CONCAT(e.first_name, ' ', e.last_name) ORDER BY e.first_name SEPARATOR ', ') as responsabili,
|
||||
GROUP_CONCAT(DISTINCT dep.name ORDER BY dep.name SEPARATOR ', ') as reparti_persone,
|
||||
d.departments as reparti_assegnati,
|
||||
(SELECT COUNT(*) FROM scad_deadline_attachments att WHERE att.deadline_id = d.id) as attachment_count
|
||||
FROM scad_deadlines d
|
||||
LEFT JOIN scad_subjects s ON s.id = d.subject_id
|
||||
LEFT JOIN scad_functions f ON f.id = d.function_id
|
||||
LEFT JOIN scad_deadline_employee de ON de.deadline_id = d.id
|
||||
LEFT JOIN employees e ON e.id = de.employee_id
|
||||
LEFT JOIN departments dep ON dep.id = e.department_id
|
||||
@@ -69,27 +71,7 @@ $stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$deadlines = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$employees = $pdo->query("
|
||||
SELECT
|
||||
e.id,
|
||||
e.first_name,
|
||||
e.last_name,
|
||||
e.department_id,
|
||||
dep.name AS department_name
|
||||
FROM employees e
|
||||
LEFT JOIN departments dep ON dep.id = e.department_id
|
||||
WHERE e.status = 'active'
|
||||
ORDER BY e.first_name, e.last_name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$departments = $pdo->query("
|
||||
SELECT id, name, code, color
|
||||
FROM departments
|
||||
WHERE is_active = 1
|
||||
ORDER BY sort_order ASC, name ASC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$subjects = $pdo->query("SELECT id, name, color FROM scad_subjects ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
|
||||
require __DIR__ . '/include/deadline_form_data.php';
|
||||
|
||||
$today = date('Y-m-d');
|
||||
|
||||
@@ -494,7 +476,8 @@ function getContrastTextColor($hexColor)
|
||||
}
|
||||
|
||||
#deadlinesTable td:first-child {
|
||||
max-width: 150px;
|
||||
max-width: 110px;
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
/* Attachment list in modal */
|
||||
@@ -824,6 +807,9 @@ function getContrastTextColor($hexColor)
|
||||
<a href="scadenzario/subjects/index.php" class="btn btn-scad-outline d-none d-md-inline-flex align-items-center gap-2">
|
||||
<i class="fa-solid fa-tags"></i><span>Argomenti</span>
|
||||
</a>
|
||||
<a href="scadenzario/functions/index.php" class="btn btn-scad-outline d-none d-md-inline-flex align-items-center gap-2">
|
||||
<i class="fa-solid fa-briefcase"></i><span>Funzioni</span>
|
||||
</a>
|
||||
<a href="scadenzario/calendar.php" class="btn btn-scad-outline d-none d-md-inline-flex align-items-center gap-2">
|
||||
<i class="fa-solid fa-calendar-days"></i><span>Calendario</span>
|
||||
</a>
|
||||
@@ -842,6 +828,7 @@ function getContrastTextColor($hexColor)
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item d-flex align-items-center gap-2" href="scadenzario/subjects/index.php"><i class="fa-solid fa-tags"></i> Argomenti</a></li>
|
||||
<li><a class="dropdown-item d-flex align-items-center gap-2" href="scadenzario/functions/index.php"><i class="fa-solid fa-briefcase"></i> Funzioni</a></li>
|
||||
<li><a class="dropdown-item d-flex align-items-center gap-2" href="scadenzario/calendar.php"><i class="fa-solid fa-calendar-days"></i> Calendario</a></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2" id="btnStampaMobile"><i class="fa-solid fa-print"></i> Stampa</button></li>
|
||||
</ul>
|
||||
@@ -923,7 +910,9 @@ function getContrastTextColor($hexColor)
|
||||
data-department="<?= htmlspecialchars($row['reparti'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-employees="<?= htmlspecialchars($row['responsabili'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-due-date="<?= htmlspecialchars($row['due_date'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-check-date="<?= htmlspecialchars($row['check_date'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
|
||||
data-check-date="<?= htmlspecialchars($row['check_date'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-recurrence="<?= htmlspecialchars($row['recurrence_type'] ?? 'once', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-att-count="<?= (int)$row['attachment_count'] ?>">
|
||||
<?php if (!empty($row['subject_name'])): ?>
|
||||
<div class="mb-1"><?php
|
||||
$subjectBadgeBg = $row['subject_color'] ?: '#6c757d';
|
||||
@@ -972,12 +961,13 @@ function getContrastTextColor($hexColor)
|
||||
<table id="deadlinesTable" class="table table-hover align-middle mb-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Argomento</th>
|
||||
<th style="width:110px">Argomento</th>
|
||||
<th>Dettaglio</th>
|
||||
<th class="d-none d-lg-table-cell">Legge/Art.</th>
|
||||
<th>Scadenza</th>
|
||||
<th class="d-none d-lg-table-cell">Verifica</th>
|
||||
<th>Responsabili</th>
|
||||
<th>Funzione</th>
|
||||
<th>Esecutore</th>
|
||||
<th>Stato</th>
|
||||
<th class="text-center" style="width:120px">Azioni</th>
|
||||
</tr>
|
||||
@@ -1000,7 +990,9 @@ function getContrastTextColor($hexColor)
|
||||
data-department="<?= htmlspecialchars($row['reparti'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-employees="<?= htmlspecialchars($row['responsabili'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-due-date="<?= htmlspecialchars($row['due_date'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-check-date="<?= htmlspecialchars($row['check_date'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
|
||||
data-check-date="<?= htmlspecialchars($row['check_date'] ?? '', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-recurrence="<?= htmlspecialchars($row['recurrence_type'] ?? 'once', ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-att-count="<?= (int)$row['attachment_count'] ?>">
|
||||
<td>
|
||||
<?php if (!empty($row['subject_name'])): ?>
|
||||
<?php
|
||||
@@ -1014,6 +1006,7 @@ function getContrastTextColor($hexColor)
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<a href="scadenzario/detail.php?id=<?= (int)$row['id'] ?>" class="fw-semibold text-decoration-none" style="color:var(--scad-heading)"><?= htmlspecialchars($row['topic'], ENT_QUOTES, 'UTF-8') ?></a>
|
||||
<?php if ((int)$row['attachment_count'] > 0): ?>
|
||||
@@ -1023,6 +1016,17 @@ function getContrastTextColor($hexColor)
|
||||
<td class="d-none d-lg-table-cell text-muted"><?= htmlspecialchars($row['law_regulation'] ?? '—', ENT_QUOTES, 'UTF-8') ?></td>
|
||||
<td><span class="text-nowrap"><?= $row['_dueFmt'] ?></span></td>
|
||||
<td class="d-none d-lg-table-cell text-muted"><?= $row['_checkFmt'] ?></td>
|
||||
|
||||
<td>
|
||||
<?php if (!empty($row['function_name'])): ?>
|
||||
<span class="text-muted">
|
||||
<i class="fa-solid fa-briefcase me-1"></i>
|
||||
<?= htmlspecialchars($row['function_name'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($row['reparti']): ?><span class="text-muted"><i class="fa-regular fa-building me-1"></i><?= htmlspecialchars($row['reparti'], ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?>
|
||||
<?php if ($row['reparti'] && $row['responsabili']): ?><br><?php endif; ?>
|
||||
@@ -1055,143 +1059,7 @@ function getContrastTextColor($hexColor)
|
||||
<?php include('../include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<!-- Deadline Modal -->
|
||||
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-fullscreen-sm-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modalTitle">Nuova Scadenza</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||||
</div>
|
||||
<form id="deadlineForm">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="dlId" name="id" value="">
|
||||
|
||||
<!-- Group 1: Informazioni principali -->
|
||||
<div class="form-section-title">Informazioni principali</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="dlSubject" class="form-label fw-semibold">Argomento</label>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select" id="dlSubject" name="subject_id" style="flex:1">
|
||||
<option value="">— Nessuno —</option>
|
||||
<?php foreach ($subjects as $s): ?>
|
||||
<option value="<?= (int)$s['id'] ?>" data-color="<?= htmlspecialchars($s['color'], ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($s['name'], ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<a href="scadenzario/subjects/index.php" target="_blank" class="btn btn-scad-outline" title="Gestisci argomenti">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="dlLaw" class="form-label fw-semibold">Legge / Articolo</label>
|
||||
<input type="text" class="form-control" id="dlLaw" name="law_regulation" maxlength="500" placeholder="es. D.Lgs. 81/2008, D.M. 10.03.1998...">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="dlTopic" class="form-label fw-semibold">Dettaglio <span class="text-danger">*</span></label>
|
||||
<textarea class="form-control" id="dlTopic" name="topic" required maxlength="500" rows="2" placeholder="es. Verifica estintori, Autorizzazione trasporto rifiuti..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 2: Date e frequenza -->
|
||||
<div class="form-section-title">Date e frequenza</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="dlRecurrence" class="form-label fw-semibold">Periodicità</label>
|
||||
<select class="form-select" id="dlRecurrence" name="recurrence_type">
|
||||
<option value="once">Una tantum</option>
|
||||
<option value="monthly">Mensile</option>
|
||||
<option value="quarterly">Trimestrale</option>
|
||||
<option value="semiannual">Semestrale</option>
|
||||
<option value="annual">Annuale</option>
|
||||
<option value="biennial">Biennale</option>
|
||||
<option value="triennial">Triennale</option>
|
||||
<option value="quadriennial">Quadriennale</option>
|
||||
<option value="quinquennial">Quinquennale</option>
|
||||
<option value="decennial">Decennale</option>
|
||||
<option value="quindecennial">Quindicennale</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="dlDocDate" class="form-label fw-semibold">Data documento</label>
|
||||
<input type="date" class="form-control" id="dlDocDate" name="document_date">
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="dlDueDate" class="form-label fw-semibold">Data scadenza <span class="text-danger">*</span></label>
|
||||
<input type="date" class="form-control" id="dlDueDate" name="due_date" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="dlCheckDate" class="form-label fw-semibold">Data ultimo controllo</label>
|
||||
<input type="date" class="form-control" id="dlCheckDate" name="check_date">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 3: Responsabili -->
|
||||
<div class="form-section-title">Responsabili</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<label for="dlDepartments" class="form-label fw-semibold">Reparti</label>
|
||||
<select class="form-select" id="dlDepartments" name="department_names[]" multiple>
|
||||
<?php foreach ($departments as $dept): ?>
|
||||
<option value="<?= htmlspecialchars($dept['name'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?= htmlspecialchars($dept['name'], ENT_QUOTES, 'UTF-8') ?>
|
||||
<?= !empty($dept['code']) ? ' (' . htmlspecialchars($dept['code'], ENT_QUOTES, 'UTF-8') . ')' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text">Tutto il reparto sarà responsabile</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="dlEmployees" class="form-label fw-semibold">Singoli responsabili</label>
|
||||
<select class="form-select" id="dlEmployees" name="employee_ids[]" multiple>
|
||||
<?php foreach ($employees as $emp): ?>
|
||||
<option value="<?= (int)$emp['id'] ?>">
|
||||
<?= htmlspecialchars($emp['first_name'] . ' ' . $emp['last_name'], ENT_QUOTES, 'UTF-8') ?><?php if (!empty($emp['department_name'])): ?> (<?= htmlspecialchars($emp['department_name'], ENT_QUOTES, 'UTF-8') ?>)<?php endif; ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 4: Dettagli aggiuntivi -->
|
||||
<div class="form-section-title">Dettagli aggiuntivi</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="dlNotifDays" class="form-label fw-semibold">Giorni preavviso</label>
|
||||
<input type="number" class="form-control" id="dlNotifDays" name="notification_days" value="7" min="1" max="365">
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<label for="dlStorage" class="form-label fw-semibold">Luogo archiviazione</label>
|
||||
<input type="text" class="form-control" id="dlStorage" name="storage_location" maxlength="500" placeholder="es. Armadio A3, Server/Documenti/Sicurezza...">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="dlNotes" class="form-label fw-semibold">Note</label>
|
||||
<textarea class="form-control" id="dlNotes" name="notes" rows="3" placeholder="es. Scadenza 09/06/2026, Attività in appalto a Ditta specializzata..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 5: Allegati -->
|
||||
<div class="form-section-title mt-4">Allegati</div>
|
||||
<div id="attachmentsList" class="mb-3"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label for="dlFiles" class="form-label fw-semibold">Carica file</label>
|
||||
<input type="file" class="form-control" id="dlFiles" multiple>
|
||||
<div class="form-text">Puoi selezionare più file contemporaneamente</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-scad-primary">
|
||||
<i class="fa-solid fa-check me-1"></i> Salva
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php include __DIR__ . '/include/deadline_modal.php'; ?>
|
||||
|
||||
<?php include('../jsinclude.php'); ?>
|
||||
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
|
||||
@@ -1221,84 +1089,6 @@ function getContrastTextColor($hexColor)
|
||||
var fpDue = flatpickr('#filterDueRange', fpOpts);
|
||||
var fpCheck = flatpickr('#filterCheckRange', fpOpts);
|
||||
|
||||
// --- Select2 ---
|
||||
$('#dlSubject').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: 'Seleziona argomento...',
|
||||
allowClear: true,
|
||||
dropdownParent: $('#deadlineModal .modal-body'),
|
||||
language: 'it',
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
$('#dlDepartments').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: 'Seleziona reparti...',
|
||||
allowClear: true,
|
||||
dropdownParent: $('#deadlineModal .modal-body'),
|
||||
language: 'it',
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
$('#dlEmployees').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: 'Seleziona persone...',
|
||||
allowClear: true,
|
||||
dropdownParent: $('#deadlineModal .modal-body'),
|
||||
language: 'it',
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
// --- Auto-calc due_date from document_date + recurrence ---
|
||||
var RECURRENCE_OFFSETS = {
|
||||
monthly: {
|
||||
months: 1
|
||||
},
|
||||
quarterly: {
|
||||
months: 3
|
||||
},
|
||||
semiannual: {
|
||||
months: 6
|
||||
},
|
||||
annual: {
|
||||
years: 1
|
||||
},
|
||||
biennial: {
|
||||
years: 2
|
||||
},
|
||||
triennial: {
|
||||
years: 3
|
||||
},
|
||||
quadriennial: {
|
||||
years: 4
|
||||
},
|
||||
quinquennial: {
|
||||
years: 5
|
||||
},
|
||||
decennial: {
|
||||
years: 10
|
||||
},
|
||||
quindecennial: {
|
||||
years: 15
|
||||
}
|
||||
};
|
||||
|
||||
function computeDueDate() {
|
||||
var docVal = document.getElementById('dlDocDate').value;
|
||||
var recurrence = document.getElementById('dlRecurrence').value;
|
||||
var offset = RECURRENCE_OFFSETS[recurrence];
|
||||
if (!docVal || !offset) return;
|
||||
var d = new Date(docVal + 'T00:00:00');
|
||||
if (isNaN(d.getTime())) return;
|
||||
if (offset.months) d.setMonth(d.getMonth() + offset.months);
|
||||
if (offset.years) d.setFullYear(d.getFullYear() + offset.years);
|
||||
var iso = d.getFullYear() + '-' +
|
||||
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(d.getDate()).padStart(2, '0');
|
||||
document.getElementById('dlDueDate').value = iso;
|
||||
}
|
||||
$('#dlDocDate, #dlRecurrence').on('change', computeDueDate);
|
||||
|
||||
// --- DataTables custom filters ---
|
||||
$.fn.dataTable.ext.search.push(function(settings, data, dataIndex) {
|
||||
if (settings.nTable.id !== 'deadlinesTable') return true;
|
||||
@@ -1460,148 +1250,8 @@ function getContrastTextColor($hexColor)
|
||||
// Apply default filter on load
|
||||
applyFiltersRefresh();
|
||||
|
||||
// --- Modal ---
|
||||
var modal = new bootstrap.Modal(document.getElementById('deadlineModal'));
|
||||
var form = document.getElementById('deadlineForm');
|
||||
|
||||
// Add
|
||||
document.getElementById('btnAddDeadline').addEventListener('click', function() {
|
||||
form.reset();
|
||||
document.getElementById('dlId').value = '';
|
||||
document.getElementById('dlNotifDays').value = '7';
|
||||
document.getElementById('modalTitle').textContent = 'Nuova Scadenza';
|
||||
document.getElementById('dlFiles').value = '';
|
||||
$('#dlSubject').val('').trigger('change');
|
||||
$('#dlDepartments').val(null).trigger('change');
|
||||
$('#dlEmployees').val(null).trigger('change');
|
||||
renderAttachments([]);
|
||||
modal.show();
|
||||
});
|
||||
|
||||
// Save
|
||||
var isSaving = false;
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
if (isSaving) return;
|
||||
isSaving = true;
|
||||
var saveBtn = form.querySelector('[type="submit"]');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin me-1"></i> Salvataggio...';
|
||||
var formData = new FormData(form);
|
||||
|
||||
fetch('scadenzario/ajax/save_deadline.php', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
var deadlineId = data.id;
|
||||
var fileInput = document.getElementById('dlFiles');
|
||||
if (fileInput.files.length > 0) {
|
||||
// Upload files
|
||||
var fileData = new FormData();
|
||||
fileData.append('deadline_id', deadlineId);
|
||||
for (var i = 0; i < fileInput.files.length; i++) {
|
||||
fileData.append('files[]', fileInput.files[i]);
|
||||
}
|
||||
return fetch('scadenzario/ajax/upload_attachment.php', {
|
||||
method: 'POST',
|
||||
body: fileData
|
||||
})
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(upData) {
|
||||
modal.hide();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Salvato',
|
||||
text: data.message + ' ' + upData.message,
|
||||
timer: 2000,
|
||||
showConfirmButton: false
|
||||
})
|
||||
.then(function() {
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
modal.hide();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Salvato',
|
||||
text: data.message,
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
})
|
||||
.then(function() {
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
Swal.fire('Errore', data.message, 'error');
|
||||
isSaving = false;
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="fa-solid fa-check me-1"></i> Salva';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
isSaving = false;
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="fa-solid fa-check me-1"></i> Salva';
|
||||
});
|
||||
});
|
||||
|
||||
// Render attachments list
|
||||
function renderAttachments(attachments) {
|
||||
var container = document.getElementById('attachmentsList');
|
||||
if (!attachments || attachments.length === 0) {
|
||||
container.innerHTML = '<div class="text-muted" style="font-size:0.85rem">Nessun allegato</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = attachments.map(function(a) {
|
||||
return '<div class="att-item" data-att-id="' + a.id + '">' +
|
||||
'<span class="att-name"><i class="fa-solid fa-paperclip me-1"></i>' + $('<span>').text(a.original_name).html() + '</span>' +
|
||||
'<span class="att-actions">' +
|
||||
'<a href="scadenzario/ajax/download_attachment.php?id=' + a.id + '" class="att-download" title="Scarica"><i class="fa-solid fa-download"></i></a>' +
|
||||
'<button type="button" class="att-remove" title="Elimina" data-att-id="' + a.id + '"><i class="fa-solid fa-xmark"></i></button>' +
|
||||
'</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Delete attachment
|
||||
$(document).on('click', '.att-remove', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = $(this);
|
||||
var attId = btn.data('att-id');
|
||||
Swal.fire({
|
||||
title: 'Eliminare allegato?',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#dc3545',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonText: 'Elimina'
|
||||
}).then(function(result) {
|
||||
if (result.isConfirmed) {
|
||||
fetch('scadenzario/ajax/delete_attachment.php?id=' + attId)
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
btn.closest('.att-item').remove();
|
||||
if ($('#attachmentsList .att-item').length === 0) {
|
||||
renderAttachments([]);
|
||||
}
|
||||
} else {
|
||||
Swal.fire('Errore', data.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (window.openDeadlineCreate) window.openDeadlineCreate();
|
||||
});
|
||||
|
||||
// Edit with confirmation
|
||||
@@ -1618,91 +1268,103 @@ function getContrastTextColor($hexColor)
|
||||
confirmButtonText: 'Sì, modifica',
|
||||
reverseButtons: true
|
||||
}).then(function(result) {
|
||||
if (!result.isConfirmed) {
|
||||
return;
|
||||
if (result.isConfirmed && window.openDeadlineEdit) {
|
||||
window.openDeadlineEdit(id);
|
||||
}
|
||||
|
||||
fetch('scadenzario/ajax/get_deadline.php?id=' + id)
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (!data.success) {
|
||||
Swal.fire('Errore', data.message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var d = data.data;
|
||||
|
||||
document.getElementById('dlId').value = d.id;
|
||||
$('#dlSubject').val(d.subject_id || '').trigger('change');
|
||||
document.getElementById('dlTopic').value = d.topic || '';
|
||||
document.getElementById('dlLaw').value = d.law_regulation || '';
|
||||
document.getElementById('dlRecurrence').value = d.recurrence_type || 'once';
|
||||
document.getElementById('dlDocDate').value = d.document_date || '';
|
||||
document.getElementById('dlDueDate').value = d.due_date || '';
|
||||
document.getElementById('dlCheckDate').value = d.check_date || '';
|
||||
document.getElementById('dlNotifDays').value = d.notification_days || 7;
|
||||
document.getElementById('dlStorage').value = d.storage_location || '';
|
||||
document.getElementById('dlNotes').value = d.notes || '';
|
||||
document.getElementById('dlFiles').value = '';
|
||||
|
||||
document.getElementById('modalTitle').textContent = 'Modifica Scadenza';
|
||||
|
||||
$('#dlDepartments').val(d.department_names || []).trigger('change');
|
||||
|
||||
if (Array.isArray(d.employee_ids)) {
|
||||
$('#dlEmployees').val(d.employee_ids.map(String)).trigger('change');
|
||||
} else {
|
||||
$('#dlEmployees').val(null).trigger('change');
|
||||
}
|
||||
|
||||
renderAttachments(d.attachments || []);
|
||||
|
||||
modal.show();
|
||||
})
|
||||
.catch(function() {
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Complete
|
||||
function submitComplete(id, createNext, copyAttachments) {
|
||||
var fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('create_next', createNext ? '1' : '0');
|
||||
fd.append('copy_attachments', copyAttachments ? '1' : '0');
|
||||
|
||||
fetch('scadenzario/ajax/complete_deadline.php', {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
})
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Completata',
|
||||
text: data.message,
|
||||
timer: 1800,
|
||||
showConfirmButton: false
|
||||
})
|
||||
.then(function() {
|
||||
// Open the new deadline's detail page with the edit modal auto-opening
|
||||
if (data.new_id) {
|
||||
window.location = 'scadenzario/detail.php?id=' + data.new_id + '#edit';
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Swal.fire('Errore', data.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('click', '.btn-complete', function() {
|
||||
var el = $(this).closest('[data-id]');
|
||||
var id = el.data('id');
|
||||
var recurrence = el.data('recurrence') || 'once';
|
||||
var attCount = parseInt(el.data('att-count'), 10) || 0;
|
||||
|
||||
// Non-recurring: simple confirm, no new deadline is created
|
||||
if (recurrence === 'once') {
|
||||
Swal.fire({
|
||||
title: 'Completare la scadenza?',
|
||||
text: 'La scadenza verrà contrassegnata come completata.',
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#198754',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonText: 'Completa',
|
||||
reverseButtons: true
|
||||
}).then(function(result) {
|
||||
if (result.isConfirmed) {
|
||||
submitComplete(id, false, false);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurring: ask whether to create the next deadline; optionally carry attachments over
|
||||
var attCheckbox = attCount > 0 ?
|
||||
'<div class="form-check d-flex align-items-center justify-content-center gap-2 mt-3">' +
|
||||
'<input class="form-check-input" type="checkbox" id="swCopyAtt" checked>' +
|
||||
'<label class="form-check-label" for="swCopyAtt">Copia gli allegati (' + attCount + ') sulla nuova scadenza</label>' +
|
||||
'</div>' :
|
||||
'';
|
||||
|
||||
Swal.fire({
|
||||
title: 'Completare la scadenza?',
|
||||
html: 'Vuoi creare automaticamente la prossima scadenza ricorrente?' + attCheckbox,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
showDenyButton: true,
|
||||
confirmButtonColor: '#198754',
|
||||
denyButtonColor: '#6c757d',
|
||||
confirmButtonText: 'Completa e crea la prossima',
|
||||
denyButtonText: 'Completa senza nuova',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonText: 'Completa'
|
||||
reverseButtons: true
|
||||
}).then(function(result) {
|
||||
if (result.isConfirmed) {
|
||||
fetch('scadenzario/ajax/complete_deadline.php?id=' + id)
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Completata',
|
||||
text: data.message,
|
||||
timer: 2500,
|
||||
showConfirmButton: false
|
||||
})
|
||||
.then(function() {
|
||||
location.reload();
|
||||
});
|
||||
} else {
|
||||
Swal.fire('Errore', data.message, 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
});
|
||||
var copy = attCount > 0 ? document.getElementById('swCopyAtt').checked : false;
|
||||
submitComplete(id, true, copy);
|
||||
} else if (result.isDenied) {
|
||||
submitComplete(id, false, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1748,38 +1410,6 @@ function getContrastTextColor($hexColor)
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-open edit modal from ?edit=ID
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var editId = urlParams.get('edit');
|
||||
if (editId) {
|
||||
history.replaceState(null, '', 'scadenzario/index.php');
|
||||
fetch('scadenzario/ajax/get_deadline.php?id=' + editId)
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var d = data.data;
|
||||
document.getElementById('dlId').value = d.id;
|
||||
$('#dlSubject').val(d.subject_id || '').trigger('change');
|
||||
document.getElementById('dlTopic').value = d.topic || '';
|
||||
document.getElementById('dlLaw').value = d.law_regulation || '';
|
||||
document.getElementById('dlRecurrence').value = d.recurrence_type || 'once';
|
||||
document.getElementById('dlDocDate').value = d.document_date || '';
|
||||
document.getElementById('dlDueDate').value = d.due_date || '';
|
||||
document.getElementById('dlCheckDate').value = d.check_date || '';
|
||||
document.getElementById('dlNotifDays').value = d.notification_days || 7;
|
||||
document.getElementById('dlStorage').value = d.storage_location || '';
|
||||
document.getElementById('dlNotes').value = d.notes || '';
|
||||
document.getElementById('dlFiles').value = '';
|
||||
document.getElementById('modalTitle').textContent = 'Modifica Scadenza';
|
||||
$('#dlDepartments').val(d.department_names || []).trigger('change');
|
||||
$('#dlEmployees').val(d.employee_ids.map(String)).trigger('change');
|
||||
renderAttachments(d.attachments || []);
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
// Stampa
|
||||
function doStampa() {
|
||||
var params = [];
|
||||
@@ -1802,6 +1432,7 @@ function getContrastTextColor($hexColor)
|
||||
if (btnStampaMobile) btnStampaMobile.addEventListener('click', doStampa);
|
||||
});
|
||||
</script>
|
||||
<?php include __DIR__ . '/include/deadline_modal_js.php'; ?>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
include('include/headscript.php');
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
/* ==========================================
|
||||
PERMISSIONS (mirror trainings.php)
|
||||
========================================== */
|
||||
$isHrManager = Auth::user()->hasRole('Admin')
|
||||
|| Auth::user()->hasRole('Superuser')
|
||||
|| Auth::user()->hasRole('employee-hr')
|
||||
|| Auth::user()->hasRole('manager');
|
||||
|
||||
if (!$isHrManager) {
|
||||
header('Location: employee-profile.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
/* Dropdown data */
|
||||
$employees = $pdo->query("
|
||||
SELECT id, first_name, last_name, employee_code
|
||||
FROM employees
|
||||
ORDER BY last_name, first_name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$topics = $pdo->query("
|
||||
SELECT id, name FROM training_topics WHERE is_active = 1 ORDER BY sort_order, name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$departments = $pdo->query("
|
||||
SELECT id, name FROM departments WHERE is_active = 1 ORDER BY sort_order, name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||||
<?php include('cssinclude.php'); ?>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.9/index.global.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.9/locales/it.global.min.js"></script>
|
||||
<title>Calendario Formazione - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-size: 1.05rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.back-dashboard {
|
||||
background-color: #cfe3ff !important;
|
||||
color: #1f2d3d !important;
|
||||
border: 1px solid #bcd4f4 !important;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
.training-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-training-action {
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
padding: 10px 16px;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease-in-out;
|
||||
color: #fff !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-training-action:hover {
|
||||
transform: translateY(-2px);
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-training-topics {
|
||||
background-color: #0d6efd !important;
|
||||
border: 1px solid #0b5ed7 !important;
|
||||
}
|
||||
|
||||
.btn-training-topics:hover {
|
||||
background-color: #0b5ed7 !important;
|
||||
}
|
||||
|
||||
.btn-training-history {
|
||||
background-color: #2563eb !important;
|
||||
border: 1px solid #1d4ed8 !important;
|
||||
}
|
||||
|
||||
.btn-training-history:hover {
|
||||
background-color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.training-header-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.training-header-actions .btn,
|
||||
.training-header-actions a {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* FullCalendar overrides */
|
||||
.fc {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: #2c3e6b;
|
||||
}
|
||||
|
||||
.fc .fc-button-primary {
|
||||
background: #5a8fd8;
|
||||
border-color: #5a8fd8;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.fc .fc-button-primary:hover {
|
||||
background: #4578c0;
|
||||
border-color: #4578c0;
|
||||
}
|
||||
|
||||
.fc .fc-button-primary:disabled {
|
||||
background: #9bbce6;
|
||||
border-color: #9bbce6;
|
||||
}
|
||||
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active {
|
||||
background: #2c3e6b;
|
||||
border-color: #2c3e6b;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day-number {
|
||||
color: #2c3e6b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day.fc-day-today {
|
||||
background: #f0f4ff;
|
||||
}
|
||||
|
||||
.fc .fc-event {
|
||||
border-radius: 0.3rem;
|
||||
padding: 2px 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fc .fc-event:hover {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
.fc .fc-list-event:hover td {
|
||||
background: #f0f4ff;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper" id="appWrapper">
|
||||
<?php include('include/navbar.php'); ?>
|
||||
<?php include('include/topbar.php'); ?>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="page-content">
|
||||
<div class="card p-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h5 class="mb-0">📅 Calendario Formazione</h5>
|
||||
|
||||
<div class="training-header-actions">
|
||||
<?php if (userCan('hr.training_topics.view')): ?>
|
||||
<a href="training_topics.php" class="btn btn-training-action btn-training-topics">
|
||||
📘 Corsi Formazione
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('hr.trainings.view')): ?>
|
||||
<a href="trainings.php" class="btn btn-training-action btn-training-history">
|
||||
📚 Gestione Formazione
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
|
||||
↩️ Torna alla Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- FILTERS -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Stato</label>
|
||||
<select id="filterStatus" class="form-select">
|
||||
<option value="">Tutti</option>
|
||||
<option value="expired">Scaduti</option>
|
||||
<option value="due_soon">Da aggiornare</option>
|
||||
<option value="compliant">Conformi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Corso</label>
|
||||
<select id="filterTopic" class="form-select">
|
||||
<option value="">Tutti</option>
|
||||
<?php foreach ($topics as $t): ?>
|
||||
<option value="<?= (int)$t['id'] ?>"><?= htmlspecialchars($t['name'], ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Reparto</label>
|
||||
<select id="filterDepartment" class="form-select">
|
||||
<option value="">Tutti</option>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= (int)$d['id'] ?>"><?= htmlspecialchars($d['name'], ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Dipendente</label>
|
||||
<div class="d-flex gap-2">
|
||||
<select id="filterEmployee" class="form-select">
|
||||
<option value="">Tutti</option>
|
||||
<?php foreach ($employees as $e): ?>
|
||||
<option value="<?= (int)$e['id'] ?>"><?= htmlspecialchars(trim($e['last_name'] . ' ' . $e['first_name']), ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button id="btnResetFilters" type="button" class="btn btn-light border flex-shrink-0" title="Reset filtri">
|
||||
<i class="fa-solid fa-rotate-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LEGEND -->
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#dc3545"></span> Scaduto</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#e8930c"></span> Da aggiornare</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#198754"></span> Conforme</div>
|
||||
</div>
|
||||
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<?php include('jsinclude.php'); ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var isMobile = window.innerWidth < 768;
|
||||
var calendarEl = document.getElementById('calendar');
|
||||
|
||||
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
locale: 'it',
|
||||
initialView: isMobile ? 'listMonth' : 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: isMobile ? 'listMonth,dayGridMonth' : 'dayGridMonth,listMonth'
|
||||
},
|
||||
height: 'auto',
|
||||
navLinks: true,
|
||||
eventSources: [{
|
||||
url: 'ajax/trainings/calendar_events.php',
|
||||
extraParams: function() {
|
||||
return {
|
||||
status: document.getElementById('filterStatus').value,
|
||||
topic_id: document.getElementById('filterTopic').value,
|
||||
department_id: document.getElementById('filterDepartment').value,
|
||||
employee_id: document.getElementById('filterEmployee').value
|
||||
};
|
||||
},
|
||||
failure: function() {
|
||||
if (window.Swal) Swal.fire('Errore', 'Impossibile caricare gli eventi.', 'error');
|
||||
}
|
||||
}],
|
||||
eventClick: function(info) {
|
||||
info.jsEvent.preventDefault();
|
||||
if (info.event.url) window.location.href = info.event.url;
|
||||
},
|
||||
windowResize: function() {
|
||||
calendar.changeView(window.innerWidth < 768 ? 'listMonth' : 'dayGridMonth');
|
||||
}
|
||||
});
|
||||
calendar.render();
|
||||
|
||||
document.querySelectorAll('#filterStatus, #filterTopic, #filterDepartment, #filterEmployee').forEach(function(el) {
|
||||
el.addEventListener('change', function() {
|
||||
calendar.refetchEvents();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('btnResetFilters').addEventListener('click', function() {
|
||||
['filterStatus', 'filterTopic', 'filterDepartment', 'filterEmployee'].forEach(function(id) {
|
||||
document.getElementById(id).value = '';
|
||||
});
|
||||
calendar.refetchEvents();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -33,36 +33,193 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
|
||||
|
||||
<style>
|
||||
body { font-size: 1.05rem; background: #f8fafc; }
|
||||
.card { border-radius: 16px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
|
||||
body {
|
||||
font-size: 1.05rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.back-dashboard {
|
||||
background-color: #cfe3ff !important; color: #1f2d3d !important;
|
||||
border: 1px solid #bcd4f4 !important; border-radius: 10px;
|
||||
font-weight: 600; padding: 10px 18px;
|
||||
background-color: #cfe3ff !important;
|
||||
color: #1f2d3d !important;
|
||||
border: 1px solid #bcd4f4 !important;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.back-dashboard:hover { background-color: #b9d3ff !important; transform: translateY(-2px); }
|
||||
.btn-add { background-color: #0d6efd; color: #fff; border-radius: 8px; padding: 10px 20px; font-weight: 500; }
|
||||
.btn-add:hover { background-color: #0b5ed7; transform: scale(1.02); }
|
||||
.table thead { background-color: #cfe3ff; color: #1f2d3d; }
|
||||
.modal-content { border-radius: 16px; }
|
||||
#tabellaTopics thead th { text-align: center; vertical-align: middle; }
|
||||
.badge-status { padding: 0.25rem 0.6rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
|
||||
.badge-status.active { background-color: #d1fae5; color: #065f46; }
|
||||
.badge-status.inactive { background-color: #e5e7eb; color: #374151; }
|
||||
.description-cell {
|
||||
max-width: 280px; white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis; text-align: left;
|
||||
|
||||
.back-dashboard:hover {
|
||||
background-color: #b9d3ff !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.num-pill {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 999px;
|
||||
background: #eef2ff; color: #3730a3; font-weight: 600; font-size: 0.85rem;
|
||||
|
||||
.training-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-training-action {
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
padding: 10px 16px;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease-in-out;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-training-action:hover {
|
||||
transform: translateY(-2px);
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-training-history {
|
||||
background-color: #0d6efd !important;
|
||||
border: 1px solid #0b5ed7 !important;
|
||||
}
|
||||
|
||||
.btn-training-history:hover {
|
||||
background-color: #0b5ed7 !important;
|
||||
}
|
||||
|
||||
.btn-training-calendar {
|
||||
background-color: #2563eb !important;
|
||||
border: 1px solid #1d4ed8 !important;
|
||||
}
|
||||
|
||||
.btn-training-calendar:hover {
|
||||
background-color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.card-header { flex-direction: column; align-items: flex-start !important; gap: .5rem; }
|
||||
.back-dashboard { width: 100%; }
|
||||
.btn-add { width: 100%; }
|
||||
.training-header-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.training-header-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background-color: #0b5ed7;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.training-shortcuts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.training-shortcut-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border: 1px solid #bcd4f4;
|
||||
background: #cfe3ff;
|
||||
color: #1f2d3d;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, .08);
|
||||
transition: all .2s ease-in-out;
|
||||
}
|
||||
|
||||
.training-shortcut-btn:hover {
|
||||
background: #b9d3ff;
|
||||
color: #1f2d3d;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.training-shortcut-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.table thead {
|
||||
background-color: #cfe3ff;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
#tabellaTopics thead th {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-status.active {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-status.inactive {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.description-cell {
|
||||
max-width: 280px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.num-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.back-dashboard {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.tt-card {
|
||||
@@ -73,6 +230,7 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tt-card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
@@ -80,12 +238,14 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
margin: 0 0 4px 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tt-card-desc {
|
||||
color: #475569;
|
||||
font-size: 0.95rem;
|
||||
margin: 0 0 10px 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tt-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -94,12 +254,21 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
color: #64748b;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tt-card-meta b { color: #1f2937; font-weight: 600; }
|
||||
|
||||
.tt-card-meta b {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tt-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.tt-card-actions .btn { flex: 1; }
|
||||
|
||||
.tt-card-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tt-empty {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
@@ -117,13 +286,28 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
<div class="page-content">
|
||||
<div class="card p-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h5 class="mb-0">Gestione Corsi di Formazione</h5>
|
||||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
|
||||
↩️ Torna alla Dashboard
|
||||
</button>
|
||||
<h5 class="mb-0">Corsi Formazione</h5>
|
||||
|
||||
<div class="training-header-actions">
|
||||
<?php if (userCan('hr.trainings.view')): ?>
|
||||
<a href="trainings.php" class="btn btn-training-action btn-training-history">
|
||||
🎓 Gestione Formazione
|
||||
</a>
|
||||
|
||||
<a href="training_calendar.php" class="btn btn-training-action btn-training-calendar">
|
||||
📅 Calendario Formazione
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
|
||||
↩️ Torna alla Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<h6 class="fw-semibold mb-0">Elenco Corsi / Training Topics</h6>
|
||||
<button class="btn btn-add" data-bs-toggle="modal" data-bs-target="#addTopicModal">
|
||||
@@ -420,7 +604,10 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#tabellaTopics').DataTable({
|
||||
order: [[5, 'asc'], [1, 'asc']],
|
||||
order: [
|
||||
[5, 'asc'],
|
||||
[1, 'asc']
|
||||
],
|
||||
pageLength: 25,
|
||||
language: {
|
||||
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json',
|
||||
@@ -430,23 +617,37 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
function ajaxPost(url, payload, successTitle, errorFallback) {
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: payload.toString()
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
Swal.fire({ icon: "success", title: successTitle, confirmButtonColor: "#3085d6" })
|
||||
.then(() => location.reload());
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "Errore", text: data.message || errorFallback });
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
Swal.fire({ icon: "error", title: "Errore", text: "Errore di comunicazione." });
|
||||
console.error(err);
|
||||
});
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: payload.toString()
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
Swal.fire({
|
||||
icon: "success",
|
||||
title: successTitle,
|
||||
confirmButtonColor: "#3085d6"
|
||||
})
|
||||
.then(() => location.reload());
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
title: "Errore",
|
||||
text: data.message || errorFallback
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
title: "Errore",
|
||||
text: "Errore di comunicazione."
|
||||
});
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
$("#addTopicForm").on("submit", function(e) {
|
||||
@@ -527,4 +728,5 @@ $topics = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
+702
-64
@@ -23,20 +23,38 @@ $fEmployeeId = isset($_GET['employee_id']) && $_GET['employee_id'] !== '' ?
|
||||
$fTopicId = isset($_GET['topic_id']) && $_GET['topic_id'] !== '' ? (int)$_GET['topic_id'] : 0;
|
||||
$fStatus = isset($_GET['status']) ? trim($_GET['status']) : '';
|
||||
$fType = isset($_GET['type']) ? trim($_GET['type']) : '';
|
||||
$fDepartmentId = isset($_GET['department_id'])&& $_GET['department_id']!== '' ? (int)$_GET['department_id']: 0;
|
||||
$fDepartmentId = isset($_GET['department_id']) && $_GET['department_id'] !== '' ? (int)$_GET['department_id'] : 0;
|
||||
|
||||
/* ==========================================
|
||||
LOAD DATA
|
||||
========================================== */
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($fEmployeeId > 0) { $where[] = 'et.employee_id = :eid'; $params['eid'] = $fEmployeeId; }
|
||||
if ($fTopicId > 0) { $where[] = 'et.training_topic_id = :tid'; $params['tid'] = $fTopicId; }
|
||||
// Only the most recent record per (employee, topic) — older initial/refresher
|
||||
// rows stay as history on the employee profile, not in this overview.
|
||||
$where[] = "NOT EXISTS (
|
||||
SELECT 1 FROM employee_trainings et2
|
||||
WHERE et2.employee_id = et.employee_id
|
||||
AND et2.training_topic_id = et.training_topic_id
|
||||
AND (et2.completed_date > et.completed_date
|
||||
OR (et2.completed_date = et.completed_date AND et2.id > et.id))
|
||||
)";
|
||||
if ($fEmployeeId > 0) {
|
||||
$where[] = 'et.employee_id = :eid';
|
||||
$params['eid'] = $fEmployeeId;
|
||||
}
|
||||
if ($fTopicId > 0) {
|
||||
$where[] = 'et.training_topic_id = :tid';
|
||||
$params['tid'] = $fTopicId;
|
||||
}
|
||||
if ($fType !== '' && in_array($fType, ['initial', 'refresher'], true)) {
|
||||
$where[] = 'et.training_type = :ty';
|
||||
$params['ty'] = $fType;
|
||||
}
|
||||
if ($fDepartmentId > 0) { $where[] = 'e.department_id = :did'; $params['did'] = $fDepartmentId; }
|
||||
if ($fDepartmentId > 0) {
|
||||
$where[] = 'e.department_id = :did';
|
||||
$params['did'] = $fDepartmentId;
|
||||
}
|
||||
$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
@@ -57,7 +75,8 @@ $stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
/* Filter by computed status */
|
||||
function trainingStatus(?string $nextDue, ?int $reminderDays, ?int $topicDefaultRem): array {
|
||||
function trainingStatus(?string $nextDue, ?int $reminderDays, ?int $topicDefaultRem): array
|
||||
{
|
||||
if (!$nextDue) {
|
||||
return ['code' => 'compliant', 'label' => 'Conforme', 'class' => 'success'];
|
||||
}
|
||||
@@ -74,9 +93,11 @@ function trainingStatus(?string $nextDue, ?int $reminderDays, ?int $topicDefault
|
||||
$filtered = [];
|
||||
$counters = ['compliant' => 0, 'due_soon' => 0, 'expired' => 0, 'not_present' => 0, 'all' => 0];
|
||||
foreach ($rows as $r) {
|
||||
$s = trainingStatus($r['next_due_date'] ?: null,
|
||||
$s = trainingStatus(
|
||||
$r['next_due_date'] ?: null,
|
||||
$r['reminder_days'] !== null ? (int)$r['reminder_days'] : null,
|
||||
$r['topic_default_rem'] !== null ? (int)$r['topic_default_rem'] : null);
|
||||
$r['topic_default_rem'] !== null ? (int)$r['topic_default_rem'] : null
|
||||
);
|
||||
$r['_status'] = $s;
|
||||
$counters['all']++;
|
||||
$counters[$s['code']] = ($counters[$s['code']] ?? 0) + 1;
|
||||
@@ -92,9 +113,18 @@ foreach ($rows as $r) {
|
||||
if ($fType === '' || $fType === 'initial') {
|
||||
$missingWhere = [];
|
||||
$missingParams = [];
|
||||
if ($fEmployeeId > 0) { $missingWhere[] = 'e.id = :eid'; $missingParams['eid'] = $fEmployeeId; }
|
||||
if ($fTopicId > 0) { $missingWhere[] = 'tt.id = :tid'; $missingParams['tid'] = $fTopicId; }
|
||||
if ($fDepartmentId > 0) { $missingWhere[] = 'e.department_id = :did'; $missingParams['did'] = $fDepartmentId; }
|
||||
if ($fEmployeeId > 0) {
|
||||
$missingWhere[] = 'e.id = :eid';
|
||||
$missingParams['eid'] = $fEmployeeId;
|
||||
}
|
||||
if ($fTopicId > 0) {
|
||||
$missingWhere[] = 'tt.id = :tid';
|
||||
$missingParams['tid'] = $fTopicId;
|
||||
}
|
||||
if ($fDepartmentId > 0) {
|
||||
$missingWhere[] = 'e.department_id = :did';
|
||||
$missingParams['did'] = $fDepartmentId;
|
||||
}
|
||||
$missingWhereSql = $missingWhere ? ('AND ' . implode(' AND ', $missingWhere)) : '';
|
||||
|
||||
$missingStmt = $pdo->prepare("
|
||||
@@ -142,18 +172,20 @@ if ($fType === '' || $fType === 'initial') {
|
||||
|
||||
/* Dropdown data */
|
||||
$employees = $pdo->query("
|
||||
SELECT id, first_name, last_name, employee_code
|
||||
SELECT id, first_name, last_name, employee_code, department_id
|
||||
FROM employees
|
||||
ORDER BY last_name, first_name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$topics = $pdo->query("
|
||||
SELECT id, name FROM training_topics WHERE is_active = 1 ORDER BY sort_order, name
|
||||
SELECT id, name, default_frequency_months, default_reminder_days
|
||||
FROM training_topics WHERE is_active = 1 ORDER BY sort_order, name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$departments = $pdo->query("
|
||||
SELECT id, name, color FROM departments WHERE is_active = 1 ORDER BY sort_order, name
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
function fmtDate(?string $d): string {
|
||||
function fmtDate(?string $d): string
|
||||
{
|
||||
if (!$d || $d === '0000-00-00') return '—';
|
||||
$ts = strtotime($d);
|
||||
return $ts ? date('d/m/Y', $ts) : '—';
|
||||
@@ -171,55 +203,271 @@ function fmtDate(?string $d): string {
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
|
||||
<style>
|
||||
body { font-size: 1.05rem; background: #f8fafc; }
|
||||
.card { border-radius: 16px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
|
||||
.back-dashboard {
|
||||
background-color: #cfe3ff !important; color: #1f2d3d !important;
|
||||
border: 1px solid #bcd4f4 !important; border-radius: 10px;
|
||||
font-weight: 600; padding: 10px 18px;
|
||||
body {
|
||||
font-size: 1.05rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.stat-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||
@media (max-width: 991.98px) { .stat-row { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 575.98px) { .stat-row { grid-template-columns: repeat(2, 1fr); } }
|
||||
.stat-card {
|
||||
border-radius: 14px; padding: 14px 16px; text-align: center;
|
||||
background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,.05);
|
||||
cursor: pointer; transition: transform .15s;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); }
|
||||
.stat-card.active { outline: 3px solid #0d6efd; }
|
||||
.stat-card .stat-num { font-size: 1.8rem; font-weight: 700; line-height: 1; }
|
||||
.stat-card .stat-label { font-size: 0.85rem; color: #64748b; margin-top: 4px; }
|
||||
.stat-card.all .stat-num { color: #1f2937; }
|
||||
.stat-card.compliant .stat-num { color: #16a34a; }
|
||||
.stat-card.due_soon .stat-num { color: #d97706; }
|
||||
.stat-card.expired .stat-num { color: #dc2626; }
|
||||
.stat-card.not_present .stat-num { color: #6b7280; }
|
||||
|
||||
.pill { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
|
||||
.pill-success { background: #d1fae5; color: #065f46; }
|
||||
.pill-warning { background: #fef3c7; color: #92400e; }
|
||||
.pill-danger { background: #fee2e2; color: #991b1b; }
|
||||
.pill-secondary { background: #e5e7eb; color: #374151; }
|
||||
.pill-role { background: #fff; color: #334155; border: 1px solid #cbd5e1; }
|
||||
.pill-dept-inline { padding: 2px 8px; }
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.back-dashboard {
|
||||
background-color: #cfe3ff !important;
|
||||
color: #1f2d3d !important;
|
||||
border: 1px solid #bcd4f4 !important;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
.training-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-training-action {
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
padding: 10px 16px;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease-in-out;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-training-action:hover {
|
||||
transform: translateY(-2px);
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn-training-topics {
|
||||
background-color: #0d6efd !important;
|
||||
border: 1px solid #0b5ed7 !important;
|
||||
}
|
||||
|
||||
.btn-training-topics:hover {
|
||||
background-color: #0b5ed7 !important;
|
||||
}
|
||||
|
||||
.btn-training-calendar {
|
||||
background-color: #2563eb !important;
|
||||
border: 1px solid #1d4ed8 !important;
|
||||
}
|
||||
|
||||
.btn-training-calendar:hover {
|
||||
background-color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.training-header-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.training-header-actions .btn,
|
||||
.training-header-actions a {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.stat-row {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.stat-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, .05);
|
||||
cursor: pointer;
|
||||
transition: transform .15s, box-shadow .15s, border-color .15s;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, .10);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.stat-card.active {
|
||||
outline: 3px solid #0d6efd;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.stat-card .stat-num {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Totale */
|
||||
.stat-card.all {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.stat-card.all .stat-num,
|
||||
.stat-card.all .stat-label {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Conforme - verde */
|
||||
.stat-card.compliant {
|
||||
background: #dcfce7;
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.stat-card.compliant .stat-num,
|
||||
.stat-card.compliant .stat-label {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
/* Da aggiornare - arancio */
|
||||
.stat-card.due_soon {
|
||||
background: #ffedd5;
|
||||
border-color: #fdba74;
|
||||
}
|
||||
|
||||
.stat-card.due_soon .stat-num,
|
||||
.stat-card.due_soon .stat-label {
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
/* Scaduti - rosso */
|
||||
.stat-card.expired {
|
||||
background: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.stat-card.expired .stat-num,
|
||||
.stat-card.expired .stat-label {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Non presenti - rosso diverso / bordeaux */
|
||||
.stat-card.not_present {
|
||||
background: #fce7f3;
|
||||
border-color: #f9a8d4;
|
||||
}
|
||||
|
||||
.stat-card.not_present .stat-num,
|
||||
.stat-card.not_present .stat-label {
|
||||
color: #9d174d;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pill-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.pill-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pill-danger {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.pill-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.pill-role {
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.pill-dept-inline {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.tr-card {
|
||||
border: 1px solid #e2e8f0; border-radius: 14px;
|
||||
padding: 14px 16px; margin-bottom: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.tr-card .name a { color: #1f2937; font-weight: 600; text-decoration: none; }
|
||||
.tr-card .topic { color: #475569; }
|
||||
.tr-card .meta { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.85rem; color: #64748b; margin-top: 8px; }
|
||||
.tr-card .meta b { color: #1f2937; font-weight: 600; }
|
||||
|
||||
.tr-card .name a {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tr-card .topic {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.tr-card .meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tr-card .meta b {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.card-header { flex-direction: column; align-items: flex-start !important; gap: .5rem; }
|
||||
.back-dashboard { width: 100%; }
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.back-dashboard {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -233,10 +481,29 @@ function fmtDate(?string $d): string {
|
||||
<div class="page-content">
|
||||
<div class="card p-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h5 class="mb-0">📚 Storico Formazione</h5>
|
||||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
|
||||
↩️ Torna alla Dashboard
|
||||
</button>
|
||||
<h5 class="mb-0">📚 Gestione Formazione</h5>
|
||||
|
||||
<div class="training-header-actions">
|
||||
<button type="button" class="btn btn-primary" id="btnBulkTraining">
|
||||
➕ Aggiungi sessione
|
||||
</button>
|
||||
|
||||
<?php if (userCan('hr.training_topics.view')): ?>
|
||||
<a href="training_topics.php" class="btn btn-training-action btn-training-topics">
|
||||
📘 Corsi Formazione
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (userCan('hr.trainings.view')): ?>
|
||||
<a href="training_calendar.php" class="btn btn-training-action btn-training-calendar">
|
||||
📅 Calendario Formazione
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
|
||||
↩️ Torna alla Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
@@ -305,7 +572,7 @@ function fmtDate(?string $d): string {
|
||||
<label class="form-label small fw-semibold">Tipo</label>
|
||||
<select name="type" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">— Tutti —</option>
|
||||
<option value="initial" <?= $fType === 'initial' ? 'selected' : '' ?>>Iniziale</option>
|
||||
<option value="initial" <?= $fType === 'initial' ? 'selected' : '' ?>>Iniziale</option>
|
||||
<option value="refresher" <?= $fType === 'refresher' ? 'selected' : '' ?>>Aggiornamento</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -316,6 +583,12 @@ function fmtDate(?string $d): string {
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<div id="bulkBar" class="d-none align-items-center flex-wrap gap-2 mb-3 p-2" style="background:#fff6e5;border:1px solid #ffe0a6;border-radius:10px;">
|
||||
<span class="fw-semibold"><span id="bulkSelCount">0</span> selezionati</span>
|
||||
<button type="button" class="btn btn-sm btn-warning" id="btnBulkRenew">🔄 Aggiorna scadenza</button>
|
||||
<button type="button" class="btn btn-sm btn-link text-decoration-none" id="btnBulkDeselect">Deseleziona</button>
|
||||
</div>
|
||||
|
||||
<?php if (empty($filtered)): ?>
|
||||
<div class="text-center text-muted py-4">
|
||||
Nessuna formazione corrispondente ai filtri.
|
||||
@@ -326,6 +599,7 @@ function fmtDate(?string $d): string {
|
||||
<table class="table table-striped align-middle">
|
||||
<thead style="background-color:#cfe3ff;">
|
||||
<tr>
|
||||
<th style="width:36px"><input type="checkbox" class="form-check-input" id="checkAll" title="Seleziona tutti"></th>
|
||||
<th>Dipendente</th>
|
||||
<th>Reparto</th>
|
||||
<th>Corso</th>
|
||||
@@ -344,6 +618,11 @@ function fmtDate(?string $d): string {
|
||||
$days = $r['_status']['days'] ?? null;
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php if (!empty($r['id'])): ?>
|
||||
<input type="checkbox" class="form-check-input row-check" value="<?= (int)$r['id'] ?>">
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<a href="employee-profile.php?id=<?= (int)$r['employee_id'] ?>#tab-training" class="fw-semibold text-decoration-none">
|
||||
<?= htmlspecialchars($fullName) ?>
|
||||
@@ -357,7 +636,7 @@ function fmtDate(?string $d): string {
|
||||
<span class="pill pill-dept-inline" style="background:<?= htmlspecialchars($r['department_color'] ?? '#e5e7eb', ENT_QUOTES) ?>20; color:<?= htmlspecialchars($r['department_color'] ?? '#374151', ENT_QUOTES) ?>;">
|
||||
<?= htmlspecialchars($r['department_name']) ?>
|
||||
</span>
|
||||
<?php else: ?>—<?php endif; ?>
|
||||
<?php else: ?>—<?php endif; ?>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($r['topic_name']) ?></td>
|
||||
<td><span class="pill pill-role"><?= $typeLbl ?></span></td>
|
||||
@@ -366,11 +645,11 @@ function fmtDate(?string $d): string {
|
||||
<td><span class="pill pill-<?= $r['_status']['class'] ?>"><?= $r['_status']['label'] ?></span></td>
|
||||
<td>
|
||||
<?php if ($days === null): ?>—
|
||||
<?php elseif ($days < 0): ?>
|
||||
<span class="text-danger fw-semibold"><?= $days ?></span>
|
||||
<?php else: ?>
|
||||
+<?= $days ?>
|
||||
<?php endif; ?>
|
||||
<?php elseif ($days < 0): ?>
|
||||
<span class="text-danger fw-semibold"><?= $days ?></span>
|
||||
<?php else: ?>
|
||||
+<?= $days ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
@@ -388,7 +667,10 @@ function fmtDate(?string $d): string {
|
||||
?>
|
||||
<div class="tr-card">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2 mb-1">
|
||||
<div class="name">
|
||||
<div class="name d-flex align-items-start gap-2">
|
||||
<?php if (!empty($r['id'])): ?>
|
||||
<input type="checkbox" class="form-check-input row-check mt-1" value="<?= (int)$r['id'] ?>">
|
||||
<?php endif; ?>
|
||||
<a href="employee-profile.php?id=<?= (int)$r['employee_id'] ?>#tab-training">
|
||||
<?= htmlspecialchars($fullName) ?>
|
||||
</a>
|
||||
@@ -425,6 +707,362 @@ function fmtDate(?string $d): string {
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<!-- BULK TRAINING SESSION MODAL -->
|
||||
<div class="modal fade" id="bulkTrainingModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">➕ Nuova sessione formativa</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||||
</div>
|
||||
<form id="bulkTrainingForm">
|
||||
<div class="modal-body" style="max-height:65vh; overflow-y:auto;">
|
||||
<p class="text-muted small">Registra lo stesso corso, con gli stessi parametri, per più dipendenti contemporaneamente.</p>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Corso <span class="text-danger">*</span></label>
|
||||
<select id="bulkTopic" class="form-select" required>
|
||||
<option value="">— Seleziona —</option>
|
||||
<?php foreach ($topics as $t): ?>
|
||||
<option value="<?= (int)$t['id'] ?>"
|
||||
data-freq="<?= $t['default_frequency_months'] !== null ? (int)$t['default_frequency_months'] : '' ?>"
|
||||
data-rem="<?= $t['default_reminder_days'] !== null ? (int)$t['default_reminder_days'] : '' ?>">
|
||||
<?= htmlspecialchars($t['name'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label fw-semibold">Data completamento <span class="text-danger">*</span></label>
|
||||
<input type="date" id="bulkCompletedDate" class="form-control" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label fw-semibold">Tipo</label>
|
||||
<select id="bulkType" class="form-select">
|
||||
<option value="initial">Iniziale</option>
|
||||
<option value="refresher">Aggiornamento</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label fw-semibold">Frequenza (mesi)</label>
|
||||
<input type="number" id="bulkFreq" class="form-control" min="0" max="600" placeholder="default corso">
|
||||
<div class="form-text">Vuoto = una tantum</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<label class="form-label fw-semibold">Promemoria (giorni)</label>
|
||||
<input type="number" id="bulkRem" class="form-control" min="0" max="365" placeholder="default corso">
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">Erogato da</label>
|
||||
<input type="text" id="bulkDeliveredBy" class="form-control" maxlength="255" placeholder="es. Ente formatore, docente interno...">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Descrizione / note</label>
|
||||
<textarea id="bulkDescription" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<hr class="my-1">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Dipendenti <span class="text-danger">*</span></label>
|
||||
<div class="d-flex flex-wrap gap-2 mb-2 align-items-end">
|
||||
<div>
|
||||
<select id="bulkDept" class="form-select form-select-sm" style="min-width:180px">
|
||||
<option value="">— Reparto —</option>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= (int)$d['id'] ?>"><?= htmlspecialchars($d['name'], ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="bulkAddDept">+ Aggiungi reparto</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulkSelectAll">Tutti</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulkClear">Pulisci</button>
|
||||
</div>
|
||||
<select id="bulkEmployees" class="form-select" multiple required>
|
||||
<?php foreach ($employees as $e): ?>
|
||||
<option value="<?= (int)$e['id'] ?>" data-dept="<?= (int)($e['department_id'] ?? 0) ?>">
|
||||
<?= htmlspecialchars(trim($e['last_name'] . ' ' . $e['first_name']), ENT_QUOTES, 'UTF-8') ?><?php if (!empty($e['employee_code'])): ?> (<?= htmlspecialchars($e['employee_code'], ENT_QUOTES, 'UTF-8') ?>)<?php endif; ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text"><span id="bulkCount">0</span> dipendenti selezionati</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light border" data-bs-dismiss="modal">Annulla</button>
|
||||
<button type="submit" class="btn btn-primary" id="bulkSaveBtn">Registra formazione</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include('jsinclude.php'); ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var bulkModal = new bootstrap.Modal(document.getElementById('bulkTrainingModal'));
|
||||
var $emp = $('#bulkEmployees');
|
||||
|
||||
$emp.select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: 'Seleziona dipendenti...',
|
||||
dropdownParent: $('#bulkTrainingModal'),
|
||||
closeOnSelect: false,
|
||||
width: '100%'
|
||||
});
|
||||
|
||||
function updateCount() {
|
||||
document.getElementById('bulkCount').textContent = ($emp.val() || []).length;
|
||||
}
|
||||
$emp.on('change', updateCount);
|
||||
|
||||
document.getElementById('btnBulkTraining').addEventListener('click', function() {
|
||||
document.getElementById('bulkTrainingForm').reset();
|
||||
$emp.val(null).trigger('change');
|
||||
document.getElementById('bulkTopic').value = '';
|
||||
document.getElementById('bulkType').value = 'initial';
|
||||
updateCount();
|
||||
bulkModal.show();
|
||||
});
|
||||
|
||||
// Prefill frequency/reminder from the selected course
|
||||
document.getElementById('bulkTopic').addEventListener('change', function() {
|
||||
var opt = this.options[this.selectedIndex];
|
||||
document.getElementById('bulkFreq').value = opt ? (opt.getAttribute('data-freq') || '') : '';
|
||||
document.getElementById('bulkRem').value = opt ? (opt.getAttribute('data-rem') || '') : '';
|
||||
});
|
||||
|
||||
// Add all employees of the chosen department to the selection
|
||||
document.getElementById('bulkAddDept').addEventListener('click', function() {
|
||||
var dept = document.getElementById('bulkDept').value;
|
||||
if (!dept) return;
|
||||
var current = ($emp.val() || []).map(String);
|
||||
$emp.find('option').each(function() {
|
||||
if (this.getAttribute('data-dept') === String(dept) && current.indexOf(this.value) === -1) {
|
||||
current.push(this.value);
|
||||
}
|
||||
});
|
||||
$emp.val(current).trigger('change');
|
||||
});
|
||||
|
||||
document.getElementById('bulkSelectAll').addEventListener('click', function() {
|
||||
var all = $emp.find('option').map(function() {
|
||||
return this.value;
|
||||
}).get();
|
||||
$emp.val(all).trigger('change');
|
||||
});
|
||||
document.getElementById('bulkClear').addEventListener('click', function() {
|
||||
$emp.val(null).trigger('change');
|
||||
});
|
||||
|
||||
document.getElementById('bulkTrainingForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var topicId = document.getElementById('bulkTopic').value;
|
||||
var completed = document.getElementById('bulkCompletedDate').value;
|
||||
var emps = $emp.val() || [];
|
||||
|
||||
if (!topicId) {
|
||||
Swal.fire('Attenzione', 'Selezionare un corso.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!completed) {
|
||||
Swal.fire('Attenzione', 'Indicare la data di completamento.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (emps.length === 0) {
|
||||
Swal.fire('Attenzione', 'Selezionare almeno un dipendente.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
var btn = document.getElementById('bulkSaveBtn');
|
||||
btn.disabled = true;
|
||||
var orig = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Salvataggio...';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('training_topic_id', topicId);
|
||||
fd.append('completed_date', completed);
|
||||
fd.append('training_type', document.getElementById('bulkType').value);
|
||||
fd.append('delivered_by', document.getElementById('bulkDeliveredBy').value);
|
||||
fd.append('description', document.getElementById('bulkDescription').value);
|
||||
fd.append('update_frequency_months', document.getElementById('bulkFreq').value);
|
||||
fd.append('reminder_days', document.getElementById('bulkRem').value);
|
||||
emps.forEach(function(id) {
|
||||
fd.append('employee_ids[]', id);
|
||||
});
|
||||
|
||||
fetch('ajax/trainings/save_bulk_training.php', {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
})
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
bulkModal.hide();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Fatto',
|
||||
text: data.message,
|
||||
timer: 1800,
|
||||
showConfirmButton: false
|
||||
})
|
||||
.then(function() {
|
||||
location.reload();
|
||||
});
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
Swal.fire('Errore', data.message || 'Errore.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- BULK RENEW DEADLINE MODAL -->
|
||||
<div class="modal fade" id="bulkRenewModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form id="bulkRenewForm">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">🔄 Aggiorna scadenza</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">Imposta la data di completamento per <b id="renewCount">0</b> record selezionati. Le prossime scadenze verranno ricalcolate in base alla frequenza di ciascun corso.</p>
|
||||
<label class="form-label fw-semibold">Nuova data di completamento</label>
|
||||
<input type="date" id="renewDate" class="form-control" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light border" data-bs-dismiss="modal">Annulla</button>
|
||||
<button type="submit" class="btn btn-warning" id="renewSaveBtn">Aggiorna</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var renewModal = new bootstrap.Modal(document.getElementById('bulkRenewModal'));
|
||||
var checkAll = document.getElementById('checkAll');
|
||||
|
||||
function checkedIds() {
|
||||
return Array.prototype.slice.call(document.querySelectorAll('.row-check:checked'))
|
||||
.map(function(c) {
|
||||
return c.value;
|
||||
});
|
||||
}
|
||||
|
||||
function refreshBulkBar() {
|
||||
var ids = checkedIds();
|
||||
var bar = document.getElementById('bulkBar');
|
||||
document.getElementById('bulkSelCount').textContent = ids.length;
|
||||
if (ids.length > 0) {
|
||||
bar.classList.remove('d-none');
|
||||
bar.classList.add('d-flex');
|
||||
} else {
|
||||
bar.classList.add('d-none');
|
||||
bar.classList.remove('d-flex');
|
||||
}
|
||||
var all = document.querySelectorAll('.row-check');
|
||||
if (checkAll) checkAll.checked = (all.length > 0 && ids.length === all.length);
|
||||
}
|
||||
|
||||
document.addEventListener('change', function(e) {
|
||||
if (e.target && e.target.classList && e.target.classList.contains('row-check')) refreshBulkBar();
|
||||
});
|
||||
if (checkAll) checkAll.addEventListener('change', function() {
|
||||
document.querySelectorAll('.row-check').forEach(function(c) {
|
||||
c.checked = checkAll.checked;
|
||||
});
|
||||
refreshBulkBar();
|
||||
});
|
||||
document.getElementById('btnBulkDeselect').addEventListener('click', function() {
|
||||
document.querySelectorAll('.row-check').forEach(function(c) {
|
||||
c.checked = false;
|
||||
});
|
||||
if (checkAll) checkAll.checked = false;
|
||||
refreshBulkBar();
|
||||
});
|
||||
|
||||
document.getElementById('btnBulkRenew').addEventListener('click', function() {
|
||||
var ids = checkedIds();
|
||||
if (ids.length === 0) return;
|
||||
document.getElementById('renewCount').textContent = ids.length;
|
||||
renewModal.show();
|
||||
});
|
||||
|
||||
document.getElementById('bulkRenewForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var ids = checkedIds();
|
||||
var date = document.getElementById('renewDate').value;
|
||||
if (ids.length === 0) {
|
||||
renewModal.hide();
|
||||
return;
|
||||
}
|
||||
if (!date) {
|
||||
Swal.fire('Attenzione', 'Indicare la data.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
var btn = document.getElementById('renewSaveBtn');
|
||||
btn.disabled = true;
|
||||
var orig = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Salvataggio...';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('completed_date', date);
|
||||
ids.forEach(function(id) {
|
||||
fd.append('training_ids[]', id);
|
||||
});
|
||||
|
||||
fetch('ajax/trainings/bulk_update_deadline.php', {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
})
|
||||
.then(function(r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
renewModal.hide();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Fatto',
|
||||
text: data.message,
|
||||
timer: 1800,
|
||||
showConfirmButton: false
|
||||
})
|
||||
.then(function() {
|
||||
location.reload();
|
||||
});
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
Swal.fire('Errore', data.message || 'Errore.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
Swal.fire('Errore', 'Errore di connessione.', 'error');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,868 @@
|
||||
<?php include('include/headscript.php'); ?>
|
||||
<?php
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$userId = (int)($iduserlogin ?? 0);
|
||||
|
||||
if ($userId <= 0) {
|
||||
die('Utente non valido.');
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (empty($_SESSION['user_settings_csrf'])) {
|
||||
$_SESSION['user_settings_csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
$csrfToken = $_SESSION['user_settings_csrf'];
|
||||
|
||||
$successMessage = '';
|
||||
$errorMessage = '';
|
||||
|
||||
// Load countries.
|
||||
$countries = [];
|
||||
try {
|
||||
$stmtCountries = $pdo->query("
|
||||
SELECT id, name, iso_3166_2
|
||||
FROM auth_countries
|
||||
ORDER BY name ASC
|
||||
");
|
||||
$countries = $stmtCountries->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Exception $e) {
|
||||
$countries = [];
|
||||
}
|
||||
|
||||
// Load current user.
|
||||
$stmtProfileUser = $pdo->prepare("
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
password,
|
||||
first_name,
|
||||
last_name,
|
||||
phone,
|
||||
avatar,
|
||||
address,
|
||||
country_id,
|
||||
birthday,
|
||||
role_id,
|
||||
status,
|
||||
last_login
|
||||
FROM auth_users
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
");
|
||||
$stmtProfileUser->execute([$userId]);
|
||||
$profileUser = $stmtProfileUser->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$profileUser) {
|
||||
die('Utente non trovato.');
|
||||
}
|
||||
|
||||
function e($value)
|
||||
{
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function normalizeAvatarPath($avatar)
|
||||
{
|
||||
$avatar = trim((string)$avatar);
|
||||
|
||||
if ($avatar === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If the database already contains a complete relative path, use it as it is.
|
||||
if (
|
||||
str_starts_with($avatar, '../') ||
|
||||
str_starts_with($avatar, './') ||
|
||||
str_starts_with($avatar, '/') ||
|
||||
str_starts_with($avatar, 'http://') ||
|
||||
str_starts_with($avatar, 'https://')
|
||||
) {
|
||||
return $avatar;
|
||||
}
|
||||
|
||||
// If the database contains only the filename, build the expected user upload path.
|
||||
return '../upload/users/' . $avatar;
|
||||
}
|
||||
|
||||
function getAvatarInitials($profileUser)
|
||||
{
|
||||
$first = trim((string)($profileUser['first_name'] ?? ''));
|
||||
$last = trim((string)($profileUser['last_name'] ?? ''));
|
||||
$email = trim((string)($profileUser['email'] ?? ''));
|
||||
|
||||
$initials = '';
|
||||
|
||||
if ($first !== '') {
|
||||
$initials .= mb_substr($first, 0, 1);
|
||||
}
|
||||
|
||||
if ($last !== '') {
|
||||
$initials .= mb_substr($last, 0, 1);
|
||||
}
|
||||
|
||||
if ($initials === '' && $email !== '') {
|
||||
$initials = mb_substr($email, 0, 1);
|
||||
}
|
||||
|
||||
return strtoupper($initials ?: 'U');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$postedToken = $_POST['csrf_token'] ?? '';
|
||||
|
||||
if (!hash_equals($csrfToken, $postedToken)) {
|
||||
$errorMessage = 'Sessione non valida. Ricarica la pagina e riprova.';
|
||||
} else {
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$firstName = trim($_POST['first_name'] ?? '');
|
||||
$lastName = trim($_POST['last_name'] ?? '');
|
||||
$phone = trim($_POST['phone'] ?? '');
|
||||
$address = trim($_POST['address'] ?? '');
|
||||
$countryId = $_POST['country_id'] !== '' ? (int)$_POST['country_id'] : null;
|
||||
$birthday = trim($_POST['birthday'] ?? '');
|
||||
|
||||
$currentPassword = $_POST['current_password'] ?? '';
|
||||
$newPassword = $_POST['new_password'] ?? '';
|
||||
$confirmPassword = $_POST['confirm_password'] ?? '';
|
||||
|
||||
$birthdayValue = null;
|
||||
$avatarToSave = $profileUser['avatar'];
|
||||
|
||||
if ($birthday !== '') {
|
||||
$dateObj = DateTime::createFromFormat('Y-m-d', $birthday);
|
||||
|
||||
if (!$dateObj || $dateObj->format('Y-m-d') !== $birthday) {
|
||||
$errorMessage = 'La data di nascita non è valida.';
|
||||
} else {
|
||||
$birthdayValue = $birthday;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$errorMessage && $email === '') {
|
||||
$errorMessage = 'L’email è obbligatoria.';
|
||||
}
|
||||
|
||||
if (!$errorMessage && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errorMessage = 'L’email inserita non è valida.';
|
||||
}
|
||||
|
||||
// Check unique email.
|
||||
if (!$errorMessage) {
|
||||
$stmtCheckEmail = $pdo->prepare("
|
||||
SELECT id
|
||||
FROM auth_users
|
||||
WHERE email = ? AND id <> ?
|
||||
LIMIT 1
|
||||
");
|
||||
$stmtCheckEmail->execute([$email, $userId]);
|
||||
|
||||
if ($stmtCheckEmail->fetchColumn()) {
|
||||
$errorMessage = 'Questa email è già utilizzata da un altro utente.';
|
||||
}
|
||||
}
|
||||
|
||||
// Avatar upload.
|
||||
if (!$errorMessage && isset($_FILES['avatar']) && $_FILES['avatar']['error'] !== UPLOAD_ERR_NO_FILE) {
|
||||
if ($_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
|
||||
$errorMessage = 'Errore durante il caricamento dell’avatar.';
|
||||
} else {
|
||||
$maxFileSize = 2 * 1024 * 1024; // 2 MB
|
||||
|
||||
if ($_FILES['avatar']['size'] > $maxFileSize) {
|
||||
$errorMessage = 'L’avatar non può superare 2 MB.';
|
||||
} else {
|
||||
$tmpFile = $_FILES['avatar']['tmp_name'];
|
||||
$originalName = $_FILES['avatar']['name'];
|
||||
|
||||
$allowedMimeTypes = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'image/gif' => 'gif',
|
||||
];
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($tmpFile);
|
||||
|
||||
if (!array_key_exists($mimeType, $allowedMimeTypes)) {
|
||||
$errorMessage = 'Formato avatar non valido. Sono consentiti JPG, PNG, WEBP o GIF.';
|
||||
} else {
|
||||
$uploadDir = __DIR__ . '/../upload/users/';
|
||||
|
||||
if (!is_dir($uploadDir)) {
|
||||
mkdir($uploadDir, 0755, true);
|
||||
}
|
||||
|
||||
$extension = $allowedMimeTypes[$mimeType];
|
||||
$safeOriginalName = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', pathinfo($originalName, PATHINFO_FILENAME));
|
||||
$fileName = time() . '_' . $userId . '_' . $safeOriginalName . '.' . $extension;
|
||||
|
||||
$destination = $uploadDir . $fileName;
|
||||
|
||||
if (!move_uploaded_file($tmpFile, $destination)) {
|
||||
$errorMessage = 'Impossibile salvare il file avatar.';
|
||||
} else {
|
||||
// Path used by pages inside userarea, for example:
|
||||
// <img src="../upload/users/file.jpg">
|
||||
$avatarToSave = $fileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$passwordToSave = null;
|
||||
$wantsPasswordChange = ($currentPassword !== '' || $newPassword !== '' || $confirmPassword !== '');
|
||||
|
||||
if (!$errorMessage && $wantsPasswordChange) {
|
||||
if ($currentPassword === '') {
|
||||
$errorMessage = 'Inserisci la password attuale.';
|
||||
} elseif ($newPassword === '') {
|
||||
$errorMessage = 'Inserisci la nuova password.';
|
||||
} elseif (strlen($newPassword) < 8) {
|
||||
$errorMessage = 'La nuova password deve contenere almeno 8 caratteri.';
|
||||
} elseif ($newPassword !== $confirmPassword) {
|
||||
$errorMessage = 'La conferma password non corrisponde.';
|
||||
} elseif (!password_verify($currentPassword, $profileUser['password'])) {
|
||||
$errorMessage = 'La password attuale non è corretta.';
|
||||
} else {
|
||||
// Password is encrypted before saving.
|
||||
$passwordToSave = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$errorMessage) {
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$stmtUpdate = $pdo->prepare("
|
||||
UPDATE auth_users
|
||||
SET
|
||||
email = :email,
|
||||
first_name = :first_name,
|
||||
last_name = :last_name,
|
||||
phone = :phone,
|
||||
avatar = :avatar,
|
||||
address = :address,
|
||||
country_id = :country_id,
|
||||
birthday = :birthday,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id
|
||||
LIMIT 1
|
||||
");
|
||||
|
||||
$stmtUpdate->execute([
|
||||
':email' => $email,
|
||||
':first_name' => $firstName !== '' ? $firstName : null,
|
||||
':last_name' => $lastName !== '' ? $lastName : null,
|
||||
':phone' => $phone !== '' ? $phone : null,
|
||||
':avatar' => $avatarToSave !== '' ? $avatarToSave : null,
|
||||
':address' => $address !== '' ? $address : null,
|
||||
':country_id' => $countryId,
|
||||
':birthday' => $birthdayValue,
|
||||
':id' => $userId,
|
||||
]);
|
||||
|
||||
if ($passwordToSave !== null) {
|
||||
$stmtPassword = $pdo->prepare("
|
||||
UPDATE auth_users
|
||||
SET password = ?, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
");
|
||||
$stmtPassword->execute([$passwordToSave, $userId]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
$successMessage = $passwordToSave !== null
|
||||
? 'Profilo, avatar e password aggiornati correttamente.'
|
||||
: 'Profilo aggiornato correttamente.';
|
||||
|
||||
// Reload updated user.
|
||||
$stmtProfileUser->execute([$userId]);
|
||||
$profileUser = $stmtProfileUser->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$_SESSION['user_settings_csrf'] = bin2hex(random_bytes(32));
|
||||
$csrfToken = $_SESSION['user_settings_csrf'];
|
||||
} catch (Exception $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
$errorMessage = 'Errore durante il salvataggio delle impostazioni.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$avatarPath = normalizeAvatarPath($profileUser['avatar'] ?? '');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||||
<?php include('cssinclude.php'); ?>
|
||||
<title>Impostazioni Utente <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #f3f6f8, #e8eef3);
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
color: #2b3e50;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 1.4rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-wrap {
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
h3.settings-title {
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
color: #2b3e50;
|
||||
margin-bottom: 1.2rem;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-heading {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
margin: 0;
|
||||
color: #6b7a89;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.avatar-panel {
|
||||
background: linear-gradient(135deg, #f7fbff, #edf5ff);
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-box {
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
border-radius: 34px;
|
||||
background: linear-gradient(135deg, #cde5ff, #dff0ff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
color: #2b3e50;
|
||||
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
margin: 0 auto 14px auto;
|
||||
}
|
||||
|
||||
.avatar-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
color: #2b3e50;
|
||||
}
|
||||
|
||||
.avatar-email {
|
||||
color: #6b7a89;
|
||||
margin: 4px 0 14px 0;
|
||||
font-size: 0.92rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.avatar-upload-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
padding: 11px 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
color: #2b3e50;
|
||||
background: linear-gradient(135deg, #e5e7eb, #f3f4f6);
|
||||
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.avatar-upload-label:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.13);
|
||||
}
|
||||
|
||||
.avatar-upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.avatar-help {
|
||||
font-size: 0.82rem;
|
||||
color: #6b7a89;
|
||||
margin-top: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.profile-meta {
|
||||
color: #6b7a89;
|
||||
font-size: 0.88rem;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 700;
|
||||
color: #2b3e50;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #d8e0e7;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: #8bbcf7;
|
||||
box-shadow: 0 0 0 0.18rem rgba(139, 188, 247, 0.25);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.86rem;
|
||||
color: #6b7a89;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.password-box {
|
||||
background: linear-gradient(135deg, #fff7e6, #fffaf0);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 184, 107, 0.45);
|
||||
}
|
||||
|
||||
.btn-save-settings {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
padding: 13px 24px;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #61ce5dff, #61ce5dff);
|
||||
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-save-settings:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.13);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
padding: 13px 20px;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: #2b3e50;
|
||||
background: linear-gradient(135deg, #e5e7eb, #f3f4f6);
|
||||
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.13);
|
||||
color: #2b3e50;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 16px;
|
||||
border: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.readonly-note {
|
||||
background: linear-gradient(135deg, #cde5ff, #dff0ff);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
color: #2b3e50;
|
||||
font-weight: 600;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.selected-file-name {
|
||||
font-size: 0.84rem;
|
||||
color: #2b3e50;
|
||||
margin-top: 8px;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.profile-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.avatar-panel {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-save-settings,
|
||||
.btn-back {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper toggled">
|
||||
<?php include('include/navbar.php'); ?>
|
||||
<?php include('include/topbar.php'); ?>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="page-content">
|
||||
|
||||
<div class="settings-wrap">
|
||||
<h3 class="settings-title">Impostazioni Utente</h3>
|
||||
|
||||
<?php if ($successMessage): ?>
|
||||
<div class="alert alert-success">
|
||||
<?= e($successMessage); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($errorMessage): ?>
|
||||
<div class="alert alert-danger">
|
||||
<?= e($errorMessage); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="<?= e($csrfToken); ?>">
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-header">
|
||||
<div class="settings-icon">👤</div>
|
||||
<div>
|
||||
<p class="settings-heading">Profilo personale</p>
|
||||
<p class="settings-subtitle">Dati anagrafici, contatti e avatar utente</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
|
||||
<div class="readonly-note">
|
||||
Ruolo, stato account e impostazioni di sicurezza avanzate non sono modificabili da questa pagina.
|
||||
</div>
|
||||
|
||||
<div class="profile-layout">
|
||||
<div class="avatar-panel">
|
||||
<div class="avatar-box" id="avatarPreviewBox">
|
||||
<?php if (!empty($avatarPath)): ?>
|
||||
<img src="<?= e($avatarPath); ?>" class="user-img" alt="user avatar" id="avatarPreviewImage">
|
||||
<?php else: ?>
|
||||
<span id="avatarInitials"><?= e(getAvatarInitials($profileUser)); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<p class="avatar-name">
|
||||
<?= e(trim(($profileUser['first_name'] ?? '') . ' ' . ($profileUser['last_name'] ?? '')) ?: 'Utente'); ?>
|
||||
</p>
|
||||
|
||||
<p class="avatar-email">
|
||||
<?= e($profileUser['email']); ?>
|
||||
</p>
|
||||
|
||||
<label for="avatar" class="avatar-upload-label">
|
||||
Carica avatar
|
||||
</label>
|
||||
|
||||
<input type="file"
|
||||
class="avatar-upload-input"
|
||||
id="avatar"
|
||||
name="avatar"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif">
|
||||
|
||||
<div id="selectedFileName" class="selected-file-name"></div>
|
||||
|
||||
<div class="avatar-help">
|
||||
Formati consentiti: JPG, PNG, WEBP, GIF.<br>
|
||||
Dimensione massima: 2 MB.
|
||||
</div>
|
||||
|
||||
<div class="profile-meta">
|
||||
Stato account: <?= e($profileUser['status']); ?>
|
||||
<?php if (!empty($profileUser['last_login'])): ?>
|
||||
<br>Ultimo accesso: <?= e(date('d/m/Y H:i', strtotime($profileUser['last_login']))); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="first_name">Nome</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value="<?= e($profileUser['first_name']); ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="last_name">Cognome</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value="<?= e($profileUser['last_name']); ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="email">Email</label>
|
||||
<input type="email"
|
||||
class="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
value="<?= e($profileUser['email']); ?>"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="phone">Telefono</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value="<?= e($profileUser['phone']); ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="birthday">Data di nascita</label>
|
||||
<input type="date"
|
||||
class="form-control"
|
||||
id="birthday"
|
||||
name="birthday"
|
||||
value="<?= e($profileUser['birthday']); ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="country_id">Paese</label>
|
||||
<select class="form-select" id="country_id" name="country_id">
|
||||
<option value="">Seleziona...</option>
|
||||
<?php foreach ($countries as $country): ?>
|
||||
<option value="<?= (int)$country['id']; ?>"
|
||||
<?= ((int)$profileUser['country_id'] === (int)$country['id']) ? 'selected' : ''; ?>>
|
||||
<?= e($country['name'] . (!empty($country['iso_3166_2']) ? ' (' . $country['iso_3166_2'] . ')' : '')); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-12">
|
||||
<label class="form-label" for="address">Indirizzo</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="address"
|
||||
name="address"
|
||||
value="<?= e($profileUser['address']); ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-header">
|
||||
<div class="settings-icon">🔐</div>
|
||||
<div>
|
||||
<p class="settings-heading">Cambio password</p>
|
||||
<p class="settings-subtitle">Compila questa sezione solo se vuoi modificare la password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-body">
|
||||
<div class="password-box">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="current_password">Password attuale</label>
|
||||
<input type="password"
|
||||
class="form-control"
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="new_password">Nuova password</label>
|
||||
<input type="password"
|
||||
class="form-control"
|
||||
id="new_password"
|
||||
name="new_password"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="confirm_password">Conferma nuova password</label>
|
||||
<input type="password"
|
||||
class="form-control"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-text">
|
||||
Se lasci questi campi vuoti, la password attuale rimane invariata.
|
||||
La nuova password deve avere almeno 8 caratteri.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-row">
|
||||
<a href="production_dashboard.php" class="btn-back">← Torna alla dashboard</a>
|
||||
<button type="submit" class="btn-save-settings">Salva impostazioni</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include('jsinclude.php'); ?>
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const avatarInput = document.getElementById('avatar');
|
||||
const previewBox = document.getElementById('avatarPreviewBox');
|
||||
const selectedFileName = document.getElementById('selectedFileName');
|
||||
|
||||
if (!avatarInput || !previewBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
avatarInput.addEventListener('change', function() {
|
||||
const file = this.files && this.files[0] ? this.files[0] : null;
|
||||
|
||||
if (!file) {
|
||||
selectedFileName.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFileName.textContent = file.name;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function(event) {
|
||||
previewBox.innerHTML = '';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = event.target.result;
|
||||
img.className = 'user-img';
|
||||
img.alt = 'user avatar';
|
||||
|
||||
previewBox.appendChild(img);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,157 @@
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
import traceback
|
||||
|
||||
from cad_vector_area import calculate_pdf_vector_area
|
||||
from auto_contour import propose_contour_from_image_bytes
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "Python CAD Area service is running"
|
||||
})
|
||||
|
||||
|
||||
def get_float_or_none(name):
|
||||
value = request.form.get(name, "").strip()
|
||||
|
||||
if value == "":
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_int_or_default(name, default=1):
|
||||
value = request.form.get(name, "").strip()
|
||||
|
||||
if value == "":
|
||||
return default
|
||||
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
@app.route("/calculate", methods=["POST"])
|
||||
def calculate():
|
||||
try:
|
||||
if "file" not in request.files:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "No PDF file received"
|
||||
}), 400
|
||||
|
||||
uploaded_file = request.files["file"]
|
||||
|
||||
if uploaded_file.filename == "":
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "Empty filename"
|
||||
}), 400
|
||||
|
||||
if not uploaded_file.filename.lower().endswith(".pdf"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "Only PDF files are allowed"
|
||||
}), 400
|
||||
|
||||
pdf_bytes = uploaded_file.read()
|
||||
|
||||
scale_ratio = get_float_or_none("scale_ratio")
|
||||
|
||||
if scale_ratio is not None and scale_ratio <= 0:
|
||||
scale_ratio = None
|
||||
|
||||
roi = {
|
||||
"x": get_float_or_none("roi_x"),
|
||||
"y": get_float_or_none("roi_y"),
|
||||
"width": get_float_or_none("roi_width"),
|
||||
"height": get_float_or_none("roi_height"),
|
||||
"page": get_int_or_default("roi_page", 1)
|
||||
}
|
||||
|
||||
has_roi = (
|
||||
roi["x"] is not None and
|
||||
roi["y"] is not None and
|
||||
roi["width"] is not None and
|
||||
roi["height"] is not None and
|
||||
roi["width"] > 0 and
|
||||
roi["height"] > 0
|
||||
)
|
||||
|
||||
mode = request.form.get("mode", "auto_roi")
|
||||
|
||||
result = calculate_pdf_vector_area(
|
||||
pdf_bytes=pdf_bytes,
|
||||
filename=uploaded_file.filename,
|
||||
scale_ratio=scale_ratio,
|
||||
profile_color=None,
|
||||
roi=roi if has_roi else None,
|
||||
mode=mode
|
||||
)
|
||||
|
||||
status_code = 200 if result.get("success") else 422
|
||||
|
||||
return jsonify(result), status_code
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"trace": traceback.format_exc()
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route("/auto-contour-image", methods=["POST"])
|
||||
def auto_contour_image():
|
||||
try:
|
||||
if "image" not in request.files:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "No image received"
|
||||
}), 400
|
||||
|
||||
uploaded_image = request.files["image"]
|
||||
image_bytes = uploaded_image.read()
|
||||
|
||||
max_points_raw = request.form.get("max_points", "90").strip()
|
||||
|
||||
try:
|
||||
max_points = int(max_points_raw)
|
||||
except ValueError:
|
||||
max_points = 90
|
||||
|
||||
max_points = max(12, min(max_points, 250))
|
||||
|
||||
result = propose_contour_from_image_bytes(
|
||||
image_bytes=image_bytes,
|
||||
max_points=max_points
|
||||
)
|
||||
|
||||
status_code = 200 if result.get("success") else 422
|
||||
|
||||
return jsonify(result), status_code
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"trace": traceback.format_exc()
|
||||
}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(
|
||||
host="127.0.0.1",
|
||||
port=5055,
|
||||
debug=True
|
||||
)
|
||||
@@ -0,0 +1,404 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _normalize_contour(contour, width, height):
|
||||
points = contour.reshape(-1, 2)
|
||||
|
||||
return [
|
||||
{
|
||||
"x": round(float(x) / float(width), 8),
|
||||
"y": round(float(y) / float(height), 8),
|
||||
}
|
||||
for x, y in points
|
||||
]
|
||||
|
||||
|
||||
def _simplify_contour(contour, max_points=120):
|
||||
if contour is None or len(contour) < 3:
|
||||
return contour
|
||||
|
||||
perimeter = cv2.arcLength(contour, True)
|
||||
|
||||
if perimeter <= 0:
|
||||
return contour
|
||||
|
||||
epsilon = max(0.8, perimeter * 0.0025)
|
||||
simplified = cv2.approxPolyDP(contour, epsilon, True)
|
||||
|
||||
while len(simplified) > max_points and epsilon < perimeter * 0.06:
|
||||
epsilon *= 1.25
|
||||
simplified = cv2.approxPolyDP(contour, epsilon, True)
|
||||
|
||||
if simplified is None or len(simplified) < 3:
|
||||
return contour
|
||||
|
||||
return simplified
|
||||
|
||||
|
||||
def _contour_score(contour, image_area, width, height):
|
||||
area = abs(cv2.contourArea(contour))
|
||||
|
||||
if area <= 0:
|
||||
return None
|
||||
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
|
||||
if w < 8 or h < 8:
|
||||
return None
|
||||
|
||||
bbox_area = w * h
|
||||
|
||||
if bbox_area <= 0:
|
||||
return None
|
||||
|
||||
area_ratio = area / image_area
|
||||
bbox_ratio = bbox_area / image_area
|
||||
fill_ratio = area / bbox_area
|
||||
aspect = max(w, h) / max(1, min(w, h))
|
||||
|
||||
# Too small: usually dots/noise.
|
||||
if area_ratio < 0.0004:
|
||||
return None
|
||||
|
||||
# Too large: usually background / page.
|
||||
if area_ratio > 0.96 or bbox_ratio > 0.98:
|
||||
return None
|
||||
|
||||
# Very thin: usually dimensions/text lines.
|
||||
if aspect > 40:
|
||||
return None
|
||||
|
||||
# Prefer large compact-ish shapes, but allow irregular profiles.
|
||||
score = area
|
||||
|
||||
if 0.08 <= fill_ratio <= 0.95:
|
||||
score *= 1.25
|
||||
|
||||
# Penalize contours glued to the border because they are often crop/background artifacts.
|
||||
border_touch = (
|
||||
x <= 1 or
|
||||
y <= 1 or
|
||||
x + w >= width - 2 or
|
||||
y + h >= height - 2
|
||||
)
|
||||
|
||||
if border_touch:
|
||||
score *= 0.65
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"area": area,
|
||||
"bbox": (x, y, w, h),
|
||||
"area_ratio": area_ratio,
|
||||
"bbox_ratio": bbox_ratio,
|
||||
"fill_ratio": fill_ratio,
|
||||
"aspect": aspect,
|
||||
"border_touch": border_touch,
|
||||
}
|
||||
|
||||
|
||||
def _best_contour_from_mask(mask, image_area, width, height, mode_name):
|
||||
contours, _hierarchy = cv2.findContours(
|
||||
mask,
|
||||
cv2.RETR_EXTERNAL,
|
||||
cv2.CHAIN_APPROX_SIMPLE
|
||||
)
|
||||
|
||||
candidates = []
|
||||
|
||||
for contour in contours:
|
||||
info = _contour_score(contour, image_area, width, height)
|
||||
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
candidates.append((info["score"], contour, info))
|
||||
|
||||
if not candidates:
|
||||
return None, {
|
||||
"mode": mode_name,
|
||||
"contours_total": len(contours),
|
||||
"candidates": 0,
|
||||
}
|
||||
|
||||
candidates.sort(key=lambda item: item[0], reverse=True)
|
||||
|
||||
best_score, best_contour, best_info = candidates[0]
|
||||
|
||||
return best_contour, {
|
||||
"mode": mode_name,
|
||||
"contours_total": len(contours),
|
||||
"candidates": len(candidates),
|
||||
"selected": {
|
||||
"score": round(float(best_score), 3),
|
||||
"area": round(float(best_info["area"]), 3),
|
||||
"bbox": {
|
||||
"x": int(best_info["bbox"][0]),
|
||||
"y": int(best_info["bbox"][1]),
|
||||
"width": int(best_info["bbox"][2]),
|
||||
"height": int(best_info["bbox"][3]),
|
||||
},
|
||||
"area_ratio": round(float(best_info["area_ratio"]), 5),
|
||||
"bbox_ratio": round(float(best_info["bbox_ratio"]), 5),
|
||||
"fill_ratio": round(float(best_info["fill_ratio"]), 5),
|
||||
"aspect": round(float(best_info["aspect"]), 3),
|
||||
"border_touch": bool(best_info["border_touch"]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _remove_small_components(mask, min_area):
|
||||
num_labels, labels, stats, _centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)
|
||||
|
||||
output = np.zeros_like(mask)
|
||||
|
||||
for label in range(1, num_labels):
|
||||
area = stats[label, cv2.CC_STAT_AREA]
|
||||
|
||||
if area >= min_area:
|
||||
output[labels == label] = 255
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _make_masks(image):
|
||||
height, width = image.shape[:2]
|
||||
image_area = width * height
|
||||
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Slight blur to reduce antialias noise.
|
||||
blurred = cv2.GaussianBlur(gray, (3, 3), 0)
|
||||
|
||||
# Dark ink mask: lines, hatches, dots, technical strokes.
|
||||
mask_dark_245 = cv2.inRange(blurred, 0, 245)
|
||||
mask_dark_235 = cv2.inRange(blurred, 0, 235)
|
||||
mask_dark_220 = cv2.inRange(blurred, 0, 220)
|
||||
|
||||
# Otsu inverse.
|
||||
_t, mask_otsu = cv2.threshold(
|
||||
blurred,
|
||||
0,
|
||||
255,
|
||||
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
|
||||
)
|
||||
|
||||
# Adaptive threshold helps on scans with grey background.
|
||||
mask_adaptive = cv2.adaptiveThreshold(
|
||||
blurred,
|
||||
255,
|
||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY_INV,
|
||||
31,
|
||||
7
|
||||
)
|
||||
|
||||
base_mask = cv2.bitwise_or(mask_dark_235, mask_otsu)
|
||||
base_mask = cv2.bitwise_or(base_mask, mask_adaptive)
|
||||
|
||||
min_component_area = max(6, int(image_area * 0.00002))
|
||||
base_mask = _remove_small_components(base_mask, min_component_area)
|
||||
|
||||
kernel_3 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
||||
kernel_5 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
kernel_9 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
|
||||
kernel_13 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (13, 13))
|
||||
kernel_21 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21))
|
||||
|
||||
masks = []
|
||||
|
||||
# Strategy 1: normal dark mask, light close.
|
||||
m1 = cv2.morphologyEx(base_mask, cv2.MORPH_CLOSE, kernel_5, iterations=1)
|
||||
m1 = cv2.morphologyEx(m1, cv2.MORPH_OPEN, kernel_3, iterations=1)
|
||||
masks.append(("dark_close_5", m1))
|
||||
|
||||
# Strategy 2: stronger close for broken profile lines / dotted hatches.
|
||||
m2 = cv2.morphologyEx(base_mask, cv2.MORPH_CLOSE, kernel_9, iterations=2)
|
||||
m2 = cv2.morphologyEx(m2, cv2.MORPH_OPEN, kernel_3, iterations=1)
|
||||
masks.append(("dark_close_9x2", m2))
|
||||
|
||||
# Strategy 3: very strong close, useful when profile is made of dots/hatches.
|
||||
m3 = cv2.morphologyEx(base_mask, cv2.MORPH_CLOSE, kernel_13, iterations=2)
|
||||
m3 = cv2.morphologyEx(m3, cv2.MORPH_OPEN, kernel_5, iterations=1)
|
||||
masks.append(("dark_close_13x2", m3))
|
||||
|
||||
# Strategy 4: Canny edges closed.
|
||||
edges = cv2.Canny(blurred, 60, 180)
|
||||
e1 = cv2.dilate(edges, kernel_3, iterations=1)
|
||||
e1 = cv2.morphologyEx(e1, cv2.MORPH_CLOSE, kernel_9, iterations=2)
|
||||
masks.append(("canny_close_9x2", e1))
|
||||
|
||||
# Strategy 5: flood fill from closed boundaries.
|
||||
boundary = cv2.dilate(mask_dark_245, kernel_3, iterations=1)
|
||||
boundary = cv2.morphologyEx(boundary, cv2.MORPH_CLOSE, kernel_9, iterations=2)
|
||||
|
||||
passable = cv2.bitwise_not(boundary)
|
||||
flood = passable.copy()
|
||||
flood_mask = np.zeros((height + 2, width + 2), dtype=np.uint8)
|
||||
|
||||
for x in range(width):
|
||||
if flood[0, x] > 0:
|
||||
cv2.floodFill(flood, flood_mask, (x, 0), 128)
|
||||
if flood[height - 1, x] > 0:
|
||||
cv2.floodFill(flood, flood_mask, (x, height - 1), 128)
|
||||
|
||||
for y in range(height):
|
||||
if flood[y, 0] > 0:
|
||||
cv2.floodFill(flood, flood_mask, (0, y), 128)
|
||||
if flood[y, width - 1] > 0:
|
||||
cv2.floodFill(flood, flood_mask, (width - 1, y), 128)
|
||||
|
||||
outside = (flood == 128).astype(np.uint8) * 255
|
||||
enclosed = cv2.bitwise_not(outside)
|
||||
|
||||
enclosed[0, :] = 0
|
||||
enclosed[-1, :] = 0
|
||||
enclosed[:, 0] = 0
|
||||
enclosed[:, -1] = 0
|
||||
|
||||
enclosed = cv2.morphologyEx(enclosed, cv2.MORPH_OPEN, kernel_5, iterations=1)
|
||||
masks.append(("flood_enclosed", enclosed))
|
||||
|
||||
# Strategy 6: if everything is sparse, glue nearby strokes aggressively.
|
||||
m6 = cv2.morphologyEx(mask_dark_220, cv2.MORPH_CLOSE, kernel_21, iterations=1)
|
||||
m6 = cv2.morphologyEx(m6, cv2.MORPH_OPEN, kernel_5, iterations=1)
|
||||
masks.append(("aggressive_close_21", m6))
|
||||
|
||||
return masks, {
|
||||
"gray_mean": round(float(gray.mean()), 3),
|
||||
"base_mask_pixels": int((base_mask > 0).sum()),
|
||||
"image_width": width,
|
||||
"image_height": height,
|
||||
}
|
||||
|
||||
|
||||
def propose_contour_from_image_bytes(image_bytes, max_points=120):
|
||||
"""
|
||||
Receives a PNG/JPG image of the currently visible ROI canvas and returns
|
||||
a proposed outer contour as normalized x/y points.
|
||||
|
||||
This is a proposal only. The frontend must allow editing.
|
||||
"""
|
||||
np_buffer = np.frombuffer(image_bytes, dtype=np.uint8)
|
||||
image = cv2.imdecode(np_buffer, cv2.IMREAD_COLOR)
|
||||
|
||||
if image is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Immagine non valida o non decodificabile."
|
||||
}
|
||||
|
||||
height, width = image.shape[:2]
|
||||
|
||||
if width < 20 or height < 20:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Immagine troppo piccola per il riconoscimento del contorno."
|
||||
}
|
||||
|
||||
image_area = width * height
|
||||
|
||||
masks, base_diag = _make_masks(image)
|
||||
|
||||
attempts = []
|
||||
best_global = None
|
||||
|
||||
for mode_name, mask in masks:
|
||||
contour, diag = _best_contour_from_mask(
|
||||
mask=mask,
|
||||
image_area=image_area,
|
||||
width=width,
|
||||
height=height,
|
||||
mode_name=mode_name
|
||||
)
|
||||
|
||||
diag["mask_pixels"] = int((mask > 0).sum())
|
||||
attempts.append(diag)
|
||||
|
||||
if contour is None:
|
||||
continue
|
||||
|
||||
info = _contour_score(contour, image_area, width, height)
|
||||
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
score = info["score"]
|
||||
|
||||
if best_global is None or score > best_global["score"]:
|
||||
best_global = {
|
||||
"score": score,
|
||||
"contour": contour,
|
||||
"mode": mode_name,
|
||||
"info": info,
|
||||
"mask_pixels": int((mask > 0).sum()),
|
||||
}
|
||||
|
||||
if best_global is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Nessun contorno plausibile trovato. Prova una ROI più stretta o procedi manualmente.",
|
||||
"diagnostics": {
|
||||
**base_diag,
|
||||
"attempts": attempts,
|
||||
}
|
||||
}
|
||||
|
||||
simplified = _simplify_contour(best_global["contour"], max_points=max_points)
|
||||
|
||||
if simplified is None or len(simplified) < 3:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Il contorno trovato non ha abbastanza punti validi.",
|
||||
"diagnostics": {
|
||||
**base_diag,
|
||||
"selected_mode": best_global["mode"],
|
||||
"attempts": attempts,
|
||||
}
|
||||
}
|
||||
|
||||
area_px2 = float(abs(cv2.contourArea(simplified)))
|
||||
x, y, w, h = cv2.boundingRect(simplified)
|
||||
|
||||
# Defensive check.
|
||||
if area_px2 <= 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Il contorno trovato ha area nulla.",
|
||||
"diagnostics": {
|
||||
**base_diag,
|
||||
"selected_mode": best_global["mode"],
|
||||
"attempts": attempts,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Contorno proposto correttamente.",
|
||||
"outer_polygon": _normalize_contour(simplified, width, height),
|
||||
"holes": [],
|
||||
"diagnostics": {
|
||||
**base_diag,
|
||||
"selected_mode": best_global["mode"],
|
||||
"points_count": int(len(simplified)),
|
||||
"area_px2": round(area_px2, 3),
|
||||
"bbox": {
|
||||
"x": int(x),
|
||||
"y": int(y),
|
||||
"width": int(w),
|
||||
"height": int(h)
|
||||
},
|
||||
"selected": {
|
||||
"score": round(float(best_global["score"]), 3),
|
||||
"area": round(float(best_global["info"]["area"]), 3),
|
||||
"area_ratio": round(float(best_global["info"]["area_ratio"]), 5),
|
||||
"bbox_ratio": round(float(best_global["info"]["bbox_ratio"]), 5),
|
||||
"fill_ratio": round(float(best_global["info"]["fill_ratio"]), 5),
|
||||
"aspect": round(float(best_global["info"]["aspect"]), 3),
|
||||
"border_touch": bool(best_global["info"]["border_touch"]),
|
||||
"mask_pixels": int(best_global["mask_pixels"]),
|
||||
},
|
||||
"attempts": attempts,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once(__DIR__ . '/include/headscript.php');
|
||||
|
||||
try {
|
||||
$db = DBHandlerSelect::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
$iduser = $iduserlogin ?? null;
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$input = json_decode($rawInput, true);
|
||||
|
||||
if (!is_array($input)) {
|
||||
throw new Exception('Payload JSON non valido.');
|
||||
}
|
||||
|
||||
$id = (int)($input['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
throw new Exception('ID non valido.');
|
||||
}
|
||||
|
||||
$roiX = isset($input['roi_x']) ? (float)$input['roi_x'] : null;
|
||||
$roiY = isset($input['roi_y']) ? (float)$input['roi_y'] : null;
|
||||
$roiW = isset($input['roi_width']) ? (float)$input['roi_width'] : null;
|
||||
$roiH = isset($input['roi_height']) ? (float)$input['roi_height'] : null;
|
||||
$roiPage = isset($input['roi_page']) ? (int)$input['roi_page'] : 1;
|
||||
$mode = $input['calculation_mode'] ?? 'auto_roi';
|
||||
|
||||
if ($roiX === null || $roiY === null || $roiW === null || $roiH === null) {
|
||||
throw new Exception('ROI non valida.');
|
||||
}
|
||||
|
||||
if ($roiW <= 0 || $roiH <= 0) {
|
||||
throw new Exception('Dimensioni ROI non valide.');
|
||||
}
|
||||
|
||||
if ($roiX < 0 || $roiY < 0 || $roiX > 1 || $roiY > 1 || $roiW > 1 || $roiH > 1) {
|
||||
throw new Exception('Coordinate ROI fuori scala.');
|
||||
}
|
||||
|
||||
$allowedModes = [
|
||||
'auto_roi',
|
||||
'stitch_contour',
|
||||
'filled_union',
|
||||
'closed_path'
|
||||
];
|
||||
|
||||
if (!in_array($mode, $allowedModes, true)) {
|
||||
$mode = 'auto_roi';
|
||||
}
|
||||
|
||||
if ($iduser === null || $iduser === '') {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE cad_area_jobs
|
||||
SET
|
||||
roi_x = ?,
|
||||
roi_y = ?,
|
||||
roi_width = ?,
|
||||
roi_height = ?,
|
||||
roi_page = ?,
|
||||
calculation_mode = ?,
|
||||
status = 'uploaded',
|
||||
message = NULL
|
||||
WHERE id = ?
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$roiX,
|
||||
$roiY,
|
||||
$roiW,
|
||||
$roiH,
|
||||
$roiPage,
|
||||
$mode,
|
||||
$id
|
||||
]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE cad_area_jobs
|
||||
SET
|
||||
roi_x = ?,
|
||||
roi_y = ?,
|
||||
roi_width = ?,
|
||||
roi_height = ?,
|
||||
roi_page = ?,
|
||||
calculation_mode = ?,
|
||||
status = 'uploaded',
|
||||
message = NULL
|
||||
WHERE id = ?
|
||||
AND iduser = ?
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
$roiX,
|
||||
$roiY,
|
||||
$roiW,
|
||||
$roiH,
|
||||
$roiPage,
|
||||
$mode,
|
||||
$id,
|
||||
$iduser
|
||||
]);
|
||||
}
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
throw new Exception('Nessun record aggiornato. Controlla ID o utente.');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'ROI salvata correttamente.'
|
||||
]);
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
error_log('CAD area save ROI error: ' . $e->getMessage());
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage()
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
import math
|
||||
import re
|
||||
from collections import Counter, deque
|
||||
|
||||
import fitz # PyMuPDF
|
||||
import numpy as np
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.ops import unary_union
|
||||
from shapely.validation import make_valid
|
||||
|
||||
|
||||
POINT_TO_MM = 25.4 / 72.0
|
||||
DEFAULT_RENDER_ZOOM = 8.0
|
||||
MAX_RENDER_SIDE_PX = 3200
|
||||
STITCH_TOLERANCE = 1.2
|
||||
MAX_ASPECT_RATIO = 80
|
||||
|
||||
|
||||
_SCALE_PATTERN = re.compile(
|
||||
r'(?:scale|echelle|échelle|scala|masstab|escala)?\s*'
|
||||
r'(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _pt(point):
|
||||
return float(point.x), float(point.y)
|
||||
|
||||
|
||||
def _dist(a, b):
|
||||
return math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
|
||||
|
||||
def _color_close(c1, c2, tol=0.05):
|
||||
if c1 is None or c2 is None:
|
||||
return False
|
||||
|
||||
if len(c1) == 4:
|
||||
c1 = c1[:3]
|
||||
|
||||
if len(c2) == 4:
|
||||
c2 = c2[:3]
|
||||
|
||||
if len(c1) != len(c2):
|
||||
return False
|
||||
|
||||
return all(abs(float(a) - float(b)) <= tol for a, b in zip(c1, c2))
|
||||
|
||||
|
||||
def _cubic_bezier(p0, p1, p2, p3, steps=24):
|
||||
pts = []
|
||||
|
||||
for i in range(1, steps + 1):
|
||||
t = i / steps
|
||||
|
||||
x = (
|
||||
(1 - t) ** 3 * p0[0]
|
||||
+ 3 * (1 - t) ** 2 * t * p1[0]
|
||||
+ 3 * (1 - t) * t ** 2 * p2[0]
|
||||
+ t ** 3 * p3[0]
|
||||
)
|
||||
|
||||
y = (
|
||||
(1 - t) ** 3 * p0[1]
|
||||
+ 3 * (1 - t) ** 2 * t * p1[1]
|
||||
+ 3 * (1 - t) * t ** 2 * p2[1]
|
||||
+ t ** 3 * p3[1]
|
||||
)
|
||||
|
||||
pts.append((x, y))
|
||||
|
||||
return pts
|
||||
|
||||
|
||||
def _safe_polygon(points):
|
||||
if len(points) < 3:
|
||||
return None
|
||||
|
||||
try:
|
||||
polygon = Polygon(points)
|
||||
|
||||
if not polygon.is_valid:
|
||||
polygon = make_valid(polygon)
|
||||
|
||||
if polygon.is_empty or polygon.area <= 0:
|
||||
return None
|
||||
|
||||
if polygon.geom_type == "MultiPolygon":
|
||||
polygon = max(list(polygon.geoms), key=lambda g: g.area)
|
||||
|
||||
if polygon.geom_type != "Polygon":
|
||||
return None
|
||||
|
||||
return polygon
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _area_mm2_from_polygon(polygon, scale_ratio):
|
||||
return abs(float(polygon.area)) * (POINT_TO_MM ** 2) / (scale_ratio ** 2)
|
||||
|
||||
|
||||
def _bounds_mm_from_polygon(polygon, scale_ratio):
|
||||
minx, miny, maxx, maxy = polygon.bounds
|
||||
|
||||
width_mm = (maxx - minx) * POINT_TO_MM / scale_ratio
|
||||
height_mm = (maxy - miny) * POINT_TO_MM / scale_ratio
|
||||
|
||||
return round(width_mm, 3), round(height_mm, 3)
|
||||
|
||||
|
||||
def _detect_scale_from_text(page):
|
||||
raw = page.get_text("text")
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
candidates = []
|
||||
|
||||
for match in _SCALE_PATTERN.finditer(raw):
|
||||
try:
|
||||
a = float(match.group(1))
|
||||
b = float(match.group(2))
|
||||
|
||||
if b <= 0:
|
||||
continue
|
||||
|
||||
ratio = a / b
|
||||
|
||||
if 0.01 <= ratio <= 100:
|
||||
candidates.append(round(ratio, 4))
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
return Counter(candidates).most_common(1)[0][0]
|
||||
|
||||
|
||||
def _normalized_roi_to_page_rect(page_rect, roi):
|
||||
if not roi:
|
||||
return None
|
||||
|
||||
x = roi.get("x")
|
||||
y = roi.get("y")
|
||||
width = roi.get("width")
|
||||
height = roi.get("height")
|
||||
|
||||
if x is None or y is None or width is None or height is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
x = float(x)
|
||||
y = float(y)
|
||||
width = float(width)
|
||||
height = float(height)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
|
||||
x = max(0.0, min(1.0, x))
|
||||
y = max(0.0, min(1.0, y))
|
||||
width = max(0.0, min(1.0 - x, width))
|
||||
height = max(0.0, min(1.0 - y, height))
|
||||
|
||||
x0 = page_rect.x0 + x * page_rect.width
|
||||
y0 = page_rect.y0 + y * page_rect.height
|
||||
x1 = x0 + width * page_rect.width
|
||||
y1 = y0 + height * page_rect.height
|
||||
|
||||
return fitz.Rect(x0, y0, x1, y1)
|
||||
|
||||
|
||||
def _drawing_intersects_rect(drawing, roi_rect):
|
||||
if roi_rect is None:
|
||||
return True
|
||||
|
||||
rect = drawing.get("rect")
|
||||
|
||||
if rect is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return fitz.Rect(rect).intersects(roi_rect)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _filter_drawings_by_roi(drawings, roi_rect):
|
||||
if roi_rect is None:
|
||||
return drawings
|
||||
|
||||
return [d for d in drawings if _drawing_intersects_rect(d, roi_rect)]
|
||||
|
||||
|
||||
def _choose_render_zoom(rect):
|
||||
zoom = DEFAULT_RENDER_ZOOM
|
||||
|
||||
max_side = max(rect.width, rect.height)
|
||||
|
||||
if max_side * zoom > MAX_RENDER_SIDE_PX:
|
||||
zoom = MAX_RENDER_SIDE_PX / max_side
|
||||
|
||||
return max(3.0, min(DEFAULT_RENDER_ZOOM, zoom))
|
||||
|
||||
|
||||
def _dilate(mask, iterations=1):
|
||||
result = mask.astype(bool)
|
||||
|
||||
for _ in range(iterations):
|
||||
padded = np.pad(result, 1, mode="constant", constant_values=False)
|
||||
|
||||
result = (
|
||||
padded[1:-1, 1:-1]
|
||||
| padded[:-2, 1:-1]
|
||||
| padded[2:, 1:-1]
|
||||
| padded[1:-1, :-2]
|
||||
| padded[1:-1, 2:]
|
||||
| padded[:-2, :-2]
|
||||
| padded[:-2, 2:]
|
||||
| padded[2:, :-2]
|
||||
| padded[2:, 2:]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _erode(mask, iterations=1):
|
||||
result = mask.astype(bool)
|
||||
|
||||
for _ in range(iterations):
|
||||
padded = np.pad(result, 1, mode="constant", constant_values=False)
|
||||
|
||||
result = (
|
||||
padded[1:-1, 1:-1]
|
||||
& padded[:-2, 1:-1]
|
||||
& padded[2:, 1:-1]
|
||||
& padded[1:-1, :-2]
|
||||
& padded[1:-1, 2:]
|
||||
& padded[:-2, :-2]
|
||||
& padded[:-2, 2:]
|
||||
& padded[2:, :-2]
|
||||
& padded[2:, 2:]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _close_mask(mask, iterations=2):
|
||||
return _erode(_dilate(mask, iterations=iterations), iterations=iterations)
|
||||
|
||||
|
||||
def _largest_component(mask):
|
||||
h, w = mask.shape
|
||||
visited = np.zeros_like(mask, dtype=bool)
|
||||
|
||||
best_pixels = []
|
||||
best_count = 0
|
||||
|
||||
ys, xs = np.where(mask)
|
||||
|
||||
for start_y, start_x in zip(ys, xs):
|
||||
if visited[start_y, start_x]:
|
||||
continue
|
||||
|
||||
q = deque()
|
||||
q.append((start_y, start_x))
|
||||
visited[start_y, start_x] = True
|
||||
|
||||
pixels = []
|
||||
|
||||
while q:
|
||||
y, x = q.popleft()
|
||||
pixels.append((y, x))
|
||||
|
||||
for ny in (y - 1, y, y + 1):
|
||||
for nx in (x - 1, x, x + 1):
|
||||
if ny == y and nx == x:
|
||||
continue
|
||||
|
||||
if ny < 0 or nx < 0 or ny >= h or nx >= w:
|
||||
continue
|
||||
|
||||
if visited[ny, nx]:
|
||||
continue
|
||||
|
||||
if not mask[ny, nx]:
|
||||
continue
|
||||
|
||||
visited[ny, nx] = True
|
||||
q.append((ny, nx))
|
||||
|
||||
if len(pixels) > best_count:
|
||||
best_count = len(pixels)
|
||||
best_pixels = pixels
|
||||
|
||||
output = np.zeros_like(mask, dtype=bool)
|
||||
|
||||
for y, x in best_pixels:
|
||||
output[y, x] = True
|
||||
|
||||
return output, best_count
|
||||
|
||||
|
||||
def _flood_fill_outside(boundary_mask):
|
||||
h, w = boundary_mask.shape
|
||||
|
||||
outside = np.zeros_like(boundary_mask, dtype=bool)
|
||||
passable = ~boundary_mask
|
||||
|
||||
q = deque()
|
||||
|
||||
for x in range(w):
|
||||
if passable[0, x]:
|
||||
outside[0, x] = True
|
||||
q.append((0, x))
|
||||
|
||||
if passable[h - 1, x]:
|
||||
outside[h - 1, x] = True
|
||||
q.append((h - 1, x))
|
||||
|
||||
for y in range(h):
|
||||
if passable[y, 0]:
|
||||
outside[y, 0] = True
|
||||
q.append((y, 0))
|
||||
|
||||
if passable[y, w - 1]:
|
||||
outside[y, w - 1] = True
|
||||
q.append((y, w - 1))
|
||||
|
||||
while q:
|
||||
y, x = q.popleft()
|
||||
|
||||
for ny, nx in (
|
||||
(y - 1, x),
|
||||
(y + 1, x),
|
||||
(y, x - 1),
|
||||
(y, x + 1),
|
||||
):
|
||||
if ny < 0 or nx < 0 or ny >= h or nx >= w:
|
||||
continue
|
||||
|
||||
if outside[ny, nx]:
|
||||
continue
|
||||
|
||||
if not passable[ny, nx]:
|
||||
continue
|
||||
|
||||
outside[ny, nx] = True
|
||||
q.append((ny, nx))
|
||||
|
||||
return outside
|
||||
|
||||
|
||||
def _bbox_from_mask(mask, padding=8):
|
||||
ys, xs = np.where(mask)
|
||||
|
||||
if len(xs) == 0 or len(ys) == 0:
|
||||
return None
|
||||
|
||||
h, w = mask.shape
|
||||
|
||||
x0 = max(0, int(xs.min()) - padding)
|
||||
x1 = min(w - 1, int(xs.max()) + padding)
|
||||
y0 = max(0, int(ys.min()) - padding)
|
||||
y1 = min(h - 1, int(ys.max()) + padding)
|
||||
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
def _crop_mask(mask, bbox):
|
||||
x0, y0, x1, y1 = bbox
|
||||
return mask[y0:y1 + 1, x0:x1 + 1]
|
||||
|
||||
|
||||
def _raster_roi_area(page, roi_rect, scale_ratio, mode="auto_roi"):
|
||||
"""
|
||||
Raster ROI method:
|
||||
- render only the selected ROI
|
||||
- detect technical ink / filled geometry
|
||||
- try flood-fill to recover the enclosed section area
|
||||
- fallback to filled dark pixels for filled profiles
|
||||
"""
|
||||
if roi_rect is None:
|
||||
return None, "ROI mancante: definisci prima la sezione da misurare."
|
||||
|
||||
zoom = _choose_render_zoom(roi_rect)
|
||||
|
||||
pix = page.get_pixmap(
|
||||
matrix=fitz.Matrix(zoom, zoom),
|
||||
clip=roi_rect,
|
||||
alpha=False
|
||||
)
|
||||
|
||||
width = pix.width
|
||||
height = pix.height
|
||||
channels = pix.n
|
||||
|
||||
arr = np.frombuffer(pix.samples, dtype=np.uint8).reshape((height, width, channels))
|
||||
|
||||
if channels >= 3:
|
||||
rgb = arr[:, :, :3].astype(np.int16)
|
||||
else:
|
||||
rgb = np.repeat(arr[:, :, :1], 3, axis=2).astype(np.int16)
|
||||
|
||||
brightness = rgb.mean(axis=2)
|
||||
max_channel = rgb.max(axis=2)
|
||||
min_channel = rgb.min(axis=2)
|
||||
saturation = max_channel - min_channel
|
||||
|
||||
# Technical ink / profile geometry.
|
||||
# Keep black, grey, colored CAD strokes, anti-aliased edges.
|
||||
ink = (brightness < 245) | ((saturation > 25) & (brightness < 252))
|
||||
|
||||
# Remove very light background noise.
|
||||
ink = _close_mask(ink, iterations=1)
|
||||
|
||||
ink_bbox = _bbox_from_mask(ink, padding=12)
|
||||
|
||||
if ink_bbox is None:
|
||||
return None, "Nessuna geometria visibile trovata dentro la ROI."
|
||||
|
||||
cropped_ink = _crop_mask(ink, ink_bbox)
|
||||
|
||||
# Strengthen thin CAD lines to close small gaps.
|
||||
boundary = _dilate(cropped_ink, iterations=2)
|
||||
boundary = _close_mask(boundary, iterations=2)
|
||||
|
||||
outside = _flood_fill_outside(boundary)
|
||||
filled_inside = ~outside
|
||||
|
||||
# Keep only the largest enclosed/filled component to avoid text or small debris.
|
||||
largest_inside, largest_inside_pixels = _largest_component(filled_inside)
|
||||
|
||||
ink_pixels = int(cropped_ink.sum())
|
||||
boundary_pixels = int(boundary.sum())
|
||||
inside_pixels = int(largest_inside_pixels)
|
||||
|
||||
pixel_to_mm = POINT_TO_MM / zoom / scale_ratio
|
||||
pixel_area_mm2 = pixel_to_mm ** 2
|
||||
|
||||
ink_area_mm2 = ink_pixels * pixel_area_mm2
|
||||
flood_area_mm2 = inside_pixels * pixel_area_mm2
|
||||
|
||||
crop_h, crop_w = cropped_ink.shape
|
||||
crop_area_pixels = crop_w * crop_h
|
||||
|
||||
flood_ratio = inside_pixels / crop_area_pixels if crop_area_pixels else 0
|
||||
ink_ratio = ink_pixels / crop_area_pixels if crop_area_pixels else 0
|
||||
|
||||
# Decision logic:
|
||||
# - If flood-fill finds a plausible closed section, use it.
|
||||
# - If the ROI contains a filled/hatch mass and flood-fill is not useful, use ink area.
|
||||
# - Never trust a flood area that almost fills the whole ROI crop.
|
||||
selected_method = None
|
||||
selected_area_mm2 = None
|
||||
|
||||
if mode in ("auto_roi", "stitch_contour", "closed_path"):
|
||||
if flood_area_mm2 > max(ink_area_mm2 * 2.0, 5.0) and flood_ratio < 0.92:
|
||||
selected_method = "raster_flood_fill"
|
||||
selected_area_mm2 = flood_area_mm2
|
||||
|
||||
if selected_area_mm2 is None and mode in ("auto_roi", "filled_union"):
|
||||
if ink_area_mm2 > 1.0:
|
||||
selected_method = "raster_filled_ink"
|
||||
selected_area_mm2 = ink_area_mm2
|
||||
|
||||
if selected_area_mm2 is None:
|
||||
return None, "ROI analizzata, ma non è stata trovata un'area raster plausibile."
|
||||
|
||||
# Estimate width / height of detected ink bounding box.
|
||||
width_mm = crop_w * pixel_to_mm
|
||||
height_mm = crop_h * pixel_to_mm
|
||||
|
||||
diagnostics = {
|
||||
"render_zoom": zoom,
|
||||
"roi_rect_points": {
|
||||
"x0": roi_rect.x0,
|
||||
"y0": roi_rect.y0,
|
||||
"x1": roi_rect.x1,
|
||||
"y1": roi_rect.y1,
|
||||
},
|
||||
"render_width_px": width,
|
||||
"render_height_px": height,
|
||||
"ink_pixels": ink_pixels,
|
||||
"boundary_pixels": boundary_pixels,
|
||||
"inside_pixels": inside_pixels,
|
||||
"ink_area_mm2": round(ink_area_mm2, 4),
|
||||
"flood_area_mm2": round(flood_area_mm2, 4),
|
||||
"ink_ratio": round(ink_ratio, 4),
|
||||
"flood_ratio": round(flood_ratio, 4),
|
||||
"selected_method": selected_method,
|
||||
"crop_width_px": crop_w,
|
||||
"crop_height_px": crop_h,
|
||||
}
|
||||
|
||||
return {
|
||||
"area_mm2": selected_area_mm2,
|
||||
"width_mm": round(width_mm, 3),
|
||||
"height_mm": round(height_mm, 3),
|
||||
"method": selected_method,
|
||||
"diagnostics": diagnostics,
|
||||
}, None
|
||||
|
||||
|
||||
def _extract_points_from_drawing(drawing):
|
||||
points = []
|
||||
source_type = "path"
|
||||
|
||||
for item in drawing.get("items", []):
|
||||
cmd = item[0]
|
||||
|
||||
if cmd == "re":
|
||||
rect = item[1]
|
||||
source_type = "rectangle"
|
||||
|
||||
points = [
|
||||
(float(rect.x0), float(rect.y0)),
|
||||
(float(rect.x1), float(rect.y0)),
|
||||
(float(rect.x1), float(rect.y1)),
|
||||
(float(rect.x0), float(rect.y1)),
|
||||
(float(rect.x0), float(rect.y0)),
|
||||
]
|
||||
|
||||
return points, source_type
|
||||
|
||||
if cmd == "l":
|
||||
p1 = _pt(item[1])
|
||||
p2 = _pt(item[2])
|
||||
|
||||
if not points:
|
||||
points.append(p1)
|
||||
|
||||
if _dist(points[-1], p1) > 0.01:
|
||||
points.append(p1)
|
||||
|
||||
points.append(p2)
|
||||
|
||||
elif cmd == "c" and len(item) >= 5:
|
||||
p0 = _pt(item[1])
|
||||
c1 = _pt(item[2])
|
||||
c2 = _pt(item[3])
|
||||
p3 = _pt(item[4])
|
||||
|
||||
if not points:
|
||||
points.append(p0)
|
||||
elif _dist(points[-1], p0) > 0.01:
|
||||
points.append(p0)
|
||||
|
||||
points.extend(_cubic_bezier(p0, c1, c2, p3, steps=24))
|
||||
|
||||
return points, source_type
|
||||
|
||||
|
||||
def _vector_closed_path_area(drawings, scale_ratio):
|
||||
candidates = []
|
||||
|
||||
for index, drawing in enumerate(drawings):
|
||||
points, source_type = _extract_points_from_drawing(drawing)
|
||||
|
||||
if source_type == "rectangle":
|
||||
continue
|
||||
|
||||
if len(points) < 6:
|
||||
continue
|
||||
|
||||
if _dist(points[0], points[-1]) > 1.5:
|
||||
continue
|
||||
|
||||
polygon = _safe_polygon(points)
|
||||
|
||||
if polygon is None:
|
||||
continue
|
||||
|
||||
area_mm2 = _area_mm2_from_polygon(polygon, scale_ratio)
|
||||
|
||||
if area_mm2 < 5:
|
||||
continue
|
||||
|
||||
width_mm, height_mm = _bounds_mm_from_polygon(polygon, scale_ratio)
|
||||
|
||||
min_side = min(width_mm, height_mm)
|
||||
max_side = max(width_mm, height_mm)
|
||||
|
||||
if min_side <= 0:
|
||||
continue
|
||||
|
||||
aspect = max_side / min_side
|
||||
|
||||
if aspect > MAX_ASPECT_RATIO:
|
||||
continue
|
||||
|
||||
candidates.append({
|
||||
"drawing_index": index,
|
||||
"area_mm2": area_mm2,
|
||||
"width_mm": width_mm,
|
||||
"height_mm": height_mm,
|
||||
"points_count": len(points),
|
||||
"aspect_ratio": round(aspect, 3),
|
||||
})
|
||||
|
||||
if not candidates:
|
||||
return None, "Nessun path vettoriale chiuso plausibile trovato."
|
||||
|
||||
candidates.sort(key=lambda x: x["area_mm2"], reverse=True)
|
||||
best = candidates[0]
|
||||
|
||||
return {
|
||||
"area_mm2": best["area_mm2"],
|
||||
"width_mm": best["width_mm"],
|
||||
"height_mm": best["height_mm"],
|
||||
"method": "vector_closed_path",
|
||||
"diagnostics": {
|
||||
"candidates_count": len(candidates),
|
||||
"selected_candidate": best,
|
||||
"candidates_preview": candidates[:20],
|
||||
},
|
||||
}, None
|
||||
|
||||
|
||||
def calculate_pdf_vector_area(
|
||||
pdf_bytes,
|
||||
filename="uploaded.pdf",
|
||||
scale_ratio=None,
|
||||
profile_color=None,
|
||||
roi=None,
|
||||
mode="auto_roi",
|
||||
):
|
||||
try:
|
||||
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Impossibile aprire il PDF: {exc}",
|
||||
}
|
||||
|
||||
if len(doc) == 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Il PDF non ha pagine.",
|
||||
}
|
||||
|
||||
page_index = 0
|
||||
|
||||
if roi and roi.get("page"):
|
||||
try:
|
||||
page_index = max(0, int(roi.get("page")) - 1)
|
||||
except Exception:
|
||||
page_index = 0
|
||||
|
||||
if page_index >= len(doc):
|
||||
page_index = 0
|
||||
|
||||
page = doc[page_index]
|
||||
all_drawings = page.get_drawings()
|
||||
|
||||
if scale_ratio is None:
|
||||
detected_scale = _detect_scale_from_text(page)
|
||||
|
||||
if detected_scale is not None:
|
||||
scale_ratio = detected_scale
|
||||
scale_source = "text_detected"
|
||||
else:
|
||||
scale_ratio = 1.0
|
||||
scale_source = "default_1:1"
|
||||
else:
|
||||
try:
|
||||
scale_ratio = float(scale_ratio)
|
||||
except Exception:
|
||||
scale_ratio = 1.0
|
||||
|
||||
if scale_ratio <= 0:
|
||||
scale_ratio = 1.0
|
||||
|
||||
scale_source = "manual"
|
||||
|
||||
roi_rect = _normalized_roi_to_page_rect(page.rect, roi)
|
||||
drawings = _filter_drawings_by_roi(all_drawings, roi_rect)
|
||||
|
||||
diagnostics = {
|
||||
"filename": filename,
|
||||
"total_pages": len(doc),
|
||||
"page_index_used": page_index,
|
||||
"page_width_mm": round(page.rect.width * POINT_TO_MM / scale_ratio, 3),
|
||||
"page_height_mm": round(page.rect.height * POINT_TO_MM / scale_ratio, 3),
|
||||
"scale_ratio": scale_ratio,
|
||||
"scale_source": scale_source,
|
||||
"mode": mode,
|
||||
"roi_used": roi_rect is not None,
|
||||
"roi": roi,
|
||||
"total_drawings_page": len(all_drawings),
|
||||
"drawings_inside_roi": len(drawings),
|
||||
}
|
||||
|
||||
# First choice: ROI raster method.
|
||||
# This is safer for exploded CAD linework because it measures the selected section image.
|
||||
if roi_rect is not None and mode in ("auto_roi", "stitch_contour", "filled_union", "closed_path"):
|
||||
raster_result, raster_error = _raster_roi_area(
|
||||
page=page,
|
||||
roi_rect=roi_rect,
|
||||
scale_ratio=scale_ratio,
|
||||
mode=mode
|
||||
)
|
||||
|
||||
if raster_result is not None:
|
||||
area_mm2 = round(float(raster_result["area_mm2"]), 4)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Area calcolata sulla ROI selezionata.",
|
||||
"area_mm2": area_mm2,
|
||||
"area_cm2": round(area_mm2 / 100.0, 6),
|
||||
"area_m2": round(area_mm2 / 1_000_000.0, 9),
|
||||
"width_mm": raster_result["width_mm"],
|
||||
"height_mm": raster_result["height_mm"],
|
||||
"scale_detected": f"{scale_ratio}:1",
|
||||
"scale_used": scale_ratio,
|
||||
"scale_source": scale_source,
|
||||
"strategy_used": raster_result["method"],
|
||||
"confidence": "needs_validation",
|
||||
"diagnostics": {
|
||||
**diagnostics,
|
||||
"raster": raster_result["diagnostics"],
|
||||
},
|
||||
}
|
||||
|
||||
diagnostics["raster_error"] = raster_error
|
||||
|
||||
# Fallback: closed vector path only.
|
||||
vector_result, vector_error = _vector_closed_path_area(drawings, scale_ratio)
|
||||
|
||||
if vector_result is not None:
|
||||
area_mm2 = round(float(vector_result["area_mm2"]), 4)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Area calcolata da path vettoriale chiuso.",
|
||||
"area_mm2": area_mm2,
|
||||
"area_cm2": round(area_mm2 / 100.0, 6),
|
||||
"area_m2": round(area_mm2 / 1_000_000.0, 9),
|
||||
"width_mm": vector_result["width_mm"],
|
||||
"height_mm": vector_result["height_mm"],
|
||||
"scale_detected": f"{scale_ratio}:1",
|
||||
"scale_used": scale_ratio,
|
||||
"scale_source": scale_source,
|
||||
"strategy_used": vector_result["method"],
|
||||
"confidence": "needs_validation",
|
||||
"diagnostics": {
|
||||
**diagnostics,
|
||||
"vector": vector_result["diagnostics"],
|
||||
},
|
||||
}
|
||||
|
||||
diagnostics["vector_error"] = vector_error
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
"Nessuna area affidabile trovata. "
|
||||
"Definisci una ROI più stretta intorno alla sola sezione del profilo, "
|
||||
"oppure verifica la scala del disegno."
|
||||
),
|
||||
"area_mm2": None,
|
||||
"area_cm2": None,
|
||||
"area_m2": None,
|
||||
"scale_used": scale_ratio,
|
||||
"scale_source": scale_source,
|
||||
"strategy_used": None,
|
||||
"confidence": "low",
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
Binary file not shown.
-18
@@ -1,18 +0,0 @@
|
||||
; This file is for unifying the coding style for different editors and IDEs.
|
||||
; More information at https://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.yml]
|
||||
indent_size = 2
|
||||
@@ -1,25 +0,0 @@
|
||||
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||
* text eol=lf
|
||||
|
||||
# Explicitly declare text files you want to always be normalized and converted
|
||||
# to native line endings on checkout.
|
||||
*.c text
|
||||
*.h text
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
*.sln text eol=crlf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.otf binary
|
||||
*.eot binary
|
||||
*.svg binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
*.css linguist-vendored
|
||||
*.scss linguist-vendored
|
||||
*.js linguist-vendored
|
||||
CHANGELOG.md export-ignore
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
|
||||
name: PHP ${{ matrix.php }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.3', '7.4', '8.0', '8.1']
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Cache composer
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extension-csv: bcmath, ctype, dom, fileinfo, intl, gd, json, mbstring, pdo, pdo_sqlite, openssl, sqlite, xml, zip
|
||||
coverage: none
|
||||
|
||||
- name: Install composer
|
||||
run: composer install --no-interaction --no-scripts --no-suggest --prefer-source
|
||||
|
||||
- name: Execute tests
|
||||
run: vendor/bin/phpunit
|
||||
@@ -1,9 +0,0 @@
|
||||
/.idea
|
||||
/.history
|
||||
/.vscode
|
||||
/tests/databases
|
||||
/vendor
|
||||
.DS_Store
|
||||
.phpunit.result.cache
|
||||
composer.phar
|
||||
composer.lock
|
||||
@@ -1,4 +0,0 @@
|
||||
preset: psr2
|
||||
|
||||
enabled:
|
||||
- concat_with_spaces
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Andreas Lutro
|
||||
|
||||
Copyright (c) 2017 Akaunting
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
# Persistent settings package for Laravel
|
||||
|
||||
[](https://github.com/akaunting/laravel-setting)
|
||||
[](https://styleci.io/repos/101231817)
|
||||
[](LICENSE.md)
|
||||
|
||||
This package allows you to save settings in a more persistent way. You can use the database and/or json file to save your settings. You can also override the Laravel config.
|
||||
|
||||
* Driver support
|
||||
* Helper function
|
||||
* Blade directive
|
||||
* Override config values
|
||||
* Encryption
|
||||
* Custom file, table and columns
|
||||
* Auto save
|
||||
* Extra columns
|
||||
* Cache support
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Install
|
||||
|
||||
Run the following command:
|
||||
|
||||
```bash
|
||||
composer require akaunting/laravel-setting
|
||||
```
|
||||
|
||||
### 2. Register (for Laravel < 5.5)
|
||||
|
||||
Register the service provider in `config/app.php`
|
||||
|
||||
```php
|
||||
Akaunting\Setting\Provider::class,
|
||||
```
|
||||
|
||||
Add alias if you want to use the facade.
|
||||
|
||||
```php
|
||||
'Setting' => Akaunting\Setting\Facade::class,
|
||||
```
|
||||
|
||||
### 3. Publish
|
||||
|
||||
Publish config file.
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=setting
|
||||
```
|
||||
|
||||
### 4. Database
|
||||
|
||||
Create table for database driver
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
### 5. Configure
|
||||
|
||||
You can change the options of your app from `config/setting.php` file
|
||||
|
||||
## Usage
|
||||
|
||||
You can either use the helper method like `setting('foo')` or the facade `Setting::get('foo')`
|
||||
|
||||
### Facade
|
||||
|
||||
```php
|
||||
Setting::get('foo', 'default');
|
||||
Setting::get('nested.element');
|
||||
Setting::set('foo', 'bar');
|
||||
Setting::forget('foo');
|
||||
$settings = Setting::all();
|
||||
```
|
||||
|
||||
### Helper
|
||||
|
||||
```php
|
||||
setting('foo', 'default');
|
||||
setting('nested.element');
|
||||
setting(['foo' => 'bar']);
|
||||
setting()->forget('foo');
|
||||
$settings = setting()->all();
|
||||
```
|
||||
|
||||
You can call the `save()` method to save the changes.
|
||||
|
||||
### Auto Save
|
||||
|
||||
If you enable the `auto_save` option in the config file, settings will be saved automatically every time the application shuts down if anything has been changed.
|
||||
|
||||
### Blade Directive
|
||||
|
||||
You can get the settings directly in your blade templates using the helper method or the blade directive like `@setting('foo')`
|
||||
|
||||
### Override Config Values
|
||||
|
||||
You can easily override default config values by adding them to the `override` option in `config/setting.php`, thereby eliminating the need to modify the default config files and also allowing you to change said values during production. Ex:
|
||||
|
||||
```php
|
||||
'override' => [
|
||||
"app.name" => "app_name",
|
||||
"app.env" => "app_env",
|
||||
"mail.driver" => "app_mail_driver",
|
||||
"mail.host" => "app_mail_host",
|
||||
],
|
||||
```
|
||||
|
||||
The values on the left corresponds to the respective config value (Ex: config('app.name')) and the value on the right is the name of the `key` in your settings table/json file.
|
||||
|
||||
### Encryption
|
||||
|
||||
If you like to encrypt the values for a given key, you can pass the key to the `encrypted_keys` option in `config/setting.php` and the rest is automatically handled by using Laravel's built-in encryption facilities. Ex:
|
||||
|
||||
```php
|
||||
'encrypted_keys' => [
|
||||
"payment.key",
|
||||
],
|
||||
```
|
||||
|
||||
### JSON Storage
|
||||
|
||||
You can modify the path used on run-time using `setting()->setPath($path)`.
|
||||
|
||||
### Database Storage
|
||||
|
||||
If you want to use the database as settings storage then you should run the `php artisan migrate`. You can modify the table fields from the `create_settings_table` file in the migrations directory.
|
||||
|
||||
#### Extra Columns
|
||||
|
||||
If you want to store settings for multiple users/clients in the same database you can do so by specifying extra columns:
|
||||
|
||||
```php
|
||||
setting()->setExtraColumns(['user_id' => Auth::user()->id]);
|
||||
```
|
||||
|
||||
where `user_id = x` will now be added to the database query when settings are retrieved, and when new settings are saved, the `user_id` will be populated.
|
||||
|
||||
If you need more fine-tuned control over which data gets queried, you can use the `setConstraint` method which takes a closure with two arguments:
|
||||
|
||||
- `$query` is the query builder instance
|
||||
- `$insert` is a boolean telling you whether the query is an insert or not. If it is an insert, you usually don't need to do anything to `$query`.
|
||||
|
||||
```php
|
||||
setting()->setConstraint(function($query, $insert) {
|
||||
if ($insert) return;
|
||||
$query->where(/* ... */);
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Drivers
|
||||
|
||||
This package uses the Laravel `Manager` class under the hood, so it's easy to add your own storage driver. All you need to do is extend the abstract `Driver` class, implement the abstract methods and call `setting()->extend`.
|
||||
|
||||
```php
|
||||
class MyDriver extends Akaunting\Setting\Contracts\Driver
|
||||
{
|
||||
// ...
|
||||
}
|
||||
|
||||
app('setting.manager')->extend('mydriver', function($app) {
|
||||
return $app->make('MyDriver');
|
||||
});
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
Please see [Releases](../../releases) for more information what has changed recently.
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests are more than welcome. You must follow the PSR coding standards.
|
||||
|
||||
## Security
|
||||
|
||||
If you discover any security related issues, please email security@akaunting.com instead of using the issue tracker.
|
||||
|
||||
## Credits
|
||||
|
||||
- [Denis Duliçi](https://github.com/denisdulici)
|
||||
- [All Contributors](../../contributors)
|
||||
|
||||
## License
|
||||
|
||||
The MIT License (MIT). Please see [LICENSE](LICENSE.md) for more information.
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"name": "akaunting/laravel-setting",
|
||||
"description": "Persistent settings package for Laravel",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"persistent",
|
||||
"settings",
|
||||
"config"
|
||||
],
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Denis Duliçi",
|
||||
"email": "info@akaunting.com",
|
||||
"homepage": "https://akaunting.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=5.5.9",
|
||||
"laravel/framework": ">=5.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": ">=4.8",
|
||||
"mockery/mockery": "0.9.*",
|
||||
"laravel/framework": ">=5.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Akaunting\\Setting\\": "./src"
|
||||
},
|
||||
"files": [
|
||||
"src/helpers.php"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Akaunting\\Setting\\Provider"
|
||||
],
|
||||
"aliases": {
|
||||
"Setting": "Akaunting\\Setting\\Facade"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit backupGlobals="false"
|
||||
backupStaticAttributes="false"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
convertErrorsToExceptions="true"
|
||||
convertNoticesToExceptions="true"
|
||||
convertWarningsToExceptions="true"
|
||||
processIsolation="false"
|
||||
stopOnFailure="false"
|
||||
syntaxCheck="false"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Package Test Suite">
|
||||
<directory suffix=".php">./tests/</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
@@ -1,132 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Enable / Disable auto save
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Auto-save every time the application shuts down
|
||||
|
|
||||
*/
|
||||
'auto_save' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Options for caching. Set whether to enable cache, its key, time to live
|
||||
| in seconds and whether to auto clear after save.
|
||||
|
|
||||
*/
|
||||
'cache' => [
|
||||
'enabled' => false,
|
||||
'key' => 'setting',
|
||||
'ttl' => 3600,
|
||||
'auto_clear' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Setting driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Select where to store the settings.
|
||||
|
|
||||
| Supported: "database", "json", "memory"
|
||||
|
|
||||
*/
|
||||
'driver' => 'database',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Options for database driver. Enter which connection to use, null means
|
||||
| the default connection. Set the table and column names.
|
||||
|
|
||||
*/
|
||||
'database' => [
|
||||
'connection' => null,
|
||||
'table' => 'settings',
|
||||
'key' => 'key',
|
||||
'value' => 'value',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| JSON driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Options for json driver. Enter the full path to the .json file.
|
||||
|
|
||||
*/
|
||||
'json' => [
|
||||
'path' => storage_path() . '/settings.json',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Override application config values
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| If defined, settings package will override these config values.
|
||||
|
|
||||
| Sample:
|
||||
| "app.locale" => "settings.locale",
|
||||
|
|
||||
*/
|
||||
'override' => [
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fallback
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define fallback settings to be used in case the default is null
|
||||
|
|
||||
| Sample:
|
||||
| "currency" => "USD",
|
||||
|
|
||||
*/
|
||||
'fallback' => [
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Required Extra Columns
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The list of columns required to be set up
|
||||
|
|
||||
| Sample:
|
||||
| "user_id",
|
||||
| "tenant_id",
|
||||
|
|
||||
*/
|
||||
'required_extra_columns' => [
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define the keys which should be crypt automatically.
|
||||
|
|
||||
| Sample:
|
||||
| "payment.key"
|
||||
|
|
||||
*/
|
||||
'encrypted_keys' => [
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -1,321 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Akaunting\Setting\Contracts;
|
||||
|
||||
use Akaunting\Setting\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
abstract class Driver
|
||||
{
|
||||
/**
|
||||
* The settings data.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $data = [];
|
||||
|
||||
/**
|
||||
* Whether the store has changed since it was last loaded.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $unsaved = false;
|
||||
|
||||
/**
|
||||
* Whether the settings data are loaded.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $loaded = false;
|
||||
|
||||
/**
|
||||
* Include and merge with fallbacks
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $with_fallback = true;
|
||||
|
||||
/**
|
||||
* Excludes fallback data
|
||||
*/
|
||||
public function withoutFallback()
|
||||
{
|
||||
$this->with_fallback = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific key from the settings data.
|
||||
*
|
||||
* @param string|array $key
|
||||
* @param mixed $default Optional default value.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function get($key, $default = null)
|
||||
{
|
||||
if (!$this->checkExtraColumns()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->load();
|
||||
|
||||
return Arr::get($this->data, $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fallback value if default is null.
|
||||
*
|
||||
* @param string|array $key
|
||||
* @param mixed $default
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getFallback($key, $default = null)
|
||||
{
|
||||
if (($default !== null) || is_array($key)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return Arr::get((array) config('setting.fallback'), $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given value is same as fallback.
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $value
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEqualToFallback($key, $value)
|
||||
{
|
||||
return (string) $this->getFallback($key) == (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a key exists in the settings data.
|
||||
*
|
||||
* @param string $key
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has($key)
|
||||
{
|
||||
if (!$this->checkExtraColumns()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->load();
|
||||
|
||||
return Arr::has($this->data, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a specific key to a value in the settings data.
|
||||
*
|
||||
* @param string|array $key Key string or associative array of key => value
|
||||
* @param mixed $value Optional only if the first argument is an array
|
||||
*/
|
||||
public function set($key, $value = null)
|
||||
{
|
||||
if (!$this->checkExtraColumns()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->load();
|
||||
$this->unsaved = true;
|
||||
|
||||
if (is_array($key)) {
|
||||
foreach ($key as $k => $v) {
|
||||
Arr::set($this->data, $k, $v);
|
||||
}
|
||||
} else {
|
||||
Arr::set($this->data, $key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unset a key in the settings data.
|
||||
*
|
||||
* @param string $key
|
||||
*/
|
||||
public function forget($key)
|
||||
{
|
||||
if (!$this->checkExtraColumns()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->unsaved = true;
|
||||
|
||||
if ($this->has($key)) {
|
||||
Arr::forget($this->data, $key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unset all keys in the settings data.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function forgetAll()
|
||||
{
|
||||
if (!$this->checkExtraColumns()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config('setting.cache.enabled')) {
|
||||
Cache::forget($this->getCacheKey());
|
||||
}
|
||||
|
||||
$this->unsaved = true;
|
||||
$this->data = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings data.
|
||||
*
|
||||
* @return array|bool
|
||||
*/
|
||||
public function all()
|
||||
{
|
||||
if (!$this->checkExtraColumns()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->load();
|
||||
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save any changes done to the settings data.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
if (!$this->checkExtraColumns()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->unsaved) {
|
||||
// either nothing has been changed, or data has not been loaded, so
|
||||
// do nothing by returning early
|
||||
return;
|
||||
}
|
||||
|
||||
if (config('setting.cache.enabled') && config('setting.cache.auto_clear')) {
|
||||
Cache::forget($this->getCacheKey());
|
||||
}
|
||||
|
||||
$this->write($this->data);
|
||||
$this->unsaved = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure data is loaded.
|
||||
*
|
||||
* @param $force Force a reload of data. Default false.
|
||||
*/
|
||||
public function load($force = false)
|
||||
{
|
||||
if (!$this->checkExtraColumns()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->loaded && !$force) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fallback_data = $this->with_fallback ? config('setting.fallback') : [];
|
||||
$driver_data = $this->readData();
|
||||
|
||||
$this->data = Arr::merge((array) $fallback_data, (array) $driver_data);
|
||||
$this->loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read data from driver or cache
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function readData()
|
||||
{
|
||||
if (config('setting.cache.enabled')) {
|
||||
return $this->readDataFromCache();
|
||||
}
|
||||
|
||||
return $this->read();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read data from cache
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function readDataFromCache()
|
||||
{
|
||||
return Cache::remember($this->getCacheKey(), config('setting.cache.ttl'), function () {
|
||||
return $this->read();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if extra columns are set up.
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function checkExtraColumns()
|
||||
{
|
||||
if (!$required_extra_columns = config('setting.required_extra_columns')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (array_keys_exists($required_extra_columns, $this->getExtraColumns())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key based on extra columns.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getCacheKey()
|
||||
{
|
||||
$key = config('setting.cache.key');
|
||||
|
||||
foreach ($this->getExtraColumns() as $name => $value) {
|
||||
$key .= '_' . $name . '_' . $value;
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extra columns added to the rows.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
abstract protected function getExtraColumns();
|
||||
|
||||
/**
|
||||
* Read data from driver.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
abstract protected function read();
|
||||
|
||||
/**
|
||||
* Write data to driver.
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
abstract protected function write(array $data);
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Akaunting\Setting\Drivers;
|
||||
|
||||
use Akaunting\Setting\Contracts\Driver;
|
||||
use Akaunting\Setting\Support\Arr;
|
||||
use Closure;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Support\Arr as LaravelArr;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class Database extends Driver
|
||||
{
|
||||
/**
|
||||
* The database connection instance.
|
||||
*
|
||||
* @var \Illuminate\Database\Connection
|
||||
*/
|
||||
protected $connection;
|
||||
|
||||
/**
|
||||
* The table to query from.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table;
|
||||
|
||||
/**
|
||||
* The key column name to query from.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $key;
|
||||
|
||||
/**
|
||||
* The value column name to query from.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $value;
|
||||
|
||||
/**
|
||||
* Keys which should be encrypt automatically.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $encrypted_keys;
|
||||
|
||||
/**
|
||||
* Any query constraints that should be applied.
|
||||
*
|
||||
* @var Closure|null
|
||||
*/
|
||||
protected $query_constraint;
|
||||
|
||||
/**
|
||||
* Any extra columns that should be added to the rows.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $extra_columns = [];
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Database\Connection $connection
|
||||
* @param string $table
|
||||
*/
|
||||
public function __construct(Connection $connection, $table = null, $key = null, $value = null, array $encrypted_keys = [])
|
||||
{
|
||||
$this->connection = $connection;
|
||||
$this->table = $table ?: 'settings';
|
||||
$this->key = $key ?: 'key';
|
||||
$this->value = $value ?: 'value';
|
||||
$this->encrypted_keys = $encrypted_keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the table to query from.
|
||||
*
|
||||
* @param string $table
|
||||
*/
|
||||
public function setTable($table)
|
||||
{
|
||||
$this->table = $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the key column name to query from.
|
||||
*
|
||||
* @param string $key
|
||||
*/
|
||||
public function setKey($key)
|
||||
{
|
||||
$this->key = $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value column name to query from.
|
||||
*
|
||||
* @param string $value
|
||||
*/
|
||||
public function setValue($value)
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the query constraint.
|
||||
*
|
||||
* @param Closure $callback
|
||||
*/
|
||||
public function setConstraint(Closure $callback)
|
||||
{
|
||||
$this->data = [];
|
||||
$this->loaded = false;
|
||||
$this->query_constraint = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set extra columns to be added to the rows.
|
||||
*
|
||||
* @param array $columns
|
||||
*/
|
||||
public function setExtraColumns(array $columns)
|
||||
{
|
||||
$this->extra_columns = $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extra columns added to the rows.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getExtraColumns()
|
||||
{
|
||||
return $this->extra_columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function forget($key)
|
||||
{
|
||||
parent::forget($key);
|
||||
|
||||
// because the database driver cannot store empty arrays, remove empty
|
||||
// arrays to keep data consistent before and after saving
|
||||
$segments = explode('.', $key);
|
||||
array_pop($segments);
|
||||
|
||||
while ($segments) {
|
||||
$segment = implode('.', $segments);
|
||||
|
||||
// non-empty array - exit out of the loop
|
||||
if ($this->get($segment)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// remove the empty array and move on to the next segment
|
||||
$this->forget($segment);
|
||||
array_pop($segments);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function write(array $data)
|
||||
{
|
||||
// Get current data
|
||||
$db_data = $this->newQuery()->get([$this->key, $this->value])->toArray();
|
||||
|
||||
$insert_data = LaravelArr::dot($data);
|
||||
$update_data = [];
|
||||
$delete_keys = [];
|
||||
|
||||
foreach ($db_data as $db_row) {
|
||||
$key = $db_row->{$this->key};
|
||||
$value = $db_row->{$this->value};
|
||||
|
||||
$is_in_insert = $is_different_in_db = $is_same_as_fallback = false;
|
||||
|
||||
if (isset($insert_data[$key])) {
|
||||
$is_in_insert = true;
|
||||
$is_different_in_db = (string) $insert_data[$key] != (string) $value;
|
||||
$is_same_as_fallback = $this->isEqualToFallback($key, $insert_data[$key]);
|
||||
}
|
||||
|
||||
if ($is_in_insert) {
|
||||
if ($is_same_as_fallback) {
|
||||
// Delete if new data is same as fallback
|
||||
$delete_keys[] = $key;
|
||||
} elseif ($is_different_in_db) {
|
||||
// Update if new data is different from db
|
||||
$update_data[$key] = $insert_data[$key];
|
||||
}
|
||||
} else {
|
||||
// Delete if current db not available in new data
|
||||
$delete_keys[] = $key;
|
||||
}
|
||||
|
||||
unset($insert_data[$key]);
|
||||
}
|
||||
|
||||
foreach ($update_data as $key => $value) {
|
||||
$value = $this->prepareValue($key, $value);
|
||||
|
||||
$this->newQuery()
|
||||
->where($this->key, '=', $key)
|
||||
->update([$this->value => $value]);
|
||||
}
|
||||
|
||||
if ($insert_data) {
|
||||
$this->newQuery(true)
|
||||
->insert($this->prepareInsertData($insert_data));
|
||||
}
|
||||
|
||||
if ($delete_keys) {
|
||||
$this->newQuery()
|
||||
->whereIn($this->key, $delete_keys)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms settings data into an array ready to be insterted into the
|
||||
* database. Call array_dot on a multidimensional array before passing it
|
||||
* into this method!
|
||||
*
|
||||
* @param array $data Call array_dot on a multidimensional array before passing it into this method!
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function prepareInsertData(array $data)
|
||||
{
|
||||
$db_data = [];
|
||||
|
||||
if ($this->getExtraColumns()) {
|
||||
foreach ($data as $key => $value) {
|
||||
$value = $this->prepareValue($key, $value);
|
||||
|
||||
// Don't insert if same as fallback
|
||||
if ($this->isEqualToFallback($key, $value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$db_data[] = array_merge(
|
||||
$this->getExtraColumns(),
|
||||
[$this->key => $key, $this->value => $value]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
foreach ($data as $key => $value) {
|
||||
$value = $this->prepareValue($key, $value);
|
||||
|
||||
// Don't insert if same as fallback
|
||||
if ($this->isEqualToFallback($key, $value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$db_data[] = [$this->key => $key, $this->value => $value];
|
||||
}
|
||||
}
|
||||
|
||||
return $db_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the provided key should be encrypted or not.
|
||||
* Also type casts the given value to a string so errors with booleans or integers are handeled.
|
||||
* Otherwise it returns the original value.
|
||||
*
|
||||
* @param string $key Key to check if it's inside the encryptedValues variable.
|
||||
* @param mixed $value Info: Encryption only supports strings.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function prepareValue(string $key, $value)
|
||||
{
|
||||
// Check if key should be encrypted
|
||||
if (in_array($key, $this->encrypted_keys)) {
|
||||
// Cast to string to avoid error when a user passes a boolean value
|
||||
return Crypt::encryptString((string) $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the provided key should be decrypted or not.
|
||||
* Otherwise it returns the original value.
|
||||
*
|
||||
* @param string $key Key to check if it's inside the encryptedValues variable.
|
||||
* @param mixed $value Info: Encryption only supports strings.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function unpackValue(string $key, $value)
|
||||
{
|
||||
// Check if key should be encrypted
|
||||
if (in_array($key, $this->encrypted_keys)) {
|
||||
// Cast to string to avoid error when a user passes a boolean value
|
||||
return Crypt::decryptString((string) $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function read()
|
||||
{
|
||||
return $this->parseReadData($this->newQuery()->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse data coming from the database.
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function parseReadData($data)
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($data as $row) {
|
||||
if (is_array($row)) {
|
||||
$key = $row[$this->key];
|
||||
$value = $row[$this->value];
|
||||
} elseif (is_object($row)) {
|
||||
$key = $row->{$this->key};
|
||||
$value = $row->{$this->value};
|
||||
} else {
|
||||
$msg = 'Expected array or object, got ' . gettype($row);
|
||||
throw new \UnexpectedValueException($msg);
|
||||
}
|
||||
|
||||
// Encryption
|
||||
$value = $this->unpackValue($key, $value);
|
||||
|
||||
Arr::set($results, $key, $value);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new query builder instance.
|
||||
*
|
||||
* @param bool $insert
|
||||
*
|
||||
* @return \Illuminate\Database\Query\Builder
|
||||
*/
|
||||
protected function newQuery($insert = false)
|
||||
{
|
||||
$query = $this->connection->table($this->table);
|
||||
|
||||
if (!$insert) {
|
||||
foreach ($this->getExtraColumns() as $key => $value) {
|
||||
$query->where($key, '=', $value);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->query_constraint !== null) {
|
||||
$callback = $this->query_constraint;
|
||||
$callback($query, $insert);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Akaunting\Setting\Drivers;
|
||||
|
||||
use Akaunting\Setting\Contracts\Driver;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
|
||||
class Json extends Driver
|
||||
{
|
||||
/**
|
||||
* @param \Illuminate\Filesystem\Filesystem $files
|
||||
* @param string $path
|
||||
*/
|
||||
public function __construct(Filesystem $files, $path = null)
|
||||
{
|
||||
$this->files = $files;
|
||||
|
||||
$this->setPath($path ?: storage_path() . '/settings.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the path for the JSON file.
|
||||
*
|
||||
* @param string $path
|
||||
*/
|
||||
public function setPath($path)
|
||||
{
|
||||
// If the file does not already exist, we will attempt to create it.
|
||||
if (!$this->files->exists($path)) {
|
||||
$result = $this->files->put($path, '{}');
|
||||
if ($result === false) {
|
||||
throw new \InvalidArgumentException("Could not write to $path.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->files->isWritable($path)) {
|
||||
throw new \InvalidArgumentException("$path is not writable.");
|
||||
}
|
||||
|
||||
$this->path = $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getExtraColumns()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function read()
|
||||
{
|
||||
$contents = $this->files->get($this->path);
|
||||
|
||||
$data = json_decode($contents, true);
|
||||
|
||||
if ($data === null) {
|
||||
throw new \RuntimeException("Invalid JSON in {$this->path}");
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function write(array $data)
|
||||
{
|
||||
if ($data) {
|
||||
$contents = json_encode($data);
|
||||
} else {
|
||||
$contents = '{}';
|
||||
}
|
||||
|
||||
$this->files->put($this->path, $contents);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Akaunting\Setting\Drivers;
|
||||
|
||||
use Akaunting\Setting\Contracts\Driver;
|
||||
|
||||
class Memory extends Driver
|
||||
{
|
||||
/**
|
||||
* @param array $data
|
||||
*/
|
||||
public function __construct(array $data = null)
|
||||
{
|
||||
if ($data) {
|
||||
$this->data = $data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getExtraColumns()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function read()
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function write(array $data)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Akaunting\Setting;
|
||||
|
||||
use Illuminate\Support\Facades\Facade as BaseFacade;
|
||||
|
||||
class Facade extends BaseFacade
|
||||
{
|
||||
/**
|
||||
* Get the registered name of the component.
|
||||
*/
|
||||
public static function getFacadeAccessor()
|
||||
{
|
||||
return 'setting';
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user