Aktualizacja pytań, adres admin.php, opis AI

This commit is contained in:
Flatlogic Bot 2026-04-12 03:40:40 +00:00
parent 6d2aaeabe8
commit 33e26aa4f9
6 changed files with 609 additions and 465 deletions

489
admin-vr0heltmao.php Normal file
View File

@ -0,0 +1,489 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'login') {
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if (diagnostic_admin_login($username, $password)) {
diagnostic_flash_set('success', 'Sesja administratora została rozpoczęta.');
header('Location: ' . diagnostic_admin_path());
exit;
}
diagnostic_flash_set('danger', 'Nieprawidłowy login lub hasło administratora.');
header('Location: ' . diagnostic_admin_path());
exit;
}
if ($action === 'logout') {
diagnostic_admin_logout();
diagnostic_flash_set('info', 'Sesja administratora została zakończona.');
header('Location: ' . diagnostic_admin_path());
exit;
}
if ($action === 'save-notification-email') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby zmienić adres powiadomień.');
header('Location: ' . diagnostic_admin_path(['tab' => 'settings']));
exit;
}
$notificationEmail = trim((string)($_POST['notification_email'] ?? ''));
if (!filter_var($notificationEmail, FILTER_VALIDATE_EMAIL)) {
diagnostic_flash_set('danger', 'Podaj poprawny adres e-mail administratora do powiadomień o nowych prośbach o kontakt.');
header('Location: ' . diagnostic_admin_path(['tab' => 'settings']));
exit;
}
diagnostic_admin_setting_set('admin_notification_email', $notificationEmail);
diagnostic_flash_set('success', 'Adres e-mail administratora do powiadomień został zapisany.');
header('Location: ' . diagnostic_admin_path(['tab' => 'settings']));
exit;
}
if ($action === 'save-openai-api-key') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby zapisać klucz OpenAI.');
header('Location: ' . diagnostic_admin_path(['tab' => 'settings']));
exit;
}
$openAiApiKey = preg_replace('/\s+/', '', (string)($_POST['openai_api_key'] ?? '')) ?? '';
if (!diagnostic_openai_api_key_is_valid($openAiApiKey)) {
diagnostic_flash_set('danger', 'Podaj poprawny klucz API OpenAI. Klucz powinien być kompletny i bez spacji.');
header('Location: ' . diagnostic_admin_path(['tab' => 'settings']));
exit;
}
diagnostic_admin_setting_set('openai_api_key', $openAiApiKey);
diagnostic_flash_set('success', 'Własny klucz API OpenAI został zapisany. Nowe zapytania AI będą używały tego klucza.');
header('Location: ' . diagnostic_admin_path(['tab' => 'settings']));
exit;
}
if ($action === 'remove-openai-api-key') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby usunąć klucz OpenAI.');
header('Location: ' . diagnostic_admin_path(['tab' => 'settings']));
exit;
}
diagnostic_admin_setting_set('openai_api_key', null);
diagnostic_flash_set('info', 'Zapisany klucz API OpenAI został usunięty. System wrócił do domyślnej konfiguracji proxy.');
header('Location: ' . diagnostic_admin_path(['tab' => 'settings']));
exit;
}
if ($action === 'resend-report') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby ponownie wysłać raport.');
header('Location: ' . diagnostic_admin_path());
exit;
}
$attemptId = (int)($_POST['attempt_id'] ?? 0);
if ($attemptId <= 0) {
diagnostic_flash_set('danger', 'Nie udało się ustalić, dla której próby należy ponownie wysłać raport.');
header('Location: ' . diagnostic_admin_path());
exit;
}
$result = diagnostic_admin_resend_report($attemptId);
if (!empty($result['success'])) {
$attempt = diagnostic_get_attempt($attemptId);
$recipient = $attempt['email'] ?? '';
diagnostic_flash_set('success', 'Raport został wysłany ponownie do klienta' . ($recipient !== '' ? ' (' . $recipient . ')' : '') . '.');
} else {
diagnostic_flash_set('danger', 'Nie udało się ponownie wysłać raportu: ' . (string)($result['error'] ?? 'Nieznany błąd.'));
}
header('Location: ' . diagnostic_admin_path(['attempt' => $attemptId]));
exit;
}
}
if (diagnostic_admin_is_authenticated() && ($_GET['export'] ?? '') === 'csv') {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="wyniki-diagnozy-' . date('Ymd-His') . '.csv"');
echo diagnostic_admin_export_csv();
exit;
}
$flash = diagnostic_flash_get();
$meta = diagnostic_meta('Panel administratora', 'Zabezpieczony panel do przeglądu prób diagnozy i wyników dojrzałości procesowej.');
$definition = diagnostic_quiz_definition();
$attempts = diagnostic_admin_is_authenticated() ? diagnostic_admin_attempts() : [];
$stats = diagnostic_admin_is_authenticated() ? diagnostic_admin_stats() : [];
$selectedAttempt = null;
if (diagnostic_admin_is_authenticated() && isset($_GET['attempt'])) {
$selectedAttempt = diagnostic_get_attempt((int)$_GET['attempt']);
}
if (!$selectedAttempt && !empty($attempts)) {
$selectedAttempt = diagnostic_get_attempt((int)$attempts[0]['id']);
}
$questionMap = diagnostic_question_map();
$credentials = diagnostic_admin_credentials();
$notificationConfig = diagnostic_admin_is_authenticated() ? diagnostic_admin_notification_config() : ['configured_email' => '', 'fallback_email' => '', 'effective_email' => '', 'source' => 'none'];
$openAiConfig = diagnostic_admin_is_authenticated() ? diagnostic_admin_openai_key_config() : ['configured' => false, 'masked_key' => ''];
$currentTab = 'overview';
if (diagnostic_admin_is_authenticated()) {
$requestedTab = (string)($_GET['tab'] ?? 'overview');
$currentTab = in_array($requestedTab, ['overview', 'settings'], true) ? $requestedTab : 'overview';
}
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($meta['title']) ?></title>
<meta name="description" content="<?= htmlspecialchars($meta['description']) ?>">
<meta property="og:title" content="<?= htmlspecialchars($meta['title']) ?>">
<meta property="og:description" content="<?= htmlspecialchars($meta['description']) ?>">
<meta property="twitter:title" content="<?= htmlspecialchars($meta['title']) ?>">
<meta property="twitter:description" content="<?= htmlspecialchars($meta['description']) ?>">
<?php if ($meta['image'] !== ''): ?>
<meta property="og:image" content="<?= htmlspecialchars($meta['image']) ?>">
<meta property="twitter:image" content="<?= htmlspecialchars($meta['image']) ?>">
<?php endif; ?>
<meta name="robots" content="noindex, nofollow">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= urlencode((string)@filemtime(__DIR__ . '/assets/css/custom.css')) ?>">
</head>
<body class="app-shell">
<header class="site-header border-bottom bg-white sticky-top">
<nav class="navbar navbar-expand-lg navbar-light py-3">
<div class="container">
<?= diagnostic_brand_logo_anchor('navbar-brand doctor-biznes-logo-link--nav') ?>
<div class="d-flex align-items-center gap-2 ms-auto">
<a class="btn btn-sm btn-outline-dark" href="/diagnostic.php">Diagnoza</a>
<?php if (diagnostic_admin_is_authenticated()): ?>
<form method="post" class="m-0">
<input type="hidden" name="action" value="logout">
<button type="submit" class="btn btn-sm btn-dark">Wyloguj</button>
</form>
<?php endif; ?>
</div>
</div>
</nav>
</header>
<main class="py-4 py-lg-5">
<div class="<?= diagnostic_admin_is_authenticated() ? 'container-fluid admin-workspace' : 'container' ?>">
<?php if ($flash): ?>
<div class="alert alert-<?= htmlspecialchars($flash['type']) ?> mb-4" role="alert">
<?= htmlspecialchars($flash['message']) ?>
</div>
<?php endif; ?>
<?php if (!diagnostic_admin_is_authenticated()): ?>
<section class="row justify-content-center">
<div class="col-lg-5">
<div class="surface-card p-4 p-lg-5">
<div class="eyebrow mb-3">Strefa zabezpieczona</div>
<h1 class="h3 mb-3">Logowanie administratora</h1>
<p class="text-secondary mb-4">Panel służy do przeglądu prób, analizy wyników oraz eksportu danych z diagnozy dojrzałości procesowej.</p>
<form method="post" novalidate>
<input type="hidden" name="action" value="login">
<div class="mb-3">
<label for="username" class="form-label">Login</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-4">
<label for="password" class="form-label">Hasło</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-dark w-100">Wejdź do panelu</button>
</form>
<?php if ($credentials['using_default']): ?>
<div class="alert alert-warning mt-4 mb-0" role="alert">
Uwaga: aktywne domyślne dane logowania administratora (<strong>admin / admin123</strong>). Przed użyciem produkcyjnym ustaw własne wartości <code>DIAGNOSTIC_ADMIN_USER</code> i <code>DIAGNOSTIC_ADMIN_PASS</code>.
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php else: ?>
<section class="surface-card p-4 p-lg-5 mb-4">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
<div>
<div class="eyebrow mb-2">Panel administratora</div>
<h1 class="page-title mb-1">Operacje i wyniki diagnozy</h1>
<p class="text-secondary mb-0">Przeglądaj próby respondentów, śledź skuteczność ukończenia i analizuj wyniki w poszczególnych obszarach.</p>
</div>
<a class="btn btn-outline-dark" href="<?= htmlspecialchars(diagnostic_admin_path(['export' => 'csv'])) ?>">Eksportuj CSV</a>
</div>
</section>
<section class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3"><div class="metric-card"><strong><?= (int)$stats['total_attempts'] ?></strong><span>Wszystkie próby</span></div></div>
<div class="col-sm-6 col-lg-3"><div class="metric-card"><strong><?= (int)$stats['completed_attempts'] ?></strong><span>Ukończone</span></div></div>
<div class="col-sm-6 col-lg-3"><div class="metric-card"><strong><?= (int)$stats['completion_rate'] ?>%</strong><span>Wskaźnik ukończenia</span></div></div>
<div class="col-sm-6 col-lg-3"><div class="metric-card"><strong><?= (int)$stats['average_score'] ?>%</strong><span>Średni wynik</span></div></div>
</section>
<section class="surface-card p-3 p-lg-4 mb-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3">
<div>
<div class="eyebrow mb-2">Nawigacja panelu</div>
<h2 class="h5 mb-1">Wybierz obszar pracy</h2>
<p class="text-secondary mb-0">Ustawienia wrażliwe zostały przeniesione do osobnej zakładki, aby nie były widoczne od razu po wejściu do panelu.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn <?= $currentTab === 'overview' ? 'btn-dark' : 'btn-outline-dark' ?>" href="<?= htmlspecialchars(diagnostic_admin_path()) ?>">Wyniki i zgłoszenia</a>
<a class="btn <?= $currentTab === 'settings' ? 'btn-dark' : 'btn-outline-dark' ?>" href="<?= htmlspecialchars(diagnostic_admin_path(['tab' => 'settings'])) ?>">Ustawienia</a>
</div>
</div>
</section>
<?php if ($currentTab === 'settings'): ?>
<section class="row g-4 mb-4">
<div class="col-xl-6">
<div class="surface-card p-4 h-100">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<div class="eyebrow mb-2">Powiadomienia o konsultacji</div>
<h2 class="h4 mb-2">Adres administratora dla nowych numerów telefonu</h2>
<p class="text-secondary mb-3">Gdy użytkownik poda numer telefonu po ukończeniu diagnozy, system wyśle powiadomienie e-mail właśnie na ten adres.</p>
<form method="post" class="row g-3" novalidate>
<input type="hidden" name="action" value="save-notification-email">
<div class="col-md-8">
<label for="notificationEmail" class="form-label">Adres e-mail administratora</label>
<input type="email" class="form-control" id="notificationEmail" name="notification_email" placeholder="np. kontakt@twojafirma.pl" value="<?= htmlspecialchars((string)$notificationConfig['configured_email']) ?>" required>
</div>
<div class="col-md-4 d-grid align-self-end">
<button type="submit" class="btn btn-dark">Zapisz adres</button>
</div>
</form>
</div>
<div class="col-lg-5">
<div class="compact-card h-100">
<strong>Aktywna konfiguracja</strong>
<?php if (($notificationConfig['source'] ?? 'none') === 'panel'): ?>
<span>Powiadomienia trafiają na: <strong><?= htmlspecialchars((string)$notificationConfig['effective_email']) ?></strong></span>
<span class="text-secondary">Źródło: ustawienie zapisane w panelu administracyjnym.</span>
<?php elseif (($notificationConfig['source'] ?? 'none') === 'env'): ?>
<span>Powiadomienia tymczasowo trafią na: <strong><?= htmlspecialchars((string)$notificationConfig['effective_email']) ?></strong></span>
<span class="text-secondary">To fallback testowy z <code>MAIL_TO</code>. Ustaw adres w panelu, aby zarządzać nim bez edycji środowiska.</span>
<?php else: ?>
<span>Brak ustawionego adresu do powiadomień.</span>
<span class="text-secondary">Do czasu zapisania adresu w panelu e-mail o nowej prośbie o kontakt nie zostanie wysłany.</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="surface-card p-4 h-100">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<div class="eyebrow mb-2">Integracja AI</div>
<h2 class="h4 mb-2">Własny klucz API OpenAI</h2>
<p class="text-secondary mb-3">Zapisany tutaj klucz będzie automatycznie używany przez wszystkie obecne wywołania AI w aplikacji: diagnozę, czat i webhook Telegrama.</p>
<form method="post" class="row g-3" novalidate>
<input type="hidden" name="action" value="save-openai-api-key">
<div class="col-12">
<label for="openAiApiKey" class="form-label">Klucz API OpenAI</label>
<input type="password" class="form-control" id="openAiApiKey" name="openai_api_key" placeholder="wklej pełny klucz, np. sk-proj-..." value="" autocomplete="new-password" spellcheck="false">
<div class="form-text">Pole pozostaje puste po zapisaniu ze względów bezpieczeństwa. Wprowadź nowy klucz tylko wtedy, gdy chcesz go zmienić.</div>
</div>
<div class="col-sm-6 d-grid">
<button type="submit" class="btn btn-dark">Zapisz klucz</button>
</div>
</form>
<?php if (!empty($openAiConfig['configured'])): ?>
<form method="post" class="mt-3" onsubmit="return confirm('Usunąć zapisany klucz API OpenAI?');">
<input type="hidden" name="action" value="remove-openai-api-key">
<button type="submit" class="btn btn-outline-dark">Usuń zapisany klucz</button>
</form>
<?php endif; ?>
</div>
<div class="col-lg-5">
<div class="compact-card h-100">
<strong>Aktywna konfiguracja</strong>
<?php if (!empty($openAiConfig['configured'])): ?>
<span>Zapytania AI używają własnego klucza zapisanego w panelu.</span>
<span class="text-secondary">Zapisany klucz: <strong><?= htmlspecialchars((string)$openAiConfig['masked_key']) ?></strong></span>
<span class="text-secondary">Klucz jest wysyłany centralnie przez <code>LocalAIApi</code>, więc nie trzeba osobno zmieniać endpointów.</span>
<?php else: ?>
<span>Brak własnego klucza OpenAI w panelu.</span>
<span class="text-secondary">Do czasu zapisania klucza aplikacja korzysta z domyślnej konfiguracji proxy.</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</section>
<?php else: ?>
<section class="row g-4">
<div class="col-xl-8 col-xxl-9">
<div class="surface-card p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<div class="eyebrow mb-2">Lista prób</div>
<h2 class="h4 mb-0">Ostatnie odpowiedzi</h2>
</div>
<span class="pill-badge subdued"><?= count($attempts) ?> rekordów</span>
</div>
<?php if (empty($attempts)): ?>
<div class="empty-state text-center py-5 px-4">
<h3 class="h5 mb-2">Brak zapisanych prób</h3>
<p class="text-secondary mb-0">Po pierwszym wypełnieniu diagnozy przez użytkownika wyniki pojawią się tutaj automatycznie.</p>
</div>
<?php else: ?>
<div class="table-responsive admin-table-wrap">
<table class="table admin-table align-middle mb-0">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">E-mail</th>
<th scope="col">Status</th>
<th scope="col">Telefon</th>
<th scope="col">Wynik</th>
<th scope="col">Segment</th>
<th scope="col">Raport</th>
<th scope="col">Szczegóły</th>
</tr>
</thead>
<tbody>
<?php foreach ($attempts as $row): ?>
<tr>
<td>#<?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars((string)$row['email']) ?></td>
<td><span class="pill-badge<?= ($row['status'] ?? '') === 'completed' ? '' : ' subdued' ?>"><?= ($row['status'] ?? '') === 'completed' ? 'ukończona' : 'w trakcie' ?></span></td>
<td><?= !empty($row['contact_phone']) ? htmlspecialchars((string)$row['contact_phone']) : '—' ?></td>
<td><?= isset($row['result']['percentage_score']) ? (int)$row['result']['percentage_score'] . '%' : '—' ?></td>
<td><?= htmlspecialchars((string)($row['result']['segment_label'] ?? '—')) ?></td>
<td><?= htmlspecialchars((string)$row['email_report_status']) ?></td>
<td><a class="btn btn-sm btn-outline-dark" href="<?= htmlspecialchars(diagnostic_admin_path(['attempt' => (int)$row['id']])) ?>">Zobacz</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<div class="col-xl-4 col-xxl-3">
<div class="surface-card p-4 h-100">
<?php if (!$selectedAttempt): ?>
<div class="empty-state text-center py-5 px-4">
<h3 class="h5 mb-2">Brak wybranej próby</h3>
<p class="text-secondary mb-0">Wybierz odpowiedź z listy, aby zobaczyć szczegóły, rozkład sekcji i pełny zestaw odpowiedzi.</p>
</div>
<?php else: ?>
<?php $result = $selectedAttempt['result'] ?? []; ?>
<div class="attempt-header d-flex flex-wrap justify-content-between gap-3 mb-4 align-items-start">
<div class="attempt-identity">
<div class="eyebrow mb-2">Szczegóły próby #<?= (int)$selectedAttempt['id'] ?></div>
<h2 class="h4 mb-1 attempt-email"><?= htmlspecialchars((string)$selectedAttempt['email']) ?></h2>
<p class="text-secondary mb-0">Rozpoczęto: <?= htmlspecialchars((string)$selectedAttempt['started_at']) ?></p>
</div>
<div class="score-chip text-end">
<span>Wynik</span>
<strong><?= isset($result['percentage_score']) ? (int)$result['percentage_score'] . '%' : '—' ?></strong>
</div>
</div>
<div class="mb-4">
<div class="row g-3">
<div class="col-sm-6"><div class="compact-card"><strong>Status</strong><span><?= ($selectedAttempt['status'] ?? '') === 'completed' ? 'Ukończona' : 'W trakcie' ?></span></div></div>
<div class="col-sm-6"><div class="compact-card"><strong>Raport e-mail</strong><span><?= htmlspecialchars((string)$selectedAttempt['email_report_status']) ?></span></div></div>
<div class="col-sm-6"><div class="compact-card"><strong>Zgoda marketingowa</strong><span><?= ((int)$selectedAttempt['marketing_consent']) === 1 ? 'Tak' : 'Nie' ?></span></div></div>
<div class="col-sm-6"><div class="compact-card"><strong>Telefon kontaktowy</strong><span><?= !empty($selectedAttempt['contact_phone']) ? htmlspecialchars((string)$selectedAttempt['contact_phone']) : '—' ?></span></div></div>
<div class="col-sm-6"><div class="compact-card"><strong>Prośba o kontakt</strong><span><?= htmlspecialchars((string)($selectedAttempt['consultation_requested_at'] ?? '—')) ?></span></div></div>
<div class="col-sm-6"><div class="compact-card"><strong>Zakończono</strong><span><?= htmlspecialchars((string)($selectedAttempt['completed_at'] ?? '—')) ?></span></div></div>
<div class="col-12">
<div class="compact-card">
<strong>Ponowna wysyłka raportu</strong>
<?php if (diagnostic_attempt_can_send_report($selectedAttempt)): ?>
<span>Wyślij ponownie raport na adres klienta: <?= htmlspecialchars((string)$selectedAttempt['email']) ?></span>
<form method="post" class="mt-3">
<input type="hidden" name="action" value="resend-report">
<input type="hidden" name="attempt_id" value="<?= (int)$selectedAttempt['id'] ?>">
<button type="submit" class="btn btn-sm btn-dark">Wyślij ponownie</button>
</form>
<?php else: ?>
<span>Ponowna wysyłka będzie dostępna po ukończeniu diagnozy i wygenerowaniu wyniku.</span>
<?php endif; ?>
<?php if (!empty($selectedAttempt['email_report_error'])): ?>
<span class="text-danger small mt-2">Ostatni błąd wysyłki: <?= htmlspecialchars((string)$selectedAttempt['email_report_error']) ?></span>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php if (!empty($result)): ?>
<div class="mb-4">
<h3 class="h5 mb-3">Podsumowanie wyniku</h3>
<p class="text-secondary mb-3"><strong><?= htmlspecialchars((string)($result['segment_label'] ?? '')) ?></strong> — <?= htmlspecialchars((string)($result['segment_summary'] ?? '')) ?></p>
<div class="row g-3">
<?php foreach (($result['section_scores'] ?? []) as $section): ?>
<div class="col-12">
<div class="section-score-card">
<span class="small text-secondary d-block mb-2"><?= htmlspecialchars($section['section_name']) ?></span>
<strong class="d-block mb-2"><?= (int)$section['percentage'] ?>%</strong>
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-dark" role="progressbar" style="width: <?= (int)$section['percentage'] ?>%;" aria-valuenow="<?= (int)$section['percentage'] ?>" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div>
<h3 class="h5 mb-3">Odpowiedzi respondenta</h3>
<ul class="answer-review-list mb-0">
<?php foreach ($questionMap as $questionId => $question): ?>
<?php $rawAnswer = $selectedAttempt['answers'][$questionId] ?? null; ?>
<li>
<em><?= htmlspecialchars($question['section_name']) ?></em>
<strong><?= htmlspecialchars($question['text']) ?></strong>
<span class="text-secondary"><?= $rawAnswer === null ? 'Brak odpowiedzi' : htmlspecialchars(diagnostic_answer_label((int)$rawAnswer)) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
</div>
</section>
<section class="surface-card p-4 mt-4">
<div class="section-intro mb-3">
<div class="eyebrow">Zakres diagnozy</div>
<h2 class="h4 mb-0">Obszary oceniane w narzędziu</h2>
</div>
<div class="row g-3">
<?php foreach ($definition['sections'] as $section): ?>
<div class="col-md-6 col-xl-4">
<div class="compact-card h-100">
<strong><?= htmlspecialchars($section['name']) ?></strong>
<span><?= count($section['questions']) ?> pytań w tej sekcji.</span>
</div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php endif; ?>
</div>
</main>
<footer class="border-top py-4 bg-white">
<div class="container d-flex flex-column flex-md-row justify-content-between gap-2">
<span class="text-secondary small">Panel administracyjny diagnozy dojrzałości procesowej.</span>
<div class="d-flex gap-3 small">
<a class="text-decoration-none text-secondary" href="/">Strona główna</a>
<a class="text-decoration-none text-secondary" href="/diagnostic.php">Diagnoza</a>
<a class="text-decoration-none text-secondary" href="/healthz.php">Status</a>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="/assets/js/main.js?v=<?= urlencode((string)@filemtime(__DIR__ . '/assets/js/main.js')) ?>" defer></script>
</body>
</html>

