feat: 사용자 의견(피드백) 기능 — 화면 캡처+이미지 첨부, 관리자 검토

- 전 화면 우하단 '의견' 위젯(embed/portal/admin 레이아웃) — 로그인 사용자
- 의견 + html2canvas 화면 캡처 + 이미지 첨부(최대 5장) 저장
- 화면 URL/계정/지자체 자동 수집(계정·지자체는 서버 세션 기준)
- 관리자 /admin/feedback 목록·상세·상태(접수/처리중/완료/보류)·메모, 테넌트 분리
- feedback/feedback_file 테이블, 첨부 이미지는 관리자 전용 라우트로 서빙

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-06-16 15:39:02 +09:00
parent 4e681d08cc
commit 59b33ccb42
13 changed files with 624 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* @var list<object> $list
* @var string $status
*/
use App\Models\FeedbackModel;
$list = $list ?? [];
$status = (string) ($status ?? '');
$badge = static function (string $s): string {
$map = [
'new' => 'bg-blue-50 text-blue-700',
'in_progress' => 'bg-amber-50 text-amber-700',
'done' => 'bg-emerald-50 text-emerald-700',
'hold' => 'bg-gray-100 text-gray-500',
];
$c = $map[$s] ?? 'bg-gray-100 text-gray-600';
return '<span class="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ' . $c . '">' . esc(FeedbackModel::statusLabel($s)) . '</span>';
};
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">사용자 의견</span>
<form method="get" class="flex items-center gap-2 text-sm">
<select name="status" class="border border-gray-300 rounded px-2 py-1 text-sm" onchange="this.form.submit()">
<option value="">전체 상태</option>
<?php foreach (FeedbackModel::STATUSES as $s): ?>
<option value="<?= esc($s, 'attr') ?>" <?= $status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
<?php endforeach; ?>
</select>
</form>
</section>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">
<table class="w-full data-table">
<thead>
<tr>
<th class="w-16 text-left">번호</th>
<th class="w-36 text-left">일시</th>
<th class="w-20 text-left">상태</th>
<th class="text-left">화면</th>
<th class="text-left">내용</th>
<th class="w-24 text-left">작성자</th>
<th class="w-16 text-center">캡처</th>
</tr>
</thead>
<tbody>
<?php if (empty($list)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-6">접수된 의견이 없습니다.</td></tr>
<?php else: ?>
<?php foreach ($list as $row): ?>
<tr class="cursor-pointer hover:bg-blue-50/60" onclick="window.location.href='<?= site_url('admin/feedback/' . (int) $row->fb_idx) ?>'">
<td class="text-left text-gray-500"><?= (int) $row->fb_idx ?></td>
<td class="text-left text-gray-500 text-[12px]"><?= esc((string) ($row->fb_regdate ?? '')) ?></td>
<td class="text-left"><?= $badge((string) ($row->fb_status ?? 'new')) ?></td>
<td class="text-left text-[12px] text-gray-600"><?= esc((string) ($row->fb_page_title ?: $row->fb_page_url)) ?></td>
<td class="text-left"><?= esc(mb_substr((string) $row->fb_content, 0, 50)) ?><?= mb_strlen((string) $row->fb_content) > 50 ? '…' : '' ?></td>
<td class="text-left text-[12px]"><?= esc((string) ($row->fb_mb_name ?? '')) ?></td>
<td class="text-center"><?= ! empty($row->fb_screenshot) ? '<i class="fa-regular fa-image text-gray-400"></i>' : '' ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/** @var object $row */
use App\Models\FeedbackModel;
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">의견 상세 #<?= (int) $row->fb_idx ?></span>
<a href="<?= site_url('admin/feedback') ?>" class="text-sm text-gray-600 hover:underline">목록으로</a>
</section>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-2">
<section class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">의견 내용</div>
<div class="p-3 space-y-2 text-sm">
<p class="whitespace-pre-wrap text-gray-800"><?= esc((string) $row->fb_content) ?></p>
<hr class="my-2"/>
<dl class="grid grid-cols-[5rem_1fr] gap-y-1 text-[12px] text-gray-600">
<dt class="font-semibold">화면</dt><dd><?= esc((string) ($row->fb_page_title ?: '-')) ?><br><span class="text-gray-400"><?= esc((string) ($row->fb_page_url ?: '')) ?></span></dd>
<dt class="font-semibold">작성자</dt><dd><?= esc((string) ($row->fb_mb_name ?? '')) ?> (idx <?= (int) $row->fb_mb_idx ?>, level <?= (int) $row->fb_mb_level ?>)</dd>
<dt class="font-semibold">지자체</dt><dd><?= (int) ($row->fb_lg_idx ?? 0) ?></dd>
<dt class="font-semibold">일시</dt><dd><?= esc((string) ($row->fb_regdate ?? '')) ?></dd>
<dt class="font-semibold">브라우저</dt><dd class="break-all text-gray-400"><?= esc((string) ($row->fb_user_agent ?? '')) ?></dd>
</dl>
</div>
</section>
<section class="border border-gray-300 rounded-lg overflow-hidden">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">처리</div>
<form action="<?= site_url('admin/feedback/' . (int) $row->fb_idx . '/status') ?>" method="post" class="p-3 space-y-3">
<?= csrf_field() ?>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">상태</label>
<select name="fb_status" class="border border-gray-300 rounded px-2 py-1.5 text-sm w-40">
<?php foreach (FeedbackModel::STATUSES as $s): ?>
<option value="<?= esc($s, 'attr') ?>" <?= (string) $row->fb_status === $s ? 'selected' : '' ?>><?= esc(FeedbackModel::statusLabel($s)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-700 mb-1">처리 메모</label>
<textarea name="fb_admin_memo" rows="4" class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" maxlength="2000"><?= esc((string) ($row->fb_admin_memo ?? '')) ?></textarea>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">저장</button>
</form>
</section>
</div>
<?php $files = $files ?? []; ?>
<?php if (! empty($files)): ?>
<section class="border border-gray-300 rounded-lg overflow-hidden mt-3">
<div class="px-3 py-2 border-b bg-gray-50 text-sm font-semibold text-gray-700">첨부 이미지 (<?= count($files) ?>)</div>
<div class="p-3 flex flex-wrap gap-3">
<?php foreach ($files as $f): ?>
<figure class="m-0">
<a href="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" target="_blank" rel="noopener">
<img src="<?= site_url('admin/feedback/file/' . (int) $f->ff_idx) ?>" alt="첨부 이미지"
style="max-width:280px;max-height:220px;border:1px solid #e5e7eb;border-radius:6px;"/>
</a>
<figcaption class="text-[11px] text-gray-400 mt-1"><?= $f->ff_kind === 'capture' ? '화면 캡처' : '사용자 첨부' ?></figcaption>
</figure>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>

View File

@@ -190,5 +190,6 @@ tailwind.config = {
window.addEventListener('storage', function (e) { if (e.key === FONT_KEY) applyScale(curScale()); });
})();
</script>
<?= view('components/feedback_widget') ?>
</body>
</html>

View File

@@ -218,5 +218,6 @@ tailwind.config = {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true }); else run();
})();
</script>
<?= view('components/feedback_widget') ?>
</body>
</html>

