운영 기능 및 GA4 대시보드 반영

This commit is contained in:
박성필
2026-08-18 10:53:58 +09:00
parent 612e87847c
commit b492abd3b4
24 changed files with 1521 additions and 77 deletions

View File

@@ -1 +1,38 @@
<header class="admin-page-heading"><div><span>OVERVIEW</span><h1>대시보드</h1><p>사이트 콘텐츠와 문의 현황입니다.</p></div></header><section class="metric-grid" aria-label="운영 현황"><a href="/admin/posts"><span>전체 </span><strong><?= e($counts['posts']) ?></strong></a><a href="/admin/posts"><span>공개 글</span><strong><?= e($counts['published']) ?></strong></a><a href="/admin/inquiries"><span>전체 문의</span><strong><?= e($counts['inquiries']) ?></strong></a><a href="/admin/inquiries"><span>처리 대기</span><strong><?= e($counts['pending']) ?></strong></a></section>
<?php
$summary = $analytics['summary'] ?? [];
$daily = $analytics['daily'] ?? [];
$maxViews = max(1, ...array_map(static fn(array $item): int => (int) ($item['views'] ?? 0), $daily ?: [['views' => 0]]));
$channelLabels = [
'Direct' => '직접 유입',
'Organic Search' => '검색 유입',
'Organic Social' => '소셜 유입',
'Referral' => '외부 링크',
'Unassigned' => '미분류',
];
?>
<header class="admin-page-heading"><div><span>OVERVIEW</span><h1>대시보드</h1><p>사이트 콘텐츠, 문의, 방문 현황입니다.</p></div></header>
<section class="metric-grid" aria-label="운영 현황"><a href="/admin/posts"><span>전체 글</span><strong><?= e($counts['posts']) ?></strong></a><a href="/admin/posts"><span>공개 글</span><strong><?= e($counts['published']) ?></strong></a><a href="/admin/inquiries"><span>전체 문의</span><strong><?= e($counts['inquiries']) ?></strong></a><a href="/admin/inquiries"><span>처리 대기</span><strong><?= e($counts['pending']) ?></strong></a></section>
<section class="analytics-section" aria-labelledby="analytics-heading">
<header class="analytics-heading"><div><span>GOOGLE ANALYTICS</span><h2 id="analytics-heading">방문 현황</h2><p>운영 사이트의 최근 30일 통계입니다. 관리자와 테스트 페이지는 제외됩니다.</p></div><?php if (!empty($analytics['cached_at'])): ?><small><?= e(date('Y.m.d H:i', (int) $analytics['cached_at'])) ?> 기준 · 15분 간격 갱신<?= !empty($analytics['stale']) ? ' · 마지막 정상 데이터' : '' ?></small><?php endif; ?></header>
<?php if (empty($analytics['available'])): ?>
<div class="analytics-unavailable" role="status"><strong>방문 통계를 표시할 수 없습니다.</strong><p><?= e($analytics['message'] ?? '잠시 후 다시 확인해 주세요.') ?></p></div>
<?php else: ?>
<div class="analytics-metrics" aria-label="방문 핵심 지표">
<article><span>최근 접속자</span><strong><?= e(number_format((int) ($analytics['realtime'] ?? 0))) ?></strong><small>최근 30분 내 활동</small></article>
<article><span>전체 방문자</span><strong><?= e(number_format((int) ($analytics['all_time_users'] ?? 0))) ?></strong><small>2026.08.12 측정 시작 이후</small></article>
<article><span>30일 방문자</span><strong><?= e(number_format((int) ($summary['active_users'] ?? 0))) ?></strong><small>최근 30일 내 활동</small></article>
<article><span>처음 온 방문자</span><strong><?= e(number_format((int) ($summary['new_users'] ?? 0))) ?></strong><small>최근 30일 첫 방문</small></article>
<article><span>페이지 열람 수</span><strong><?= e(number_format((int) ($summary['page_views'] ?? 0))) ?></strong><small>같은 페이지 반복 포함</small></article>
<article><span>사이트 방문 횟수</span><strong><?= e(number_format((int) ($summary['sessions'] ?? 0))) ?></strong><small>방문을 시작한 횟수</small></article>
</div>
<div class="analytics-panel analytics-chart-panel">
<div class="analytics-panel-heading"><h3>14일 페이지 열람 추이</h3><span><i></i> 열람 수</span></div>
<?php if ($daily): ?><div class="analytics-chart" role="img" aria-label="최근 14일 일별 페이지 열람 수 막대 차트"><?php foreach ($daily as $item): ?><div class="analytics-bar" title="<?= e($item['date']) ?> · 페이지 열람 <?= e($item['views']) ?> · 방문자 <?= e($item['users']) ?>"><span><?= e($item['views']) ?></span><i style="height:<?= e(max(3, (int) round(((int) $item['views'] / $maxViews) * 100))) ?>%"></i><small><?= e($item['date']) ?></small></div><?php endforeach; ?></div><?php else: ?><p class="analytics-empty">아직 집계된 방문 데이터가 없습니다.</p><?php endif; ?>
</div>
<div class="analytics-detail-grid">
<section class="analytics-panel"><div class="analytics-panel-heading"><h3>많이 본 페이지</h3><span>열람 수</span></div><?php if (!empty($analytics['pages'])): ?><ol class="analytics-ranking"><?php foreach ($analytics['pages'] as $page): ?><li><div><strong><?= e($page['title']) ?></strong><small><?= e($page['path']) ?></small></div><b><?= e(number_format((int) $page['views'])) ?></b></li><?php endforeach; ?></ol><?php else: ?><p class="analytics-empty">아직 집계된 페이지가 없습니다.</p><?php endif; ?></section>
<section class="analytics-panel"><div class="analytics-panel-heading"><h3>방문한 경로</h3><span>방문 횟수 / 방문자</span></div><?php if (!empty($analytics['channels'])): ?><ol class="analytics-ranking"><?php foreach ($analytics['channels'] as $channel): ?><li><div><strong><?= e($channelLabels[$channel['channel']] ?? $channel['channel']) ?></strong><small><?= e($channel['channel']) ?></small></div><b><?= e(number_format((int) $channel['sessions'])) ?> / <?= e(number_format((int) $channel['users'])) ?></b></li><?php endforeach; ?></ol><?php else: ?><p class="analytics-empty">아직 집계된 유입 데이터가 없습니다.</p><?php endif; ?></section>
</div>
<?php endif; ?>
</section>

