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:
66
app/Views/admin/feedback/index.php
Normal file
66
app/Views/admin/feedback/index.php
Normal 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; ?>
|
||||
64
app/Views/admin/feedback/show.php
Normal file
64
app/Views/admin/feedback/show.php
Normal 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; ?>
|
||||
Reference in New Issue
Block a user