View File

@@ -285,5 +285,6 @@ tailwind.config = {
if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); });
})();
</script>
<?= view('components/feedback_widget') ?>
</body>
</html>

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/**
* 사용자 의견 위젯 — 화면 우하단 플로팅 버튼 + 모달.
* 보내기 시 화면을 html2canvas 로 캡처해 의견과 함께 전송한다.
* 필요한 화면(뷰)에서 <?= view('components/feedback_widget') ?> 로 포함.
*/
$apiPath = (string) parse_url(base_url('feedback'), PHP_URL_PATH);
?>
<div id="fbWidget" class="no-print" style="position:fixed;right:16px;bottom:16px;z-index:11000;">
<button type="button" id="fbOpen" title="이 화면에 대한 의견 보내기"
style="display:inline-flex;align-items:center;gap:.4rem;background:#243a5e;color:#fff;border:0;border-radius:999px;padding:.6rem .9rem;font-size:.8rem;font-weight:700;box-shadow:0 4px 14px rgba(0,0,0,.25);cursor:pointer;">
<i class="fa-regular fa-comment-dots"></i> 의견
</button>
</div>
<div id="fbModal" class="no-print" style="display:none;position:fixed;inset:0;z-index:11001;background:rgba(0,0,0,.35);">
<div style="position:absolute;right:16px;bottom:16px;width:min(380px,92vw);background:#fff;border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,.3);overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:.7rem .9rem;background:#1a2b4b;color:#fff;">
<span style="font-size:.85rem;font-weight:700;"><i class="fa-regular fa-comment-dots"></i> 이 화면 의견 보내기</span>
<button type="button" id="fbClose" style="background:transparent;border:0;color:#fff;font-size:1.1rem;cursor:pointer;">&times;</button>
</div>
<div style="padding:.9rem;">
<textarea id="fbContent" rows="5" maxlength="1000" placeholder="이 화면에서 불편하거나 바꿨으면 하는 점을 자유롭게 적어 주세요."
style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:.6rem;font-size:.85rem;resize:vertical;box-sizing:border-box;"></textarea>
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.5rem;font-size:.75rem;color:#6b7280;">
<input type="checkbox" id="fbShot" checked/> 현재 화면 캡처 첨부
</label>
<div style="margin-top:.5rem;">
<label for="fbFiles" style="display:inline-flex;align-items:center;gap:.35rem;font-size:.75rem;color:#243a5e;border:1px dashed #c7cdd6;border-radius:8px;padding:.4rem .6rem;cursor:pointer;">
<i class="fa-regular fa-image"></i> 이미지 첨부 (최대 5장)
</label>
<input type="file" id="fbFiles" accept="image/*" multiple style="display:none;"/>
<div id="fbPreview" style="display:flex;flex-wrap:wrap;gap:.3rem;margin-top:.4rem;"></div>
</div>
<div id="fbMsg" style="font-size:.75rem;margin-top:.4rem;min-height:1em;"></div>
<div style="display:flex;justify-content:flex-end;gap:.4rem;margin-top:.5rem;">
<button type="button" id="fbCancel" style="border:1px solid #d1d5db;background:#fff;border-radius:8px;padding:.45rem .8rem;font-size:.8rem;cursor:pointer;">취소</button>
<button type="button" id="fbSend" style="border:0;background:#243a5e;color:#fff;border-radius:8px;padding:.45rem .9rem;font-size:.8rem;font-weight:700;cursor:pointer;">보내기</button>
</div>
</div>
</div>
</div>
<script>
(function () {
var API = location.origin + <?= json_encode($apiPath, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var widget = document.getElementById('fbWidget');
var modal = document.getElementById('fbModal');
var content = document.getElementById('fbContent');
var msg = document.getElementById('fbMsg');
var sendBtn = document.getElementById('fbSend');
var shotChk = document.getElementById('fbShot');
var fileInput = document.getElementById('fbFiles');
var preview = document.getElementById('fbPreview');
var attachments = []; // 선택된 이미지 File 목록(최대 5)
function renderPreview() {
preview.innerHTML = '';
attachments.forEach(function (file, i) {
var url = URL.createObjectURL(file);
var box = document.createElement('span');
box.style.cssText = 'position:relative;display:inline-block;';
box.innerHTML = '<img src="' + url + '" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid #e5e7eb;">'
+ '<button type="button" data-i="' + i + '" title="제거" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:0;background:#dc2626;color:#fff;font-size:11px;line-height:1;cursor:pointer;">&times;</button>';
preview.appendChild(box);
});
}
fileInput.addEventListener('change', function () {
Array.prototype.forEach.call(fileInput.files || [], function (f) {
if (attachments.length < 5 && /^image\//.test(f.type)) { attachments.push(f); }
});
fileInput.value = '';
renderPreview();
});
preview.addEventListener('click', function (e) {
var b = e.target.closest('button[data-i]');
if (!b) return;
attachments.splice(parseInt(b.getAttribute('data-i'), 10), 1);
renderPreview();
});
function open() { modal.style.display = 'block'; msg.textContent = ''; content.focus(); }
function close() { modal.style.display = 'none'; }
document.getElementById('fbOpen').addEventListener('click', open);
document.getElementById('fbClose').addEventListener('click', close);
document.getElementById('fbCancel').addEventListener('click', close);
// html2canvas 지연 로드
function ensureH2C(cb) {
if (typeof html2canvas === 'function') { cb(); return; }
var s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js';
s.onload = function () { cb(); };
s.onerror = function () { cb(); }; // 실패해도 텍스트만 전송
document.head.appendChild(s);
}
function capture(cb) {
if (!shotChk.checked) { cb(''); return; }
ensureH2C(function () {
if (typeof html2canvas !== 'function') { cb(''); return; }
var prevW = widget.style.display, prevM = modal.style.display;
widget.style.display = 'none'; modal.style.display = 'none';
var scale = Math.min(1, 1280 / Math.max(1, window.innerWidth));
html2canvas(document.body, { scale: scale, useCORS: true, logging: false, backgroundColor: '#ffffff' })
.then(function (canvas) {
widget.style.display = prevW; modal.style.display = prevM;
try { cb(canvas.toDataURL('image/jpeg', 0.7)); } catch (e) { cb(''); }
})
.catch(function () { widget.style.display = prevW; modal.style.display = prevM; cb(''); });
});
}
function send() {
var text = (content.value || '').trim();
if (!text) { msg.style.color = '#dc2626'; msg.textContent = '의견 내용을 입력해 주세요.'; return; }
sendBtn.disabled = true; msg.style.color = '#6b7280'; msg.textContent = '전송 중…';
capture(function (shot) {
var fd = new FormData();
fd.append('content', text);
fd.append('page_url', location.pathname + location.search);
fd.append('page_title', (document.title || '').slice(0, 200));
if (shot) fd.append('screenshot', shot);
attachments.forEach(function (file) { fd.append('files[]', file, file.name); });
fetch(API, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
sendBtn.disabled = false;
if (d && d.ok) {
msg.style.color = '#059669'; msg.textContent = '의견이 접수되었습니다. 감사합니다.';
content.value = '';
attachments.length = 0; renderPreview();
setTimeout(close, 900);
} else {
msg.style.color = '#dc2626'; msg.textContent = (d && d.message) || '전송에 실패했습니다.';
}
})
.catch(function () { sendBtn.disabled = false; msg.style.color = '#dc2626'; msg.textContent = '전송 중 오류가 발생했습니다.'; });
});
}
sendBtn.addEventListener('click', send);
})();
</script>