feat(approvals): Approval Flow v2 프런트엔드 전면 개편

- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**)
- ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화
- ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원
- Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영
- Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신
- SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리
- 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용
- Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가
- 실행: flutter analyze, flutter test
This commit is contained in:
JiWoong Sul
2025-10-31 01:05:39 +09:00
parent 259b056072
commit d76f765814
133 changed files with 13878 additions and 947 deletions

View File

@@ -2,29 +2,62 @@ import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/failure.dart';
import '../../../domain/entities/approval.dart';
import '../../../domain/entities/approval_flow.dart';
import '../../../domain/repositories/approval_repository.dart';
import '../../../domain/usecases/recall_approval_use_case.dart';
import '../../../domain/usecases/resubmit_approval_use_case.dart';
import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart';
/// 결재 이력에서 필터링 가능한 행위 타입.
enum ApprovalHistoryActionFilter { all, approve, reject, comment }
/// 결재 이력 화면 탭 종류.
enum ApprovalHistoryTab { flow, audit }
/// 결재 이력 화면의 목록/필터 상태를 관리하는 컨트롤러.
///
/// 기간, 검색어, 행위 타입에 따라 목록을 조회하고 페이지 사이즈를 조절한다.
class ApprovalHistoryController extends ChangeNotifier {
ApprovalHistoryController({required ApprovalHistoryRepository repository})
: _repository = repository;
ApprovalHistoryController({
required ApprovalHistoryRepository repository,
ApprovalRepository? approvalRepository,
RecallApprovalUseCase? recallUseCase,
ResubmitApprovalUseCase? resubmitUseCase,
}) : _repository = repository,
_approvalRepository = approvalRepository,
_recallUseCase = recallUseCase,
_resubmitUseCase = resubmitUseCase;
final ApprovalHistoryRepository _repository;
final ApprovalRepository? _approvalRepository;
final RecallApprovalUseCase? _recallUseCase;
final ResubmitApprovalUseCase? _resubmitUseCase;
final Map<int, ApprovalFlow> _flowCache = <int, ApprovalFlow>{};
PaginatedResult<ApprovalHistoryRecord>? _result;
PaginatedResult<ApprovalHistory>? _auditResult;
bool _isLoading = false;
bool _isLoadingAudit = false;
bool _isPerformingAction = false;
bool _isLoadingFlow = false;
ApprovalHistoryTab _activeTab = ApprovalHistoryTab.flow;
String _query = '';
ApprovalHistoryActionFilter _actionFilter = ApprovalHistoryActionFilter.all;
DateTime? _from;
DateTime? _to;
String? _errorMessage;
int _pageSize = 20;
int _auditPageSize = 20;
int? _selectedApprovalId;
ApprovalFlow? _selectedFlow;
int? _auditActorId;
String? _auditActionCode;
DateTime? _auditFrom;
DateTime? _auditTo;
final Map<String, ApprovalAction> _auditActions = <String, ApprovalAction>{};
bool _isSelectionForbidden = false;
PaginatedResult<ApprovalHistoryRecord>? get result => _result;
bool get isLoading => _isLoading;
@@ -34,6 +67,30 @@ class ApprovalHistoryController extends ChangeNotifier {
DateTime? get to => _to;
String? get errorMessage => _errorMessage;
int get pageSize => _result?.pageSize ?? _pageSize;
PaginatedResult<ApprovalHistory>? get auditResult => _auditResult;
bool get isLoadingAudit => _isLoadingAudit;
bool get isPerformingAction => _isPerformingAction;
ApprovalHistoryTab get activeTab => _activeTab;
int get auditPageSize => _auditResult?.pageSize ?? _auditPageSize;
int? get selectedApprovalId => _selectedApprovalId;
bool get hasAuditSelection => _selectedApprovalId != null;
bool get hasAuditResults => _auditResult?.items.isNotEmpty ?? false;
bool get isLoadingFlow => _isLoadingFlow;
ApprovalFlow? get selectedFlow => _selectedFlow;
int? get auditActorId => _auditActorId;
String? get auditActionCode => _auditActionCode;
DateTime? get auditFrom => _auditFrom;
DateTime? get auditTo => _auditTo;
List<ApprovalAction> get auditActions {
if (_auditActions.isEmpty) {
return const <ApprovalAction>[];
}
final items = _auditActions.values.toList(growable: false);
items.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return items;
}
bool get isSelectionForbidden => _isSelectionForbidden;
/// 현재 필터 조건에 맞춰 결재 이력 목록을 불러온다.
///
@@ -43,17 +100,7 @@ class ApprovalHistoryController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final previous = _result;
final int resolvedPage;
if (page < 1) {
resolvedPage = 1;
} else if (previous != null && previous.pageSize > 0) {
final calculated = (previous.total / previous.pageSize).ceil();
final maxPage = calculated < 1 ? 1 : calculated;
resolvedPage = page > maxPage ? maxPage : page;
} else {
resolvedPage = page;
}
final resolvedPage = _resolvePage(page, _result);
final action = switch (_actionFilter) {
ApprovalHistoryActionFilter.all => null,
ApprovalHistoryActionFilter.approve => 'approve',
@@ -80,6 +127,125 @@ class ApprovalHistoryController extends ChangeNotifier {
}
}
/// 지정한 결재의 감사 로그를 조회한다.
Future<void> fetchAuditLogs({required int approvalId, int page = 1}) async {
final approvalRepository = _approvalRepository;
if (approvalRepository == null) {
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
}
_isLoadingAudit = true;
_errorMessage = null;
_selectedApprovalId = approvalId;
notifyListeners();
try {
final resolvedPage = _resolvePage(page, _auditResult);
final response = await approvalRepository.listHistory(
approvalId: approvalId,
page: resolvedPage,
pageSize: auditPageSize,
from: _auditFrom ?? _from,
to: _auditTo ?? _to,
actorId: _auditActorId,
approvalActionId: _resolveAuditActionId(),
);
_auditResult = response;
_auditPageSize = response.pageSize;
if (response.items.isNotEmpty) {
final actionMap = <String, ApprovalAction>{};
for (final log in response.items) {
final code = log.action.code?.trim();
if (code == null || code.isEmpty) {
continue;
}
actionMap.putIfAbsent(code, () => log.action);
}
if (actionMap.isNotEmpty) {
_auditActions
..clear()
..addAll(actionMap);
}
}
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
if (failure.statusCode == 403) {
_isSelectionForbidden = true;
_auditResult = null;
}
} finally {
_isLoadingAudit = false;
notifyListeners();
}
}
/// 결재 상세와 타임라인 정보를 조회해 선택 상태를 갱신한다.
Future<void> loadApprovalFlow(int approvalId, {bool force = false}) async {
final approvalRepository = _approvalRepository;
if (approvalRepository == null) {
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
}
if (!force && _flowCache.containsKey(approvalId)) {
_selectedApprovalId = approvalId;
_selectedFlow = _flowCache[approvalId];
notifyListeners();
return;
}
_isLoadingFlow = true;
_errorMessage = null;
_selectedApprovalId = approvalId;
_isSelectionForbidden = false;
_selectedFlow = null;
notifyListeners();
try {
final detail = await approvalRepository.fetchDetail(
approvalId,
includeSteps: true,
includeHistories: true,
);
final flow = ApprovalFlow.fromApproval(detail);
_flowCache[approvalId] = flow;
_selectedFlow = flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
if (failure.statusCode == 403) {
_isSelectionForbidden = true;
_selectedFlow = null;
_auditResult = null;
}
} finally {
_isLoadingFlow = false;
notifyListeners();
}
}
/// 선택된 결재 흐름을 최신 상태로 갱신한다.
Future<ApprovalFlow?> refreshFlow(int approvalId) async {
final approvalRepository = _approvalRepository;
if (approvalRepository == null) {
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
}
try {
final detail = await approvalRepository.fetchDetail(
approvalId,
includeSteps: true,
includeHistories: true,
);
final flow = ApprovalFlow.fromApproval(detail);
_flowCache[approvalId] = flow;
if (_selectedApprovalId == approvalId) {
_selectedFlow = flow;
notifyListeners();
}
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
}
}
/// 검색어를 업데이트해 다음 조회 시 적용될 수 있도록 한다.
void updateQuery(String value) {
_query = value;
@@ -99,6 +265,82 @@ class ApprovalHistoryController extends ChangeNotifier {
notifyListeners();
}
/// 활성 탭을 변경한다.
void updateActiveTab(ApprovalHistoryTab tab) {
if (_activeTab == tab) {
return;
}
_activeTab = tab;
notifyListeners();
}
/// 감사 로그 페이지 사이즈를 변경한다.
void updateAuditPageSize(int value) {
if (value <= 0) {
return;
}
_auditPageSize = value;
notifyListeners();
}
/// 감사 로그 행위자를 필터링한다.
void updateAuditActor(int? actorId) {
final normalized = actorId != null && actorId > 0 ? actorId : null;
if (_auditActorId == normalized) {
return;
}
_auditActorId = normalized;
notifyListeners();
}
/// 감사 로그 행위 타입을 필터링한다.
void updateAuditAction(String? actionCode) {
final normalized = actionCode?.trim();
if (normalized != null && normalized.isEmpty) {
_auditActionCode = null;
} else {
if (_auditActionCode == normalized) {
return;
}
_auditActionCode = normalized;
}
notifyListeners();
}
/// 감사 로그 기간 필터를 설정한다.
void updateAuditDateRange(DateTime? from, DateTime? to) {
DateTime? normalizedFrom = from;
DateTime? normalizedTo = to;
if (normalizedFrom != null && normalizedTo != null) {
if (normalizedFrom.isAfter(normalizedTo)) {
final temp = normalizedFrom;
normalizedFrom = normalizedTo;
normalizedTo = temp;
}
}
if (_auditFrom == normalizedFrom && _auditTo == normalizedTo) {
return;
}
_auditFrom = normalizedFrom;
_auditTo = normalizedTo;
notifyListeners();
}
/// 감사 로그 필터를 초기화한다.
void clearAuditFilters() {
if (_auditActorId == null &&
(_auditActionCode == null || _auditActionCode!.isEmpty) &&
_auditFrom == null &&
_auditTo == null) {
return;
}
_auditActorId = null;
_auditActionCode = null;
_auditFrom = null;
_auditTo = null;
notifyListeners();
}
/// 검색어/행위/기간 필터를 초기화한다.
void clearFilters() {
_query = '';
@@ -108,6 +350,20 @@ class ApprovalHistoryController extends ChangeNotifier {
notifyListeners();
}
/// 감사 로그 선택 상태를 초기화한다.
void clearAuditSelection() {
clearSelection();
}
/// 선택된 결재의 감사 로그를 새로고침한다.
Future<void> refreshAudit() async {
final approvalId = _selectedApprovalId;
if (approvalId == null) {
return;
}
await fetchAuditLogs(approvalId: approvalId, page: _auditResult?.page ?? 1);
}
/// 축적된 오류 메시지를 초기화한다.
void clearError() {
_errorMessage = null;
@@ -123,9 +379,124 @@ class ApprovalHistoryController extends ChangeNotifier {
notifyListeners();
}
/// 결재를 회수한다.
Future<ApprovalFlow?> recallApproval(ApprovalRecallInput input) async {
final useCase = _recallUseCase;
if (useCase == null) {
throw StateError('RecallApprovalUseCase가 주입되지 않았습니다.');
}
_isPerformingAction = true;
_errorMessage = null;
notifyListeners();
try {
final flow = await useCase.call(input);
final targetId = flow.approval.id ?? input.approvalId;
await _refreshAfterAction(targetId, flow: flow);
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_isPerformingAction = false;
notifyListeners();
}
}
/// 결재를 재상신한다.
Future<ApprovalFlow?> resubmitApproval(
ApprovalResubmissionInput input,
) async {
final useCase = _resubmitUseCase;
if (useCase == null) {
throw StateError('ResubmitApprovalUseCase가 주입되지 않았습니다.');
}
_isPerformingAction = true;
_errorMessage = null;
notifyListeners();
try {
final flow = await useCase.call(input);
final targetId = flow.approval.id ?? input.approvalId;
await _refreshAfterAction(targetId, flow: flow);
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_isPerformingAction = false;
notifyListeners();
}
}
bool get hasActiveFilters =>
_query.trim().isNotEmpty ||
_actionFilter != ApprovalHistoryActionFilter.all ||
_from != null ||
_to != null;
bool get hasActiveAuditFilters =>
(_auditActorId ?? 0) > 0 ||
(_auditActionCode != null && _auditActionCode!.trim().isNotEmpty) ||
_auditFrom != null ||
_auditTo != null;
int? _resolveAuditActionId() {
final code = _auditActionCode?.trim();
if (code == null || code.isEmpty) {
return null;
}
final action = _auditActions[code];
return action?.id;
}
/// 현재 선택 상태와 캐시를 초기화한다.
void clearSelection() {
if (_selectedApprovalId == null &&
_auditResult == null &&
_selectedFlow == null) {
return;
}
_selectedApprovalId = null;
_selectedFlow = null;
_auditResult = null;
_isSelectionForbidden = false;
notifyListeners();
}
int _resolvePage(int requested, PaginatedResult<dynamic>? current) {
if (requested < 1) {
return 1;
}
if (current != null && current.pageSize > 0) {
final calculated = (current.total / current.pageSize).ceil();
final maxPage = calculated < 1 ? 1 : calculated;
return requested > maxPage ? maxPage : requested;
}
return requested;
}
Future<void> _refreshAfterAction(int approvalId, {ApprovalFlow? flow}) async {
await fetch(page: _result?.page ?? 1);
if (flow != null) {
_flowCache[approvalId] = flow;
if (_selectedApprovalId == approvalId) {
_selectedFlow = flow;
notifyListeners();
}
}
if (_selectedApprovalId == approvalId) {
if (flow == null && _approvalRepository != null) {
await loadApprovalFlow(approvalId, force: true);
}
if (_approvalRepository != null) {
await fetchAuditLogs(
approvalId: approvalId,
page: _auditResult?.page ?? 1,
);
}
}
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../approvals/domain/entities/approval.dart';
import '../../../shared/widgets/approval_ui_helpers.dart';
import '../../../../../widgets/components/superport_table.dart';
/// 결재 감사 로그를 표 형태로 렌더링하는 위젯.
class ApprovalAuditLogTable extends StatelessWidget {
const ApprovalAuditLogTable({
super.key,
required this.logs,
required this.dateFormat,
this.pagination,
this.onPageChange,
this.onPageSizeChange,
this.isLoading = false,
});
/// 감사 로그 목록.
final List<ApprovalHistory> logs;
/// 날짜 포맷터.
final DateFormat dateFormat;
/// 페이지네이션 상태.
final SuperportTablePagination? pagination;
/// 페이지 변경 콜백.
final ValueChanged<int>? onPageChange;
/// 페이지 크기 변경 콜백.
final ValueChanged<int>? onPageSizeChange;
/// 로딩 여부.
final bool isLoading;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
if (logs.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Center(
child: Text('선택한 결재의 감사 로그가 없습니다.', style: theme.textTheme.muted),
),
);
}
return SuperportTable(
columns: const [
Text('행위'),
Text('변경 상태'),
Text('승인자'),
Text('메모'),
Text('일시'),
],
rows: logs.map((log) {
final statusLabel = _buildStatusLabel(log);
final timestamp = dateFormat.format(log.actionAt.toLocal());
return [
ShadBadge.outline(child: Text(log.action.name)),
ApprovalStatusBadge(label: statusLabel, colorHex: log.toStatus.color),
ApprovalApproverCell(
name: log.approver.name,
employeeNo: log.approver.employeeNo,
),
ApprovalNoteTooltip(note: log.note),
Text(timestamp),
];
}).toList(),
rowHeight: 68,
maxHeight: 420,
columnSpanExtent: (index) {
switch (index) {
case 0:
return const FixedTableSpanExtent(120);
case 2:
return const FixedTableSpanExtent(220);
case 3:
return const FixedTableSpanExtent(220);
case 4:
return const FixedTableSpanExtent(160);
default:
return const FixedTableSpanExtent(140);
}
},
pagination: pagination,
onPageChange: onPageChange,
onPageSizeChange: onPageSizeChange,
isLoading: isLoading,
);
}
String _buildStatusLabel(ApprovalHistory log) {
final from = log.fromStatus?.name ?? '시작';
final to = log.toStatus.name;
return '$from$to';
}
}

View File

@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../approvals/domain/entities/approval.dart';
import '../../../../approvals/domain/entities/approval_flow.dart';
import '../../../shared/widgets/approval_ui_helpers.dart';
/// 결재 흐름의 상태 변화를 타임라인으로 표현하는 위젯.
class ApprovalFlowTimeline extends StatelessWidget {
const ApprovalFlowTimeline({
super.key,
required this.flow,
required this.dateFormat,
});
/// 표시할 결재 흐름.
final ApprovalFlow flow;
/// 일시 포맷터.
final DateFormat dateFormat;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final histories = List<ApprovalHistory>.from(flow.histories)
..sort((a, b) => a.actionAt.compareTo(b.actionAt));
final summary = flow.statusSummary;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSummary(theme, summary),
const SizedBox(height: 16),
if (histories.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 20),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
),
child: Text('결재 상태 변경 이력이 없습니다.', style: theme.textTheme.muted),
)
else
Column(
children: [
for (var index = 0; index < histories.length; index++)
_TimelineEntry(
history: histories[index],
isFirst: index == 0,
isLast: index == histories.length - 1,
dateFormat: dateFormat,
),
],
),
],
);
}
Widget _buildSummary(ShadThemeData theme, ApprovalFlowStatusSummary summary) {
final requester = flow.requester;
final finalApprover = flow.finalApprover;
final status = flow.status;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
ApprovalStatusBadge(label: status.name, colorHex: status.color),
const SizedBox(width: 12),
Text(
'${summary.totalSteps}단계 · 완료 ${summary.completedSteps} · 대기 ${summary.pendingSteps}',
style: theme.textTheme.small,
),
],
),
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'상신자',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 4),
Text(
'${requester.name} (${requester.employeeNo})',
style: theme.textTheme.p,
),
if (finalApprover != null) ...[
const SizedBox(height: 12),
Text(
'최종 승인자',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 4),
Text(
'${finalApprover.name} (${finalApprover.employeeNo})',
style: theme.textTheme.p,
),
],
const SizedBox(height: 12),
Text(
'결재번호 ${flow.approvalNo}',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
),
],
);
}
}
class _TimelineEntry extends StatelessWidget {
const _TimelineEntry({
required this.history,
required this.isFirst,
required this.isLast,
required this.dateFormat,
});
final ApprovalHistory history;
final bool isFirst;
final bool isLast;
final DateFormat dateFormat;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final fromStatus = history.fromStatus?.name ?? '시작';
final toStatus = history.toStatus.name;
final timestamp = dateFormat.format(history.actionAt.toLocal());
return Padding(
padding: EdgeInsets.only(top: isFirst ? 0 : 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTimelineIndicator(theme),
const SizedBox(width: 12),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
history.action.name,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(timestamp, style: theme.textTheme.muted),
],
),
const SizedBox(height: 8),
Text('$fromStatus$toStatus', style: theme.textTheme.small),
const SizedBox(height: 12),
ApprovalApproverCell(
name: history.approver.name,
employeeNo: history.approver.employeeNo,
),
if (history.note?.trim().isNotEmpty == true) ...[
const SizedBox(height: 12),
ApprovalNoteTooltip(note: history.note),
],
],
),
),
),
],
),
);
}
Widget _buildTimelineIndicator(ShadThemeData theme) {
final primary = theme.colorScheme.primary;
return SizedBox(
width: 20,
child: Column(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: primary,
borderRadius: BorderRadius.circular(12),
),
),
if (!isLast)
Container(
width: 2,
height: 40,
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(color: primary.withValues(alpha: 0.4)),
),
],
),
);
}
}