View File

@@ -1,23 +1,61 @@
<header class="admin-page-heading">
<div><span>CONTENTS</span><h1><?= $post ? '글 수정' : '새 글 작성' ?></h1><p>초안으로 저장하거나 공개 상태로 게시합니다.</p></div>
</header>
<?php if (!empty($mediaExperiment)): ?><div class="admin-experiment-notice"><strong>테스트 기능</strong><span>여러 대표 이미지와 본문 이미지는 테스트 경로의 초안 미리보기에서만 확인합니다. 확인 전 운영 공개는 제한됩니다.</span></div><?php endif; ?>
<?php if ($errors): ?><div class="admin-error" role="alert"><strong>입력 내용을 확인해 주세요.</strong><ul><?php foreach ($errors as $error): ?><li><?= e($error) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
<form class="admin-form" method="post" enctype="multipart/form-data" action="<?= $post ? '/admin/posts/' . e($post['id']) . '/edit' : '/admin/posts/new' ?>">
<?= csrf_field() ?>
<label>제목 <span>*</span><input name="title" value="<?= old('title', $post['title'] ?? '') ?>" required maxlength="200"></label>
<div class="admin-field-grid">
<label>URL slug <span>*</span><input name="slug" value="<?= old('slug', $post['slug'] ?? '') ?>" required maxlength="220" pattern="[a-z0-9-]+" placeholder="office-network-checklist"><small>영문 소문자, 숫자, 하이픈만 사용</small></label>
<label>게시글 URL<?php if ($post): ?><input value="/blog/post-<?= e($post['id']) ?>" readonly aria-describedby="post-url-help"><?php else: ?><input value="저장 시 자동 생성" readonly aria-describedby="post-url-help"><?php endif; ?><small id="post-url-help"><?= $post ? '게시글 번호를 기준으로 자동 관리됩니다.' : '저장 후 post-글번호 형식으로 생성됩니다.' ?></small></label>
<label>카테고리<select name="category"><?php $currentCategory=$_POST['category']??$post['category']??'네트워크'; foreach(['네트워크','키폰시스템','CCTV','시공사례'] as $option): ?><option<?= $currentCategory===$option?' selected':'' ?>><?= e($option) ?></option><?php endforeach; ?></select></label>
</div>
<label>목록 요약<textarea name="excerpt" rows="3" maxlength="500"><?= old('excerpt', $post['excerpt'] ?? '') ?></textarea></label>
<fieldset class="admin-upload-field">
<legend>대표 이미지</legend>
<?php if (!empty($post['image_path'])): ?><img src="<?= e($post['image_path']) ?>" alt="<?= e($post['image_alt']) ?>"><p>새 파일을 선택하면 현재 대표 이미지가 교체됩니다.</p><?php endif; ?>
<label>이미지 파일<input type="file" name="image" accept="image/jpeg,image/png,image/webp"><small>JPG, PNG, WebP · 최대 8MB · 최대 6000×6000px</small></label>
<label>대체 텍스트<input name="image_alt" value="<?= old('image_alt', $post['image_alt'] ?? '') ?>" maxlength="255"><small>이미지 내용을 간단히 설명합니다. 이미지를 등록할 때 필수입니다.</small></label>
</fieldset>
<label>본문 <span>*</span><textarea name="body" rows="16" required><?= old('body', $post['body'] ?? '') ?></textarea><small>빈 줄을 기준으로 문단이 구분됩니다.</small></label>
<?php if (!empty($mediaExperiment)): ?>
<?php
$galleryImages = $post['gallery_images'] ?? [];
$inlineImages = $post['inline_images'] ?? [];
$nextInlineToken = 1;
foreach ($inlineImages as $inlineImage) $nextInlineToken = max($nextInlineToken, (int) $inlineImage['token'] + 1);
?>
<fieldset class="admin-upload-field" data-media-upload="gallery">
<legend>대표 이미지 슬라이드</legend>
<p>최대 6장 · 2장 이상이면 글 상단에서 5초 간격으로 자동 슬라이드됩니다.</p>
<?php if ($galleryImages): ?><div class="admin-media-list"><?php foreach ($galleryImages as $image): ?><article class="admin-media-item"><img src="<?= e($image['file_path']) ?>" alt=""><div><label>대체 텍스트<input name="media_alt[<?= e($image['id']) ?>]" value="<?= e($image['alt_text']) ?>" maxlength="255" required></label><label class="admin-media-delete"><input type="checkbox" name="delete_images[]" value="<?= e($image['id']) ?>"> 이 이미지 삭제</label></div></article><?php endforeach; ?></div><?php endif; ?>
<label>이미지 파일<input type="file" name="gallery_images[]" accept="image/jpeg,image/png,image/webp" multiple data-media-files><small>JPG, PNG, WebP · 파일당 최대 8MB · 최대 6000×6000px</small></label>
<div class="admin-new-media-list" data-media-list aria-live="polite"></div>
</fieldset>
<div class="admin-body-source" data-body-source><label>본문 <span>*</span><textarea name="body" rows="16" required data-post-body><?= old('body', $post['body'] ?? '') ?></textarea><small>JavaScript를 사용할 수 없을 때는 빈 줄로 문단을 나누고 이미지 위치 토큰을 사용합니다.</small></label></div>
<section class="admin-block-editor" data-block-editor data-next-token="<?= e($nextInlineToken) ?>" aria-labelledby="block-editor-title">
<header><div><h2 id="block-editor-title">본문 편집</h2><p>텍스트 문단과 이미지만 사용해 글의 구성을 정리합니다.</p></div><span>본문 이미지 최대 10장</span></header>
<div class="admin-block-list" data-block-list></div>
<div class="admin-block-editor-actions"><button class="button button-secondary" type="button" data-add-text>문단 추가</button><button class="button button-primary" type="button" data-add-image>이미지 추가</button></div>
<p class="admin-block-help">이미지는 JPG, PNG, WebP 형식으로 파일당 8MB·6000×6000px까지 등록할 수 있습니다.</p>
<div data-delete-bin></div>
<div data-inline-media-inventory hidden><?php foreach ($inlineImages as $image): ?><span data-existing-inline data-id="<?= e($image['id']) ?>" data-token="<?= e($image['token']) ?>" data-path="<?= e(url($image['file_path'])) ?>" data-alt="<?= e($image['alt_text']) ?>"></span><?php endforeach; ?></div>
</section>
<?php else: ?>
<fieldset class="admin-upload-field">
<legend>대표 이미지</legend>
<?php if (!empty($post['image_path'])): ?><img src="<?= e($post['image_path']) ?>" alt="<?= e($post['image_alt']) ?>"><p>새 파일을 선택하면 현재 대표 이미지가 교체됩니다.</p><?php endif; ?>
<label>이미지 파일<input type="file" name="image" accept="image/jpeg,image/png,image/webp"><small>JPG, PNG, WebP · 최대 8MB · 최대 6000×6000px</small></label>
<label>대체 텍스트<input name="image_alt" value="<?= old('image_alt', $post['image_alt'] ?? '') ?>" maxlength="255"><small>이미지 내용을 간단히 설명합니다. 이미지를 등록할 때 필수입니다.</small></label>
</fieldset>
<label>본문 <span>*</span><textarea name="body" rows="16" required><?= old('body', $post['body'] ?? '') ?></textarea><small>빈 줄을 기준으로 문단이 구분됩니다.</small></label>
<?php endif; ?>
<div class="admin-field-grid"><label>작성자<input name="author" value="<?= old('author', $post['author'] ?? config('app.legal_name')) ?>" maxlength="100"></label><label>공개 상태<select name="status"><option value="draft"<?= ($_POST['status']??$post['status']??'draft')==='draft'?' selected':'' ?>>초안</option><option value="published"<?= ($_POST['status']??$post['status']??'draft')==='published'?' selected':'' ?>>공개</option></select></label></div>
<div class="admin-form-actions"><button class="button button-primary" type="submit">저장하기</button><a class="button button-secondary" href="/admin/posts">취소</a></div>
<div class="admin-form-actions"><button class="button button-primary" type="submit">저장하기</button><?php if (!empty($mediaExperiment)): ?><button class="button button-secondary" type="button" data-open-live-preview>작성 내용 미리보기</button><?php endif; ?><a class="button button-secondary" href="/admin/posts">취소</a></div>
</form>
<?php if (!empty($mediaExperiment)): ?>
<dialog class="admin-live-preview" data-live-preview aria-labelledby="live-preview-title">
<div class="admin-live-preview-shell">
<header><div><span>LIVE PREVIEW</span><h2 id="live-preview-title">작성 내용 미리보기</h2><p>현재 입력 상태를 저장하지 않고 확인합니다.</p></div><button type="button" data-close-live-preview aria-label="미리보기 닫기">×</button></header>
<article class="admin-live-preview-article">
<div class="admin-live-preview-meta"><span data-preview-category></span><strong data-preview-title></strong><small data-preview-author></small></div>
<section class="admin-live-preview-gallery" data-preview-gallery hidden><div data-preview-gallery-main></div><div data-preview-gallery-thumbs></div><p data-preview-gallery-count></p></section>
<div class="admin-live-preview-body" data-preview-body></div>
</article>
</div>
</dialog>
<?php endif; ?>
<?php if ($post): ?><form class="danger-zone" method="post" action="/admin/posts/<?= e($post['id']) ?>/delete" onsubmit="return confirm('이 글을 삭제할까요? 삭제 후 목록에 표시되지 않습니다.');"><?= csrf_field() ?><div><strong>글 삭제</strong><p>게시글을 복구 가능한 삭제 상태로 전환합니다.</p></div><button type="submit">삭제</button></form><?php endif; ?>

View File

@@ -1 +1 @@
<header class="admin-page-heading"><div><span>CONTENTS</span><h1> 관리</h1><p>블로그 글을 작성하고 공개 상태를 관리합니다.</p></div><a class="button button-primary" href="/admin/posts/new"> 작성</a></header><?php if ($posts): ?><div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>제목</th><th>분류</th><th>상태</th><th>공개일</th><th>관리</th></tr></thead><tbody><?php foreach ($posts as $post): ?><tr><td data-label="제목"><strong><?= e($post['title']) ?></strong><small>/blog/<?= e($post['slug']) ?></small></td><td data-label="분류"><?= e($post['category']) ?></td><td data-label="상태"><span class="status status-<?= e($post['status']) ?>"><?= $post['status'] === 'published' ? '공개' : '초안' ?></span></td><td data-label="공개일"><?= $post['published_at'] ? e(date('Y.m.d', strtotime($post['published_at']))) : '-' ?></td><td data-label="관리"><a href="/admin/posts/<?= e($post['id']) ?>/edit">수정</a></td></tr><?php endforeach; ?></tbody></table></div><?php else: ?><div class="empty-state"><h2>등록된 글이 없습니다</h2><p>새 글을 작성해 콘텐츠를 추가해 주세요.</p></div><?php endif; ?>
<header class="admin-page-heading"><div><span>CONTENTS</span><h1> 관리</h1><p>블로그 글을 작성하고 공개 상태를 관리합니다.</p></div><a class="button button-primary" href="/admin/posts/new"> 작성</a></header><?php if ($posts): ?><div class="admin-table-wrap"><table class="admin-table"><thead><tr><th>제목</th><th>분류</th><th>상태</th><th>공개일</th><th>관리</th></tr></thead><tbody><?php foreach ($posts as $post): ?><tr><td data-label="제목"><strong><?= e($post['title']) ?></strong><small>/blog/<?= e($post['slug']) ?></small></td><td data-label="분류"><?= e($post['category']) ?></td><td data-label="상태"><span class="status status-<?= e($post['status']) ?>"><?= $post['status'] === 'published' ? '공개' : '초안' ?></span></td><td data-label="공개일"><?= $post['published_at'] ? e(date('Y.m.d', strtotime($post['published_at']))) : '-' ?></td><td data-label="관리"><span class="admin-row-actions"><a href="/admin/posts/<?= e($post['id']) ?>/preview" target="_blank" rel="noopener">미리보기</a><a href="/admin/posts/<?= e($post['id']) ?>/edit">수정</a></span></td></tr><?php endforeach; ?></tbody></table></div><?php else: ?><div class="empty-state"><h2>등록된 글이 없습니다</h2><p>새 글을 작성해 콘텐츠를 추가해 주세요.</p></div><?php endif; ?>