diff --git a/application/cli/seed_stakeholder_activities.php b/application/cli/seed_stakeholder_activities.php index 784f83c..9586f7e 100644 --- a/application/cli/seed_stakeholder_activities.php +++ b/application/cli/seed_stakeholder_activities.php @@ -89,6 +89,25 @@ $ddl = [ CONSTRAINT fk_stkap_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE, CONSTRAINT fk_stkap_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + +// C5.2b (mig.053) — feedback: risposte + commenti +"CREATE TABLE IF NOT EXISTS stk_activity_responses ( + id INT NOT NULL AUTO_INCREMENT, target_id INT NOT NULL, + answers JSON NULL, acknowledged_at DATETIME NULL, + respondent_name VARCHAR(255) NULL, respondent_email VARCHAR(255) NULL, ip_address VARCHAR(45) NULL, + submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), UNIQUE KEY uq_stkar_target (target_id), + CONSTRAINT fk_stkar_target FOREIGN KEY (target_id) REFERENCES stk_activity_targets (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + +"CREATE TABLE IF NOT EXISTS stk_activity_comments ( + id INT NOT NULL AUTO_INCREMENT, activity_id INT NOT NULL, body TEXT NOT NULL, + author_kind ENUM('internal','external') NOT NULL DEFAULT 'internal', + author_user_id INT NULL, author_label VARCHAR(255) NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), KEY idx_stkac_act (activity_id), + CONSTRAINT fk_stkac_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE, + CONSTRAINT fk_stkac_user FOREIGN KEY (author_user_id) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ]; foreach ($ddl as $stmt) { try { $pdo->exec($stmt); } @@ -105,7 +124,8 @@ try { $counts = []; foreach (['stk_questionnaire_templates','stk_template_procedures','stk_template_misure','stk_template_requisiti', - 'stk_activities','stk_activity_targets','stk_activity_procedures'] as $t) { + 'stk_activities','stk_activity_targets','stk_activity_procedures', + 'stk_activity_responses','stk_activity_comments'] as $t) { $counts[$t] = (int) $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn(); } $enum = $pdo->query("SELECT COLUMN_TYPE FROM information_schema.COLUMNS diff --git a/application/controllers/StakeholderActivityController.php b/application/controllers/StakeholderActivityController.php index 4ae7690..0f0a29e 100644 --- a/application/controllers/StakeholderActivityController.php +++ b/application/controllers/StakeholderActivityController.php @@ -430,10 +430,160 @@ class StakeholderActivityController extends BaseController ], 'Attività inviata (magic-link generati)'); } + // ───────────────────────────────────────────────────────────────────────── + // FEEDBACK (C5.2b) — risposte, commenti, allegati (lato interno) + // ───────────────────────────────────────────────────────────────────────── + + /** GET /api/stakeholder-activities/{id}/feedback */ + public function feedback(int $id): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + $a = Database::fetchOne('SELECT id, title, type, template_id FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId]); + if (!$a) { $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); } + + $template = null; + if ($a['template_id']) { + $t = Database::fetchOne('SELECT kind, content, questions FROM stk_questionnaire_templates WHERE id = ? AND organization_id = ?', [(int) $a['template_id'], $orgId]); + if ($t) { + $q = $t['questions'] ? json_decode($t['questions'], true) : []; + $template = ['kind' => $t['kind'], 'content' => $t['content'], 'questions' => is_array($q) ? $q : []]; + } + } + $rows = Database::fetchAll( + 'SELECT g.id AS target_id, g.state, g.sent_at, g.responded_at, + s.name AS stakeholder_name, s.stak_code, + r.answers, r.acknowledged_at, r.respondent_name, r.submitted_at + FROM stk_activity_targets g + JOIN stakeholders s ON s.id = g.stakeholder_id + LEFT JOIN stk_activity_responses r ON r.target_id = g.id + WHERE g.activity_id = ? ORDER BY s.name ASC', + [$id] + ); + $targets = array_map(static function ($r) { + $ans = $r['answers'] ? json_decode($r['answers'], true) : null; + return [ + 'target_id' => (int) $r['target_id'], 'stakeholder_name' => $r['stakeholder_name'], 'stak_code' => $r['stak_code'], + 'state' => $r['state'], 'sent_at' => $r['sent_at'], 'responded_at' => $r['responded_at'], + 'answers' => is_array($ans) ? $ans : null, 'acknowledged_at' => $r['acknowledged_at'], + 'respondent_name' => $r['respondent_name'], 'submitted_at' => $r['submitted_at'], + ]; + }, $rows); + $this->jsonSuccess([ + 'activity' => ['id' => (int) $a['id'], 'title' => $a['title'], 'type' => $a['type']], + 'template' => $template, + 'targets' => $targets, + ]); + } + + /** GET /api/stakeholder-activities/{id}/comments */ + public function comments(int $id): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + $this->assertActivity($id, $orgId); + $rows = Database::fetchAll( + 'SELECT c.id, c.body, c.author_kind, c.author_label, c.created_at, u.full_name AS author_name + FROM stk_activity_comments c LEFT JOIN users u ON u.id = c.author_user_id + WHERE c.activity_id = ? ORDER BY c.created_at ASC', + [$id] + ); + $this->jsonSuccess(['comments' => array_map(static fn($c) => [ + 'id' => (int) $c['id'], 'body' => $c['body'], 'author_kind' => $c['author_kind'], + 'author' => $c['author_kind'] === 'external' ? ($c['author_label'] ?: 'Stakeholder') : ($c['author_name'] ?: 'Interno'), + 'created_at' => $c['created_at'], + ], $rows)]); + } + + /** POST /api/stakeholder-activities/{id}/comments Body: {body*} */ + public function addComment(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $this->assertActivity($id, $orgId); + $body = trim((string) ($this->getJsonBody()['body'] ?? '')); + if ($body === '') { $this->jsonError('Commento vuoto', 422, 'EMPTY_COMMENT'); } + $cid = Database::insert('stk_activity_comments', [ + 'activity_id' => $id, 'body' => mb_substr($body, 0, 5000), + 'author_kind' => 'internal', 'author_user_id' => $this->getCurrentUserId(), + ]); + $this->jsonSuccess(['id' => (int) $cid], 'Commento aggiunto', 201); + } + + /** GET /api/stakeholder-activities/{id}/attachments */ + public function attachments(int $id): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + $this->assertActivity($id, $orgId); + $rows = Database::fetchAll( + "SELECT id, file_name, file_path, file_size, mime_type, created_at + FROM evidence_files WHERE organization_id = ? AND entity_type = 'stk_activity' AND entity_id = ? + ORDER BY created_at DESC", + [$orgId, $id] + ); + $this->jsonSuccess(['attachments' => array_map(static fn($f) => [ + 'id' => (int) $f['id'], 'file_name' => $f['file_name'], 'url' => '/uploads/' . $f['file_path'], + 'file_size' => (int) $f['file_size'], 'created_at' => $f['created_at'], + ], $rows)]); + } + + /** POST /api/stakeholder-activities/{id}/attachments (multipart: file) */ + public function uploadAttachment(int $id): void + { + $this->requireOrgRole(self::MANAGE_ROLES); + $orgId = $this->getCurrentOrgId(); + $this->assertActivity($id, $orgId); + $fid = $this->storeUpload($orgId, $id, $this->getCurrentUserId()); + $this->jsonSuccess(['id' => $fid], 'Allegato caricato', 201); + } + // ───────────────────────────────────────────────────────────────────────── // HELPER // ───────────────────────────────────────────────────────────────────────── + private function assertActivity(int $id, int $orgId): void + { + if (!Database::fetchOne('SELECT id FROM stk_activities WHERE id = ? AND organization_id = ?', [$id, $orgId])) { + $this->jsonError('Attività non trovata', 404, 'NOT_FOUND'); + } + } + + /** + * Salva un file caricato (campo 'file') sotto public/uploads/stk_activity/{org}/ + * e registra in evidence_files (entity_type='stk_activity'). Riusa il pattern di + * AuditController::uploadEvidence. uploadedBy null per upload esterni dal portale. + * Ritorna l'id evidence_files. + */ + public function storeUpload(int $orgId, int $activityId, ?int $uploadedBy): int + { + if (!isset($_FILES['file'])) { $this->jsonError('File non fornito', 400, 'NO_FILE'); } + $file = $_FILES['file']; + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { $this->jsonError('Caricamento non riuscito', 400, 'UPLOAD_ERROR'); } + if ($file['size'] > 10 * 1024 * 1024) { $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); } + + $ext = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', pathinfo($file['name'], PATHINFO_EXTENSION))); + $blocked = ['php','phtml','phar','php3','php4','php5','phps','cgi','pl','sh','exe','htaccess']; + if ($ext === '' || in_array($ext, $blocked, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); } + + $uploadDir = UPLOAD_PATH . "/stk_activity/{$orgId}"; + if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } + $filename = uniqid('sa_') . '.' . $ext; + if (!move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $filename)) { + $this->jsonError('Errore caricamento file', 500, 'UPLOAD_ERROR'); + } + return (int) Database::insert('evidence_files', [ + 'organization_id' => $orgId, + 'entity_type' => 'stk_activity', + 'entity_id' => $activityId, + 'file_name' => mb_substr((string) $file['name'], 0, 255), + 'file_path' => "stk_activity/{$orgId}/{$filename}", + 'file_size' => (int) $file['size'], + 'mime_type' => mb_substr((string) ($file['type'] ?? ''), 0, 100), + 'uploaded_by' => $uploadedBy, + ]); + } + private function validateTemplate($id, int $orgId): ?int { $id = ($id === null || $id === '') ? null : (int) $id; diff --git a/application/controllers/StakeholderPortalController.php b/application/controllers/StakeholderPortalController.php new file mode 100644 index 0000000..5e47a97 --- /dev/null +++ b/application/controllers/StakeholderPortalController.php @@ -0,0 +1,205 @@ + */ + public function access(): void + { + $tg = $this->resolveTarget(); + $template = $this->loadTemplate($tg); + $resp = Database::fetchOne( + 'SELECT answers, acknowledged_at, submitted_at FROM stk_activity_responses WHERE target_id = ?', + [(int) $tg['target_id']] + ); + $existing = null; + if ($resp) { + $ans = $resp['answers'] ? json_decode($resp['answers'], true) : null; + $existing = [ + 'answers' => is_array($ans) ? $ans : null, + 'acknowledged_at' => $resp['acknowledged_at'], + 'submitted_at' => $resp['submitted_at'], + ]; + } + $this->jsonSuccess([ + 'activity' => ['title' => $tg['title'], 'type' => $tg['type'], 'description' => $tg['description']], + 'stakeholder_name' => $tg['stakeholder_name'], + 'state' => $tg['state'], + 'template' => $template, + 'submitted' => in_array($tg['state'], ['responded', 'acknowledged'], true), + 'existing' => $existing, + ]); + } + + /** POST /api/stakeholder-portal/respond Body: {t*, answers*, respondent_name?} */ + public function respond(): void + { + $tg = $this->resolveTarget(); + if ($tg['type'] === 'read_ack') { $this->jsonError('Questa attività richiede una firma di avvenuta lettura, non un questionario.', 422, 'WRONG_TYPE'); } + if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Risposta già inviata: non è più modificabile.', 409, 'ALREADY_SUBMITTED'); } + + $b = $this->getJsonBody(); + $answers = $b['answers'] ?? null; + if (!is_array($answers) || !$answers) { $this->jsonError('Nessuna risposta fornita', 422, 'NO_ANSWERS'); } + + // verifica risposte obbligatorie del template (se presente) + $template = $this->loadTemplate($tg); + if ($template && !empty($template['questions'])) { + $missing = []; + foreach ($template['questions'] as $q) { + if (!empty($q['required'])) { + $code = $q['code'] ?? ''; + $v = $answers[$code] ?? null; + if ($v === null || $v === '' || (is_array($v) && !$v)) { $missing[] = $code; } + } + } + if ($missing) { $this->jsonError('Mancano risposte obbligatorie.', 422, 'REQUIRED_MISSING', ['missing' => $missing]); } + } + + $this->saveResponse((int) $tg['target_id'], json_encode($answers, JSON_UNESCAPED_UNICODE), null, $b['respondent_name'] ?? null); + Database::update('stk_activity_targets', ['state' => 'responded', 'responded_at' => date('Y-m-d H:i:s')], 'id = ?', [(int) $tg['target_id']]); + $this->maybeComplete((int) $tg['activity_id']); + $this->jsonSuccess(['state' => 'responded'], 'Grazie, risposta inviata.'); + } + + /** POST /api/stakeholder-portal/acknowledge Body: {t*, respondent_name?} */ + public function acknowledge(): void + { + $tg = $this->resolveTarget(); + if ($tg['type'] !== 'read_ack') { $this->jsonError('Questa attività è un questionario da compilare.', 422, 'WRONG_TYPE'); } + if (in_array($tg['state'], ['responded', 'acknowledged'], true)) { $this->jsonError('Firma già registrata.', 409, 'ALREADY_SUBMITTED'); } + + $b = $this->getJsonBody(); + $now = date('Y-m-d H:i:s'); + $this->saveResponse((int) $tg['target_id'], null, $now, $b['respondent_name'] ?? null); + Database::update('stk_activity_targets', ['state' => 'acknowledged', 'responded_at' => $now], 'id = ?', [(int) $tg['target_id']]); + $this->maybeComplete((int) $tg['activity_id']); + $this->jsonSuccess(['state' => 'acknowledged'], 'Firma di avvenuta lettura registrata. Grazie.'); + } + + /** POST /api/stakeholder-portal/comment Body: {t*, body*} */ + public function comment(): void + { + $tg = $this->resolveTarget(); + $body = trim((string) ($this->getJsonBody()['body'] ?? '')); + if ($body === '') { $this->jsonError('Commento vuoto', 422, 'EMPTY_COMMENT'); } + Database::insert('stk_activity_comments', [ + 'activity_id' => (int) $tg['activity_id'], + 'body' => mb_substr($body, 0, 5000), + 'author_kind' => 'external', + 'author_label'=> mb_substr((string) $tg['stakeholder_name'], 0, 255), + ]); + $this->jsonSuccess(null, 'Commento inviato.'); + } + + /** POST /api/stakeholder-portal/attachment?t= (multipart: file) */ + public function attachment(): void + { + $tg = $this->resolveTarget(); + if (!isset($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + $this->jsonError('File non fornito', 400, 'NO_FILE'); + } + $file = $_FILES['file']; + if ($file['size'] > 10 * 1024 * 1024) { $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); } + $ext = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', pathinfo($file['name'], PATHINFO_EXTENSION))); + $blocked = ['php','phtml','phar','php3','php4','php5','phps','cgi','pl','sh','exe','htaccess']; + if ($ext === '' || in_array($ext, $blocked, true)) { $this->jsonError('Tipo di file non consentito', 422, 'BAD_FILE_TYPE'); } + + $orgId = (int) $tg['organization_id']; + $dir = UPLOAD_PATH . "/stk_activity/{$orgId}"; + if (!is_dir($dir)) { mkdir($dir, 0755, true); } + $filename = uniqid('sa_') . '.' . $ext; + if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { $this->jsonError('Errore caricamento', 500, 'UPLOAD_ERROR'); } + Database::insert('evidence_files', [ + 'organization_id' => $orgId, + 'entity_type' => 'stk_activity', + 'entity_id' => (int) $tg['activity_id'], + 'file_name' => mb_substr((string) $file['name'], 0, 255), + 'file_path' => "stk_activity/{$orgId}/{$filename}", + 'file_size' => (int) $file['size'], + 'mime_type' => mb_substr((string) ($file['type'] ?? ''), 0, 100), + 'uploaded_by' => null, + ]); + $this->jsonSuccess(null, 'Allegato caricato.', 201); + } + + // ───────────────────────────────────────────────────────────────────────── + // HELPER + // ───────────────────────────────────────────────────────────────────────── + + /** Risolve il destinatario dal token (?t= o body.t). 401 se assente, 404 se non valido. */ + private function resolveTarget(): array + { + $token = trim((string) ($_GET['t'] ?? $this->getJsonBody()['t'] ?? '')); + if ($token === '' || !preg_match('/^[a-f0-9]{48}$/', $token)) { + $this->jsonError('Link di accesso mancante o non valido.', 401, 'MISSING_TOKEN'); + } + $row = Database::fetchOne( + 'SELECT g.id AS target_id, g.activity_id, g.state, s.name AS stakeholder_name, + a.organization_id, a.title, a.type, a.description, a.template_id + FROM stk_activity_targets g + JOIN stk_activities a ON a.id = g.activity_id + JOIN stakeholders s ON s.id = g.stakeholder_id + WHERE g.access_token_hash = ?', + [hash('sha256', $token)] + ); + if (!$row) { $this->jsonError('Link di accesso non valido o scaduto.', 404, 'INVALID_TOKEN'); } + return $row; + } + + private function loadTemplate(array $tg): ?array + { + if (empty($tg['template_id'])) { return null; } + $t = Database::fetchOne('SELECT kind, content, questions FROM stk_questionnaire_templates WHERE id = ?', [(int) $tg['template_id']]); + if (!$t) { return null; } + $q = $t['questions'] ? json_decode($t['questions'], true) : []; + return ['kind' => $t['kind'], 'content' => $t['content'], 'questions' => is_array($q) ? $q : []]; + } + + /** Upsert della risposta (una per target). */ + private function saveResponse(int $targetId, ?string $answersJson, ?string $ackAt, $respondentName): void + { + $ip = $_SERVER['REMOTE_ADDR'] ?? null; + $name = $respondentName !== null ? mb_substr(trim((string) $respondentName), 0, 255) : null; + Database::query( + 'INSERT INTO stk_activity_responses (target_id, answers, acknowledged_at, respondent_name, ip_address, submitted_at) + VALUES (?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE answers = VALUES(answers), acknowledged_at = VALUES(acknowledged_at), + respondent_name = VALUES(respondent_name), ip_address = VALUES(ip_address), submitted_at = NOW()', + [$targetId, $answersJson, $ackAt, $name, $ip] + ); + } + + /** Se tutti i destinatari hanno risposto/firmato, segna l'attività 'completed'. */ + private function maybeComplete(int $activityId): void + { + $tot = (int) Database::fetchOne('SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ?', [$activityId])['c']; + $done = (int) Database::fetchOne( + "SELECT COUNT(*) AS c FROM stk_activity_targets WHERE activity_id = ? AND state IN ('responded','acknowledged')", + [$activityId] + )['c']; + if ($tot > 0 && $done >= $tot) { + Database::update('stk_activities', ['status' => 'completed'], 'id = ?', [$activityId]); + } else { + Database::query("UPDATE stk_activities SET status = 'in_progress' WHERE id = ? AND status = 'sent'", [$activityId]); + } + } +} diff --git a/docs/sql/053_stakeholder_feedback.sql b/docs/sql/053_stakeholder_feedback.sql new file mode 100644 index 0000000..b09ef73 --- /dev/null +++ b/docs/sql/053_stakeholder_feedback.sql @@ -0,0 +1,51 @@ +-- ===================================================================== +-- 053 — Feedback attività stakeholder: risposte + commenti (Epic C / C5.2b) +-- ===================================================================== +-- Sotto-dashboard di ricezione feedback (Simon C5 §4.3): per ogni destinatario +-- (stk_activity_targets, mig.052) si raccoglie la RISPOSTA al questionario oppure +-- la FIRMA di avvenuta lettura; sull'attività si possono aggiungere COMMENTI e +-- ALLEGATI. Gli allegati RIUSANO evidence_files (entity_type='stk_activity', +-- entity_id=activity_id) — nessuna tabella nuova per i file. +-- +-- Le risposte arrivano dal portale esterno self-service (token magic-link, +-- StakeholderPortalController, non-JWT) oppure registrate internamente. +-- +-- Additivo, reversibile. Runner-safe: CREATE TABLE IF NOT EXISTS con FK dentro +-- il CREATE; nessun DELIMITER; nessun ';' nei commenti. DDL eseguita dal CLI +-- idempotente application/cli/seed_stakeholder_activities.php (estende C5.2a). +-- ===================================================================== + +-- Risposta del destinatario (una per target): answers JSON (questionario) oppure +-- acknowledged_at (firma di avvenuta lettura). +CREATE TABLE IF NOT EXISTS stk_activity_responses ( + id INT NOT NULL AUTO_INCREMENT, + target_id INT NOT NULL, + answers JSON NULL, + acknowledged_at DATETIME NULL, + respondent_name VARCHAR(255) NULL, + respondent_email VARCHAR(255) NULL, + ip_address VARCHAR(45) NULL, + submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_stkar_target (target_id), + CONSTRAINT fk_stkar_target FOREIGN KEY (target_id) REFERENCES stk_activity_targets (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Commenti append-only sull'attività (interni o esterni dal portale). +CREATE TABLE IF NOT EXISTS stk_activity_comments ( + id INT NOT NULL AUTO_INCREMENT, + activity_id INT NOT NULL, + body TEXT NOT NULL, + author_kind ENUM('internal','external') NOT NULL DEFAULT 'internal', + author_user_id INT NULL, + author_label VARCHAR(255) NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_stkac_act (activity_id), + CONSTRAINT fk_stkac_act FOREIGN KEY (activity_id) REFERENCES stk_activities (id) ON DELETE CASCADE, + CONSTRAINT fk_stkac_user FOREIGN KEY (author_user_id) REFERENCES users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ROLLBACK (manuale): +-- DROP TABLE IF EXISTS stk_activity_comments +-- DROP TABLE IF EXISTS stk_activity_responses diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html index 71f16f3..f575569 100644 --- a/public/_app-bi-demo.html +++ b/public/_app-bi-demo.html @@ -70,9 +70,9 @@ - - - + + + - - - + + + + - + + @@ -165,9 +165,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -372,9 +372,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + - + + - - - + + + diff --git a/public/cross-analysis.html b/public/cross-analysis.html index b667691..d785029 100644 --- a/public/cross-analysis.html +++ b/public/cross-analysis.html @@ -382,8 +382,8 @@ - - + + @@ -393,8 +393,8 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - + + - + + @@ -154,9 +154,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + - + + @@ -1152,9 +1152,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -362,9 +362,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - + + @@ -195,9 +195,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + diff --git a/public/js/api.js b/public/js/api.js index 9f99caf..cd4204c 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -327,6 +327,11 @@ class NIS2API { stkActDelete(id) { return this._acn(this.del(`/stakeholder-activities/${id}`)); } stkActAssign(id, d) { return this._acn(this.post(`/stakeholder-activities/${id}/assign`, d || {})); } stkActSend(id) { return this._acn(this.post(`/stakeholder-activities/${id}/send`, {})); } + // C5.2b feedback (interno) + stkActFeedback(id) { return this._acn(this.get(`/stakeholder-activities/${id}/feedback`)); } + stkActComments(id) { return this._acn(this.get(`/stakeholder-activities/${id}/comments`)); } + stkActAddComment(id, d) { return this._acn(this.post(`/stakeholder-activities/${id}/comments`, d || {})); } + stkActAttachments(id) { return this._acn(this.get(`/stakeholder-activities/${id}/attachments`)); } // ═══════════════════════════════════════════════════════════════════ // Dashboard diff --git a/public/kb.html b/public/kb.html index 1856d88..82f56a1 100644 --- a/public/kb.html +++ b/public/kb.html @@ -151,8 +151,8 @@ - - + + @@ -161,9 +161,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + + - - + + - + + - - - + + + + diff --git a/public/normative.html b/public/normative.html index 6927473..03671bb 100644 --- a/public/normative.html +++ b/public/normative.html @@ -112,8 +112,8 @@ - - + + @@ -123,9 +123,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - - + + - + + - - - + + + diff --git a/public/policies.html b/public/policies.html index 484653d..02fd052 100644 --- a/public/policies.html +++ b/public/policies.html @@ -333,8 +333,8 @@ - - + + @@ -344,9 +344,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + diff --git a/public/register.html b/public/register.html index 5c021a6..1f01cad 100644 --- a/public/register.html +++ b/public/register.html @@ -268,8 +268,8 @@ - - + + - + + @@ -454,9 +454,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + - + + @@ -505,9 +505,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -683,9 +683,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - + + - - - + + + - + + - - - + + + + + diff --git a/public/supply-chain.html b/public/supply-chain.html index cb4cfdd..e9da7ef 100644 --- a/public/supply-chain.html +++ b/public/supply-chain.html @@ -477,8 +477,8 @@ - - + + @@ -488,9 +488,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -303,9 +303,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -218,9 +218,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - - + + +