This commit is contained in:
2025-10-29 01:18:29 +09:00
parent 1047d3089d
commit 7677007b49
6 changed files with 338 additions and 61 deletions
+21
View File
@@ -9,6 +9,27 @@ const averageService = require('../services/averageService');
exports.publicEventEntry = async (req, res) => {
const { publicHash } = req.params;
const { password } = req.body || {};
// 팀 핸디캡 수정
exports.updateTeamHandicap = async (req, res) => {
const { publicHash } = req.params;
const { teamNumber, handicap } = req.body || {};
try {
const event = await Event.findOne({ where: { publicHash } });
if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' });
if (event.status !== 'ready' && event.status !== 'active') return res.status(403).json({ message: '팀 핸디캡 수정이 불가능한 상태입니다.' });
const team = await EventTeam.findOne({ where: { eventId: event.id, teamNumber } });
if (!team) return res.status(404).json({ message: '팀을 찾을 수 없습니다.' });
const hc = Number(handicap) || 0;
await team.update({ handicap: hc });
return res.json({ message: '팀 핸디캡이 저장되었습니다.', data: { teamNumber, handicap: hc } });
} catch (e) {
console.error('팀 핸디캡 수정 오류:', e);
res.status(500).json({ message: '팀 핸디캡 저장 중 오류가 발생했습니다.' });
}
};
const auth = req.headers.authorization;
let event;
try {
+3
View File
@@ -12,4 +12,7 @@ router.post('/:publicHash/guest', publicAuth, publicController.addPublicGuest);
// 공개 점수 수정
router.put('/:publicHash/score', publicAuth, publicController.updatePublicScore);
// 팀 핸디캡 수정
router.put('/:publicHash/team/:teamNumber/handicap', publicAuth, publicController.updateTeamHandicap);
module.exports = router;
+35
View File
@@ -67,3 +67,38 @@ class PublicEvent {
bool get isActive => (status == 'active');
bool get isFinished => (status == 'completed');
}
enum EventStatus { ready, active, completed, canceled, unknown }
enum EventType { regular, tournament, practice, league, unknown }
extension PublicEventEnums on PublicEvent {
EventStatus get statusEnum {
switch ((status ?? '').toLowerCase()) {
case 'ready':
return EventStatus.ready;
case 'active':
return EventStatus.active;
case 'completed':
return EventStatus.completed;
case 'canceled':
return EventStatus.canceled;
default:
return EventStatus.unknown;
}
}
EventType get typeEnum {
switch ((eventType ?? '').toLowerCase()) {
case 'regular':
return EventType.regular;
case 'tournament':
return EventType.tournament;
case 'practice':
return EventType.practice;
case 'league':
return EventType.league;
default:
return EventType.unknown;
}
}
}
+47
View File
@@ -88,3 +88,50 @@ class PublicParticipant {
);
}
}
enum ParticipantStatus { pending, confirmed, canceled, unknown }
enum PaymentStatus { unpaid, paid, refunded, partial, unknown }
enum MemberType { member, guest, staff, unknown }
extension PublicParticipantEnums on PublicParticipant {
ParticipantStatus get statusEnum {
switch ((status ?? '').toLowerCase()) {
case 'pending':
return ParticipantStatus.pending;
case 'confirmed':
return ParticipantStatus.confirmed;
case 'canceled':
return ParticipantStatus.canceled;
default:
return ParticipantStatus.unknown;
}
}
PaymentStatus get paymentEnum {
switch ((paymentStatus ?? '').toLowerCase()) {
case 'paid':
return PaymentStatus.paid;
case 'unpaid':
return PaymentStatus.unpaid;
case 'refunded':
return PaymentStatus.refunded;
case 'partial':
return PaymentStatus.partial;
default:
return PaymentStatus.unknown;
}
}
MemberType get memberTypeEnum {
switch ((memberType ?? '').toLowerCase()) {
case 'member':
return MemberType.member;
case 'guest':
return MemberType.guest;
case 'staff':
return MemberType.staff;
default:
return MemberType.unknown;
}
}
}
@@ -32,6 +32,79 @@ class _PublicEventScreenState extends State<PublicEventScreen> {
final Map<String, Timer> _postTimers = {};
_ResultSort _resultSort = _ResultSort.total;
String _statusLabel(EventStatus s) {
switch (s) {
case EventStatus.ready:
return 'ready';
case EventStatus.active:
return 'active';
case EventStatus.completed:
return 'completed';
case EventStatus.canceled:
return 'canceled';
default:
return 'unknown';
}
}
Color _statusColor(EventStatus s) {
switch (s) {
case EventStatus.ready:
return Colors.blue.shade50;
case EventStatus.active:
return Colors.green.shade50;
case EventStatus.completed:
return Colors.grey.shade200;
case EventStatus.canceled:
return Colors.red.shade50;
default:
return Colors.grey.shade100;
}
}
String _typeLabel(EventType t) {
switch (t) {
case EventType.regular:
return 'regular';
case EventType.tournament:
return 'tournament';
case EventType.practice:
return 'practice';
case EventType.league:
return 'league';
default:
return 'unknown';
}
}
String _participantStatusLabel(ParticipantStatus s) {
switch (s) {
case ParticipantStatus.pending:
return 'pending';
case ParticipantStatus.confirmed:
return 'confirmed';
case ParticipantStatus.canceled:
return 'canceled';
default:
return 'unknown';
}
}
String _paymentLabel(PaymentStatus s) {
switch (s) {
case PaymentStatus.paid:
return 'paid';
case PaymentStatus.unpaid:
return 'unpaid';
case PaymentStatus.refunded:
return 'refunded';
case PaymentStatus.partial:
return 'partial';
default:
return 'unknown';
}
}
@override
void initState() {
super.initState();
@@ -385,10 +458,13 @@ class _PublicEventScreenState extends State<PublicEventScreen> {
spacing: 8,
runSpacing: 8,
children: [
if (event.status != null)
Chip(key: const Key('status_chip'), label: Text(event.status!)),
Chip(
key: const Key('status_chip'),
label: Text(_statusLabel(event.statusEnum)),
backgroundColor: _statusColor(event.statusEnum),
),
if (event.eventType != null && event.eventType!.isNotEmpty)
Chip(key: const Key('type_chip'), label: Text(event.eventType!)),
Chip(key: const Key('type_chip'), label: Text(_typeLabel(event.typeEnum))),
if (event.startDate != null || event.endDate != null)
Chip(
key: const Key('period_chip'),
@@ -402,32 +478,52 @@ class _PublicEventScreenState extends State<PublicEventScreen> {
Chip(key: const Key('deadline_chip'), label: Text('신청마감 ' + event.registrationDeadline!.toLocal().toString().split('.').first)),
if (event.participantFee != null)
Chip(key: const Key('fee_chip'), label: Text('참가비 ₩' + event.participantFee!.toString())),
if (event.location != null && event.location!.isNotEmpty)
Chip(key: const Key('location_chip'), label: Text(event.location!)),
],
),
const SizedBox(height: 8),
],
const SizedBox(height: 8),
const Text('참가자', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_participants')),
Text('' + participants.length.toString() + '', key: const Key('participants_count')),
const SizedBox(height: 8),
Card(
child: participants.isEmpty
? const ListTile(
key: Key('empty_participants'),
title: Text('참가자가 없습니다'),
)
: Column(
children: [
for (final p in participants)
ListTile(
if (teams.isEmpty) ...[
const SizedBox(height: 8),
const Text('참가자', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_participants')),
Text('' + participants.length.toString() + '', key: const Key('participants_count')),
const SizedBox(height: 8),
Card(
child: participants.isEmpty
? const ListTile(
key: Key('empty_participants'),
title: Text('참가자가 없습니다'),
)
: Column(
children: [
for (final p in participants)
ListTile(
key: Key('participant_tile_' + p.participantId),
title: Text(p.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(p.status ?? '-') + ' · avg:' + (p.average?.toString() ?? '-') + ' · hc:' + p.handicap.toString(),
key: Key('participant_info_' + p.participantId),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
Chip(
key: Key('participant_status_' + p.participantId),
label: Text(_participantStatusLabel(p.statusEnum)),
),
if (p.paymentStatus != null && p.paymentStatus!.isNotEmpty)
Chip(
key: Key('participant_payment_' + p.participantId),
label: Text(_paymentLabel(p.paymentEnum)),
),
if (p.memberType != null && p.memberType!.isNotEmpty)
Chip(
key: Key('participant_type_' + p.participantId),
label: Text(p.memberType!),
),
Text('avg:' + (p.average?.toString() ?? '-') + ' · hc:' + p.handicap.toString(), key: Key('participant_info_' + p.participantId)),
],
),
if (canInputScore && event != null)
Padding(
@@ -438,7 +534,7 @@ class _PublicEventScreenState extends State<PublicEventScreen> {
Padding(
padding: const EdgeInsets.only(right: 6),
child: SizedBox(
width: 54,
width: 60,
child: Column(
children: [
Row(
@@ -466,7 +562,7 @@ class _PublicEventScreenState extends State<PublicEventScreen> {
isDense: true,
hintText: '---',
counterText: '',
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 6),
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
),
maxLength: 3,
onChanged: (v) => _onScoreChanged(p, g, v),
@@ -480,10 +576,25 @@ class _PublicEventScreenState extends State<PublicEventScreen> {
),
],
),
trailing: Text(
'R ' + (partRankMap[p.participantId]?['rank']?.toString() ?? '-') +
' · 총 ' + PublicRankingUtils.participantTotalWithHc(p).toString(),
key: Key('participant_total_' + p.participantId),
trailing: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
key: Key('participant_rank_' + p.participantId),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(10),
),
child: Text('R ' + (partRankMap[p.participantId]?['rank']?.toString() ?? '-'), style: const TextStyle(fontSize: 12)),
),
const SizedBox(height: 6),
Text(
'' + PublicRankingUtils.participantTotalWithHc(p).toString(),
key: Key('participant_total_' + p.participantId),
),
],
),
onTap: () {
if (!canInputScore) {
@@ -492,10 +603,11 @@ class _PublicEventScreenState extends State<PublicEventScreen> {
}
_openScoreDialog(p.participantId, initial: p.rawScores.isNotEmpty ? p.rawScores[0] : null);
},
),
],
),
),
),
],
),
),
],
const SizedBox(height: 24),
const Text('', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_teams')),
Text('' + teams.length.toString() + '', key: const Key('teams_count')),
@@ -509,41 +621,88 @@ class _PublicEventScreenState extends State<PublicEventScreen> {
: Column(
children: [
for (final t in teams)
ListTile(
title: Row(
Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('' + t.teamNumber.toString()),
const SizedBox(width: 6),
Container(
key: Key('team_rank_' + t.teamNumber.toString()),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(10),
),
child: Text('R ' + (teamRankMap[t.teamNumber]?['rank']?.toString() ?? '-'), style: const TextStyle(fontSize: 12)),
Row(
children: [
Text('' + t.teamNumber.toString()),
const SizedBox(width: 6),
Container(
key: Key('team_rank_' + t.teamNumber.toString()),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(10),
),
child: Text('R ' + (teamRankMap[t.teamNumber]?['rank']?.toString() ?? '-'), style: const TextStyle(fontSize: 12)),
),
const Spacer(),
SizedBox(
width: 100,
child: TextFormField(
key: Key('team_hc_input_' + t.teamNumber.toString()),
initialValue: t.handicap.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
labelText: '팀 HC',
counterText: '',
),
maxLength: 3,
onChanged: (v) async {
final val = int.tryParse(v);
if (val == null || val < 0 || val > 300) return;
try {
await _service.updateTeamHandicap(widget.publicHash, teamNumber: t.teamNumber, handicap: val);
// 로컬 반영
final idx = _teams.indexWhere((tt) => tt.teamNumber == t.teamNumber);
if (idx >= 0) {
_teams[idx] = PublicTeam(teamId: _teams[idx].teamId, teamNumber: _teams[idx].teamNumber, handicap: val, members: _teams[idx].members);
setState(() {});
}
} catch (_) {}
},
),
),
],
),
const SizedBox(height: 6),
Builder(builder: (context) {
int membersTotal = 0;
for (final m in t.members) {
int mt = 0;
for (int i = 0; i < m.rawScores.length; i++) {
final raw = m.rawScores[i];
final hc = (i < m.handicaps.length) ? m.handicaps[i] : 0;
if (raw == null) continue;
mt += PublicRankingUtils.capScore(raw + hc);
}
membersTotal += mt;
}
final total = membersTotal + t.handicap;
final avg = t.members.isNotEmpty ? (total / t.members.length).toStringAsFixed(1) : '-';
return Text(
'' + total.toString() + ' · 평균 ' + avg + ' (팀 HC ' + t.handicap.toString() + ')',
key: Key('team_summary_' + t.teamNumber.toString()),
);
}),
const SizedBox(height: 6),
// 팀 구성원 리스트
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final m in t.members)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text('' + m.name),
),
],
),
],
),
subtitle: Builder(builder: (context) {
int membersTotal = 0;
for (final m in t.members) {
int mt = 0;
for (int i = 0; i < m.rawScores.length; i++) {
final raw = m.rawScores[i];
final hc = (i < m.handicaps.length) ? m.handicaps[i] : 0;
if (raw == null) continue;
mt += PublicRankingUtils.capScore(raw + hc);
}
membersTotal += mt;
}
final total = membersTotal + t.handicap;
final avg = t.members.isNotEmpty ? (total / t.members.length).toStringAsFixed(1) : '-';
return Text(
'' + total.toString() + ' · 평균 ' + avg + ' (팀 HC ' + t.handicap.toString() + ')',
key: Key('team_summary_' + t.teamNumber.toString()),
);
}),
),
],
),
@@ -166,4 +166,16 @@ class PublicEventService {
headers: _authHeader(token),
);
}
Future<void> updateTeamHandicap(String publicHash, {
required int teamNumber,
required int handicap,
}) async {
final token = getSavedToken(publicHash);
await _http.put(
'/public/' + publicHash + '/team/' + teamNumber.toString() + '/handicap',
data: { 'teamNumber': teamNumber, 'handicap': handicap },
headers: _authHeader(token),
);
}
}