결재 템플릿 단계 적용 구현
- ApprovalTemplate 엔티티·DTO·원격 리포지토리 추가 - ApprovalController에 템플릿 로딩/적용 상태와 assignSteps 호출 연동 - ApprovalPage 단계 탭에 템플릿 선택 UI 및 적용 확인 다이얼로그 구현 - 템플릿 적용 단위 테스트와 IMPLEMENTATION_TASKS 현황 갱신
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_template.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
import '../../domain/repositories/approval_template_repository.dart';
|
||||
|
||||
enum ApprovalStatusFilter {
|
||||
all,
|
||||
pending,
|
||||
inProgress,
|
||||
onHold,
|
||||
approved,
|
||||
rejected,
|
||||
}
|
||||
|
||||
typedef DateRange = ({DateTime from, DateTime to});
|
||||
|
||||
const Map<ApprovalStepActionType, List<String>> _actionAliases = {
|
||||
ApprovalStepActionType.approve: ['approve', '승인'],
|
||||
ApprovalStepActionType.reject: ['reject', '반려'],
|
||||
ApprovalStepActionType.comment: ['comment', '코멘트', '의견'],
|
||||
};
|
||||
|
||||
/// 결재 목록 및 상세 화면 상태 컨트롤러
|
||||
///
|
||||
/// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다.
|
||||
/// - 승인/반려 등의 후속 액션은 추후 구현 시 추가한다.
|
||||
class ApprovalController extends ChangeNotifier {
|
||||
ApprovalController({
|
||||
required ApprovalRepository approvalRepository,
|
||||
required ApprovalTemplateRepository templateRepository,
|
||||
}) : _repository = approvalRepository,
|
||||
_templateRepository = templateRepository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
final ApprovalTemplateRepository _templateRepository;
|
||||
|
||||
PaginatedResult<Approval>? _result;
|
||||
Approval? _selected;
|
||||
bool _isLoadingList = false;
|
||||
bool _isLoadingDetail = false;
|
||||
bool _isLoadingActions = false;
|
||||
bool _isPerformingAction = false;
|
||||
int? _processingStepId;
|
||||
bool _isLoadingTemplates = false;
|
||||
bool _isApplyingTemplate = false;
|
||||
int? _applyingTemplateId;
|
||||
String? _errorMessage;
|
||||
String _query = '';
|
||||
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
|
||||
DateTime? _fromDate;
|
||||
DateTime? _toDate;
|
||||
List<ApprovalAction> _actions = const [];
|
||||
List<ApprovalTemplate> _templates = const [];
|
||||
|
||||
PaginatedResult<Approval>? get result => _result;
|
||||
Approval? get selected => _selected;
|
||||
bool get isLoadingList => _isLoadingList;
|
||||
bool get isLoadingDetail => _isLoadingDetail;
|
||||
bool get isLoadingActions => _isLoadingActions;
|
||||
bool get isPerformingAction => _isPerformingAction;
|
||||
int? get processingStepId => _processingStepId;
|
||||
String? get errorMessage => _errorMessage;
|
||||
String get query => _query;
|
||||
ApprovalStatusFilter get statusFilter => _statusFilter;
|
||||
DateTime? get fromDate => _fromDate;
|
||||
DateTime? get toDate => _toDate;
|
||||
List<ApprovalAction> get actionOptions => _actions;
|
||||
bool get hasActionOptions => _actions.isNotEmpty;
|
||||
List<ApprovalTemplate> get templates => _templates;
|
||||
bool get isLoadingTemplates => _isLoadingTemplates;
|
||||
bool get isApplyingTemplate => _isApplyingTemplate;
|
||||
int? get applyingTemplateId => _applyingTemplateId;
|
||||
|
||||
Future<void> fetch({int page = 1}) async {
|
||||
_isLoadingList = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final statusParam = switch (_statusFilter) {
|
||||
ApprovalStatusFilter.all => null,
|
||||
ApprovalStatusFilter.pending => 'pending',
|
||||
ApprovalStatusFilter.inProgress => 'in_progress',
|
||||
ApprovalStatusFilter.onHold => 'on_hold',
|
||||
ApprovalStatusFilter.approved => 'approved',
|
||||
ApprovalStatusFilter.rejected => 'rejected',
|
||||
};
|
||||
final response = await _repository.list(
|
||||
page: page,
|
||||
pageSize: _result?.pageSize ?? 20,
|
||||
query: _query.isEmpty ? null : _query,
|
||||
status: statusParam,
|
||||
from: _fromDate,
|
||||
to: _toDate,
|
||||
includeSteps: false,
|
||||
includeHistories: false,
|
||||
);
|
||||
_result = response;
|
||||
if (_selected != null) {
|
||||
final exists = response.items.any((item) => item.id == _selected?.id);
|
||||
if (!exists) {
|
||||
_selected = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
_isLoadingList = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadActionOptions({bool force = false}) async {
|
||||
if (_actions.isNotEmpty && !force) {
|
||||
return;
|
||||
}
|
||||
_isLoadingActions = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final items = await _repository.listActions(activeOnly: true);
|
||||
_actions = items;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
_isLoadingActions = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadTemplates({bool force = false}) async {
|
||||
if (_templates.isNotEmpty && !force) {
|
||||
return;
|
||||
}
|
||||
_isLoadingTemplates = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final items = await _templateRepository.list(activeOnly: true);
|
||||
_templates = items;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
_isLoadingTemplates = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectApproval(int id) async {
|
||||
_isLoadingDetail = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final detail = await _repository.fetchDetail(
|
||||
id,
|
||||
includeSteps: true,
|
||||
includeHistories: true,
|
||||
);
|
||||
_selected = detail;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
_isLoadingDetail = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearSelection() {
|
||||
_selected = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> performStepAction({
|
||||
required ApprovalStep step,
|
||||
required ApprovalStepActionType type,
|
||||
String? note,
|
||||
}) async {
|
||||
if (step.id == null) {
|
||||
_errorMessage = '단계 식별자가 없어 행위를 수행할 수 없습니다.';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
final action = _findActionByType(type);
|
||||
if (action == null) {
|
||||
_errorMessage = '사용 가능한 결재 행위를 찾을 수 없습니다.';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
_isPerformingAction = true;
|
||||
_processingStepId = step.id;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final sanitizedNote = note?.trim();
|
||||
final updated = await _repository.performStepAction(
|
||||
ApprovalStepActionInput(
|
||||
stepId: step.id!,
|
||||
actionId: action.id,
|
||||
note: sanitizedNote?.isEmpty ?? true ? null : sanitizedNote,
|
||||
),
|
||||
);
|
||||
_selected = updated;
|
||||
if (_result != null && updated.id != null) {
|
||||
final items = _result!.items
|
||||
.map((item) => item.id == updated.id ? updated : item)
|
||||
.toList();
|
||||
_result = _result!.copyWith(items: items);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
return false;
|
||||
} finally {
|
||||
_isPerformingAction = false;
|
||||
_processingStepId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> applyTemplate(int templateId) async {
|
||||
final approvalId = _selected?.id;
|
||||
if (approvalId == null) {
|
||||
_errorMessage = '선택된 결재가 없어 템플릿을 적용할 수 없습니다.';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
_isApplyingTemplate = true;
|
||||
_applyingTemplateId = templateId;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final template = await _templateRepository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (template.steps.isEmpty) {
|
||||
_errorMessage = '선택한 템플릿에 등록된 단계가 없습니다.';
|
||||
return false;
|
||||
}
|
||||
|
||||
final sortedSteps = List.of(template.steps)
|
||||
..sort((a, b) => a.stepOrder.compareTo(b.stepOrder));
|
||||
final input = ApprovalStepAssignmentInput(
|
||||
approvalId: approvalId,
|
||||
steps: sortedSteps
|
||||
.map(
|
||||
(step) => ApprovalStepAssignmentItem(
|
||||
stepOrder: step.stepOrder,
|
||||
approverId: step.approver.id,
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
final updated = await _repository.assignSteps(input);
|
||||
_selected = updated;
|
||||
if (_result != null && updated.id != null) {
|
||||
final items = _result!.items
|
||||
.map((item) => item.id == updated.id ? updated : item)
|
||||
.toList();
|
||||
_result = _result!.copyWith(items: items);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
return false;
|
||||
} finally {
|
||||
_isApplyingTemplate = false;
|
||||
_applyingTemplateId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void updateQuery(String value) {
|
||||
_query = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateStatusFilter(ApprovalStatusFilter filter) {
|
||||
_statusFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateDateRange(DateTime? from, DateTime? to) {
|
||||
_fromDate = from;
|
||||
_toDate = to;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearFilters() {
|
||||
_query = '';
|
||||
_statusFilter = ApprovalStatusFilter.all;
|
||||
_fromDate = null;
|
||||
_toDate = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
ApprovalAction? _findActionByType(ApprovalStepActionType type) {
|
||||
final aliases = _actionAliases[type] ?? [type.code];
|
||||
for (final action in _actions) {
|
||||
final normalized = action.name.toLowerCase();
|
||||
for (final alias in aliases) {
|
||||
if (normalized == alias.toLowerCase()) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
1466
lib/features/approvals/presentation/pages/approval_page.dart
Normal file
1466
lib/features/approvals/presentation/pages/approval_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user