feat: 결재·마스터 실연동 업데이트

This commit is contained in:
JiWoong Sul
2025-10-14 18:10:24 +09:00
parent 1325109fba
commit 8067416c09
66 changed files with 2129 additions and 222 deletions

View File

@@ -1,7 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../inventory/lookups/domain/entities/lookup_item.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../domain/entities/approval.dart';
import '../../domain/entities/approval_proceed_status.dart';
import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_repository.dart';
import '../../domain/repositories/approval_template_repository.dart';
@@ -23,6 +27,14 @@ const Map<ApprovalStepActionType, List<String>> _actionAliases = {
ApprovalStepActionType.comment: ['comment', '코멘트', '의견'],
};
const Map<ApprovalStatusFilter, String> _defaultStatusCodes = {
ApprovalStatusFilter.pending: 'pending',
ApprovalStatusFilter.inProgress: 'in_progress',
ApprovalStatusFilter.onHold: 'on_hold',
ApprovalStatusFilter.approved: 'approved',
ApprovalStatusFilter.rejected: 'rejected',
};
/// 결재 목록 및 상세 화면 상태 컨트롤러
///
/// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다.
@@ -31,11 +43,14 @@ class ApprovalController extends ChangeNotifier {
ApprovalController({
required ApprovalRepository approvalRepository,
required ApprovalTemplateRepository templateRepository,
InventoryLookupRepository? lookupRepository,
}) : _repository = approvalRepository,
_templateRepository = templateRepository;
_templateRepository = templateRepository,
_lookupRepository = lookupRepository;
final ApprovalRepository _repository;
final ApprovalTemplateRepository _templateRepository;
final InventoryLookupRepository? _lookupRepository;
PaginatedResult<Approval>? _result;
Approval? _selected;
@@ -47,6 +62,7 @@ class ApprovalController extends ChangeNotifier {
bool _isLoadingTemplates = false;
bool _isApplyingTemplate = false;
int? _applyingTemplateId;
ApprovalProceedStatus? _proceedStatus;
String? _errorMessage;
String _query = '';
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
@@ -54,6 +70,12 @@ class ApprovalController extends ChangeNotifier {
DateTime? _toDate;
List<ApprovalAction> _actions = const [];
List<ApprovalTemplate> _templates = const [];
final Map<String, LookupItem> _statusLookup = {};
final Map<String, String> _statusCodeAliases = Map.fromEntries(
_defaultStatusCodes.entries.map(
(entry) => MapEntry(entry.value, entry.value),
),
);
PaginatedResult<Approval>? get result => _result;
Approval? get selected => _selected;
@@ -73,6 +95,17 @@ class ApprovalController extends ChangeNotifier {
bool get isLoadingTemplates => _isLoadingTemplates;
bool get isApplyingTemplate => _isApplyingTemplate;
int? get applyingTemplateId => _applyingTemplateId;
ApprovalProceedStatus? get proceedStatus => _proceedStatus;
bool get canProceedSelected => _proceedStatus?.canProceed ?? true;
String? get cannotProceedReason {
final reason = _proceedStatus?.reason?.trim();
if (reason == null || reason.isEmpty) {
return null;
}
return reason;
}
Map<String, LookupItem> get statusLookup => _statusLookup;
/// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다.
///
@@ -83,14 +116,7 @@ class ApprovalController extends ChangeNotifier {
_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 statusParam = _statusCodeFor(_statusFilter);
final response = await _repository.list(
page: page,
pageSize: _result?.pageSize ?? 20,
@@ -106,10 +132,12 @@ class ApprovalController extends ChangeNotifier {
final exists = response.items.any((item) => item.id == _selected?.id);
if (!exists) {
_selected = null;
_proceedStatus = null;
}
}
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingList = false;
notifyListeners();
@@ -129,14 +157,78 @@ class ApprovalController extends ChangeNotifier {
try {
final items = await _repository.listActions(activeOnly: true);
_actions = items;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingActions = false;
notifyListeners();
}
}
Future<void> loadStatusLookups() async {
final repository = _lookupRepository;
if (repository == null) {
return;
}
try {
final items = await repository.fetchApprovalStatuses();
_statusLookup
..clear()
..addEntries(
items.map(
(item) => MapEntry(
(item.code ?? item.name).toLowerCase(),
item,
),
),
);
for (final entry in _defaultStatusCodes.entries) {
final code = entry.value.toLowerCase();
final lookup = _statusLookup[code];
if (lookup != null) {
_statusCodeAliases[entry.value] = lookup.code?.toLowerCase() ?? code;
}
}
notifyListeners();
} catch (_) {
// 실패 시 기본 라벨 사용
}
}
String statusLabel(ApprovalStatusFilter filter) {
if (filter == ApprovalStatusFilter.all) {
return '전체 상태';
}
final code = _statusCodeFor(filter);
if (code != null) {
final normalized = code.toLowerCase();
final lookup = _statusLookup[normalized];
if (lookup != null && lookup.name.isNotEmpty) {
return lookup.name;
}
}
return switch (filter) {
ApprovalStatusFilter.pending => '승인대기',
ApprovalStatusFilter.inProgress => '진행중',
ApprovalStatusFilter.onHold => '보류',
ApprovalStatusFilter.approved => '승인완료',
ApprovalStatusFilter.rejected => '반려',
ApprovalStatusFilter.all => '전체 상태',
};
}
String? _statusCodeFor(ApprovalStatusFilter filter) {
if (filter == ApprovalStatusFilter.all) {
return null;
}
final defaultCode = _defaultStatusCodes[filter];
if (defaultCode == null) {
return null;
}
return _statusCodeAliases[defaultCode] ?? defaultCode;
}
/// 활성화된 결재 템플릿 목록을 조회해 캐싱한다.
///
/// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다.
@@ -154,8 +246,9 @@ class ApprovalController extends ChangeNotifier {
isActive: true,
);
_templates = result.items;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingTemplates = false;
notifyListeners();
@@ -169,6 +262,7 @@ class ApprovalController extends ChangeNotifier {
Future<void> selectApproval(int id) async {
_isLoadingDetail = true;
_errorMessage = null;
_proceedStatus = null;
notifyListeners();
try {
final detail = await _repository.fetchDetail(
@@ -177,8 +271,12 @@ class ApprovalController extends ChangeNotifier {
includeHistories: true,
);
_selected = detail;
} catch (e) {
_errorMessage = e.toString();
if (detail.id != null) {
await _loadProceedStatus(detail.id!);
}
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
} finally {
_isLoadingDetail = false;
notifyListeners();
@@ -188,6 +286,7 @@ class ApprovalController extends ChangeNotifier {
/// 선택된 결재 상세를 비우고 화면을 초기화한다.
void clearSelection() {
_selected = null;
_proceedStatus = null;
notifyListeners();
}
@@ -200,6 +299,12 @@ class ApprovalController extends ChangeNotifier {
required ApprovalStepActionType type,
String? note,
}) async {
final approvalId = _selected?.id;
if (approvalId == null) {
_errorMessage = '선택한 결재 정보가 없어 단계를 처리할 수 없습니다.';
notifyListeners();
return false;
}
if (step.id == null) {
_errorMessage = '단계 식별자가 없어 행위를 수행할 수 없습니다.';
notifyListeners();
@@ -217,6 +322,14 @@ class ApprovalController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final proceedStatus = await _repository.canProceed(approvalId);
_proceedStatus = proceedStatus;
if (!proceedStatus.canProceed) {
_errorMessage = proceedStatus.reason ??
'결재 단계가 현재 상태에서 진행될 수 없습니다.';
return false;
}
final sanitizedNote = note?.trim();
final updated = await _repository.performStepAction(
ApprovalStepActionInput(
@@ -232,9 +345,15 @@ class ApprovalController extends ChangeNotifier {
.toList();
_result = _result!.copyWith(items: items);
}
if (updated.id != null) {
await _loadProceedStatus(updated.id!);
} else {
await _loadProceedStatus(approvalId);
}
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return false;
} finally {
_isPerformingAction = false;
@@ -291,8 +410,9 @@ class ApprovalController extends ChangeNotifier {
_result = _result!.copyWith(items: items);
}
return true;
} catch (e) {
_errorMessage = e.toString();
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
return false;
} finally {
_isApplyingTemplate = false;
@@ -348,4 +468,15 @@ class ApprovalController extends ChangeNotifier {
}
return null;
}
Future<void> _loadProceedStatus(int approvalId) async {
try {
final status = await _repository.canProceed(approvalId);
_proceedStatus = status;
} catch (error) {
_proceedStatus = null;
final failure = Failure.from(error);
_errorMessage ??= failure.describe();
}
}
}

View File

@@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/constants/app_sections.dart';
import '../../../../core/permissions/permission_manager.dart';
import '../../../../core/permissions/permission_resources.dart';
import '../../../../widgets/app_layout.dart';
import '../../../../widgets/components/feedback.dart';
import '../../../../widgets/components/filter_bar.dart';
@@ -18,9 +19,10 @@ import '../../domain/entities/approval.dart';
import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_repository.dart';
import '../../domain/repositories/approval_template_repository.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../controllers/approval_controller.dart';
const _approvalsResourcePath = '/approvals/requests';
const _approvalsResourcePath = PermissionResources.approvals;
/// 결재 관리 최상위 페이지.
///
@@ -85,11 +87,15 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
_controller = ApprovalController(
approvalRepository: GetIt.I<ApprovalRepository>(),
templateRepository: GetIt.I<ApprovalTemplateRepository>(),
lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>()
? GetIt.I<InventoryLookupRepository>()
: null,
)..addListener(_handleControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.wait([
_controller.loadActionOptions(),
_controller.loadTemplates(),
_controller.loadStatusLookups(),
]);
await _controller.fetch();
});
@@ -335,6 +341,8 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
selectedTemplateId: _selectedTemplateId,
canPerformStepActions: canPerformStepActions,
canApplyTemplate: canManageTemplates,
canProceed: _controller.canProceedSelected,
cannotProceedReason: _controller.cannotProceedReason,
dateFormat: _dateTimeFormat,
onRefresh: () {
final id = selectedApproval?.id;
@@ -660,22 +668,8 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
return confirmed ?? false;
}
String _statusLabel(ApprovalStatusFilter filter) {
switch (filter) {
case ApprovalStatusFilter.all:
return '전체 상태';
case ApprovalStatusFilter.pending:
return '대기';
case ApprovalStatusFilter.inProgress:
return '진행중';
case ApprovalStatusFilter.onHold:
return '보류';
case ApprovalStatusFilter.approved:
return '승인';
case ApprovalStatusFilter.rejected:
return '반려';
}
}
String _statusLabel(ApprovalStatusFilter filter) =>
_controller.statusLabel(filter);
String _dialogTitle(ApprovalStepActionType type) {
switch (type) {
@@ -827,6 +821,8 @@ class _DetailSection extends StatelessWidget {
required this.selectedTemplateId,
required this.canPerformStepActions,
required this.canApplyTemplate,
required this.canProceed,
required this.cannotProceedReason,
required this.dateFormat,
required this.onRefresh,
required this.onClose,
@@ -849,6 +845,8 @@ class _DetailSection extends StatelessWidget {
final int? selectedTemplateId;
final bool canPerformStepActions;
final bool canApplyTemplate;
final bool canProceed;
final String? cannotProceedReason;
final intl.DateFormat dateFormat;
final VoidCallback onRefresh;
final VoidCallback? onClose;
@@ -929,6 +927,8 @@ class _DetailSection extends StatelessWidget {
selectedTemplateId: selectedTemplateId,
canPerformStepActions: canPerformStepActions,
canApplyTemplate: canApplyTemplate,
canProceed: canProceed,
cannotProceedReason: cannotProceedReason,
onSelectTemplate: onSelectTemplate,
onApplyTemplate: onApplyTemplate,
onReloadTemplates: onReloadTemplates,
@@ -1028,6 +1028,8 @@ class _StepTab extends StatelessWidget {
required this.selectedTemplateId,
required this.canPerformStepActions,
required this.canApplyTemplate,
required this.canProceed,
required this.cannotProceedReason,
required this.onSelectTemplate,
required this.onApplyTemplate,
required this.onReloadTemplates,
@@ -1048,6 +1050,8 @@ class _StepTab extends StatelessWidget {
final int? selectedTemplateId;
final bool canPerformStepActions;
final bool canApplyTemplate;
final bool canProceed;
final String? cannotProceedReason;
final void Function(int?) onSelectTemplate;
final void Function(int templateId) onApplyTemplate;
final VoidCallback onReloadTemplates;
@@ -1097,6 +1101,14 @@ class _StepTab extends StatelessWidget {
style: theme.textTheme.muted,
),
),
if (!canProceed)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Text(
cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.',
style: theme.textTheme.muted,
),
),
if (steps.isEmpty)
Expanded(
child: Center(
@@ -1112,6 +1124,8 @@ class _StepTab extends StatelessWidget {
final disabledReason = _disabledReason(
step,
canPerformStepActions,
canProceed,
cannotProceedReason,
);
final isProcessingStep =
isPerformingAction && processingStepId == step.id;
@@ -1284,7 +1298,12 @@ class _StepTab extends StatelessWidget {
return button;
}
String? _disabledReason(ApprovalStep step, bool canPerformStepActions) {
String? _disabledReason(
ApprovalStep step,
bool canPerformStepActions,
bool canProceed,
String? cannotProceedReason,
) {
if (!canPerformStepActions) {
return '결재 행위를 수행할 권한이 없습니다.';
}
@@ -1294,6 +1313,9 @@ class _StepTab extends StatelessWidget {
if (!hasActionOptions) {
return '사용 가능한 결재 행위가 없습니다.';
}
if (!canProceed) {
return cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.';
}
if (isPerformingAction && processingStepId != step.id) {
return '다른 결재 단계를 처리 중입니다.';
}