From 7677007b4931456799ce7efe897fabb54fd2a795 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Wed, 29 Oct 2025 01:18:29 +0900 Subject: [PATCH] =?UTF-8?q?ui=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/publicController.js | 21 ++ backend/routes/publicEventRoutes.js | 3 + mobile/lib/models/public_event.dart | 35 +++ mobile/lib/models/public_participant.dart | 47 +++ .../public_event/public_event_screen.dart | 281 ++++++++++++++---- mobile/lib/services/public_event_service.dart | 12 + 6 files changed, 338 insertions(+), 61 deletions(-) diff --git a/backend/controllers/publicController.js b/backend/controllers/publicController.js index c71e651..3e616ff 100644 --- a/backend/controllers/publicController.js +++ b/backend/controllers/publicController.js @@ -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 { diff --git a/backend/routes/publicEventRoutes.js b/backend/routes/publicEventRoutes.js index d73b14b..9312895 100644 --- a/backend/routes/publicEventRoutes.js +++ b/backend/routes/publicEventRoutes.js @@ -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; diff --git a/mobile/lib/models/public_event.dart b/mobile/lib/models/public_event.dart index ef452aa..1d707cb 100644 --- a/mobile/lib/models/public_event.dart +++ b/mobile/lib/models/public_event.dart @@ -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; + } + } +} diff --git a/mobile/lib/models/public_participant.dart b/mobile/lib/models/public_participant.dart index 0990587..061dc3e 100644 --- a/mobile/lib/models/public_participant.dart +++ b/mobile/lib/models/public_participant.dart @@ -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; + } + } +} diff --git a/mobile/lib/screens/public_event/public_event_screen.dart b/mobile/lib/screens/public_event/public_event_screen.dart index 0afd6ea..9c8890e 100644 --- a/mobile/lib/screens/public_event/public_event_screen.dart +++ b/mobile/lib/screens/public_event/public_event_screen.dart @@ -32,6 +32,79 @@ class _PublicEventScreenState extends State { final Map _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 { 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 { 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 { 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 { 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 { ), ], ), - 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 { } _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 { : 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()), - ); - }), ), ], ), diff --git a/mobile/lib/services/public_event_service.dart b/mobile/lib/services/public_event_service.dart index 8a3e86d..f629739 100644 --- a/mobile/lib/services/public_event_service.dart +++ b/mobile/lib/services/public_event_service.dart @@ -166,4 +166,16 @@ class PublicEventService { headers: _authHeader(token), ); } + + Future 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), + ); + } }