이벤트 개발완료
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
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';
|
||||
@@ -38,6 +41,38 @@ class EventService with ChangeNotifier {
|
||||
_apiService.setToken(token);
|
||||
}
|
||||
|
||||
// [팀 생성 기능] 서버 포맷으로 팀 배정 저장
|
||||
// serverTeams 형태 예시:
|
||||
// [
|
||||
// { 'teamNumber': 1, 'handicap': 0, 'members': [ { 'id': <eventParticipantId>, 'order': 1 }, ... ] },
|
||||
// ...
|
||||
// ]
|
||||
Future<bool> createOrUpdateEventTeams(String eventId, List<Map<String, dynamic>> 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;
|
||||
@@ -95,12 +130,19 @@ class EventService with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/detail',
|
||||
data: {'eventId': eventId},
|
||||
// 백엔드 라우트: GET /api/club/events/:id
|
||||
final data = await _apiService.get(
|
||||
'${ApiConfig.clubs}/events/$eventId',
|
||||
queryParameters: {'clubId': _clubId},
|
||||
);
|
||||
|
||||
return Event.fromJson(data['event']);
|
||||
// 서버가 { event: {...} } 또는 이벤트 객체 자체를 반환할 수 있음
|
||||
final dynamic body = data;
|
||||
final Map<String, dynamic> json =
|
||||
body is Map<String, dynamic>
|
||||
? (body.containsKey('event') ? Map<String, dynamic>.from(body['event']) : body)
|
||||
: <String, dynamic>{};
|
||||
return Event.fromJson(json);
|
||||
} catch (e) {
|
||||
throw Exception('이벤트 정보를 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
@@ -141,17 +183,41 @@ class EventService with ChangeNotifier {
|
||||
String eventId,
|
||||
Map<String, dynamic> updates,
|
||||
) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
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<String, dynamic>.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: updates,
|
||||
data: {
|
||||
'id': eventId,
|
||||
'clubId': _clubId,
|
||||
...sanitized,
|
||||
},
|
||||
);
|
||||
|
||||
final updatedEvent = Event.fromJson(data['event']);
|
||||
@@ -183,7 +249,10 @@ class EventService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _apiService.delete('${ApiConfig.clubs}/events/$eventId');
|
||||
await _apiService.deleteWithData(
|
||||
'${ApiConfig.clubs}/events/$eventId',
|
||||
data: { 'clubId': _clubId },
|
||||
);
|
||||
|
||||
// 이벤트 목록에서 삭제
|
||||
_events.removeWhere((event) => event.id == eventId);
|
||||
@@ -199,6 +268,73 @@ class EventService with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트 완전 삭제 (하드 삭제)
|
||||
Future<bool> 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<bool> 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<List<Participant>> fetchEventParticipants(String eventId) async {
|
||||
@@ -284,8 +420,22 @@ class EventService with ChangeNotifier {
|
||||
String participantId,
|
||||
Map<String, dynamic> updates,
|
||||
) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
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;
|
||||
@@ -294,9 +444,37 @@ class EventService with ChangeNotifier {
|
||||
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/$participantId',
|
||||
data: normalized,
|
||||
'${ApiConfig.clubs}/events/$eventId/participants',
|
||||
data: payload,
|
||||
);
|
||||
|
||||
final updatedParticipant = Participant.fromJson(data['participant']);
|
||||
@@ -316,6 +494,10 @@ class EventService with ChangeNotifier {
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
final msg = e.toString();
|
||||
if (msg.contains('404') || msg.contains('Cannot PUT')) {
|
||||
throw Exception('참가자 업데이트 경로가 서버에서 지원되지 않거나 대상이 없습니다(404). 목록 새로고침 후 다시 시도하거나 서버 라우트를 확인해주세요. 상세: $e');
|
||||
}
|
||||
throw Exception('참가자 정보 업데이트에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
@@ -324,18 +506,15 @@ class EventService with ChangeNotifier {
|
||||
Map<String, dynamic> _sanitizeParticipantPayload(Map<String, dynamic> data) {
|
||||
final Map<String, dynamic> out = Map<String, dynamic>.from(data);
|
||||
|
||||
// 상태 표준화: 구버전/철자 변형 호환
|
||||
// 상태 표준화: 구버전/철자 변형 호환 + 서버 Enum 호환 임시 매핑
|
||||
final statusRaw = (out['status']?.toString().trim() ?? '').toLowerCase();
|
||||
if (statusRaw.isNotEmpty) {
|
||||
String status = statusRaw;
|
||||
if (status == 'cancelled') status = 'canceled';
|
||||
// 허용 목록: registered, pending, confirmed, canceled
|
||||
if (![
|
||||
'registered',
|
||||
'pending',
|
||||
'confirmed',
|
||||
'canceled',
|
||||
].contains(status)) {
|
||||
// 서버는 registered를 Enum으로 지원하지 않음 → 요청 전 'pending'으로 보정
|
||||
if (status == 'registered') status = 'pending';
|
||||
// 허용 목록: pending, confirmed, canceled (registered는 위에서 보정)
|
||||
if (!['pending', 'confirmed', 'canceled'].contains(status)) {
|
||||
// 허용 외 값은 안전하게 pending 처리
|
||||
status = 'pending';
|
||||
}
|
||||
@@ -408,20 +587,43 @@ class EventService with ChangeNotifier {
|
||||
// 점수 관리 기능
|
||||
// 이벤트 점수 목록 가져오기
|
||||
Future<List<Score>> fetchEventScores(String eventId) async {
|
||||
if (_token == null || _clubId == null) {
|
||||
throw Exception('인증 또는 클럽 정보가 필요합니다');
|
||||
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 {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/scores',
|
||||
data: {'eventId': eventId},
|
||||
// 서버 라우트: GET /api/club/events/:id/scores
|
||||
// requireFeature 미들웨어가 clubId를 요구하므로 query로 전달
|
||||
final data = await _apiService.get(
|
||||
'${ApiConfig.clubs}/events/$eventId/scores',
|
||||
queryParameters: {'clubId': _clubId},
|
||||
);
|
||||
|
||||
final List<dynamic> scoresData = data['scores'] ?? [];
|
||||
// 서버가 배열 자체를 반환하거나 { scores: [...] } 형태로 반환할 수 있어 모두 처리
|
||||
final List<dynamic> scoresData = (data is List)
|
||||
? data
|
||||
: (data['scores'] ?? []);
|
||||
_scores = scoresData
|
||||
.map((scoreData) => Score.fromJson(scoreData))
|
||||
.toList();
|
||||
@@ -433,6 +635,11 @@ class EventService with ChangeNotifier {
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
final msg = e.toString();
|
||||
// 서버 미들웨어에서 clubId 누락 시 400을 반환할 수 있는데, 초기 진입에서는 조용히 무시하고 빈 목록 반환
|
||||
if (msg.contains('400') && (msg.contains('클럽 정보가 필요합니다') || msg.contains('club'))) {
|
||||
return [];
|
||||
}
|
||||
throw Exception('점수 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
@@ -447,9 +654,18 @@ class EventService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// 빈 문자열을 명시적 null로 변환해 서버에서 정확히 처리되도록 함
|
||||
final sanitized = Map<String, dynamic>.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: scoreData,
|
||||
data: {
|
||||
'clubId': _clubId,
|
||||
...sanitized,
|
||||
},
|
||||
);
|
||||
|
||||
final newScore = Score.fromJson(data['score']);
|
||||
@@ -481,9 +697,45 @@ class EventService with ChangeNotifier {
|
||||
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<String, dynamic>.from(updates)..updateAll((key, value) {
|
||||
if (value is String && value.trim().isEmpty) return null;
|
||||
return value;
|
||||
});
|
||||
|
||||
// 서버 호환: totalScore -> score 매핑, 필수 필드 포함
|
||||
final Map<String, dynamic> 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: updates,
|
||||
data: payload,
|
||||
);
|
||||
|
||||
final updatedScore = Score.fromJson(data['score']);
|
||||
@@ -541,17 +793,73 @@ class EventService with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.events}/teams',
|
||||
data: {'eventId': eventId},
|
||||
// 백엔드 라우트: GET /api/club/events/:id/teams
|
||||
final data = await _apiService.get(
|
||||
'${ApiConfig.clubs}/events/$eventId/teams',
|
||||
queryParameters: { 'clubId': _clubId },
|
||||
);
|
||||
|
||||
final List<dynamic> teamsData = data['teams'] ?? [];
|
||||
_teams = teamsData.map((teamData) => Team.fromJson(teamData)).toList();
|
||||
final List<dynamic> 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<String, dynamic>);
|
||||
}
|
||||
|
||||
// 대체 경로: members 배열로 내려오는 경우 각 요소에서 memberId 또는 participantId를 memberId로 매핑
|
||||
if (teamData is Map && teamData['members'] is List) {
|
||||
final List members = teamData['members'] as List;
|
||||
final List<String> 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<String, dynamic>.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<dynamic> jsonList = jsonDecode(raw);
|
||||
_teams = jsonList.map((t) => Team.fromJson(t)).toList();
|
||||
notifyListeners();
|
||||
return _teams;
|
||||
}
|
||||
} catch (_) {}
|
||||
throw Exception('팀 목록을 불러오는데 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
@@ -563,12 +871,13 @@ class EventService with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
final data = await _apiService.post(
|
||||
'${ApiConfig.events}/teams',
|
||||
data: {'eventId': eventId},
|
||||
// 백엔드 라우트: GET /api/club/events/:id/teams
|
||||
final data = await _apiService.get(
|
||||
'${ApiConfig.clubs}/events/$eventId/teams',
|
||||
queryParameters: { 'clubId': _clubId },
|
||||
);
|
||||
|
||||
final List<dynamic> teamsData = data['teams'] ?? [];
|
||||
final List<dynamic> teamsData = (data is List) ? data : (data['teams'] ?? []);
|
||||
return teamsData.isNotEmpty;
|
||||
} catch (e) {
|
||||
print('팀 확인 실패: $e');
|
||||
@@ -619,8 +928,11 @@ class EventService with ChangeNotifier {
|
||||
|
||||
try {
|
||||
await _apiService.post(
|
||||
'${ApiConfig.clubs}/events/$eventId/teams/save',
|
||||
data: {'teams': teams.map((team) => team.toJson()).toList()},
|
||||
'${ApiConfig.clubs}/events/$eventId/teams',
|
||||
data: {
|
||||
'clubId': _clubId,
|
||||
'teams': teams.map((team) => team.toJson()).toList(),
|
||||
},
|
||||
);
|
||||
|
||||
_teams = teams;
|
||||
@@ -630,9 +942,21 @@ class EventService with ChangeNotifier {
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
throw Exception('팀 저장에 실패했습니다: $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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,4 +1057,227 @@ class EventService with ChangeNotifier {
|
||||
throw Exception('이벤트 복제에 실패했습니다: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================
|
||||
// 파일 업로드 기반 일괄 생성 플로우 (Vue 호환)
|
||||
// =============================
|
||||
|
||||
// 파일 업로드: /club/events/upload
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>.from(data as Map);
|
||||
// 서버 응답: { message, file: { filename, originalname, ... } }
|
||||
if (map['file'] is Map) {
|
||||
final fm = Map<String, dynamic>.from(map['file'] as Map);
|
||||
final out = <String, dynamic>{
|
||||
'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<String, dynamic>.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<Map<String, dynamic>> parseEventFile({
|
||||
required String filename,
|
||||
String? sheetName,
|
||||
}) async {
|
||||
if (_token == null) {
|
||||
throw Exception('인증 정보가 필요합니다');
|
||||
}
|
||||
try {
|
||||
final payload = <String, dynamic>{
|
||||
'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<String, dynamic>.from(data);
|
||||
if (map['data'] is Map) {
|
||||
return Map<String, dynamic>.from(map['data']);
|
||||
}
|
||||
return map;
|
||||
} catch (e) {
|
||||
throw Exception('파일 미리보기 파싱 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 데이터 저장: /club/events/save-file-data
|
||||
Future<Map<String, dynamic>> saveEventFileData({
|
||||
required Map<String, dynamic> eventData,
|
||||
required List<dynamic> 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<String, dynamic>.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<dynamic> sanitizedParticipants = participants.map((p) {
|
||||
if (p is Map) {
|
||||
final out = Map<String, dynamic>.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<String, dynamic>.from(data);
|
||||
} catch (e) {
|
||||
throw Exception('파일 데이터 저장 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 업로드 파일 삭제: /club/events/delete-file
|
||||
Future<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user