468
admin.php
View File

@ -2,132 +2,8 @@
declare(strict_types=1);
require_once __DIR__ . '/app/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'login') {
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if (diagnostic_admin_login($username, $password)) {
diagnostic_flash_set('success', 'Sesja administratora została rozpoczęta.');
header('Location: /admin.php');
exit;
}
diagnostic_flash_set('danger', 'Nieprawidłowy login lub hasło administratora.');
header('Location: /admin.php');
exit;
}
if ($action === 'logout') {
diagnostic_admin_logout();
diagnostic_flash_set('info', 'Sesja administratora została zakończona.');
header('Location: /admin.php');
exit;
}
if ($action === 'save-notification-email') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby zmienić adres powiadomień.');
header('Location: /admin.php?tab=settings');
exit;
}
$notificationEmail = trim((string)($_POST['notification_email'] ?? ''));
if (!filter_var($notificationEmail, FILTER_VALIDATE_EMAIL)) {
diagnostic_flash_set('danger', 'Podaj poprawny adres e-mail administratora do powiadomień o nowych prośbach o kontakt.');
header('Location: /admin.php?tab=settings');
exit;
}
diagnostic_admin_setting_set('admin_notification_email', $notificationEmail);
diagnostic_flash_set('success', 'Adres e-mail administratora do powiadomień został zapisany.');
header('Location: /admin.php?tab=settings');
exit;
}
if ($action === 'save-openai-api-key') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby zapisać klucz OpenAI.');
header('Location: /admin.php?tab=settings');
exit;
}
$openAiApiKey = preg_replace('/\s+/', '', (string)($_POST['openai_api_key'] ?? '')) ?? '';
if (!diagnostic_openai_api_key_is_valid($openAiApiKey)) {
diagnostic_flash_set('danger', 'Podaj poprawny klucz API OpenAI. Klucz powinien być kompletny i bez spacji.');
header('Location: /admin.php?tab=settings');
exit;
}
diagnostic_admin_setting_set('openai_api_key', $openAiApiKey);
diagnostic_flash_set('success', 'Własny klucz API OpenAI został zapisany. Nowe zapytania AI będą używały tego klucza.');
header('Location: /admin.php?tab=settings');
exit;
}
if ($action === 'remove-openai-api-key') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby usunąć klucz OpenAI.');
header('Location: /admin.php?tab=settings');
exit;
}
diagnostic_admin_setting_set('openai_api_key', null);
diagnostic_flash_set('info', 'Zapisany klucz API OpenAI został usunięty. System wrócił do domyślnej konfiguracji proxy.');
header('Location: /admin.php?tab=settings');
exit;
}
if ($action === 'resend-report') {
if (!diagnostic_admin_is_authenticated()) {
diagnostic_flash_set('warning', 'Zaloguj się jako administrator, aby ponownie wysłać raport.');
header('Location: /admin.php');
exit;
}
$attemptId = (int)($_POST['attempt_id'] ?? 0);
if ($attemptId <= 0) {
diagnostic_flash_set('danger', 'Nie udało się ustalić, dla której próby należy ponownie wysłać raport.');
header('Location: /admin.php');
exit;
}
$result = diagnostic_admin_resend_report($attemptId);
if (!empty($result['success'])) {
$attempt = diagnostic_get_attempt($attemptId);
$recipient = $attempt['email'] ?? '';
diagnostic_flash_set('success', 'Raport został wysłany ponownie do klienta' . ($recipient !== '' ? ' (' . $recipient . ')' : '') . '.');
} else {
diagnostic_flash_set('danger', 'Nie udało się ponownie wysłać raportu: ' . (string)($result['error'] ?? 'Nieznany błąd.'));
}
header('Location: /admin.php?attempt=' . $attemptId);
exit;
}
}
if (diagnostic_admin_is_authenticated() && ($_GET['export'] ?? '') === 'csv') {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="wyniki-diagnozy-' . date('Ymd-His') . '.csv"');
echo diagnostic_admin_export_csv();
exit;
}
$flash = diagnostic_flash_get();
$meta = diagnostic_meta('Panel administratora', 'Zabezpieczony panel do przeglądu prób diagnozy i wyników dojrzałości procesowej.');
$definition = diagnostic_quiz_definition();
$attempts = diagnostic_admin_is_authenticated() ? diagnostic_admin_attempts() : [];
$stats = diagnostic_admin_is_authenticated() ? diagnostic_admin_stats() : [];
$selectedAttempt = null;
if (diagnostic_admin_is_authenticated() && isset($_GET['attempt'])) {
$selectedAttempt = diagnostic_get_attempt((int)$_GET['attempt']);
}
if (!$selectedAttempt && !empty($attempts)) {
$selectedAttempt = diagnostic_get_attempt((int)$attempts[0]['id']);
}
$questionMap = diagnostic_question_map();
$credentials = diagnostic_admin_credentials();
$notificationConfig = diagnostic_admin_is_authenticated() ? diagnostic_admin_notification_config() : ['configured_email' => '', 'fallback_email' => '', 'effective_email' => '', 'source' => 'none'];
$openAiConfig = diagnostic_admin_is_authenticated() ? diagnostic_admin_openai_key_config() : ['configured' => false, 'masked_key' => ''];
$currentTab = 'overview';
if (diagnostic_admin_is_authenticated()) {
$requestedTab = (string)($_GET['tab'] ?? 'overview');
$currentTab = in_array($requestedTab, ['overview', 'settings'], true) ? $requestedTab : 'overview';
}
http_response_code(404);
$meta = diagnostic_meta('Nie znaleziono strony', 'Ten adres panelu administracyjnego nie jest aktywny.');
?>
<!doctype html>
<html lang="pl">
@ -136,353 +12,31 @@ if (diagnostic_admin_is_authenticated()) {
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($meta['title']) ?></title>
<meta name="description" content="<?= htmlspecialchars($meta['description']) ?>">
<meta property="og:title" content="<?= htmlspecialchars($meta['title']) ?>">
<meta property="og:description" content="<?= htmlspecialchars($meta['description']) ?>">
<meta property="twitter:title" content="<?= htmlspecialchars($meta['title']) ?>">
<meta property="twitter:description" content="<?= htmlspecialchars($meta['description']) ?>">
<?php if ($meta['image'] !== ''): ?>
<meta property="og:image" content="<?= htmlspecialchars($meta['image']) ?>">
<meta property="twitter:image" content="<?= htmlspecialchars($meta['image']) ?>">
<?php endif; ?>
<meta name="robots" content="noindex, nofollow">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= urlencode((string)@filemtime(__DIR__ . '/assets/css/custom.css')) ?>">
</head>
<body class="app-shell">
<header class="site-header border-bottom bg-white sticky-top">
<header class="site-header border-bottom sticky-top">
<nav class="navbar navbar-expand-lg navbar-light py-3">
<div class="container">
<?= diagnostic_brand_logo_anchor('navbar-brand doctor-biznes-logo-link--nav') ?>
<div class="d-flex align-items-center gap-2 ms-auto">
<a class="btn btn-sm btn-outline-dark" href="/diagnostic.php">Diagnoza</a>
<?php if (diagnostic_admin_is_authenticated()): ?>
<form method="post" class="m-0">
<input type="hidden" name="action" value="logout">
<button type="submit" class="btn btn-sm btn-dark">Wyloguj</button>
</form>
<?php endif; ?>
<a class="btn btn-sm btn-dark" href="/">Strona główna</a>
</div>
</div>
</nav>
</header>
<main class="py-4 py-lg-5">
<div class="<?= diagnostic_admin_is_authenticated() ? 'container-fluid admin-workspace' : 'container' ?>">
<?php if ($flash): ?>
<div class="alert alert-<?= htmlspecialchars($flash['type']) ?> mb-4" role="alert">
<?= htmlspecialchars($flash['message']) ?>
</div>
<?php endif; ?>
<?php if (!diagnostic_admin_is_authenticated()): ?>
<section class="row justify-content-center">
<div class="col-lg-5">
<div class="surface-card p-4 p-lg-5">
<div class="eyebrow mb-3">Strefa zabezpieczona</div>
<h1 class="h3 mb-3">Logowanie administratora</h1>
<p class="text-secondary mb-4">Panel służy do przeglądu prób, analizy wyników oraz eksportu danych z diagnozy dojrzałości procesowej.</p>
<form method="post" novalidate>
<input type="hidden" name="action" value="login">
<div class="mb-3">
<label for="username" class="form-label">Login</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-4">
<label for="password" class="form-label">Hasło</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-dark w-100">Wejdź do panelu</button>
</form>
<?php if ($credentials['using_default']): ?>
<div class="alert alert-warning mt-4 mb-0" role="alert">
Uwaga: aktywne domyślne dane logowania administratora (<strong>admin / admin123</strong>). Przed użyciem produkcyjnym ustaw własne wartości <code>DIAGNOSTIC_ADMIN_USER</code> i <code>DIAGNOSTIC_ADMIN_PASS</code>.
</div>
<?php endif; ?>
</div>
</div>
<main class="py-5">
<div class="container">
<section class="empty-state text-center py-5 px-4">
<div class="eyebrow mb-3">404</div>
<h1 class="page-title mb-3">Nie znaleziono strony</h1>
<p class="text-secondary mb-4">Ten adres panelu administracyjnego nie jest aktywny.</p>
<a class="btn btn-dark" href="/">Wróć na stronę główną</a>
</section>
<?php else: ?>
<section class="surface-card p-4 p-lg-5 mb-4">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center">
<div>
<div class="eyebrow mb-2">Panel administratora</div>
<h1 class="page-title mb-1">Operacje i wyniki diagnozy</h1>
<p class="text-secondary mb-0">Przeglądaj próby respondentów, śledź skuteczność ukończenia i analizuj wyniki w poszczególnych obszarach.</p>
</div>
<a class="btn btn-outline-dark" href="/admin.php?export=csv">Eksportuj CSV</a>
</div>
</section>
<section class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3"><div class="metric-card"><strong><?= (int)$stats['total_attempts'] ?></strong><span>Wszystkie próby</span></div></div>
<div class="col-sm-6 col-lg-3"><div class="metric-card"><strong><?= (int)$stats['completed_attempts'] ?></strong><span>Ukończone</span></div></div>
<div class="col-sm-6 col-lg-3"><div class="metric-card"><strong><?= (int)$stats['completion_rate'] ?>%</strong><span>Wskaźnik ukończenia</span></div></div>
<div class="col-sm-6 col-lg-3"><div class="metric-card"><strong><?= (int)$stats['average_score'] ?>%</strong><span>Średni wynik</span></div></div>
</section>
<section class="surface-card p-3 p-lg-4 mb-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3">
<div>
<div class="eyebrow mb-2">Nawigacja panelu</div>
<h2 class="h5 mb-1">Wybierz obszar pracy</h2>
<p class="text-secondary mb-0">Ustawienia wrażliwe zostały przeniesione do osobnej zakładki, aby nie były widoczne od razu po wejściu do panelu.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn <?= $currentTab === 'overview' ? 'btn-dark' : 'btn-outline-dark' ?>" href="/admin.php">Wyniki i zgłoszenia</a>
<a class="btn <?= $currentTab === 'settings' ? 'btn-dark' : 'btn-outline-dark' ?>" href="/admin.php?tab=settings">Ustawienia</a>
</div>
</div>
</section>
<?php if ($currentTab === 'settings'): ?>
<section class="row g-4 mb-4">
<div class="col-xl-6">
<div class="surface-card p-4 h-100">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<div class="eyebrow mb-2">Powiadomienia o konsultacji</div>
<h2 class="h4 mb-2">Adres administratora dla nowych numerów telefonu</h2>
<p class="text-secondary mb-3">Gdy użytkownik poda numer telefonu po ukończeniu diagnozy, system wyśle powiadomienie e-mail właśnie na ten adres.</p>
<form method="post" class="row g-3" novalidate>
<input type="hidden" name="action" value="save-notification-email">
<div class="col-md-8">
<label for="notificationEmail" class="form-label">Adres e-mail administratora</label>
<input type="email" class="form-control" id="notificationEmail" name="notification_email" placeholder="np. kontakt@twojafirma.pl" value="<?= htmlspecialchars((string)$notificationConfig['configured_email']) ?>" required>
</div>
<div class="col-md-4 d-grid align-self-end">
<button type="submit" class="btn btn-dark">Zapisz adres</button>
</div>
</form>
</div>
<div class="col-lg-5">
<div class="compact-card h-100">
<strong>Aktywna konfiguracja</strong>
<?php if (($notificationConfig['source'] ?? 'none') === 'panel'): ?>
<span>Powiadomienia trafiają na: <strong><?= htmlspecialchars((string)$notificationConfig['effective_email']) ?></strong></span>
<span class="text-secondary">Źródło: ustawienie zapisane w panelu administracyjnym.</span>
<?php elseif (($notificationConfig['source'] ?? 'none') === 'env'): ?>
<span>Powiadomienia tymczasowo trafią na: <strong><?= htmlspecialchars((string)$notificationConfig['effective_email']) ?></strong></span>
<span class="text-secondary">To fallback testowy z <code>MAIL_TO</code>. Ustaw adres w panelu, aby zarządzać nim bez edycji środowiska.</span>
<?php else: ?>
<span>Brak ustawionego adresu do powiadomień.</span>
<span class="text-secondary">Do czasu zapisania adresu w panelu e-mail o nowej prośbie o kontakt nie zostanie wysłany.</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="surface-card p-4 h-100">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<div class="eyebrow mb-2">Integracja AI</div>
<h2 class="h4 mb-2">Własny klucz API OpenAI</h2>
<p class="text-secondary mb-3">Zapisany tutaj klucz będzie automatycznie używany przez wszystkie obecne wywołania AI w aplikacji: diagnozę, czat i webhook Telegrama.</p>
<form method="post" class="row g-3" novalidate>
<input type="hidden" name="action" value="save-openai-api-key">
<div class="col-12">
<label for="openAiApiKey" class="form-label">Klucz API OpenAI</label>
<input type="password" class="form-control" id="openAiApiKey" name="openai_api_key" placeholder="wklej pełny klucz, np. sk-proj-..." value="" autocomplete="new-password" spellcheck="false">
<div class="form-text">Pole pozostaje puste po zapisaniu ze względów bezpieczeństwa. Wprowadź nowy klucz tylko wtedy, gdy chcesz go zmienić.</div>
</div>
<div class="col-sm-6 d-grid">
<button type="submit" class="btn btn-dark">Zapisz klucz</button>
</div>
</form>
<?php if (!empty($openAiConfig['configured'])): ?>
<form method="post" class="mt-3" onsubmit="return confirm('Usunąć zapisany klucz API OpenAI?');">
<input type="hidden" name="action" value="remove-openai-api-key">
<button type="submit" class="btn btn-outline-dark">Usuń zapisany klucz</button>
</form>
<?php endif; ?>
</div>
<div class="col-lg-5">
<div class="compact-card h-100">
<strong>Aktywna konfiguracja</strong>
<?php if (!empty($openAiConfig['configured'])): ?>
<span>Zapytania AI używają własnego klucza zapisanego w panelu.</span>
<span class="text-secondary">Zapisany klucz: <strong><?= htmlspecialchars((string)$openAiConfig['masked_key']) ?></strong></span>
<span class="text-secondary">Klucz jest wysyłany centralnie przez <code>LocalAIApi</code>, więc nie trzeba osobno zmieniać endpointów.</span>
<?php else: ?>
<span>Brak własnego klucza OpenAI w panelu.</span>
<span class="text-secondary">Do czasu zapisania klucza aplikacja korzysta z domyślnej konfiguracji proxy.</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</section>
<?php else: ?>
<section class="row g-4">
<div class="col-xl-8 col-xxl-9">
<div class="surface-card p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<div class="eyebrow mb-2">Lista prób</div>
<h2 class="h4 mb-0">Ostatnie odpowiedzi</h2>
</div>
<span class="pill-badge subdued"><?= count($attempts) ?> rekordów</span>
</div>
<?php if (empty($attempts)): ?>
<div class="empty-state text-center py-5 px-4">
<h3 class="h5 mb-2">Brak zapisanych prób</h3>
<p class="text-secondary mb-0">Po pierwszym wypełnieniu diagnozy przez użytkownika wyniki pojawią się tutaj automatycznie.</p>
</div>
<?php else: ?>
<div class="table-responsive admin-table-wrap">
<table class="table admin-table align-middle mb-0">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">E-mail</th>
<th scope="col">Status</th>
<th scope="col">Telefon</th>
<th scope="col">Wynik</th>
<th scope="col">Segment</th>
<th scope="col">Raport</th>
<th scope="col">Szczegóły</th>
</tr>
</thead>
<tbody>
<?php foreach ($attempts as $row): ?>
<tr>
<td>#<?= (int)$row['id'] ?></td>
<td><?= htmlspecialchars((string)$row['email']) ?></td>
<td><span class="pill-badge<?= ($row['status'] ?? '') === 'completed' ? '' : ' subdued' ?>"><?= ($row['status'] ?? '') === 'completed' ? 'ukończona' : 'w trakcie' ?></span></td>
<td><?= !empty($row['contact_phone']) ? htmlspecialchars((string)$row['contact_phone']) : '—' ?></td>
<td><?= isset($row['result']['percentage_score']) ? (int)$row['result']['percentage_score'] . '%' : '—' ?></td>
<td><?= htmlspecialchars((string)($row['result']['segment_label'] ?? '—')) ?></td>
<td><?= htmlspecialchars((string)$row['email_report_status']) ?></td>
<td><a class="btn btn-sm btn-outline-dark" href="/admin.php?attempt=<?= (int)$row['id'] ?>">Zobacz</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<div class="col-xl-4 col-xxl-3">
<div class="surface-card p-4 h-100">
<?php if (!$selectedAttempt): ?>
<div class="empty-state text-center py-5 px-4">
<h3 class="h5 mb-2">Brak wybranej próby</h3>
<p class="text-secondary mb-0">Wybierz odpowiedź z listy, aby zobaczyć szczegóły, rozkład sekcji i pełny zestaw odpowiedzi.</p>
</div>
<?php else: ?>
<?php $result = $selectedAttempt['result'] ?? []; ?>
<div class="attempt-header d-flex flex-wrap justify-content-between gap-3 mb-4 align-items-start">
<div class="attempt-identity">
<div class="eyebrow mb-2">Szczegóły próby #<?= (int)$selectedAttempt['id'] ?></div>
<h2 class="h4 mb-1 attempt-email"><?= htmlspecialchars((string)$selectedAttempt['email']) ?></h2>
<p class="text-secondary mb-0">Rozpoczęto: <?= htmlspecialchars((string)$selectedAttempt['started_at']) ?></p>
</div>
<div class="score-chip text-end">
<span>Wynik</span>
<strong><?= isset($result['percentage_score']) ? (int)$result['percentage_score'] . '%' : '—' ?></strong>
</div>
</div>
<div class="mb-4">
<div class="row g-3">
<div class="col-sm-6"><div class="compact-card"><strong>Status</strong><span><?= ($selectedAttempt['status'] ?? '') === 'completed' ? 'Ukończona' : 'W trakcie' ?></span></div></div>
<div class="col-sm-6"><div class="compact-card"><strong>Raport e-mail</strong><span><?= htmlspecialchars((string)$selectedAttempt['email_report_status']) ?></span></div></div>
<div class="col-sm-6"><div class="compact-card"><strong>Zgoda marketingowa</strong><span><?= ((int)$selectedAttempt['marketing_consent']) === 1 ? 'Tak' : 'Nie' ?></span></div></div>
<div class="col-sm-6"><div class="compact-card"><strong>Telefon kontaktowy</strong><span><?= !empty($selectedAttempt['contact_phone']) ? htmlspecialchars((string)$selectedAttempt['contact_phone']) : '—' ?></span></div></div>
<div class="col-sm-6"><div class="compact-card"><strong>Prośba o kontakt</strong><span><?= htmlspecialchars((string)($selectedAttempt['consultation_requested_at'] ?? '—')) ?></span></div></div>
<div class="col-sm-6"><div class="compact-card"><strong>Zakończono</strong><span><?= htmlspecialchars((string)($selectedAttempt['completed_at'] ?? '—')) ?></span></div></div>
<div class="col-12">
<div class="compact-card">
<strong>Ponowna wysyłka raportu</strong>
<?php if (diagnostic_attempt_can_send_report($selectedAttempt)): ?>
<span>Wyślij ponownie raport na adres klienta: <?= htmlspecialchars((string)$selectedAttempt['email']) ?></span>
<form method="post" class="mt-3">
<input type="hidden" name="action" value="resend-report">
<input type="hidden" name="attempt_id" value="<?= (int)$selectedAttempt['id'] ?>">
<button type="submit" class="btn btn-sm btn-dark">Wyślij ponownie</button>
</form>
<?php else: ?>
<span>Ponowna wysyłka będzie dostępna po ukończeniu diagnozy i wygenerowaniu wyniku.</span>
<?php endif; ?>
<?php if (!empty($selectedAttempt['email_report_error'])): ?>
<span class="text-danger small mt-2">Ostatni błąd wysyłki: <?= htmlspecialchars((string)$selectedAttempt['email_report_error']) ?></span>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php if (!empty($result)): ?>
<div class="mb-4">
<h3 class="h5 mb-3">Podsumowanie wyniku</h3>
<p class="text-secondary mb-3"><strong><?= htmlspecialchars((string)($result['segment_label'] ?? '')) ?></strong> — <?= htmlspecialchars((string)($result['segment_summary'] ?? '')) ?></p>
<div class="row g-3">
<?php foreach (($result['section_scores'] ?? []) as $section): ?>
<div class="col-12">
<div class="section-score-card">
<span class="small text-secondary d-block mb-2"><?= htmlspecialchars($section['section_name']) ?></span>
<strong class="d-block mb-2"><?= (int)$section['percentage'] ?>%</strong>
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-dark" role="progressbar" style="width: <?= (int)$section['percentage'] ?>%;" aria-valuenow="<?= (int)$section['percentage'] ?>" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div>
<h3 class="h5 mb-3">Odpowiedzi respondenta</h3>
<ul class="answer-review-list mb-0">
<?php foreach ($questionMap as $questionId => $question): ?>
<?php $rawAnswer = $selectedAttempt['answers'][$questionId] ?? null; ?>
<li>
<em><?= htmlspecialchars($question['section_name']) ?></em>
<strong><?= htmlspecialchars($question['text']) ?></strong>
<span class="text-secondary"><?= $rawAnswer === null ? 'Brak odpowiedzi' : htmlspecialchars(diagnostic_answer_label((int)$rawAnswer)) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
</div>
</section>
<section class="surface-card p-4 mt-4">
<div class="section-intro mb-3">
<div class="eyebrow">Zakres diagnozy</div>
<h2 class="h4 mb-0">Obszary oceniane w narzędziu</h2>
</div>
<div class="row g-3">
<?php foreach ($definition['sections'] as $section): ?>
<div class="col-md-6 col-xl-4">
<div class="compact-card h-100">
<strong><?= htmlspecialchars($section['name']) ?></strong>
<span><?= count($section['questions']) ?> pytań w tej sekcji.</span>
</div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php endif; ?>
</div>
</main>
<footer class="border-top py-4 bg-white">
<div class="container d-flex flex-column flex-md-row justify-content-between gap-2">
<span class="text-secondary small">Panel administracyjny diagnozy dojrzałości procesowej.</span>
<div class="d-flex gap-3 small">
<a class="text-decoration-none text-secondary" href="/">Strona główna</a>
<a class="text-decoration-none text-secondary" href="/diagnostic.php">Diagnoza</a>
<a class="text-decoration-none text-secondary" href="/healthz.php">Status</a>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="/assets/js/main.js?v=<?= urlencode((string)@filemtime(__DIR__ . '/assets/js/main.js')) ?>" defer></script>
</body>

View File

@ -23,7 +23,7 @@ function diagnostic_quiz_definition(): array
'questions' => [
['id' => 'q1', 'text' => 'Czy w dowolnym momencie możesz sprawdzić, kto aktualnie pracuje nad jakim zadaniem?'],
['id' => 'q2', 'text' => 'Czy wszystkie zadania w firmie są zapisywane w jednym miejscu?'],
['id' => 'q3', 'text' => 'Czy zdarza się, że zadania „giną” lub są zapominane?', 'reverse' => true],
['id' => 'q3', 'text' => 'Czy masz pewność, że żadne zadanie nie zostanie pominięte lub zapomniane?'],
['id' => 'q4', 'text' => 'Czy pracownicy wiedzą dokładnie, co mają robić bez konieczności dopytywania?'],
['id' => 'q5', 'text' => 'Czy masz możliwość sprawdzenia, ile czasu zajmuje realizacja konkretnych zadań?'],
['id' => 'q6', 'text' => 'Czy jesteś w stanie szybko zidentyfikować opóźnienia w pracy?'],
@ -46,10 +46,10 @@ function diagnostic_quiz_definition(): array
'name' => 'Komunikacja wewnętrzna',
'questions' => [
['id' => 'q13', 'text' => 'Czy informacje w firmie są przekazywane w uporządkowany sposób (np. jedno narzędzie)?'],
['id' => 'q14', 'text' => 'Czy zdarza się, że te same tematy są omawiane wielokrotnie?', 'reverse' => true],
['id' => 'q14', 'text' => 'Czy komunikacja w firmie jest uporządkowana i unika powtarzania tych samych tematów?'],
['id' => 'q15', 'text' => 'Czy pracownicy mają dostęp do wszystkich potrzebnych informacji?'],
['id' => 'q16', 'text' => 'Czy decyzje są jasno komunikowane i rozumiane przez zespół?'],
['id' => 'q17', 'text' => 'Czy zdarzają się sytuacje, w których różne osoby działają na podstawie sprzecznych informacji?', 'reverse' => true],
['id' => 'q17', 'text' => 'Czy różne osoby działają na podstawie tych samych, spójnych i jednoznacznych informacji?'],
['id' => 'q18', 'text' => 'Czy ustalenia ze spotkań są zapisywane i przekładane na konkretne działania?'],
],
],

View File

@ -17,6 +17,8 @@ function ensure_diagnostic_schema(): void
report_html LONGTEXT NULL,
report_text LONGTEXT NULL,
report_generated_at DATETIME NULL,
ai_summary_text TEXT NULL,
ai_summary_generated_at DATETIME NULL,
email_report_status VARCHAR(20) NOT NULL DEFAULT 'pending',
email_report_error TEXT NULL,
contact_phone VARCHAR(40) NULL,
@ -45,6 +47,14 @@ function ensure_diagnostic_schema(): void
db()->exec("ALTER TABLE diagnostic_attempts ADD COLUMN report_generated_at DATETIME NULL AFTER report_text");
}
if (!diagnostic_schema_column_exists('diagnostic_attempts', 'ai_summary_text')) {
db()->exec("ALTER TABLE diagnostic_attempts ADD COLUMN ai_summary_text TEXT NULL AFTER report_generated_at");
}
if (!diagnostic_schema_column_exists('diagnostic_attempts', 'ai_summary_generated_at')) {
db()->exec("ALTER TABLE diagnostic_attempts ADD COLUMN ai_summary_generated_at DATETIME NULL AFTER ai_summary_text");
}
if (!diagnostic_schema_column_exists('diagnostic_attempts', 'contact_phone')) {
db()->exec("ALTER TABLE diagnostic_attempts ADD COLUMN contact_phone VARCHAR(40) NULL AFTER email_report_error");
}
@ -87,6 +97,22 @@ function diagnostic_meta(string $pageTitle, ?string $fallbackDescription = null)
];
}
function diagnostic_admin_entry_filename(): string
{
return 'admin-vr0heltmao.php';
}
function diagnostic_admin_path(array $query = []): string
{
$path = '/' . diagnostic_admin_entry_filename();
if ($query === []) {
return $path;
}
$queryString = http_build_query($query);
return $queryString !== '' ? $path . '?' . $queryString : $path;
}
function diagnostic_brand_home_url(): string
{
return 'https://doktorbiznes.pl/';
@ -397,6 +423,8 @@ function diagnostic_finalize_attempt(int $attemptId): ?array
report_html = NULL,
report_text = NULL,
report_generated_at = NULL,
ai_summary_text = NULL,
ai_summary_generated_at = NULL,
completed_at = NOW(),
updated_at = NOW()
WHERE id = :id'
@ -621,9 +649,49 @@ function diagnostic_default_report_copy(array $attempt): array
'priorities' => diagnostic_report_list($priorities),
'next_steps' => diagnostic_report_list($nextSteps),
'closing' => '',
'result_overview' => diagnostic_default_result_overview($attempt),
];
}
function diagnostic_default_result_overview(array $attempt): string
{
$result = $attempt['result'] ?? [];
$score = (int)($result['percentage_score'] ?? 0);
$segmentLabel = (string)($result['segment_label'] ?? 'Diagnoza zakończona');
$segmentSummary = diagnostic_report_limit((string)($result['segment_summary'] ?? ''), 380);
$strongest = array_map(
static fn(array $area): string => sprintf('%s (%d%%)', (string)($area['section_name'] ?? 'Mocny obszar'), (int)($area['percentage'] ?? 0)),
array_slice((array)($result['strongest_areas'] ?? []), 0, 2)
);
$strongest = array_values(array_filter($strongest, static fn(string $item): bool => trim($item) !== ''));
$weakest = array_map(
static fn(array $area): string => sprintf('%s (%d%%)', (string)($area['section_name'] ?? 'Priorytet'), (int)($area['percentage'] ?? 0)),
array_slice((array)($result['weakest_areas'] ?? []), 0, 2)
);
$weakest = array_values(array_filter($weakest, static fn(string $item): bool => trim($item) !== ''));
$recommendations = diagnostic_report_list((array)($result['recommendations'] ?? []), 2, 180);
$parts = [];
$parts[] = 'Wynik ' . $score . '% klasyfikuje firmę w segmencie „' . $segmentLabel . '”.';
if ($segmentSummary !== '') {
$parts[] = $segmentSummary;
}
if ($strongest !== []) {
$parts[] = 'Najmocniej wypadają obecnie: ' . implode(', ', $strongest) . '.';
}
if ($weakest !== []) {
$parts[] = 'Najwięcej uporządkowania wymagają: ' . implode(', ', $weakest) . '.';
}
if ($recommendations !== []) {
$parts[] = 'Najbardziej praktyczny kolejny krok to: ' . implode(' ', $recommendations);
}
return diagnostic_report_limit(implode(' ', $parts), 1000);
}
function diagnostic_ai_report_prompt_payload(array $attempt): array
{
$result = $attempt['result'] ?? [];
@ -652,7 +720,7 @@ function diagnostic_ai_report_prompt_payload(array $attempt): array
function diagnostic_normalize_report_copy(array $candidate, array $defaults): array
{
$copy = $defaults;
foreach (['subject' => 140, 'preheader' => 180, 'intro' => 420, 'executive_summary' => 650, 'what_it_means' => 520, 'closing' => 320] as $field => $maxLength) {
foreach (['subject' => 140, 'preheader' => 180, 'intro' => 420, 'executive_summary' => 650, 'what_it_means' => 520, 'closing' => 320, 'result_overview' => 1000] as $field => $maxLength) {
if (!empty($candidate[$field]) && is_scalar($candidate[$field])) {
$normalized = diagnostic_report_limit((string)$candidate[$field], $maxLength);
if ($normalized !== '') {
@ -684,7 +752,7 @@ function diagnostic_generate_ai_report_copy(array $attempt): array
'input' => [
[
'role' => 'system',
'content' => 'Jesteś konsultantem operacyjnym B2B. Tworzysz krótkie, konkretne i profesjonalne podsumowania e-mail po polsku na podstawie danych z quizu. Odpowiadaj wyłącznie poprawnym JSON-em UTF-8 bez markdownu i bez HTML. Zachowaj rzeczowy ton. Nie wymyślaj faktów wykraczających poza dane. Zwróć dokładnie pola: subject, preheader, intro, executive_summary, what_it_means, strengths, priorities, next_steps, closing. strengths/priorities/next_steps mają być tablicami 2-3 krótkich punktów.'
'content' => 'Jesteś konsultantem operacyjnym B2B. Tworzysz krótkie, konkretne i profesjonalne podsumowania e-mail po polsku na podstawie danych z quizu. Odpowiadaj wyłącznie poprawnym JSON-em UTF-8 bez markdownu i bez HTML. Zachowaj rzeczowy ton. Nie wymyślaj faktów wykraczających poza dane. Zwróć dokładnie pola: subject, preheader, intro, executive_summary, what_it_means, strengths, priorities, next_steps, closing, result_overview. strengths/priorities/next_steps mają być tablicami 2-3 krótkich punktów. result_overview ma być jednym spójnym opisem wyniku na stronę podsumowania, maksymalnie 1000 znaków.'
],
[
'role' => 'user',
@ -879,18 +947,23 @@ function diagnostic_strip_legacy_report_cta(array $package): array
function diagnostic_store_report_content(int $attemptId, array $package): void
{
$stmt = db()->prepare(
'UPDATE diagnostic_attempts
"UPDATE diagnostic_attempts
SET report_subject = :report_subject,
report_html = :report_html,
report_text = :report_text,
report_generated_at = NOW(),
ai_summary_text = :ai_summary_text,
ai_summary_generated_at = CASE WHEN :ai_summary_text = '' THEN ai_summary_generated_at ELSE NOW() END,
updated_at = NOW()
WHERE id = :id'
WHERE id = :id"
);
$summary = diagnostic_report_limit((string)($package['result_overview'] ?? ''), 1000);
$stmt->execute([
':report_subject' => (string)($package['subject'] ?? ''),
':report_html' => (string)($package['html'] ?? ''),
':report_text' => (string)($package['text'] ?? ''),
':ai_summary_text' => $summary,
':id' => $attemptId,
]);
}
@ -928,6 +1001,7 @@ function diagnostic_cached_report_content(array $attempt): ?array
'subject' => $subject,
'html' => $html,
'text' => $text,
'result_overview' => diagnostic_report_limit((string)($attempt['ai_summary_text'] ?? ''), 1000),
'source' => 'cache',
];
}
@ -946,10 +1020,29 @@ function diagnostic_generate_report_content(array $attempt): array
'subject' => (string)($copy['subject'] ?? 'Raport z diagnozy dojrzałości procesowej'),
'html' => diagnostic_build_report_html($attempt, $copy),
'text' => diagnostic_build_report_text($attempt, $copy),
'result_overview' => diagnostic_report_limit((string)($copy['result_overview'] ?? ''), 1000),
'source' => (string)($ai['source'] ?? 'fallback'),
];
}
function diagnostic_resolve_result_overview(array $attempt): string
{
$cached = diagnostic_report_limit((string)($attempt['ai_summary_text'] ?? ''), 1000);
if ($cached !== '') {
return $cached;
}
$report = diagnostic_resolve_report_content($attempt, true);
if (!empty($report['success'])) {
$generated = diagnostic_report_limit((string)($report['result_overview'] ?? ''), 1000);
if ($generated !== '') {
return $generated;
}
}
return diagnostic_default_result_overview($attempt);
}
function diagnostic_resolve_report_content(array $attempt, bool $forceRegenerate = false): array
{
if (!$forceRegenerate) {

View File

@ -140,6 +140,7 @@ $currentAnswer = $attempt['answers'][$currentQuestion['id'] ?? ''] ?? null;
$progressPercent = (int)round(($currentStep / max($totalQuestions, 1)) * 100);
$openConsultationModal = isset($_GET['modal']) && $_GET['modal'] === 'consultation';
$meta = diagnostic_meta('Diagnoza dojrzałości procesowej', 'Profesjonalna diagnoza dojrzałości procesowej z natychmiastowym podsumowaniem i raportem wysyłanym na e-mail.');
$resultOverview = ($view === 'result' && $attempt && ($attempt['status'] ?? '') === 'completed') ? diagnostic_resolve_result_overview($attempt) : '';
?>
<!doctype html>
<html lang="pl">
@ -332,6 +333,14 @@ $meta = diagnostic_meta('Diagnoza dojrzałości procesowej', 'Profesjonalna diag
</div>
</section>
<?php if ($resultOverview !== ''): ?>
<section class="surface-card p-4 p-lg-5 mb-4">
<div class="eyebrow mb-3">Opis wygenerowany przez AI</div>
<h2 class="section-title mb-3">Co ten wynik mówi o Twojej organizacji</h2>
<p class="text-secondary mb-0"><?= nl2br(htmlspecialchars($resultOverview)) ?></p>
</section>
<?php endif; ?>
<section class="row g-4">
<div class="col-lg-4">
<div class="surface-card p-4 h-100">

View File

@ -42,7 +42,6 @@ $meta = diagnostic_meta('Diagnoza dojrzałości procesowej', 'Profesjonalne narz
<div class="container py-lg-4">
<div class="row g-4 align-items-center">
<div class="col-lg-7">
<span class="pill-badge mb-3">Narzędzie dla firm B2B i zespołów operacyjnych</span>
<h1 class="display-title mb-4">Sprawdź, na ile procesy w Twojej firmie gotowe do wzrostu.</h1>
<p class="lead text-secondary mb-3">To narzędzie pomaga właścicielom firm, dyrektorom zarządzającym i liderom operacyjnym szybko ocenić dojrzałość operacyjną organizacji, wskazać obszary chaosu i ustalić priorytety zmian.</p>
<div class="d-inline-flex align-items-center gap-2 px-3 py-2 mb-4 rounded-3 border bg-white shadow-sm">