import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/navigation/host_app_channel.dart'; import '../../core/storage/draft_storage.dart'; import '../../shared/widgets/action_bar.dart'; import '../../shared/widgets/submitting_dialog.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../shared/widgets/attachment_picker.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/constants/enums.dart'; import '../../core/data/mock_api_data.dart'; import 'expense_apply_api.dart'; import 'widgets/expense_apply_detail_dialog.dart'; class ExpenseApplyCreatePage extends ConsumerStatefulWidget { final String? id; const ExpenseApplyCreatePage({super.key, this.id}); @override ConsumerState createState() => _ExpenseApplyCreatePageState(); } class _ExpenseApplyCreatePageState extends ConsumerState with WidgetsBindingObserver { static const _draftKey = 'expense_apply'; // ── 基本信息 ── String _urgency = Urgency.normal.value; final _purposeController = TextEditingController(); final _purposeFocus = FocusNode(); String _validUntil = ''; final _referenceNoController = TextEditingController(); final _remarkController = TextEditingController(); final _remarkFocus = FocusNode(); final _scrollCtrl = ScrollController(); // ── 费用明细 ── final List<_DetailItem> _details = []; int _detailIdCounter = 1; // ── 附件 ── late final AttachmentPickerController _attachmentController; // ── 草稿 ── late Future _draftFuture; bool _draftHandled = false; bool _isPoppingToNative = false; // ── 参考数据(从 API 加载) ── List _costTypes = []; List _projects = []; List _departments = []; bool _refDataLoading = true; // ── 申请部门 ── String _selectedDeptId = ''; String _selectedDeptName = ''; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, ), ); _attachmentController = AttachmentPickerController(maxCount: 9) ..addListener(() => setState(() {})); _purposeFocus.addListener(() => _ensureVisible(_purposeFocus)); _remarkFocus.addListener(() => _ensureVisible(_remarkFocus)); _draftFuture = DraftStorage.has(_draftKey); _loadRefData(); } Future _loadRefData() async { try { final api = ref.read(expenseApplyApiProvider); final results = await Future.wait([ api.getCostTypes(), api.getProjectCodes(), api.getDepartments(), ]); if (!mounted) return; setState(() { _costTypes = results[0] as List; _projects = results[1] as List; _departments = results[2] as List; _refDataLoading = false; // 自动匹配当前用户的部门 _autoSelectDept(); }); } catch (_) { if (!mounted) return; setState(() => _refDataLoading = false); } } void _autoSelectDept() { if (_selectedDeptId.isNotEmpty) return; // 已选中则不覆盖 final dep = HostAppChannel.dep; if (dep.isEmpty) return; final match = _departments.where((d) => d.dep == dep); if (match.isNotEmpty) { _selectedDeptId = match.first.dep; _selectedDeptName = match.first.name; } } void _ensureVisible(FocusNode node) { if (!node.hasFocus) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (node.hasFocus && _scrollCtrl.hasClients) { final ctx = node.context; if (ctx != null) { Scrollable.ensureVisible( ctx, alignment: 0.3, duration: const Duration(milliseconds: 300), ); } } }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _purposeController.dispose(); _purposeFocus.dispose(); _referenceNoController.dispose(); _remarkController.dispose(); _remarkFocus.dispose(); _attachmentController.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed && _isPoppingToNative) { _isPoppingToNative = false; HostAppChannel.refresh(); setState(() { _draftHandled = false; _draftFuture = DraftStorage.has(_draftKey); _refDataLoading = true; }); _loadRefData(); } } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: l10n.get('expenseApplyRequest'), showBack: true, onBack: () => _doPop(), ), ); return FutureBuilder( future: _draftFuture, builder: (ctx, snapshot) { final hasDraft = snapshot.hasData && snapshot.data == true; if (hasDraft && !_draftHandled) { _draftHandled = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showDraftDialog(); }); } return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; _doPop(); }, child: Column( children: [ Expanded( child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: SingleChildScrollView( controller: _scrollCtrl, padding: const EdgeInsets.all(16), child: Column( children: [ _buildBasicInfo(l10n), const SizedBox(height: 16), _buildDetailsSection(l10n), const SizedBox(height: 16), _buildAttachmentSection(l10n), const SizedBox(height: 80), ], ), ), ), ), _buildBottomBar(l10n), ], ), ); }, ); } // ═══ 草稿持久化 ═══ Future _restoreDraft() async { final data = await DraftStorage.load(_draftKey); if (data == null) return; final attData = data['attachments'] as List?; if (attData != null) { await _attachmentController.restoreFromPaths(attData.cast()); } setState(() { _urgency = data['urgency'] as String? ?? Urgency.normal.value; _purposeController.text = data['purpose'] as String? ?? ''; _validUntil = data['validUntil'] as String? ?? ''; _referenceNoController.text = data['referenceNo'] as String? ?? ''; _remarkController.text = data['remark'] as String? ?? ''; _selectedDeptId = data['deptId'] as String? ?? ''; _selectedDeptName = data['deptName'] as String? ?? ''; _details.clear(); final detailList = data['details'] as List?; if (detailList != null) { for (final d in detailList) { final m = d as Map; _details.add(_DetailItem( id: m['id'] as int? ?? _detailIdCounter++, category: m['category'] as String? ?? '', categoryName: m['categoryName'] as String? ?? '', acctSubjectId: m['acctSubjectId'] as String? ?? '', acctSubjectName: m['acctSubjectName'] as String? ?? '', purpose: m['purpose'] as String? ?? '', projectId: m['projectId'] as int? ?? 0, projectName: m['projectName'] as String? ?? '', costDeptId: m['costDeptId'] as String? ?? '', costDeptName: m['costDeptName'] as String? ?? '', startDate: m['startDate'] as String? ?? '', endDate: m['endDate'] as String? ?? '', estimatedAmount: (m['estimatedAmount'] as num?)?.toDouble() ?? 0, remark: m['remark'] as String? ?? '', )); } } _detailIdCounter = _details.isEmpty ? 1 : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1; }); } Future _saveDraftToStorage() async { final detailList = _details.map((d) => { 'id': d.id, 'category': d.category, 'categoryName': d.categoryName, 'acctSubjectId': d.acctSubjectId, 'acctSubjectName': d.acctSubjectName, 'purpose': d.purpose, 'projectId': d.projectId, 'projectName': d.projectName, 'costDeptId': d.costDeptId, 'costDeptName': d.costDeptName, 'startDate': d.startDate, 'endDate': d.endDate, 'estimatedAmount': d.estimatedAmount, 'remark': d.remark, }).toList(); await DraftStorage.save(_draftKey, { 'urgency': _urgency, 'purpose': _purposeController.text, 'deptId': _selectedDeptId, 'deptName': _selectedDeptName, 'validUntil': _validUntil, 'referenceNo': _referenceNoController.text, 'remark': _remarkController.text, 'attachments': _attachmentController.toPathList(), 'details': detailList, }); } // ═══ 草稿弹窗 ═══ // 使用 showDialog 而非内联渲染,确保 TDAlertDialog 获取正确的主题上下文 void _showDraftDialog() { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; showDialog( context: context, barrierDismissible: false, builder: (ctx) => TDAlertDialog( title: l10n.get('draftFound'), content: l10n.get('draftRestorePrompt'), leftBtn: TDDialogButtonOptions( title: l10n.get('discard'), titleColor: colors.textSecondary, action: () { Navigator.pop(ctx); DraftStorage.delete(_draftKey); }, ), rightBtn: TDDialogButtonOptions( title: l10n.get('restore'), titleColor: colors.primary, action: () { Navigator.pop(ctx); _restoreDraft(); }, ), ), ); } // ═══ 1. 基本信息 ═══ Widget _buildBasicInfo(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow( label: l10n.get('date'), value: _today(), readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('applicant'), value: HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty ? '${HostAppChannel.usr}/${HostAppChannel.usrName}' : '--', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('applyDept'), value: _selectedDeptName, hint: l10n.get('pleaseSelect'), onTap: _refDataLoading ? null : () => _showDeptPicker(), ), const SizedBox(height: 16), _label(l10n.get('emergencyLevel'), required: true), const SizedBox(height: 8), _buildUrgencyRadio(l10n), const SizedBox(height: 16), _label(l10n.get('applyReason'), required: true), const SizedBox(height: 8), TDTextarea( controller: _purposeController, focusNode: _purposeFocus, hintText: l10n.get('enterApplyReason'), maxLines: 4, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), // TODO: 暂不支持录入,后续开放 // const SizedBox(height: 16), // FormFieldRow( // label: l10n.get('validUntil'), // value: _validUntil, // hint: l10n.get('pleaseSelect'), // onTap: () => _pickDate((d) => setState(() => _validUntil = d)), // ), // const SizedBox(height: 16), // FormFieldRow( // label: l10n.get('relatedContractNo'), // value: _referenceNoController.text, // hint: l10n.get('optional'), // onTap: () => _showTextInput( // l10n.get('relatedContractNo'), // (v) => setState(() { // _referenceNoController.text = v; // _referenceNoController.selection = TextSelection.fromPosition( // TextPosition(offset: v.length), // ); // }), // initialText: _referenceNoController.text, // ), // ), const SizedBox(height: 16), _label(l10n.get('remark')), const SizedBox(height: 8), TDTextarea( controller: _remarkController, focusNode: _remarkFocus, hintText: l10n.get('enterRemark'), maxLines: 3, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), ], ); } Widget _buildUrgencyRadio(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return Row( children: Urgency.values.asMap().entries.map((e) { final sel = _urgency == e.value.value; final isCritical = e.value.value == Urgency.critical.value; final activeColor = isCritical ? colors.danger : colors.primary; return Padding( padding: EdgeInsets.only(right: e.key < 2 ? 24 : 0), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _urgency = e.value.value), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 18, height: 18, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: sel ? activeColor : colors.textPlaceholder, width: 2, ), ), child: sel ? Center( child: Container( width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: activeColor, ), ), ) : null, ), const SizedBox(width: 6), Text( l10n.get(e.value.labelKey), style: TextStyle( fontSize: AppFontSizes.subtitle, color: sel ? activeColor : colors.textPrimary, ), ), ], ), ), ); }).toList(), ); } // ═══ 2. 费用明细 ═══ Widget _buildDetailsSection(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, showAction: !_refDataLoading, actionText: l10n.get('add'), onActionTap: _showDetailDialog, children: [ if (_details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noDetailHint'), style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder, ), ), ) else ..._details.asMap().entries.map((e) { final d = e.value; return GestureDetector( onTap: () => _showDetailDialog(editIndex: e.key), child: Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Expanded( flex: 3, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( d.categoryName, style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPrimary, ), ), if (d.purpose.isNotEmpty) Text( d.purpose, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPrimary, ), ), const SizedBox(height: 4), Text( '¥${d.estimatedAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.amountPrimary, ), ), if (d.projectName.isNotEmpty) Text( '${d.projectName} | ${d.acctSubjectName}', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.remark.isNotEmpty) Text( d.remark, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), ], ), ), GestureDetector( onTap: () => setState(() => _details.removeAt(e.key)), child: Container( width: 24, height: 24, decoration: BoxDecoration( color: colors.primaryLight, shape: BoxShape.circle, ), child: Icon( Icons.close, size: 14, color: colors.primary700, ), ), ), ], ), ), ); }), const SizedBox(height: 8), Container( height: 36, padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('total'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), Text( '¥${_totalAmount().toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary, ), ), ], ), ), ], ); } double _totalAmount() => _details.fold(0, (s, d) => s + d.estimatedAmount); Future _showDetailDialog({int? editIndex}) async { final l10n = AppLocalizations.of(context); if (_costTypes.isEmpty) { TDToast.showText(l10n.get('noCostTypeData'), context: context); return; } ExpenseDetailData? initialData; if (editIndex != null) { final d = _details[editIndex]; initialData = ExpenseDetailData( category: d.category, categoryName: d.categoryName, acctSubjectId: d.acctSubjectId, acctSubjectName: d.acctSubjectName, purpose: d.purpose, projectId: d.projectId, projectName: d.projectName, costDeptId: d.costDeptId, costDeptName: d.costDeptName, startDate: d.startDate, endDate: d.endDate, estimatedAmount: d.estimatedAmount, remark: d.remark, ); } final result = await ExpenseApplyDetailDialog.show( context, categories: _dialogCategories, projects: _dialogProjects, costDepts: _dialogCostDepts, l10n: l10n, initialData: initialData, ); if (result != null && mounted) { setState(() { final item = _DetailItem( id: editIndex != null ? _details[editIndex].id : _detailIdCounter++, category: result.category, categoryName: result.categoryName, acctSubjectId: result.acctSubjectId, acctSubjectName: result.acctSubjectName, purpose: result.purpose, projectId: result.projectId, projectName: result.projectName, costDeptId: result.costDeptId, costDeptName: result.costDeptName, startDate: result.startDate, endDate: result.endDate, estimatedAmount: result.estimatedAmount, remark: result.remark, ); if (editIndex != null) { _details[editIndex] = item; } else { _details.add(item); } }); } } // ═══ 3. 附件上传 ═══ Widget _buildAttachmentSection(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('attachmentUpload'), leadingIcon: Icons.attach_file_outlined, children: [ Text( l10n.get('maxAttachment'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), const SizedBox(height: 8), AttachmentPicker( controller: _attachmentController, maxImageSizeMB: 10, maxFileSizeMB: 20, allowedExtensions: const [ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', ], onFileRejected: (file, reason) { if (context.mounted) { TDToast.showText(reason, context: context); } }, ), ], ); } void _showDeptPicker() { if (_departments.isEmpty) { TDToast.showText(AppLocalizations.of(context).get('noData'), context: context); return; } final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final labels = _departments.map((d) => d.name).toList(); TDPicker.showMultiPicker( context, title: l10n.get('applyDept'), backgroundColor: colors.bgCard, data: [labels], onConfirm: (selected) { if (selected.isNotEmpty && selected[0] is int) { final idx = selected[0] as int; if (idx >= 0 && idx < labels.length) { Navigator.of(context).pop(); setState(() { _selectedDeptId = _departments[idx].dep; _selectedDeptName = _departments[idx].name; }); } } }, ); } // ═══ API 数据 → 弹窗类型转换 ═══ List get _dialogCategories => _costTypes .map((c) => CostCategory( code: c.typeNo, nameKey: c.typeName, acctSubjectId: c.accNo, acctSubjectName: c.accName, )) .toList(); List get _dialogProjects => _projects .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name)) .toList(); List get _dialogCostDepts => _departments .map((d) => CostDept(id: d.dep, name: d.name)) .toList(); // ═══ 4. 底部操作栏 ═══ Widget _buildBottomBar(AppLocalizations l10n) { return ActionBar( showLeft: false, centerLabel: l10n.get('saveDraft'), rightLabel: l10n.get('submit'), centerTextOnly: true, onCenterTap: () async { try { await _saveDraftToStorage(); if (mounted) _forcePop(); } catch (_) { if (mounted) { TDToast.showFail(l10n.get('saveFailed'), context: context); } } }, onRightTap: () async { final err = _validate(l10n); if (err.isNotEmpty) { TDToast.showText(err.first, context: context); return; } SubmittingDialog.show(context); try { final data = _buildSubmitData(); await ref.read(expenseApplyApiProvider).submit(data); await DraftStorage.delete(_draftKey); if (mounted) { SubmittingDialog.hide(context); TDToast.showSuccess(l10n.get('submittedAwaitingApproval'), context: context); GoRouter.of(context).go('/expense-apply/list'); } } catch (_) { if (mounted) { SubmittingDialog.hide(context); TDToast.showFail(l10n.get('submitFailedRetry'), context: context); } } }, ); } Map _buildSubmitData() { // 紧急程度映射:normal→1, urgent→2, critical→3 String priority; switch (_urgency) { case 'urgent': priority = '2'; break; case 'critical': priority = '3'; break; default: priority = '1'; } return { 'HeadData': { 'AE_DD': _today(), 'PRIORITY': priority, 'AMTN_YJ': _totalAmount(), 'REASON': _purposeController.text.trim(), 'REM': _remarkController.text, 'DEP': _selectedDeptId, 'USR': HostAppChannel.usr, }, 'BodyData1': _details.asMap().entries.map((e) { final i = e.key; final d = e.value; return { 'ITM': i + 1, 'SQ_MAN': HostAppChannel.usr, 'TYPE_NO': d.category, 'AMTN_YJ': d.estimatedAmount, 'ACC_NO': d.acctSubjectId, 'ACC_NAME': d.acctSubjectName, 'DEP': d.costDeptId, 'OBJ_NO': d.projectId > 0 ? d.projectId.toString() : '', 'START_DD': d.startDate, 'END_DD': d.endDate, 'REM': d.remark.isNotEmpty ? d.remark : d.purpose, }; }).toList(), }; } List _validate(AppLocalizations l10n) { final e = []; if (_purposeController.text.trim().isEmpty) { e.add(l10n.get('enterApplyReason')); } if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail')); return e; } void _doPop() { if (_hasUnsaved()) { final l10n = AppLocalizations.of(context); _showConfirmDialog( l10n.get('confirmExit'), l10n.get('unsavedContentWarning'), l10n.get('continueEditing'), l10n.get('discardAndExit'), () async { await DraftStorage.delete(_draftKey); if (!mounted) return; setState(() => _clearLocalState()); _forcePop(); }, ); } else { _forcePop(); } } void _forcePop() { _isPoppingToNative = true; SystemNavigator.pop(); } bool _hasUnsaved() => _purposeController.text.isNotEmpty || _details.isNotEmpty || _attachmentController.files.isNotEmpty || _referenceNoController.text.isNotEmpty || _remarkController.text.isNotEmpty || _urgency != Urgency.normal.value || _validUntil.isNotEmpty || _selectedDeptId.isNotEmpty; void _clearLocalState() { _urgency = Urgency.normal.value; _purposeController.clear(); _validUntil = ''; _referenceNoController.clear(); _remarkController.clear(); _details.clear(); _detailIdCounter = 1; _attachmentController.clear(); _selectedDeptId = ''; _selectedDeptName = ''; } void _unfocus() => FocusScope.of(context).unfocus(); // ═══ 通用弹窗方法 ═══ void _showConfirmDialog( String title, String content, String leftText, String rightText, VoidCallback onConfirm, ) { _unfocus(); final colors = Theme.of(context).extension()!; showDialog( context: context, useRootNavigator: true, builder: (ctx) => TDAlertDialog( title: title, content: content, buttonStyle: TDDialogButtonStyle.text, leftBtn: TDDialogButtonOptions( title: leftText, titleColor: colors.primary, action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: rightText, titleColor: colors.danger, action: () { Navigator.pop(ctx); onConfirm(); }, ), ), ); } // TODO: 有效期至 / 关联合同号 暂不支持,方法暂时注释 // void _showTextInput( // String title, // Function(String) onConfirm, { // String initialText = '', // }) { // ... // } // void _pickDate(Function(String) onPick) { // ... // } Widget _label(String t, {bool required = false}) { final colors = Theme.of(context).extension()!; return Text.rich( TextSpan( children: [ TextSpan( text: t, style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textSecondary, ), ), if (required) TextSpan( text: ' *', style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.danger, ), ), ], ), ); } String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; } } class _DetailItem { final int id; final String category; final String categoryName; final String acctSubjectId; final String acctSubjectName; final String purpose; final int projectId; final String projectName; final String costDeptId; final String costDeptName; final String startDate; final String endDate; final double estimatedAmount; final String remark; const _DetailItem({ required this.id, required this.category, required this.categoryName, required this.acctSubjectId, required this.acctSubjectName, required this.purpose, required this.projectId, required this.projectName, required this.costDeptId, required this.costDeptName, required this.startDate, required this.endDate, required this.estimatedAmount, required this.remark, }); }