이벤트 개발완료

This commit is contained in:
2025-10-23 22:04:03 +09:00
parent ce1aee67d6
commit cd0e139b5e
48 changed files with 7855 additions and 3459 deletions
+93 -1
View File
@@ -5,6 +5,97 @@ const Map<String, String> roleOptions = {
'user': '일반 사용자',
};
// -------------------------------
// Strong enums for client usage
// -------------------------------
/// 참가자 상태 (서버 enum과 1:1 매칭)
enum ParticipantStatus { pending, confirmed, canceled, attended }
extension ParticipantStatusX on ParticipantStatus {
String get apiValue {
switch (this) {
case ParticipantStatus.pending:
return 'pending';
case ParticipantStatus.confirmed:
return 'confirmed';
case ParticipantStatus.canceled:
return 'canceled';
case ParticipantStatus.attended:
return 'attended';
}
}
String get labelKo {
switch (this) {
case ParticipantStatus.pending:
return '대기';
case ParticipantStatus.confirmed:
return '확인됨';
case ParticipantStatus.canceled:
return '취소됨';
case ParticipantStatus.attended:
return '참석';
}
}
static ParticipantStatus? from(String? value) {
switch ((value ?? '').toLowerCase()) {
case 'pending':
return ParticipantStatus.pending;
case 'confirmed':
return ParticipantStatus.confirmed;
case 'canceled':
return ParticipantStatus.canceled;
case 'attended':
return ParticipantStatus.attended;
default:
return null;
}
}
}
/// 결제 상태 (서버 enum과 1:1 매칭)
enum PaymentStatus { unpaid, paid }
extension PaymentStatusX on PaymentStatus {
String get apiValue {
switch (this) {
case PaymentStatus.unpaid:
return 'unpaid';
case PaymentStatus.paid:
return 'paid';
}
}
String get labelKo {
switch (this) {
case PaymentStatus.unpaid:
return '미납';
case PaymentStatus.paid:
return '납부';
}
}
static PaymentStatus? from(String? value) {
switch ((value ?? '').toLowerCase()) {
case 'unpaid':
return PaymentStatus.unpaid;
case 'paid':
return PaymentStatus.paid;
default:
return null;
}
}
}
// Backward-compatible helpers for non-enum callsites
String toKoreanParticipantStatus(String? value) =>
(ParticipantStatusX.from(value) ?? ParticipantStatus.pending).labelKo;
String toKoreanPaymentStatus(String? value) =>
(PaymentStatusX.from(value) ?? PaymentStatus.unpaid).labelKo;
// 성별 옵션
const Map<String, String> genderOptions = {
'male': '남성',
@@ -48,9 +139,10 @@ const Map<String, String> eventStatusOptions = {
const Map<String, String> eventTypeOptions = {
'regular': '정기전',
'exchange': '교류전',
'tournament': '토너먼트',
'tournament': '대회',
'lightning': '번개',
'friendly': '친선전',
'event': '이벤트',
'other': '기타',
};
+36 -14
View File
@@ -23,11 +23,39 @@ class EventCsvParser {
};
}
final headers = _parseCsvLine(lines.first)
.map((e) => e.trim().toLowerCase())
final headersRaw = _parseCsvLine(lines.first);
final headers = headersRaw
.map((e) => e.trim())
.toList();
if (!headers.contains('title') || !headers.contains('startdate')) {
// 한글 헤더를 영문 키로 정규화하기 위한 매핑
String _normalizeHeader(String raw) {
final v = raw.trim().toLowerCase();
// 공백 제거 버전도 별도로 준비
final vn = v.replaceAll(' ', '');
// 한국어 -> 영문 매핑
if (v == '제목') return 'title';
if (v == '시작일') return 'startDate';
if (v == '종료일') return 'endDate';
if (v == '설명') return 'description';
if (v == '장소') return 'location';
if (v == '상태') return 'status';
if (v == '유형') return 'type';
if (v == '게임 수' || vn == '게임수') return 'gameCount';
if (v == '참가비') return 'participantFee';
if (v == '신청마감' || v == '신청 마감') return 'registrationDeadline';
if (v == '최대 참가자' || vn == '최대참가자') return 'maxParticipants';
// 기존 영문 키들 정규화
if (vn == 'maxparticipants') return 'maxParticipants';
if (vn == 'startdate') return 'startDate';
if (vn == 'enddate') return 'endDate';
return raw.trim();
}
final normalizedHeaders = headers.map(_normalizeHeader).toList();
if (!normalizedHeaders.contains('title') || !normalizedHeaders.contains('startDate')) {
return {
'processedRows': 0,
'validEvents': <Map<String, dynamic>>[],
@@ -52,26 +80,20 @@ class EventCsvParser {
processedRows++;
final event = <String, dynamic>{};
for (var j = 0; j < headers.length && j < row.length; j++) {
final header = headers[j];
for (var j = 0; j < normalizedHeaders.length && j < row.length; j++) {
final header = normalizedHeaders[j];
var value = row[j].trim();
// 헤더 정규화
var normalizedHeader = header;
if (header == 'maxparticipants') normalizedHeader = 'maxParticipants';
if (header == 'startdate') normalizedHeader = 'startDate';
if (header == 'enddate') normalizedHeader = 'endDate';
if (normalizedHeader == 'startDate' || normalizedHeader == 'endDate') {
if (header == 'startDate' || header == 'endDate') {
// 날짜 문자열은 그대로 전달하고, 화면/서버 측에서 ISO 변환/검증 처리
// 비어있으면 startDate는 필수, endDate는 null
if (normalizedHeader == 'startDate') {
if (header == 'startDate') {
event['startDate'] = value.isEmpty ? DateTime.now().toIso8601String() : value;
} else {
event['endDate'] = value.isEmpty ? null : value;
}
} else {
event[normalizedHeader] = value.isEmpty ? null : value;
event[header] = value.isEmpty ? null : value;
}
}
+40 -1
View File
@@ -310,5 +310,44 @@ class TieBreakerUtil {
return ranks;
}
/// 재사용 가능한: 점수 리스트에 대한 순위 계산 (외부에서 쉽게 호출)
static Map<String, int> computeScoreRanks(
List<Score> scores, {
required bool includeHandicap,
List<TieBreakerOption>? options,
List<Member>? members,
}) {
return calculateRanks(
scores,
includeHandicap,
options: options,
members: members,
);
}
/// 재사용 가능한: 참가자 요약(Map) 리스트에 정렬/순위 부여
/// - summaries: 각 항목은 최소 'total'과 'totalH' 키를 포함해야 함
/// - includeHandicap: 어떤 합계를 정렬 기준으로 사용할지 결정
/// - totalKey/totalHKey: 키명이 다른 경우 오버라이드 가능
static List<Map<String, dynamic>> rankParticipantSummaries(
List<Map<String, dynamic>> summaries, {
required bool includeHandicap,
String totalKey = 'total',
String totalHKey = 'totalH',
}) {
final items = List<Map<String, dynamic>>.of(summaries);
items.sort((a, b) {
final at = includeHandicap ? (a[totalHKey] as int) : (a[totalKey] as int);
final bt = includeHandicap ? (b[totalHKey] as int) : (b[totalKey] as int);
return bt.compareTo(at); // desc
});
for (var i = 0; i < items.length; i++) {
items[i] = {
...items[i],
'rank': i + 1,
};
}
return items;
}
}
+2 -2
View File
@@ -8,8 +8,8 @@ import 'test_utils.dart';
/// URL 관련 유틸리티 기능을 제공하는 클래스
class UrlUtils {
/// 이벤트 공개 URL의 기본 도메인
static const String eventBaseUrl = 'https://bowling.example.com/events/';
/// 이벤트 공개 URL의 기본 도메인 (프로덕션)
static const String eventBaseUrl = 'https://lanebow.com/events/';
/// 공개 URL 해시 생성
///