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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user