diff --git a/lib/screen/admin/admin_report_detail_screen.dart b/lib/screen/admin/admin_report_detail_screen.dart index 6e3520c..331b5af 100644 --- a/lib/screen/admin/admin_report_detail_screen.dart +++ b/lib/screen/admin/admin_report_detail_screen.dart @@ -211,10 +211,78 @@ class _AdminReportDetailScreenState extends State { '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 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 _markFacilityClosed(Map 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( @@ -599,6 +667,7 @@ class _AdminReportDetailScreenState extends State { 'comment' => '댓글', 'chat_message' => '채팅', 'user' => '회원', + 'facility' => '시설 정보', _ => t, }; diff --git a/lib/screen/admin/admin_reports_screen.dart b/lib/screen/admin/admin_reports_screen.dart index c6d23f7..0760a7b 100644 --- a/lib/screen/admin/admin_reports_screen.dart +++ b/lib/screen/admin/admin_reports_screen.dart @@ -195,6 +195,7 @@ class _ReportCard extends StatelessWidget { 'comment' => '댓글', 'chat_message' => '채팅', 'user' => '회원', + 'facility' => '시설 정보', _ => t, }; diff --git a/lib/services/admin/admin_moderation_repository.dart b/lib/services/admin/admin_moderation_repository.dart index 0652851..299f632 100644 --- a/lib/services/admin/admin_moderation_repository.dart +++ b/lib/services/admin/admin_moderation_repository.dart @@ -30,6 +30,18 @@ class AdminModerationRepository { ); } + /// 시설 폐업/이전 판정. 제보를 확인한 관리자가 지도에서 내린다. + /// + /// 단순히 is_open 을 끄는 게 아니라 facilities.reported_closed_at 을 세운다 — + /// 그게 없으면 다음 공공데이터 재적재가 is_open 을 도로 켠다(원천은 아직 + /// '영업/정상'이라고 하기 때문). 해제하면 원천 상태로 되돌아간다. + Future markFacilityClosed(String facilityId, bool closed) async { + await _c.rpc( + 'admin_mark_facility_closed', + params: {'p_facility': facilityId, 'p_closed': closed}, + ); + } + /// 신고 대상의 실제 내용 조회. Future getReportTarget(String reportId) async { final res = await _c.rpc( diff --git a/lib/services/report_repository.dart b/lib/services/report_repository.dart index 973d895..9c84bc1 100644 --- a/lib/services/report_repository.dart +++ b/lib/services/report_repository.dart @@ -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). /// 일반(댓글/사용자/채팅)용 사유. @@ -36,11 +41,22 @@ class ReportRepository { '실제 반려동물이 아니에요', '기타(직접작성)', ]; + + /// 시설 전용 제보 사유. + static const facilityCategories = [ + '폐업했어요', + '이사갔어요', + '정보가 달라요', + '기타(직접작성)', + ]; static const categoryEtc = '기타'; /// 대상 타입에 맞는 신고 사유 목록. - static List categoriesFor(String targetType) => - targetType == targetPost ? postCategories : categories; + static List categoriesFor(String targetType) => switch (targetType) { + targetPost => postCategories, + targetFacility => facilityCategories, + _ => categories, + }; /// 상세설명이 필수인 '기타'류 사유인지. static bool isEtc(String category) => category.startsWith('기타'); diff --git a/lib/widgets/facility_sheet.dart b/lib/widgets/facility_sheet.dart index 97f7dac..52f611e 100644 --- a/lib/widgets/facility_sheet.dart +++ b/lib/widgets/facility_sheet.dart @@ -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'; /// 시설 상세 콘텐츠(정보 + 후기/사진 + 후기 작성 + 네이버 지도 링크). @@ -137,6 +139,33 @@ class _FacilityDetailContentState extends State { if (ok == true) unawaited(_load()); } + /// 시설 정보 제보(폐업·이전·정보 불일치). + /// + /// 다른 신고와 성격이 다르다 — 누구를 벌하자는 게 아니라 공공데이터가 늦어서 + /// 틀린 정보를 고치는 경로다. 관리자가 확인하면 지도에서 내려간다. + Future _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 _openInNaverMap() async { final f = widget.facility; final name = Uri.encodeComponent(f.name); @@ -369,7 +398,38 @@ class _FacilityDetailContentState extends State { ), ], ), - 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(