clubId수정
This commit is contained in:
@@ -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 participant;
|
||||||
let isUpdate = false;
|
let isUpdate = false;
|
||||||
if (existingParticipant) {
|
if (existingParticipant) {
|
||||||
@@ -1496,8 +1519,8 @@ exports.addEventParticipant = async (req, res) => {
|
|||||||
eventId,
|
eventId,
|
||||||
memberId,
|
memberId,
|
||||||
clubId,
|
clubId,
|
||||||
status: status || existingParticipant.status,
|
status: normStatus || existingParticipant.status,
|
||||||
paymentStatus: paymentStatus || existingParticipant.paymentStatus,
|
paymentStatus: normPayment || existingParticipant.paymentStatus,
|
||||||
comment: comment || existingParticipant.comment
|
comment: comment || existingParticipant.comment
|
||||||
});
|
});
|
||||||
participant = await EventParticipant.findOne({
|
participant = await EventParticipant.findOne({
|
||||||
@@ -1515,8 +1538,8 @@ exports.addEventParticipant = async (req, res) => {
|
|||||||
participant = await EventParticipant.create({
|
participant = await EventParticipant.create({
|
||||||
eventId,
|
eventId,
|
||||||
memberId,
|
memberId,
|
||||||
status: status || '참가예정',
|
status: normStatus || 'pending',
|
||||||
paymentStatus: paymentStatus || '미납',
|
paymentStatus: normPayment || 'unpaid',
|
||||||
comment: comment || null,
|
comment: comment || null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const { clubHasFeature } = require('../utils/featureAccess');
|
|||||||
function requireFeature(featureCode) {
|
function requireFeature(featureCode) {
|
||||||
return async function(req, res, next) {
|
return async function(req, res, next) {
|
||||||
try {
|
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);
|
// console.log('backendClubId:', clubId);
|
||||||
if (!clubId) {
|
if (!clubId) {
|
||||||
return res.status(400).json({ error: '클럽 정보가 필요합니다.' });
|
return res.status(400).json({ error: '클럽 정보가 필요합니다.' });
|
||||||
|
|||||||
@@ -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<ParticipantFormScreen> createState() => _ParticipantFormScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ParticipantFormScreenState extends State<ParticipantFormScreen> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
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<String> _statusKoOptions;
|
|
||||||
late final Map<String, String> _statusEnToKo;
|
|
||||||
late final Map<String, String> _statusKoToEn;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
|
|
||||||
// enum 매핑 구성
|
|
||||||
_statusEnToKo = Map<String, String>.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<void> _saveParticipant() async {
|
|
||||||
if (!_formKey.currentState!.validate()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final eventService = Provider.of<EventService>(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<String>(
|
|
||||||
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<String>(
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user