diff --git a/backend/controllers/eventController.js b/backend/controllers/eventController.js index 3adf694..433bf82 100644 --- a/backend/controllers/eventController.js +++ b/backend/controllers/eventController.js @@ -1487,6 +1487,29 @@ exports.addEventParticipant = async (req, res) => { } }); + // 입력 상태/결제값을 DB Enum에 맞추어 정규화 + const normalizeStatus = (s) => { + if (!s) return undefined; + const map = { + '참가예정': 'pending', + '참가확정': 'confirmed', + '취소': 'canceled', + }; + return ['pending', 'confirmed', 'canceled'].includes(s) ? s : (map[s] || undefined); + }; + const normalizePayment = (p) => { + if (!p) return undefined; + const map = { + '미납': 'unpaid', + '납부완료': 'paid', + '완납': 'paid', + }; + return ['unpaid', 'paid'].includes(p) ? p : (map[p] || undefined); + }; + + const normStatus = normalizeStatus(status); + const normPayment = normalizePayment(paymentStatus); + let participant; let isUpdate = false; if (existingParticipant) { @@ -1496,8 +1519,8 @@ exports.addEventParticipant = async (req, res) => { eventId, memberId, clubId, - status: status || existingParticipant.status, - paymentStatus: paymentStatus || existingParticipant.paymentStatus, + status: normStatus || existingParticipant.status, + paymentStatus: normPayment || existingParticipant.paymentStatus, comment: comment || existingParticipant.comment }); participant = await EventParticipant.findOne({ @@ -1515,8 +1538,8 @@ exports.addEventParticipant = async (req, res) => { participant = await EventParticipant.create({ eventId, memberId, - status: status || '참가예정', - paymentStatus: paymentStatus || '미납', + status: normStatus || 'pending', + paymentStatus: normPayment || 'unpaid', comment: comment || null, createdAt: new Date(), updatedAt: new Date() diff --git a/backend/middleware/requireFeature.js b/backend/middleware/requireFeature.js index 89013e5..46c7c19 100644 --- a/backend/middleware/requireFeature.js +++ b/backend/middleware/requireFeature.js @@ -4,7 +4,7 @@ const { clubHasFeature } = require('../utils/featureAccess'); function requireFeature(featureCode) { return async function(req, res, next) { try { - const clubId = req.session.clubId || req.clubId || req.body.clubId || req.params.clubId; + const clubId = req.session.clubId || req.clubId || req.body.clubId || req.params.clubId || req.query.clubId; // console.log('backendClubId:', clubId); if (!clubId) { return res.status(400).json({ error: '클럽 정보가 필요합니다.' }); diff --git a/mobile/lib/screens/club/participant_form_screen.dart b/mobile/lib/screens/club/participant_form_screen.dart deleted file mode 100644 index feb1d6d..0000000 --- a/mobile/lib/screens/club/participant_form_screen.dart +++ /dev/null @@ -1,410 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../models/participant_model.dart'; -import '../../services/event_service.dart'; -import '../../widgets/loading_indicator.dart'; -import '../../utils/enum_mappings.dart'; - -class ParticipantFormScreen extends StatefulWidget { - final String eventId; - final Participant? participant; // null이면 새 참가자 추가, 아니면 참가자 수정 - - const ParticipantFormScreen({ - Key? key, - required this.eventId, - this.participant, - }) : super(key: key); - - @override - State createState() => _ParticipantFormScreenState(); -} - -class _ParticipantFormScreenState extends State { - final _formKey = GlobalKey(); - bool _isLoading = false; - - // 폼 필드 컨트롤러 - final _nameController = TextEditingController(); - final _emailController = TextEditingController(); - final _phoneNumberController = TextEditingController(); - final _notesController = TextEditingController(); - final _paidAmountController = TextEditingController(); - - // 참가자 데이터 - String? _status; - bool _isPaid = false; - - // 참가자 상태 옵션 (enum_mappings 사용) - late final List _statusKoOptions; - late final Map _statusEnToKo; - late final Map _statusKoToEn; - - @override - void initState() { - super.initState(); - - // enum 매핑 구성 - _statusEnToKo = Map.from(participantStatusOptions); - _statusKoToEn = { - for (final e in participantStatusOptions.entries) e.value: e.key, - }; - _statusKoOptions = participantStatusOptions.values.toList(growable: false); - - // 수정 모드인 경우 기존 데이터 로드 - if (widget.participant != null) { - _nameController.text = widget.participant!.name ?? ''; - _emailController.text = widget.participant!.email ?? ''; - _phoneNumberController.text = widget.participant!.phoneNumber ?? ''; - _notesController.text = widget.participant!.notes ?? ''; - _paidAmountController.text = widget.participant!.paidAmount?.toString() ?? ''; - - // 서버/모델 상태(영문)를 UI 드롭다운(한글)으로 변환 - final currentStatusEn = widget.participant!.status ?? 'pending'; - final currentStatusKo = _statusEnToKo[currentStatusEn] ?? _statusKoOptions.first; - _status = _statusKoOptions.contains(currentStatusKo) ? currentStatusKo : _statusKoOptions.first; - _isPaid = widget.participant!.isPaid; - } else { - // 새 참가자 추가 시 기본값 설정 - _status = _statusKoOptions.first; // pending - _isPaid = false; - } - } - - @override - void dispose() { - _nameController.dispose(); - _emailController.dispose(); - _phoneNumberController.dispose(); - _notesController.dispose(); - _paidAmountController.dispose(); - super.dispose(); - } - - // 참가자 저장 - Future _saveParticipant() async { - if (!_formKey.currentState!.validate()) { - return; - } - - setState(() { - _isLoading = true; - }); - - try { - final eventService = Provider.of(context, listen: false); - - // 참가자 데이터 준비 - final participantData = { - 'eventId': widget.eventId, - 'name': _nameController.text.trim(), - 'email': _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), - 'phoneNumber': _phoneNumberController.text.trim().isEmpty ? null : _phoneNumberController.text.trim(), - // 서버는 영문 상태를 기대, UI는 한글 상태를 사용하므로 변환 (pending/confirmed/canceled) - 'status': _statusKoToEn[_status ?? _statusKoOptions.first] ?? 'pending', - // 결제 상태는 enum 키 사용 (unpaid/paid) - 'isPaid': _isPaid, - 'paymentStatus': _isPaid ? 'paid' : 'unpaid', - 'paidAmount': _paidAmountController.text.isEmpty ? null : double.parse(_paidAmountController.text), - 'notes': _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), - }; - - if (widget.participant == null) { - // 새 참가자 추가 - await eventService.addParticipant(widget.eventId, participantData); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('참가자가 추가되었습니다')), - ); - } - } else { - // 기존 참가자 수정 - await eventService.updateParticipant(widget.eventId, widget.participant!.id, participantData); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('참가자 정보가 수정되었습니다')), - ); - } - } - - if (mounted) { - Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기 - } - } catch (e) { - if (mounted) { - setState(() { - _isLoading = false; - }); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('참가자 저장에 실패했습니다: $e')), - ); - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.participant == null ? '참가자 추가' : '참가자 수정'), - actions: [ - IconButton( - icon: const Icon(Icons.save), - onPressed: _saveParticipant, - ), - ], - ), - body: _isLoading - ? const LoadingIndicator() - : SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 게스트 사전입력 버튼 - Align( - alignment: Alignment.centerRight, - child: OutlinedButton.icon( - onPressed: _showGuestPrefillSheet, - icon: const Icon(Icons.person_add), - label: const Text('게스트 사전입력'), - ), - ), - const SizedBox(height: 8), - // 기본 정보 섹션 - _buildSectionTitle('기본 정보'), - // 이름 - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - labelText: '이름 *', - hintText: '참가자 이름을 입력하세요', - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return '참가자 이름을 입력해주세요'; - } - return null; - }, - ), - const SizedBox(height: 16), - // 이메일 - TextFormField( - controller: _emailController, - decoration: const InputDecoration( - labelText: '이메일', - hintText: '참가자 이메일을 입력하세요', - ), - keyboardType: TextInputType.emailAddress, - validator: (value) { - if (value != null && value.isNotEmpty) { - if (!value.contains('@') || !value.contains('.')) { - return '유효한 이메일 주소를 입력해주세요'; - } - } - return null; - }, - ), - const SizedBox(height: 16), - // 전화번호 - TextFormField( - controller: _phoneNumberController, - decoration: const InputDecoration( - labelText: '전화번호', - hintText: '참가자 전화번호를 입력하세요', - ), - keyboardType: TextInputType.phone, - ), - const SizedBox(height: 24), - // 상태 섹션 - _buildSectionTitle('상태 정보'), - // 참가자 상태 - DropdownButtonFormField( - key: const Key('participant_form_status_dropdown'), - value: _statusKoOptions.contains(_status) ? _status : _statusKoOptions.first, - decoration: const InputDecoration( - labelText: '참가자 상태', - ), - isExpanded: true, - items: _statusKoOptions - .map((status) => DropdownMenuItem( - value: status, - child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis), - )) - .toList(), - onChanged: (value) { - setState(() { - _status = value; - }); - }, - ), - const SizedBox(height: 16), - // 결제 여부 - SwitchListTile( - title: const Text('결제 완료'), - value: _isPaid, - onChanged: (value) { - setState(() { - _isPaid = value; - }); - }, - ), - // 결제 금액 - TextFormField( - controller: _paidAmountController, - decoration: const InputDecoration( - labelText: '결제 금액', - hintText: '결제 금액을 입력하세요', - suffixText: '원', - ), - keyboardType: TextInputType.number, - validator: (value) { - if (value != null && value.isNotEmpty) { - if (double.tryParse(value) == null) { - return '숫자를 입력해주세요'; - } - } - return null; - }, - ), - const SizedBox(height: 24), - // 메모 섹션 - _buildSectionTitle('추가 정보'), - // 메모 - TextFormField( - controller: _notesController, - decoration: const InputDecoration( - labelText: '메모', - hintText: '참가자에 대한 메모를 입력하세요', - ), - maxLines: 3, - ), - const SizedBox(height: 32), - // 저장 버튼 - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _saveParticipant, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - child: Text( - widget.participant == null ? '참가자 추가' : '참가자 정보 수정', - style: const TextStyle(fontSize: 16), - ), - ), - ), - ], - ), - ), - ), - ); - } - - // 게스트 사전입력 BottomSheet - void _showGuestPrefillSheet() { - final nameCtrl = TextEditingController(text: _nameController.text); - final emailCtrl = TextEditingController(text: _emailController.text); - final phoneCtrl = TextEditingController(text: _phoneNumberController.text); - - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (ctx) { - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, - top: 16, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('게스트 사전입력', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - TextField( - controller: nameCtrl, - decoration: const InputDecoration( - labelText: '이름 *', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - TextField( - controller: emailCtrl, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: '이메일', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - TextField( - controller: phoneCtrl, - keyboardType: TextInputType.phone, - decoration: const InputDecoration( - labelText: '전화번호', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('취소'), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: () { - if (nameCtrl.text.trim().isEmpty) { - ScaffoldMessenger.of(ctx).showSnackBar( - const SnackBar(content: Text('이름은 필수 입력 항목입니다')), - ); - return; - } - setState(() { - _nameController.text = nameCtrl.text.trim(); - _emailController.text = emailCtrl.text.trim(); - _phoneNumberController.text = phoneCtrl.text.trim(); - }); - Navigator.of(ctx).pop(); - }, - child: const Text('적용'), - ), - ], - ), - ], - ), - ); - }, - ); - } - - // 섹션 제목 위젯 - Widget _buildSectionTitle(String title) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Divider(), - const SizedBox(height: 8), - ], - ); - } -}