Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions lib/screen/admin/admin_report_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,78 @@ class _AdminReportDetailScreenState extends State<AdminReportDetailScreen> {
'comment' => _commentTarget(t.data),
'user' => _userTarget(t.data),
'chat_message' => _chatTarget(t.data),
'facility' => _facilityTarget(t.data),
_ => _box(Text('알 수 없는 대상 (${t.kind})')),
};
}

/// 시설 정보 제보 대상.
///
/// 다른 신고와 달리 처벌이 아니라 **데이터 교정**이다. 판정하면
/// facilities.reported_closed_at 이 서고, 그 덕에 다음 공공데이터 재적재가
/// is_open 을 도로 올리지 못한다(그쪽은 아직 '영업/정상'이라고 하기 때문).
Widget _facilityTarget(Map<String, dynamic> d) {
final closed = d['reported_closed_at'] != null;
final open = d['is_open'] == true;
return _box(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(d['name'] ?? '이름 없음').toString(),
style: TextStyle(
fontWeight: FontWeight.w800,
color: context.colors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
(d['address'] ?? '').toString(),
style: TextStyle(fontSize: 12, color: context.colors.textSecondary),
),
const SizedBox(height: 8),
Text(
'공공데이터 상태: ${d['biz_status'] ?? '-'}'
' · 지도 노출: ${open ? '노출중' : '숨김'}'
' · 후기 ${d['review_count'] ?? 0}건'
'${closed ? ' · 관리자 폐업 판정됨' : ''}',
style: TextStyle(fontSize: 12, color: context.colors.textTertiary),
),
const SizedBox(height: 12),
_actionRow([
if (!closed)
_btn(
'폐업/이전으로 판정',
() => _markFacilityClosed(d, true),
primary: true,
)
else
_btn('판정 해제', () => _markFacilityClosed(d, false)),
]),
],
),
);
}

Future<void> _markFacilityClosed(Map<String, dynamic> d, bool closed) async {
try {
await AdminModerationRepository.instance.markFacilityClosed(
d['id'] as String,
closed,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(closed ? '폐업으로 판정했어요' : '판정을 해제했어요')),
);
await _load();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('처리 실패: $e')));
}
}

Widget _sectionTitle(String s) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
Expand Down Expand Up @@ -599,6 +667,7 @@ class _AdminReportDetailScreenState extends State<AdminReportDetailScreen> {
'comment' => '댓글',
'chat_message' => '채팅',
'user' => '회원',
'facility' => '시설 정보',
_ => t,
};

Expand Down
1 change: 1 addition & 0 deletions lib/screen/admin/admin_reports_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ class _ReportCard extends StatelessWidget {
'comment' => '댓글',
'chat_message' => '채팅',
'user' => '회원',
'facility' => '시설 정보',
_ => t,
};

Expand Down
12 changes: 12 additions & 0 deletions lib/services/admin/admin_moderation_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ class AdminModerationRepository {
);
}

/// 시설 폐업/이전 판정. 제보를 확인한 관리자가 지도에서 내린다.
///
/// 단순히 is_open 을 끄는 게 아니라 facilities.reported_closed_at 을 세운다 —
/// 그게 없으면 다음 공공데이터 재적재가 is_open 을 도로 켠다(원천은 아직
/// '영업/정상'이라고 하기 때문). 해제하면 원천 상태로 되돌아간다.
Future<void> markFacilityClosed(String facilityId, bool closed) async {
await _c.rpc(
'admin_mark_facility_closed',
params: {'p_facility': facilityId, 'p_closed': closed},
);
}

