import 'package:flutter/foundation.dart'; import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../config/api_config.dart'; import '../models/event_model.dart'; import '../models/participant_model.dart'; import '../models/score_model.dart'; import '../models/team_model.dart'; import './api_service.dart'; class EventService with ChangeNotifier { List _events = []; Event? _currentEvent; List _participants = []; List _scores = []; List _teams = []; bool _isLoading = false; String? _token; final ApiService _apiService; String? _clubId; // 기본 생성자 EventService() : _apiService = ApiService(); // 테스트용 생성자 EventService.forTest(this._apiService); List get events => [..._events]; Event? get currentEvent => _currentEvent; List get participants => [..._participants]; List get scores => [..._scores]; List get teams => [..._teams]; bool get isLoading => _isLoading; // 초기화 함수 Future initialize(String token) async { _token = token; _apiService.setToken(token); } // [팀 생성 기능] 서버 포맷으로 팀 배정 저장 // serverTeams 형태 예시: // [ // { 'teamNumber': 1, 'handicap': 0, 'members': [ { 'id': , 'order': 1 }, ... ] }, // ... // ] Future createOrUpdateEventTeams(String eventId, List> serverTeams) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { await _apiService.post( '${ApiConfig.clubs}/events/$eventId/teams', data: { 'clubId': _clubId, 'teams': serverTeams, }, ); _isLoading = false; notifyListeners(); return true; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('팀 배정 저장에 실패했습니다: $e'); } } // 클럽 ID 설정 void setClubId(String clubId) { _clubId = clubId; notifyListeners(); } // 클럽의 모든 이벤트 가져오기 Future fetchClubEvents() async { print('fetchClubEvents 호출됨: token=$_token'); _isLoading = true; notifyListeners(); try { // 클럽 ID 가져오기 final prefs = await SharedPreferences.getInstance(); final clubId = prefs.getString(ApiConfig.clubIdKey); if (clubId == null) { throw Exception('선택된 클럽이 없습니다'); } final data = await _apiService.post( '${ApiConfig.clubs}/events', data: {'clubId': clubId}, ); // 응답 데이터가 리스트 형태로 직접 왔는지 확인 if (data is List) { final List eventsData = data; _events = eventsData .map((eventData) => Event.fromJson(eventData)) .toList(); } else { // 응답 데이터가 객체 형태로 왔을 경우 events 키를 통해 접근 final List eventsData = data['events'] ?? []; _events = eventsData .map((eventData) => Event.fromJson(eventData)) .toList(); } _isLoading = false; notifyListeners(); } catch (e) { _isLoading = false; notifyListeners(); throw Exception('이벤트 목록을 불러오는데 실패했습니다: $e'); } } // ID로 이벤트 정보 가져오기 Future fetchEventById(String eventId) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } try { // 백엔드 라우트: GET /api/club/events/:id final data = await _apiService.get( '${ApiConfig.clubs}/events/$eventId', queryParameters: {'clubId': _clubId}, ); // 서버가 { event: {...} } 또는 이벤트 객체 자체를 반환할 수 있음 final dynamic body = data; final Map json = body is Map ? (body.containsKey('event') ? Map.from(body['event']) : body) : {}; return Event.fromJson(json); } catch (e) { throw Exception('이벤트 정보를 불러오는데 실패했습니다: $e'); } } // 이벤트 생성 Future createEvent(Map eventData) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { final data = await _apiService.post( '${ApiConfig.clubs}/events', data: eventData, ); final newEvent = Event.fromJson(data['event']); _events.add(newEvent); _isLoading = false; notifyListeners(); return newEvent; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('이벤트 생성에 실패했습니다: $e'); } } // 이벤트 정보 업데이트 Future updateEvent( String eventId, Map updates, ) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } // 업데이트 전에 clubId가 비어 있으면 SharedPreferences에서 보강 시도 if (_clubId == null) { try { final prefs = await SharedPreferences.getInstance(); final cid = prefs.getString(ApiConfig.clubIdKey); if (cid != null) { _clubId = cid; } } catch (_) {} } if (_clubId == null) { throw Exception('클럽 정보가 아직 설정되지 않았습니다. 클럽 선택 후 다시 시도해 주세요.'); } _isLoading = true; notifyListeners(); try { // 빈 문자열을 명시적 null로 변환하여 서버에서 정확히 해석되도록 함 final sanitized = Map.from(updates)..updateAll((key, value) { if (value is String && value.trim().isEmpty) return null; return value; }); final data = await _apiService.put( '${ApiConfig.clubs}/events/$eventId', data: { 'id': eventId, 'clubId': _clubId, ...sanitized, }, ); final updatedEvent = Event.fromJson(data['event']); // 이벤트 목록 업데이트 final index = _events.indexWhere((event) => event.id == eventId); if (index >= 0) { _events[index] = updatedEvent; } _isLoading = false; notifyListeners(); return updatedEvent; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('이벤트 정보 업데이트에 실패했습니다: $e'); } } // 이벤트 삭제 Future deleteEvent(String eventId) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { await _apiService.deleteWithData( '${ApiConfig.clubs}/events/$eventId', data: { 'clubId': _clubId }, ); // 이벤트 목록에서 삭제 _events.removeWhere((event) => event.id == eventId); _isLoading = false; notifyListeners(); return true; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('이벤트 삭제에 실패했습니다: $e'); } } // 이벤트 완전 삭제 (하드 삭제) Future deleteEventPermanently(String eventId) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { await _apiService.deleteWithData( '${ApiConfig.clubs}/events/$eventId/permanent', data: { 'clubId': _clubId }, ); // 이벤트 목록에서 제거 _events.removeWhere((event) => event.id == eventId); _isLoading = false; notifyListeners(); return true; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('이벤트 완전 삭제에 실패했습니다: $e'); } } // 임포트 저장 롤백 Future rollbackEventImport({ required String eventId, String? clubId, bool includeGuests = false, }) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } // clubId 확보 String? cid = clubId ?? _clubId; if (cid == null) { try { final prefs = await SharedPreferences.getInstance(); cid = prefs.getString(ApiConfig.clubIdKey); } catch (_) {} } if (cid == null) { throw Exception('클럽 정보가 필요합니다'); } try { await _apiService.post( '${ApiConfig.clubs}${ApiConfig.events}/rollback-import', data: { 'eventId': int.tryParse(eventId) ?? eventId, 'clubId': int.tryParse(cid) ?? cid, 'includeGuests': includeGuests, }, ); // 로컬 캐시 반영 _events.removeWhere((e) => e.id == eventId); notifyListeners(); return true; } catch (e) { throw Exception('임포트 롤백에 실패했습니다: $e'); } } // 참가자 관리 기능 // 이벤트 참가자 목록 가져오기 Future> fetchEventParticipants(String eventId) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { // userId는 일부 라우트에서 필수이므로 prefs에서 읽어 포함 final prefs = await SharedPreferences.getInstance(); final userId = prefs.getString(ApiConfig.userIdKey); final payload = { 'clubId': _clubId, if (userId != null) 'userId': userId, 'eventId': eventId, }; final data = await _apiService.post( '${ApiConfig.clubs}/events/$eventId/participants/list', data: payload, ); // 응답이 리스트이거나 객체 내 participants 키로 올 수 있어 모두 처리 final List participantsData = (data is List) ? data : (data['participants'] ?? []); _participants = participantsData .map((participantData) => Participant.fromJson(participantData)) .toList(); _isLoading = false; notifyListeners(); return _participants; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('참가자 목록을 불러오는데 실패했습니다: $e'); } } // 참가자 추가 Future addParticipant( String eventId, Map participantData, ) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { // 참가자 페이로드 정규화: 상태/결제 상태 표준화 및 빈 문자열 null 처리 final normalized = _sanitizeParticipantPayload(participantData); final payload = {'clubId': _clubId, ...normalized}; final data = await _apiService.post( '${ApiConfig.clubs}/events/$eventId/participants', data: payload, ); final newParticipant = Participant.fromJson(data['participant']); _participants.add(newParticipant); _isLoading = false; notifyListeners(); return newParticipant; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('참가자 추가에 실패했습니다: $e'); } } // 참가자 정보 업데이트 Future updateParticipant( String eventId, String participantId, Map updates, ) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } // 업데이트 전에 clubId가 비어 있으면 SharedPreferences에서 보강 시도 if (_clubId == null) { try { final prefs = await SharedPreferences.getInstance(); final cid = prefs.getString(ApiConfig.clubIdKey); if (cid != null) { _clubId = cid; } } catch (_) {} } if (_clubId == null) { throw Exception('클럽 정보가 아직 설정되지 않았습니다. 클럽 선택 후 다시 시도해 주세요.'); } _isLoading = true; notifyListeners(); try { // 업데이트 페이로드도 동일한 규칙으로 정규화 final normalized = _sanitizeParticipantPayload(updates); // 서버는 update 시 memberId가 필요함 → 로컬 캐시에서 찾아 보강 String? memberId = normalized['memberId']?.toString(); if (memberId == null || memberId.isEmpty) { final existing = _participants.firstWhere( (p) => p.id == participantId, orElse: () => Participant( id: participantId, eventId: eventId, memberId: '', status: 'pending', isPaid: false, ), ); if (existing.memberId.isNotEmpty) { memberId = existing.memberId; } } if (memberId == null || memberId.isEmpty) { throw Exception('참가자 수정에 필요한 memberId를 찾을 수 없습니다. 목록 새로고침 후 다시 시도해 주세요.'); } final payload = { 'id': participantId, 'clubId': _clubId, 'memberId': memberId, ...normalized, }; final data = await _apiService.put( '${ApiConfig.clubs}/events/$eventId/participants', data: payload, ); final updatedParticipant = Participant.fromJson(data['participant']); // 참가자 목록 업데이트 final index = _participants.indexWhere( (participant) => participant.id == participantId, ); if (index >= 0) { _participants[index] = updatedParticipant; } _isLoading = false; notifyListeners(); return updatedParticipant; } catch (e) { _isLoading = false; notifyListeners(); final msg = e.toString(); if (msg.contains('404') || msg.contains('Cannot PUT')) { throw Exception('참가자 업데이트 경로가 서버에서 지원되지 않거나 대상이 없습니다(404). 목록 새로고침 후 다시 시도하거나 서버 라우트를 확인해주세요. 상세: $e'); } throw Exception('참가자 정보 업데이트에 실패했습니다: $e'); } } // 참가자 페이로드 정규화 헬퍼 Map _sanitizeParticipantPayload(Map data) { final Map out = Map.from(data); // 상태 표준화: 구버전/철자 변형 호환 + 서버 Enum 호환 임시 매핑 final statusRaw = (out['status']?.toString().trim() ?? '').toLowerCase(); if (statusRaw.isNotEmpty) { String status = statusRaw; if (status == 'cancelled') status = 'canceled'; // 서버는 registered를 Enum으로 지원하지 않음 → 요청 전 'pending'으로 보정 if (status == 'registered') status = 'pending'; // 허용 목록: pending, confirmed, canceled (registered는 위에서 보정) if (!['pending', 'confirmed', 'canceled'].contains(status)) { // 허용 외 값은 안전하게 pending 처리 status = 'pending'; } out['status'] = status; } // 결제 상태 표준화: isPaid -> paymentStatus 변환 및 강제 if (out.containsKey('isPaid') && (out['paymentStatus'] == null || (out['paymentStatus'].toString().isEmpty))) { final isPaidVal = out['isPaid'] == true; out['paymentStatus'] = isPaidVal ? 'paid' : 'unpaid'; } final payRaw = (out['paymentStatus']?.toString().trim() ?? '') .toLowerCase(); if (payRaw.isNotEmpty) { out['paymentStatus'] = (payRaw == 'paid') ? 'paid' : 'unpaid'; } // 서버 스키마에 맞추기 위해 isPaid 키는 제거 (서버는 paymentStatus만 사용) out.remove('isPaid'); // 빈 문자열을 명시적 null로 변환 (이름/이메일/전화/메모 등) out.updateAll((key, value) { if (value is String && value.trim().isEmpty) return null; return value; }); return out; } // 참가자 삭제 Future removeParticipant(String eventId, String participantId) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { await _apiService.delete( '${ApiConfig.clubs}/events/$eventId/participants/$participantId', ); // 참가자 목록에서 삭제 _participants.removeWhere( (participant) => participant.id == participantId, ); _isLoading = false; notifyListeners(); return true; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('참가자 삭제에 실패했습니다: $e'); } } // 참가자 상태 업데이트 Future updateParticipantStatus( String eventId, String participantId, String status, ) async { return updateParticipant(eventId, participantId, {'status': status}); } // 점수 관리 기능 // 이벤트 점수 목록 가져오기 Future> fetchEventScores(String eventId) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } // 초기 진입 시점에 _clubId가 아직 설정되지 않았을 수 있으므로, 우선 SharedPreferences에서 시도 if (_clubId == null) { try { final prefs = await SharedPreferences.getInstance(); final cid = prefs.getString(ApiConfig.clubIdKey); if (cid != null) { _clubId = cid; } } catch (_) {} } // 그래도 없으면 점수는 조용히 빈 목록으로 처리하여 첫 진입 오류를 방지 if (_clubId == null) { _isLoading = false; notifyListeners(); return []; } _isLoading = true; notifyListeners(); try { // 서버 라우트: GET /api/club/events/:id/scores // requireFeature 미들웨어가 clubId를 요구하므로 query로 전달 final data = await _apiService.get( '${ApiConfig.clubs}/events/$eventId/scores', queryParameters: {'clubId': _clubId}, ); // 서버가 배열 자체를 반환하거나 { scores: [...] } 형태로 반환할 수 있어 모두 처리 final List scoresData = (data is List) ? data : (data['scores'] ?? []); _scores = scoresData .map((scoreData) => Score.fromJson(scoreData)) .toList(); _isLoading = false; notifyListeners(); return _scores; } catch (e) { _isLoading = false; notifyListeners(); final msg = e.toString(); // 서버 미들웨어에서 clubId 누락 시 400을 반환할 수 있는데, 초기 진입에서는 조용히 무시하고 빈 목록 반환 if (msg.contains('400') && (msg.contains('클럽 정보가 필요합니다') || msg.contains('club'))) { return []; } throw Exception('점수 목록을 불러오는데 실패했습니다: $e'); } } // 점수 추가 Future addScore(String eventId, Map scoreData) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { // 빈 문자열을 명시적 null로 변환해 서버에서 정확히 처리되도록 함 final sanitized = Map.from(scoreData)..updateAll((key, value) { if (value is String && value.trim().isEmpty) return null; return value; }); final data = await _apiService.post( '${ApiConfig.clubs}/events/$eventId/scores', data: { 'clubId': _clubId, ...sanitized, }, ); final newScore = Score.fromJson(data['score']); _scores.add(newScore); _isLoading = false; notifyListeners(); return newScore; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('점수 추가에 실패했습니다: $e'); } } // 점수 업데이트 Future updateScore( String eventId, String scoreId, Map updates, ) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { // 캐시에서 대상 점수 찾아 gameNumber 확보 final Score current = _scores.firstWhere( (s) => s.id == scoreId, orElse: () => Score( id: scoreId, memberId: '', eventId: eventId, clubId: _clubId ?? '', frames: const [], totalScore: 0, date: DateTime.now(), ), ); // 업데이트 시에도 빈 문자열을 null로 변환 final sanitized = Map.from(updates)..updateAll((key, value) { if (value is String && value.trim().isEmpty) return null; return value; }); // 서버 호환: totalScore -> score 매핑, 필수 필드 포함 final Map payload = { 'clubId': _clubId, 'eventId': eventId, 'scoreId': scoreId, 'gameNumber': sanitized['gameNumber'] ?? current.gameNumber, 'score': sanitized['score'] ?? sanitized['totalScore'], // 날짜 보존: 서버가 업데이트 시 현재 시각으로 덮어쓰지 않도록 기존 날짜 전송 'date': (sanitized['date'] ?? current.date.toIso8601String()), // 참고: 일부 라우트는 totalScore도 허용할 수 있어 함께 전달(서버가 무시해도 무해) if (sanitized.containsKey('totalScore')) 'totalScore': sanitized['totalScore'], if (sanitized.containsKey('handicap')) 'handicap': sanitized['handicap'], if (sanitized.containsKey('notes')) 'notes': sanitized['notes'], if (sanitized.containsKey('frames')) 'frames': sanitized['frames'], }..removeWhere((key, value) => value == null); final data = await _apiService.put( '${ApiConfig.clubs}/events/$eventId/scores/$scoreId', data: payload, ); final updatedScore = Score.fromJson(data['score']); // 점수 목록 업데이트 final index = _scores.indexWhere((score) => score.id == scoreId); if (index >= 0) { _scores[index] = updatedScore; } _isLoading = false; notifyListeners(); return updatedScore; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('점수 업데이트에 실패했습니다: $e'); } } // 점수 삭제 Future deleteScore(String eventId, String scoreId) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { await _apiService.delete( '${ApiConfig.clubs}/events/$eventId/scores/$scoreId', ); // 점수 목록에서 삭제 _scores.removeWhere((score) => score.id == scoreId); _isLoading = false; notifyListeners(); return true; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('점수 삭제에 실패했습니다: $e'); } } // 팀 관리 기능 // 이벤트 팀 목록 가져오기 Future> fetchEventTeams(String eventId) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } try { // 백엔드 라우트: GET /api/club/events/:id/teams final data = await _apiService.get( '${ApiConfig.clubs}/events/$eventId/teams', queryParameters: { 'clubId': _clubId }, ); final List teamsData = (data is List) ? data : (data['teams'] ?? []); // 서버가 'memberIds' 또는 'members' 배열(참가자/멤버 참조)로 반환할 수 있어 모두 처리 _teams = teamsData.map((teamData) { // 표준 경로: Team.fromJson에서 memberIds가 바로 존재 final hasMemberIds = teamData is Map && teamData['memberIds'] != null; if (hasMemberIds) { return Team.fromJson(teamData as Map); } // 대체 경로: members 배열로 내려오는 경우 각 요소에서 memberId 또는 participantId를 memberId로 매핑 if (teamData is Map && teamData['members'] is List) { final List members = teamData['members'] as List; final List memberIds = []; for (final m in members) { if (m is Map) { // 우선 memberId가 있으면 사용 final mid = m['memberId']?.toString(); if (mid != null && mid.isNotEmpty) { memberIds.add(mid); continue; } // 없으면 participantId(id)로 참가자 캐시에서 memberId를 조회 final pid = (m['participantId'] ?? m['id'])?.toString(); if (pid != null && pid.isNotEmpty) { try { final p = _participants.firstWhere((pp) => pp.id == pid); if (p.memberId.isNotEmpty) memberIds.add(p.memberId); } catch (_) {} } } } return Team( id: teamData['id']?.toString() ?? '', eventId: teamData['eventId']?.toString() ?? eventId, name: teamData['name']?.toString() ?? '', memberIds: memberIds, description: teamData['description']?.toString(), createdAt: teamData['createdAt'] != null ? DateTime.tryParse(teamData['createdAt'].toString()) : null, updatedAt: teamData['updatedAt'] != null ? DateTime.tryParse(teamData['updatedAt'].toString()) : null, ); } // 알 수 없는 포맷은 방어적으로 기본 파싱 return Team.fromJson(Map.from(teamData as Map)); }).toList(); notifyListeners(); return _teams; } catch (e) { // 서버 미지원/404 시 로컬 저장소에서 로드 시도 try { final prefs = await SharedPreferences.getInstance(); final key = 'local_teams_' + eventId; final raw = prefs.getString(key); if (raw != null && raw.isNotEmpty) { final List jsonList = jsonDecode(raw); _teams = jsonList.map((t) => Team.fromJson(t)).toList(); notifyListeners(); return _teams; } } catch (_) {} throw Exception('팀 목록을 불러오는데 실패했습니다: $e'); } } // 이벤트에 팀이 있는지 확인 (간략 조회) Future hasEventTeams(String eventId) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } try { // 백엔드 라우트: GET /api/club/events/:id/teams final data = await _apiService.get( '${ApiConfig.clubs}/events/$eventId/teams', queryParameters: { 'clubId': _clubId }, ); final List teamsData = (data is List) ? data : (data['teams'] ?? []); return teamsData.isNotEmpty; } catch (e) { print('팀 확인 실패: $e'); return false; } } // 팀 생성 Future> generateTeams( String eventId, Map teamConfig, ) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { final data = await _apiService.post( '${ApiConfig.clubs}/events/$eventId/teams/generate', data: teamConfig, ); final List teamsData = data['teams'] ?? []; _teams = teamsData.map((teamData) => Team.fromJson(teamData)).toList(); _isLoading = false; notifyListeners(); return _teams; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('팀 생성에 실패했습니다: $e'); } } // 팀 저장 Future saveTeams(String eventId, List teams) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { await _apiService.post( '${ApiConfig.clubs}/events/$eventId/teams', data: { 'clubId': _clubId, 'teams': teams.map((team) => team.toJson()).toList(), }, ); _teams = teams; _isLoading = false; notifyListeners(); return true; } catch (e) { // 서버 미지원/404 시 로컬로 저장 try { final prefs = await SharedPreferences.getInstance(); final key = 'local_teams_' + eventId; final raw = jsonEncode(teams.map((t) => t.toJson()).toList()); await prefs.setString(key, raw); _teams = teams; _isLoading = false; notifyListeners(); return true; } catch (e2) { _isLoading = false; notifyListeners(); throw Exception('팀 저장에 실패했습니다(로컬 폴백도 실패): $e2'); } } } // 파일에서 이벤트 일괄 생성 Future> createEventsFromFile( List> eventsData, ) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { final List createdEvents = []; // 각 이벤트 데이터에 클럽 ID 추가 for (var eventData in eventsData) { eventData['clubId'] = _clubId; // null 값 처리 - 빈 문자열을 명시적 null로 변환 eventData.forEach((key, value) { if (value is String && value.isEmpty) { eventData[key] = null; } }); // 이벤트 생성 API 호출 final data = await _apiService.post( '${ApiConfig.clubs}/events', data: eventData, ); final newEvent = Event.fromJson(data['event']); createdEvents.add(newEvent); _events.add(newEvent); } _isLoading = false; notifyListeners(); return createdEvents; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('파일에서 이벤트 생성에 실패했습니다: $e'); } } // 이벤트 복제 Future cloneEvent(String eventId, {String? newName}) async { if (_token == null || _clubId == null) { throw Exception('인증 또는 클럽 정보가 필요합니다'); } _isLoading = true; notifyListeners(); try { // 원본 이벤트 정보 가져오기 final originalEvent = await fetchEventById(eventId); // 복제할 이벤트 데이터 준비 final Map cloneData = { 'clubId': _clubId, 'title': newName ?? '${originalEvent.title} (복사본)', 'description': originalEvent.description, 'location': originalEvent.location, 'type': originalEvent.type, 'status': 'draft', // 복제된 이벤트는 초안 상태로 시작 'startDate': originalEvent.startDate.toIso8601String(), 'endDate': originalEvent.endDate?.toIso8601String(), 'maxParticipants': originalEvent.maxParticipants, 'participantFee': originalEvent.participantFee, 'gameCount': originalEvent.gameCount, 'publicHash': originalEvent.publicHash, 'accessPassword': originalEvent.accessPassword, 'isActive': true, }; // 이벤트 생성 API 호출 final data = await _apiService.post( '${ApiConfig.clubs}/events', data: cloneData, ); final newEvent = Event.fromJson(data['event']); _events.add(newEvent); _isLoading = false; notifyListeners(); return newEvent; } catch (e) { _isLoading = false; notifyListeners(); throw Exception('이벤트 복제에 실패했습니다: $e'); } } // ============================= // 파일 업로드 기반 일괄 생성 플로우 (Vue 호환) // ============================= // 파일 업로드: /club/events/upload Future> uploadEventFile(File file) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } try { // clubId 보강: 아직 설정 전인 경우 SharedPreferences에서 로드 if (_clubId == null) { try { final prefs = await SharedPreferences.getInstance(); final cid = prefs.getString(ApiConfig.clubIdKey); if (cid != null && cid.isNotEmpty) { _clubId = cid; } } catch (_) {} } if (_clubId == null || _clubId!.isEmpty) { throw Exception('클럽 정보가 필요합니다. 클럽을 먼저 선택해 주세요.'); } final fileName = file.path.split('/').last; final formData = FormData.fromMap({ 'file': await MultipartFile.fromFile(file.path, filename: fileName), 'clubId': _clubId, }); final data = await _apiService.post( '${ApiConfig.clubs}${ApiConfig.events}/upload?clubId=${Uri.encodeComponent(_clubId!)}', data: formData, ); if (data is Map) { final map = Map.from(data as Map); // 서버 응답: { message, file: { filename, originalname, ... } } if (map['file'] is Map) { final fm = Map.from(map['file'] as Map); final out = { 'filename': fm['filename']?.toString(), }; if (fm.containsKey('originalname')) { final orig = fm['originalname']?.toString(); out['originalname'] = orig; // URL 안전 인코딩 버전 (다운로드 링크 등에 사용) if (orig != null) { out['originalnameEncoded'] = Uri.encodeComponent(orig); // 유니코드 이스케이프 버전 (로그/표시 안전) out['originalnameUnicode'] = _toUnicodeEscape(orig); } } if (fm.containsKey('mimetype')) out['mimetype'] = fm['mimetype']; if (fm.containsKey('size')) out['size'] = fm['size']; if (fm.containsKey('path')) out['path'] = fm['path']; if (fm.containsKey('clubId')) out['clubId'] = fm['clubId']; return out; } // 혹시 서버가 평면으로 줄 경우도 방어 if (map.containsKey('filename')) return Map.from(map); } return {'filename': data.toString()}; } catch (e) { throw Exception('파일 업로드 실패: $e'); } } // 비 ASCII 문자들을 \uXXXX 형태로 이스케이프 String _toUnicodeEscape(String input) { final StringBuffer sb = StringBuffer(); for (final int codeUnit in input.codeUnits) { if (codeUnit >= 0x20 && codeUnit <= 0x7E) { sb.writeCharCode(codeUnit); } else { sb.write('\\u'); sb.write(codeUnit.toRadixString(16).padLeft(4, '0')); } } return sb.toString(); } // 파일 파싱 미리보기: /club/events/parse-preview Future> parseEventFile({ required String filename, String? sheetName, }) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } try { final payload = { 'filename': filename, if (_clubId != null) 'clubId': _clubId, if (sheetName != null) 'sheetName': sheetName, }; final data = await _apiService.post( '${ApiConfig.clubs}${ApiConfig.events}/parse-preview', data: payload, ); // 서버는 { message, data: { participants: [...], gameCount, inferredEventInfo, sheets? } } 형태로 응답 // UI는 최상위에서 participants 등을 기대하므로 data 래퍼를 풀어 반환 if (data is! Map) return {'raw': data}; final map = Map.from(data); if (map['data'] is Map) { return Map.from(map['data']); } return map; } catch (e) { throw Exception('파일 미리보기 파싱 실패: $e'); } } // 파일 데이터 저장: /club/events/save-file-data Future> saveEventFileData({ required Map eventData, required List participants, }) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } try { // 보장: clubId 확보 (메모리에 없으면 SharedPreferences에서 로드) if (_clubId == null) { try { final prefs = await SharedPreferences.getInstance(); final cid = prefs.getString(ApiConfig.clubIdKey); if (cid != null && cid.isNotEmpty) { _clubId = cid; } } catch (_) {} } // clubId 보강 및 날짜/빈 문자열 정리 final payloadEvent = Map.from(eventData); payloadEvent['clubId'] = _clubId ?? payloadEvent['clubId']; payloadEvent.updateAll((key, value) { if (value is DateTime) return value.toIso8601String(); if (value is String && value.trim().isEmpty) return null; return value; }); // 서버 ENUM 호환을 위한 상태/유형 정규화 String? rawStatus = payloadEvent['status']?.toString(); if (rawStatus != null && rawStatus.isNotEmpty) { final s = rawStatus.toLowerCase(); if (['active', '활성'].contains(s)) payloadEvent['status'] = 'active'; else if (['ready', '대기', '준비', 'draft'].contains(s)) payloadEvent['status'] = 'ready'; else if (['completed', '완료', 'done', 'finished'].contains(s)) payloadEvent['status'] = 'completed'; else if (['canceled', 'cancelled', '취소'].contains(s)) payloadEvent['status'] = 'canceled'; else if (['deleted', '삭제'].contains(s)) payloadEvent['status'] = 'deleted'; else payloadEvent['status'] = 'ready'; } String? rawType = payloadEvent['eventType']?.toString(); if (rawType != null && rawType.isNotEmpty) { final t = rawType.toLowerCase(); if (['regular','일반'].contains(t)) payloadEvent['eventType'] = 'regular'; else if (['lightning','번개'].contains(t)) payloadEvent['eventType'] = 'lightning'; else if (['practice','연습'].contains(t)) payloadEvent['eventType'] = 'practice'; else if (['exchange','교류전'].contains(t)) payloadEvent['eventType'] = 'exchange'; else if (['event','행사'].contains(t)) payloadEvent['eventType'] = 'event'; else payloadEvent['eventType'] = 'other'; } // 참가자 페이로드 정리: teamNumber를 숫자로 정규화, handicap 숫자 보정 final List sanitizedParticipants = participants.map((p) { if (p is Map) { final out = Map.from(p); // teamNumber: '2팀' 같은 문자열에서 숫자만 추출 final tn = out['teamNumber']; if (tn != null) { final m = RegExp(r"\d+").firstMatch(tn.toString()); out['teamNumber'] = m != null ? int.tryParse(m.group(0)!) : null; } // handicap: 숫자 보정 if (out.containsKey('handicap')) { final hv = out['handicap']; out['handicap'] = (hv is num) ? hv.toInt() : int.tryParse(hv?.toString() ?? '') ?? 0; } return out; } return p; }).toList(); final data = await _apiService.post( '${ApiConfig.clubs}${ApiConfig.events}/save-file-data', data: { 'eventData': payloadEvent, 'participants': sanitizedParticipants, if (_clubId != null) 'clubId': _clubId, }, ); if (data is! Map) return {'raw': data}; return Map.from(data); } catch (e) { throw Exception('파일 데이터 저장 실패: $e'); } } // 업로드 파일 삭제: /club/events/delete-file Future deleteUploadedFile(String filename) async { if (_token == null) { throw Exception('인증 정보가 필요합니다'); } try { await _apiService.post( '${ApiConfig.clubs}${ApiConfig.events}/delete-file', data: { 'filename': filename, if (_clubId != null) 'clubId': _clubId, }, ); return true; } catch (e) { // 실패해도 치명적이지 않음 if (kDebugMode) { print('임시 파일 삭제 실패: $e'); } return false; } } }