feat: 사용자 의견(피드백) 전체보기 — 한 페이지 스크롤로 전체 확인/처리

- /admin/feedback/all: 모든 의견을 상세 클릭 없이 전체 내용+첨부이미지로
  세로 나열, 상단 빠른이동 앵커(#fb-{id})로 특정 건 바로 스크롤
- 각 항목에 상태·메모 인라인 저장 폼 포함(저장 후 같은 위치로 복귀)
- 목록형(index) 화면에 "전체보기(스크롤)" 링크 추가
This commit is contained in:
taekyoungc
2026-07-02 12:32:02 +09:00
parent 5ef50344af
commit f0182f75c2
4 changed files with 165 additions and 8 deletions

View File

@@ -244,6 +244,7 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
// 사용자 의견 검토 // 사용자 의견 검토
$routes->get('feedback', 'Admin\Feedback::index'); $routes->get('feedback', 'Admin\Feedback::index');
$routes->get('feedback/all', 'Admin\Feedback::all');
$routes->get('feedback/image/(:num)', 'Admin\Feedback::image/$1'); $routes->get('feedback/image/(:num)', 'Admin\Feedback::image/$1');
$routes->get('feedback/file/(:num)', 'Admin\Feedback::file/$1'); $routes->get('feedback/file/(:num)', 'Admin\Feedback::file/$1');
$routes->get('feedback/(:num)', 'Admin\Feedback::show/$1'); $routes->get('feedback/(:num)', 'Admin\Feedback::show/$1');

View File

@@ -54,6 +54,48 @@ class Feedback extends BaseController
]); ]);
} }
/**
* 전체 의견을 한 페이지에서 스크롤로 훑어볼 수 있는 화면(상세 클릭 없이 내용 전체 표시).
*/
public function all(): string
{
$model = model(FeedbackModel::class);
$builder = $model->orderBy('fb_idx', 'DESC');
$scope = $this->scopeLgIdx();
if ($scope !== null) {
$builder->where('fb_lg_idx', $scope);
}
$status = (string) ($this->request->getGet('status') ?? '');
if (in_array($status, FeedbackModel::STATUSES, true)) {
$builder->where('fb_status', $status);
}
$list = $builder->findAll();
$ids = array_map(static fn ($r): int => (int) $r->fb_idx, $list);
$filesByFb = [];
if ($ids !== []) {
$files = model(FeedbackFileModel::class)
->whereIn('ff_fb_idx', $ids)
->orderBy('ff_idx', 'ASC')
->findAll();
foreach ($files as $f) {
$filesByFb[(int) $f->ff_fb_idx][] = $f;
}
}
return view('admin/layout', [
'title' => '사용자 의견 · 전체보기',
'content' => view('admin/feedback/all', [
'list' => $list,
'filesByFb' => $filesByFb,
'status' => $status,
]),
]);
}
public function show(int $id): string|ResponseInterface public function show(int $id): string|ResponseInterface
{ {
$model = model(FeedbackModel::class); $model = model(FeedbackModel::class);
@@ -91,6 +133,12 @@ class Feedback extends BaseController
} }
$model->update($id, $update); $model->update($id, $update);
// 전체보기 화면에서 저장한 경우 스크롤 위치(#fb-{id})를 유지한 채 그 화면으로 되돌아간다.
$returnTo = (string) $this->request->getPost('return_to');
if ($returnTo === 'all') {
return redirect()->to(site_url('admin/feedback/all') . '#fb-' . $id)->with('success', '처리 상태를 저장했습니다.');
}
return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.'); return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.');
} }

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/**
* @var list<object> $list
* @var array<int, list<object>> $filesByFb
* @var string $status
*/
use App\Models\FeedbackModel;
$list = $list ?? [];
$filesByFb = $filesByFb ?? [];
$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">사용자 의견 · 전체보기 (<?= count($list) ?>건)</span>
<div class="flex items-center gap-2">
<form method="get" class="flex items-center gap-2 text-sm">
<select name="status" class="border border-gray-300 rounded px-3 py-1 text-sm w-40 min-w-[10rem]" 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>
<a href="<?= site_url('admin/feedback') ?>" class="text-sm text-gray-600 hover:underline whitespace-nowrap">목록형으로 보기</a>
</div>
</section>
<?php if (empty($list)): ?>
<div class="border border-gray-300 rounded-lg p-10 text-center text-gray-400 mt-2">접수된 의견이 없습니다.</div>
<?php else: ?>
<!-- 빠른 이동 — 클릭하면 아래 해당 의견으로 스크롤 -->
<div class="border border-gray-300 rounded-lg bg-white p-2 mt-2 sticky top-0 z-10 shadow-sm">
<div class="flex flex-wrap gap-1.5 max-h-24 overflow-auto">
<?php foreach ($list as $row): ?>
<a href="#fb-<?= (int) $row->fb_idx ?>" class="inline-flex items-center gap-1 border border-gray-200 rounded-full px-2 py-0.5 text-[11px] text-gray-600 hover:bg-blue-50 hover:border-blue-200 whitespace-nowrap">
#<?= (int) $row->fb_idx ?> <?= $badge((string) ($row->fb_status ?? 'new')) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<div class="space-y-3 mt-3">
<?php foreach ($list as $row): $fid = (int) $row->fb_idx; $files = $filesByFb[$fid] ?? []; ?>
<section id="fb-<?= $fid ?>" class="border border-gray-300 rounded-lg overflow-hidden bg-white scroll-mt-20">
<div class="px-3 py-2 border-b bg-gray-50 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2 text-sm">
<span class="font-bold text-gray-800">#<?= $fid ?></span>
<?= $badge((string) ($row->fb_status ?? 'new')) ?>
<span class="text-gray-500 text-[12px]"><?= esc((string) ($row->fb_regdate ?? '')) ?></span>
<span class="text-gray-400 text-[12px]">·</span>
<span class="text-gray-600 text-[12px]"><?= esc((string) ($row->fb_mb_name ?? '')) ?></span>
</div>
<div class="text-[12px] text-gray-500">
<?= esc((string) ($row->fb_page_title ?: '-')) ?>
<span class="text-gray-400"> · <?= esc((string) ($row->fb_page_url ?: '')) ?></span>
</div>
</div>
<div class="p-3 grid grid-cols-1 lg:grid-cols-[1fr_20rem] gap-3">
<div>
<p class="whitespace-pre-wrap text-sm text-gray-800"><?= esc((string) $row->fb_content) ?></p>
<?php if (! empty($files)): ?>
<div class="flex flex-wrap gap-2 mt-3">
<?php foreach ($files as $f): ?>
<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:220px;max-height:170px;border:1px solid #e5e7eb;border-radius:6px;"/>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<form action="<?= site_url('admin/feedback/' . $fid . '/status') ?>" method="post" class="space-y-2 border-t lg:border-t-0 lg:border-l border-gray-200 pt-3 lg:pt-0 lg:pl-3">
<?= csrf_field() ?>
<input type="hidden" name="return_to" value="all"/>
<div class="flex items-center gap-2">
<select name="fb_status" class="border border-gray-300 rounded px-2 py-1 text-sm flex-1">
<?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>
<a href="<?= site_url('admin/feedback/' . $fid) ?>" class="text-[12px] text-gray-500 hover:underline whitespace-nowrap">상세</a>
</div>
<textarea name="fb_admin_memo" rows="3" placeholder="처리 메모" class="w-full border border-gray-300 rounded px-2 py-1.5 text-[12px]" maxlength="2000"><?= esc((string) ($row->fb_admin_memo ?? '')) ?></textarea>
<button type="submit" class="bg-btn-search text-white px-3 py-1 rounded-sm text-[12px] w-full">저장</button>
</form>
</div>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>

View File

@@ -21,6 +21,7 @@ $badge = static function (string $s): string {
?> ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2"> <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> <span class="text-sm font-bold text-gray-700">사용자 의견</span>
<div class="flex items-center gap-2">
<form method="get" class="flex items-center gap-2 text-sm"> <form method="get" class="flex items-center gap-2 text-sm">
<select name="status" class="border border-gray-300 rounded px-3 py-1 text-sm w-40 min-w-[10rem]" onchange="this.form.submit()"> <select name="status" class="border border-gray-300 rounded px-3 py-1 text-sm w-40 min-w-[10rem]" onchange="this.form.submit()">
<option value="">전체 상태</option> <option value="">전체 상태</option>
@@ -29,6 +30,8 @@ $badge = static function (string $s): string {
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</form> </form>
<a href="<?= site_url('admin/feedback/all') ?>" class="inline-flex items-center rounded-lg bg-[#243a5e] px-3 py-1.5 text-white text-xs font-semibold shadow-sm hover:opacity-90 whitespace-nowrap">전체보기(스크롤)</a>
</div>
</section> </section>
<div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2"> <div class="border border-gray-300 rounded-lg p-4 overflow-auto mt-2">