/// 신고 대상의 실제 내용 조회.
Future<ReportTarget> getReportTarget(String reportId) async {
final res = await _c.rpc(
Expand Down
20 changes: 18 additions & 2 deletions lib/services/report_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ class ReportRepository {
static const targetChatMessage = 'chat_message';
static const targetUser = 'user';

/// 시설 정보 제보 — 다른 신고와 성격이 다르다. 누구를 벌하자는 게 아니라
/// **공공데이터가 늦어서 틀린 정보**를 사용자가 고쳐 주는 경로다.
/// (LOCALDATA 는 인허가 신고 기반이라 폐업·이전 반영이 2~3개월 늦는다.)
static const targetFacility = 'facility';

/// 신고 사유 — DB CHECK(reports_categories_allowed)와 일치해야 한다.
/// '기타'류 선택 시 [extraDescription] 필수(reports_extra_required).
/// 일반(댓글/사용자/채팅)용 사유.
Expand All @@ -36,11 +41,22 @@ class ReportRepository {
'실제 반려동물이 아니에요',
'기타(직접작성)',
];

/// 시설 전용 제보 사유.
static const facilityCategories = <String>[
'폐업했어요',
'이사갔어요',
'정보가 달라요',
'기타(직접작성)',
];
static const categoryEtc = '기타';

/// 대상 타입에 맞는 신고 사유 목록.
static List<String> categoriesFor(String targetType) =>
targetType == targetPost ? postCategories : categories;
static List<String> categoriesFor(String targetType) => switch (targetType) {
targetPost => postCategories,
targetFacility => facilityCategories,
_ => categories,
};

/// 상세설명이 필수인 '기타'류 사유인지.
static bool isEtc(String category) => category.startsWith('기타');
Expand Down
62 changes: 61 additions & 1 deletion lib/widgets/facility_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import '../screen/facility_review_screen.dart';
import '../screen/user_profile_screen.dart';
import '../services/facility_repository.dart';
import '../services/facility_review_repository.dart';
import '../services/report_repository.dart';
import '../services/session.dart';
import '../theme/app_palette.dart';
import '../utils/phone_format.dart';
import 'report_sheet.dart';
import 'review_cards.dart';

/// 시설 상세 콘텐츠(정보 + 후기/사진 + 후기 작성 + 네이버 지도 링크).
Expand Down Expand Up @@ -137,6 +139,33 @@ class _FacilityDetailContentState extends State<FacilityDetailContent> {
if (ok == true) unawaited(_load());
}

/// 시설 정보 제보(폐업·이전·정보 불일치).
///
/// 다른 신고와 성격이 다르다 — 누구를 벌하자는 게 아니라 공공데이터가 늦어서
/// 틀린 정보를 고치는 경로다. 관리자가 확인하면 지도에서 내려간다.
Future<void> _reportFacility(Facility f) async {
if (!SessionManager.instance.isLoggedIn) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('제보는 로그인 후 남길 수 있어요')));
return;
}
// 카페는 마커 id 가 가짜다 — 후기와 같은 기준(승격된 실제 id)으로 보낸다.
final targetId = f.isNaver ? _fid : f.id;
if (targetId == null) return;
final ok = await showReportSheet(
context,
targetType: ReportRepository.targetFacility,
targetId: targetId,
targetLabel: '시설 정보',
targetTitle: f.name,
);
if (!mounted || !ok) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('제보 감사합니다. 확인 후 반영할게요')));
}

Future<void> _openInNaverMap() async {
final f = widget.facility;
final name = Uri.encodeComponent(f.name);
Expand Down Expand Up @@ -369,7 +398,38 @@ class _FacilityDetailContentState extends State<FacilityDetailContent> {
),
],
),
const SizedBox(height: 18),
const SizedBox(height: 10),
// 공공데이터(LOCALDATA)는 인허가 신고 기반이라 폐업·이전 반영이 2~3개월
// 늦는다. 가게 앞에 선 사용자가 가장 빠른 탐지기라 제보 경로를 둔다.
// 후기 쓰기와 경쟁하지 않게 눈에 띄지 않는 텍스트로 둔다.
// 네이버 카페는 마커 id 가 가짜라, 승격된 실제 id(_fid)가 잡히기 전에는
// 제보해도 관리자가 대상을 찾을 수 없다 → 그때는 노출하지 않는다.
// Align 은 무한 너비에서 터진다(이 화면 규칙) — Row(min) 로 왼쪽 정렬.
if (!f.isNaver || _fid != null) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _reportFacility(f),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'정보가 달라요 · 폐업/이전 제보',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: context.colors.textTertiary,
decoration: TextDecoration.underline,
decorationColor: context.colors.textTertiary,
),
),
),
),
],
),
const SizedBox(height: 8),
],
Divider(height: 1, color: context.colors.border),
const SizedBox(height: 14),
Text(
Expand Down
Loading