diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md index 629371d..215f742 100644 --- a/doc/IMPLEMENTATION_TASKS.md +++ b/doc/IMPLEMENTATION_TASKS.md @@ -56,11 +56,11 @@ - [x] 그룹 권한: 목록/필터(그룹/메뉴/사용), 체크박스 매트릭스 편집 UI (현황: 그룹·메뉴 lookup + 권한 매트릭스 편집/일괄 저장 흐름 구현, 실제 API 응답 미연결) ## 7) 결재(UI) -- [ ] 결재(`/approvals`): 목록/필터, 상세(개요/단계/이력 탭) (현황: `/approvals/requests` 라우트를 `ApprovalPage`로 연결하고 AppLayout/FilterBar·단계 행위·템플릿 적용까지 연동했으며 추가 액션/권한 제어는 후속 예정) +- [x] 결재(`/approvals`): 목록/필터, 상세(개요/단계/이력 탭) (현황: `/approvals/requests` 라우트를 `ApprovalPage`로 연결하고 AppLayout/FilterBar·단계 탭/이력 탭 UI와 단계 행위·템플릿 적용까지 연동 완료, 신규 결재 버튼은 API 연동 시 확장 예정) - [x] 템플릿 불러오기: 단계 탭에서 템플릿 선택 UI(단계 리스트 반영) (현황: 템플릿 목록 로딩·선택·확인 다이얼로그·`assignSteps` 호출로 단계 일괄 적용까지 구현, 템플릿 CRUD 화면과 연동되어 최신 목록/단계 구성이 반영됨) - [x] 단계 행위: 승인/반려/코멘트 버튼(가능 여부 상태에 따라 비활성/툴팁) (현황: 단계 버튼·툴팁·행위 다이얼로그를 구현했고 `ApprovalRepository.performStepAction` 연동 완료, 권한 기반 노출/후속 알림은 TODO) -- [ ] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) (현황: 단계 목록·필터·상세 다이얼로그까지 구현했으며 생성/수정 등의 CRUD 플로우는 후속 예정) -- [ ] 이력(`/approval-histories`): 조회 전용 테이블 (현황: AppLayout 기반 안내 화면으로 전환, 실제 테이블/필터/다운로드는 미구현) +- [x] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) (현황: 목록/필터 + 상세/신규/수정 모달 UI를 구현하고 컨트롤러에서 생성·수정 호출까지 연동, 삭제/권한 제어는 후속 예정) +- [x] 이력(`/approval-histories`): 조회 전용 테이블 (현황: AppLayout 기반 필터·페이지네이션 테이블과 기간 선택/엑셀 비활성 버튼까지 구현, 다운로드 API 연동은 후속 예정) - [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정) ## 8) 우편번호 검색 모달(UI) diff --git a/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart b/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart new file mode 100644 index 0000000..cf9a7eb --- /dev/null +++ b/lib/features/approvals/history/data/dtos/approval_history_record_dto.dart @@ -0,0 +1,114 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; + +import '../../domain/entities/approval_history_record.dart'; + +class ApprovalHistoryRecordDto { + ApprovalHistoryRecordDto({ + required this.id, + required this.approvalId, + required this.approvalNo, + this.stepOrder, + required this.action, + this.fromStatus, + required this.toStatus, + required this.approver, + required this.actionAt, + this.note, + }); + + final int id; + final int approvalId; + final String approvalNo; + final int? stepOrder; + final ApprovalAction action; + final ApprovalStatus? fromStatus; + final ApprovalStatus toStatus; + final ApprovalApprover approver; + final DateTime actionAt; + final String? note; + + factory ApprovalHistoryRecordDto.fromJson(Map json) { + final approvalData = json['approval'] as Map?; + final id = json['id'] as int? ?? 0; + final approvalId = + json['approval_id'] as int? ?? approvalData?['id'] as int? ?? 0; + final approvalNo = + json['approval_no'] as String? ?? + approvalData?['approval_no'] as String? ?? + approvalData?['approvalNo'] as String? ?? + '-'; + final stepOrder = + json['step_order'] as int? ?? + (json['step'] as Map?)?['step_order'] as int?; + final action = ApprovalActionDto.fromJson( + json['action'] as Map? ?? const {}, + ).toEntity(); + final fromStatus = json['from_status'] is Map + ? ApprovalStatusDto.fromJson( + json['from_status'] as Map, + ).toEntity() + : null; + final toStatus = ApprovalStatusDto.fromJson( + json['to_status'] as Map? ?? const {}, + ).toEntity(); + final approver = ApprovalApproverDto.fromJson( + json['approver'] as Map? ?? const {}, + ).toEntity(); + final actionAt = _parseDate(json['action_at']) ?? DateTime.now(); + final note = json['note'] as String?; + + return ApprovalHistoryRecordDto( + id: id, + approvalId: approvalId, + approvalNo: approvalNo, + stepOrder: stepOrder, + action: action, + fromStatus: fromStatus, + toStatus: toStatus, + approver: approver, + actionAt: actionAt, + note: note, + ); + } + + ApprovalHistoryRecord toEntity() { + return ApprovalHistoryRecord( + id: id, + approvalId: approvalId, + approvalNo: approvalNo, + stepOrder: stepOrder, + action: action, + fromStatus: fromStatus, + toStatus: toStatus, + approver: approver, + actionAt: actionAt, + note: note, + ); + } + + static PaginatedResult parsePaginated( + Map? json, + ) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(ApprovalHistoryRecordDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(); + + return PaginatedResult( + items: items, + page: json?['page'] as int? ?? 1, + pageSize: json?['page_size'] as int? ?? items.length, + total: json?['total'] as int? ?? items.length, + ); + } +} + +DateTime? _parseDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + return null; +} diff --git a/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart b/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart new file mode 100644 index 0000000..fdc9ba9 --- /dev/null +++ b/lib/features/approvals/history/data/repositories/approval_history_repository_remote.dart @@ -0,0 +1,41 @@ +import 'package:dio/dio.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/network/api_client.dart'; + +import '../../domain/entities/approval_history_record.dart'; +import '../../domain/repositories/approval_history_repository.dart'; +import '../dtos/approval_history_record_dto.dart'; + +class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository { + ApprovalHistoryRepositoryRemote({required ApiClient apiClient}) + : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/approval-histories'; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + String? action, + DateTime? from, + DateTime? to, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (action != null && action.isNotEmpty) 'action': action, + if (from != null) 'from': from.toIso8601String(), + if (to != null) 'to': to.toIso8601String(), + }, + options: Options(responseType: ResponseType.json), + ); + + return ApprovalHistoryRecordDto.parsePaginated(response.data); + } +} diff --git a/lib/features/approvals/history/domain/entities/approval_history_record.dart b/lib/features/approvals/history/domain/entities/approval_history_record.dart new file mode 100644 index 0000000..c73f93c --- /dev/null +++ b/lib/features/approvals/history/domain/entities/approval_history_record.dart @@ -0,0 +1,56 @@ +import '../../../domain/entities/approval.dart'; + +/// 결재 이력 레코드 +/// +/// - 결재별 단계 변경 로그를 목록 화면에서 표시하기 위한 모델이다. +class ApprovalHistoryRecord { + ApprovalHistoryRecord({ + required this.id, + required this.approvalId, + required this.approvalNo, + this.stepOrder, + required this.action, + this.fromStatus, + required this.toStatus, + required this.approver, + required this.actionAt, + this.note, + }); + + final int id; + final int approvalId; + final String approvalNo; + final int? stepOrder; + final ApprovalAction action; + final ApprovalStatus? fromStatus; + final ApprovalStatus toStatus; + final ApprovalApprover approver; + final DateTime actionAt; + final String? note; + + ApprovalHistoryRecord copyWith({ + int? id, + int? approvalId, + String? approvalNo, + int? stepOrder, + ApprovalAction? action, + ApprovalStatus? fromStatus, + ApprovalStatus? toStatus, + ApprovalApprover? approver, + DateTime? actionAt, + String? note, + }) { + return ApprovalHistoryRecord( + id: id ?? this.id, + approvalId: approvalId ?? this.approvalId, + approvalNo: approvalNo ?? this.approvalNo, + stepOrder: stepOrder ?? this.stepOrder, + action: action ?? this.action, + fromStatus: fromStatus ?? this.fromStatus, + toStatus: toStatus ?? this.toStatus, + approver: approver ?? this.approver, + actionAt: actionAt ?? this.actionAt, + note: note ?? this.note, + ); + } +} diff --git a/lib/features/approvals/history/domain/repositories/approval_history_repository.dart b/lib/features/approvals/history/domain/repositories/approval_history_repository.dart new file mode 100644 index 0000000..1b206f3 --- /dev/null +++ b/lib/features/approvals/history/domain/repositories/approval_history_repository.dart @@ -0,0 +1,14 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/approval_history_record.dart'; + +abstract class ApprovalHistoryRepository { + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + String? action, + DateTime? from, + DateTime? to, + }); +} diff --git a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart new file mode 100644 index 0000000..4562e8c --- /dev/null +++ b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart @@ -0,0 +1,94 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../domain/entities/approval_history_record.dart'; +import '../../domain/repositories/approval_history_repository.dart'; + +enum ApprovalHistoryActionFilter { all, approve, reject, comment } + +class ApprovalHistoryController extends ChangeNotifier { + ApprovalHistoryController({required ApprovalHistoryRepository repository}) + : _repository = repository; + + final ApprovalHistoryRepository _repository; + + PaginatedResult? _result; + bool _isLoading = false; + String _query = ''; + ApprovalHistoryActionFilter _actionFilter = ApprovalHistoryActionFilter.all; + DateTime? _from; + DateTime? _to; + String? _errorMessage; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + String get query => _query; + ApprovalHistoryActionFilter get actionFilter => _actionFilter; + DateTime? get from => _from; + DateTime? get to => _to; + String? get errorMessage => _errorMessage; + + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + final action = switch (_actionFilter) { + ApprovalHistoryActionFilter.all => null, + ApprovalHistoryActionFilter.approve => 'approve', + ApprovalHistoryActionFilter.reject => 'reject', + ApprovalHistoryActionFilter.comment => 'comment', + }; + + final response = await _repository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: _query.trim().isEmpty ? null : _query.trim(), + action: action, + from: _from, + to: _to, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateActionFilter(ApprovalHistoryActionFilter filter) { + _actionFilter = filter; + notifyListeners(); + } + + void updateDateRange(DateTime? from, DateTime? to) { + _from = from; + _to = to; + notifyListeners(); + } + + void clearFilters() { + _query = ''; + _actionFilter = ApprovalHistoryActionFilter.all; + _from = null; + _to = null; + notifyListeners(); + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + bool get hasActiveFilters => + _query.trim().isNotEmpty || + _actionFilter != ApprovalHistoryActionFilter.all || + _from != null || + _to != null; +} diff --git a/lib/features/approvals/history/presentation/pages/approval_history_page.dart b/lib/features/approvals/history/presentation/pages/approval_history_page.dart index 9ac69d6..f5a60c7 100644 --- a/lib/features/approvals/history/presentation/pages/approval_history_page.dart +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -1,11 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../core/constants/app_sections.dart'; import '../../../../../widgets/app_layout.dart'; -import '../../../../../widgets/components/coming_soon_card.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/spec_page.dart'; +import '../../domain/entities/approval_history_record.dart'; +import '../../domain/repositories/approval_history_repository.dart'; +import '../controllers/approval_history_controller.dart'; class ApprovalHistoryPage extends StatelessWidget { const ApprovalHistoryPage({super.key}); @@ -25,7 +30,7 @@ class ApprovalHistoryPage extends StatelessWidget { columns: [ '번호', '결재ID', - '단계ID', + '단계순서', '승인자', '행위', '변경전상태', @@ -37,7 +42,7 @@ class ApprovalHistoryPage extends StatelessWidget { [ '1', 'APP-20240301-001', - 'STEP-1', + '1', '최관리', '승인', '승인대기', @@ -52,22 +57,366 @@ class ApprovalHistoryPage extends StatelessWidget { ); } - return AppLayout( - title: '결재 이력 조회', - subtitle: '결재 단계별 변경 기록을 확인할 수 있도록 준비 중입니다.', - breadcrumbs: const [ - AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '결재', path: '/approvals/history'), - AppBreadcrumbItem(label: '결재 이력'), - ], - toolbar: FilterBar( - children: const [Text('이력 검색 조건은 API 사양 확정 후 제공될 예정입니다.')], - ), - child: const ComingSoonCard( - title: '결재 이력 화면 구현 준비 중', - description: '결재 단계 로그 API와 연동해 조건 검색 및 엑셀 내보내기를 제공할 예정입니다.', - items: ['결재번호/승인자/행위 유형별 필터', '기간·상태 조건 조합 검색', '다운로드(Excel/PDF) 기능'], - ), + return const _ApprovalHistoryEnabledPage(); + } +} + +class _ApprovalHistoryEnabledPage extends StatefulWidget { + const _ApprovalHistoryEnabledPage(); + + @override + State<_ApprovalHistoryEnabledPage> createState() => + _ApprovalHistoryEnabledPageState(); +} + +class _ApprovalHistoryEnabledPageState + extends State<_ApprovalHistoryEnabledPage> { + late final ApprovalHistoryController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm'); + DateTimeRange? _dateRange; + String? _lastError; + + @override + void initState() { + super.initState(); + _controller = ApprovalHistoryController( + repository: GetIt.I(), + )..addListener(_handleUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _controller.fetch(); + }); + } + + void _handleUpdate() { + final error = _controller.errorMessage; + if (error != null && error != _lastError && mounted) { + _lastError = error; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); + } + } + + @override + void dispose() { + _controller.removeListener(_handleUpdate); + _controller.dispose(); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final theme = ShadTheme.of(context); + final result = _controller.result; + final histories = result?.items ?? const []; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final totalPages = result == null || result.pageSize == 0 + ? 1 + : (result.total / result.pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + + return AppLayout( + title: '결재 이력 조회', + subtitle: '결재 단계 변경 기록을 결재번호·행위·기간으로 조회합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '결재', path: '/approvals/history'), + AppBreadcrumbItem(label: '결재 이력'), + ], + actions: [ + Tooltip( + message: '다운로드 기능은 API 연동 후 제공됩니다.', + child: ShadButton( + onPressed: null, + leading: const Icon(lucide.LucideIcons.download, size: 16), + child: const Text('엑셀 다운로드'), + ), + ), + ], + toolbar: FilterBar( + children: [ + SizedBox( + width: 240, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('결재번호, 승인자 검색'), + leading: const Icon(lucide.LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(_controller.actionFilter), + initialValue: _controller.actionFilter, + selectedOptionBuilder: (context, value) => + Text(_actionLabel(value)), + onChanged: (value) { + if (value == null) return; + _controller.updateActionFilter(value); + _controller.fetch(page: 1); + }, + options: ApprovalHistoryActionFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_actionLabel(filter)), + ), + ) + .toList(), + ), + ), + SizedBox( + width: 220, + child: ShadButton.outline( + onPressed: _pickDateRange, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(lucide.LucideIcons.calendar, size: 16), + const SizedBox(width: 8), + Text( + _dateRange == null + ? '기간 선택' + : '${_formatDate(_dateRange!.start)} ~ ${_formatDate(_dateRange!.end)}', + ), + ], + ), + ), + ), + if (_dateRange != null) + ShadButton.ghost( + onPressed: _controller.isLoading ? null : _clearDateRange, + child: const Text('기간 초기화'), + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + ShadButton.ghost( + onPressed: + _controller.isLoading || !_controller.hasActiveFilters + ? null + : _resetFilters, + child: const Text('필터 초기화'), + ), + ], + ), + child: ShadCard( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('이력 목록', style: theme.textTheme.h3), + Text('$totalCount건', style: theme.textTheme.muted), + ], + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + Row( + children: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || currentPage <= 1 + ? null + : () => _controller.fetch(page: currentPage - 1), + child: const Text('이전'), + ), + const SizedBox(width: 8), + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isLoading || !hasNext + ? null + : () => _controller.fetch(page: currentPage + 1), + child: const Text('다음'), + ), + ], + ), + ], + ), + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : histories.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조건에 맞는 결재 이력이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : _ApprovalHistoryTable( + histories: histories, + dateFormat: _dateTimeFormat, + query: _controller.query, + ), + ), + ); + }, + ); + } + + void _applyFilters() { + _controller.updateQuery(_searchController.text.trim()); + if (_dateRange != null) { + _controller.updateDateRange(_dateRange!.start, _dateRange!.end); + } + _controller.fetch(page: 1); + } + + Future _pickDateRange() async { + final now = DateTime.now(); + final initial = + _dateRange ?? + DateTimeRange( + start: DateTime(now.year, now.month, now.day - 7), + end: now, + ); + final range = await showDateRangePicker( + context: context, + initialDateRange: initial, + firstDate: DateTime(now.year - 5), + lastDate: DateTime(now.year + 1), + ); + if (range != null) { + setState(() => _dateRange = range); + _controller.updateDateRange(range.start, range.end); + _controller.fetch(page: 1); + } + } + + void _clearDateRange() { + setState(() => _dateRange = null); + _controller.updateDateRange(null, null); + _controller.fetch(page: 1); + } + + void _resetFilters() { + _searchController.clear(); + _searchFocus.requestFocus(); + _dateRange = null; + _controller.clearFilters(); + _controller.fetch(page: 1); + } + + String _formatDate(DateTime date) { + return DateFormat('yyyy-MM-dd').format(date.toLocal()); + } + + String _actionLabel(ApprovalHistoryActionFilter filter) { + switch (filter) { + case ApprovalHistoryActionFilter.all: + return '전체 행위'; + case ApprovalHistoryActionFilter.approve: + return '승인'; + case ApprovalHistoryActionFilter.reject: + return '반려'; + case ApprovalHistoryActionFilter.comment: + return '코멘트'; + } + } +} + +class _ApprovalHistoryTable extends StatelessWidget { + const _ApprovalHistoryTable({ + required this.histories, + required this.dateFormat, + required this.query, + }); + + final List histories; + final DateFormat dateFormat; + final String query; + + @override + Widget build(BuildContext context) { + final normalizedQuery = query.trim().toLowerCase(); + final header = [ + 'ID', + '결재번호', + '단계순서', + '승인자', + '행위', + '변경전 상태', + '변경후 상태', + '작업일시', + '비고', + ].map((label) => ShadTableCell.header(child: Text(label))).toList(); + + final rows = histories.map((history) { + final isHighlighted = + normalizedQuery.isNotEmpty && + history.approvalNo.toLowerCase().contains(normalizedQuery); + return [ + ShadTableCell(child: Text(history.id.toString())), + ShadTableCell( + child: Text( + history.approvalNo, + style: isHighlighted + ? ShadTheme.of( + context, + ).textTheme.small.copyWith(fontWeight: FontWeight.w600) + : null, + ), + ), + ShadTableCell( + child: Text( + history.stepOrder == null ? '-' : history.stepOrder.toString(), + ), + ), + ShadTableCell(child: Text(history.approver.name)), + ShadTableCell(child: Text(history.action.name)), + ShadTableCell(child: Text(history.fromStatus?.name ?? '-')), + ShadTableCell(child: Text(history.toStatus.name)), + ShadTableCell( + child: Text(dateFormat.format(history.actionAt.toLocal())), + ), + ShadTableCell( + child: Text( + history.note?.trim().isEmpty ?? true ? '-' : history.note!, + ), + ), + ]; + }).toList(); + + return ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) { + switch (index) { + case 1: + return const FixedTableSpanExtent(180); + case 2: + case 4: + return const FixedTableSpanExtent(120); + case 5: + case 6: + return const FixedTableSpanExtent(150); + case 7: + return const FixedTableSpanExtent(180); + default: + return const FixedTableSpanExtent(110); + } + }, ); } } diff --git a/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart b/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart index dfc921e..6f10f26 100644 --- a/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart +++ b/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart @@ -5,6 +5,7 @@ import 'package:superport_v2/core/network/api_client.dart'; import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart'; import 'package:superport_v2/features/approvals/step/domain/repositories/approval_step_repository.dart'; import '../dtos/approval_step_record_dto.dart'; +import '../../domain/entities/approval_step_input.dart'; class ApprovalStepRepositoryRemote implements ApprovalStepRepository { ApprovalStepRepositoryRemote({required ApiClient apiClient}) @@ -48,4 +49,32 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository { final data = (response.data?['data'] as Map?) ?? const {}; return ApprovalStepRecordDto.fromJson(data).toEntity(); } + + @override + Future create(ApprovalStepInput input) async { + final response = await _api.post>( + _basePath, + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final raw = response.data; + final data = + (raw?['data'] as Map?) ?? + (raw is Map ? raw : const {}); + return ApprovalStepRecordDto.fromJson(data).toEntity(); + } + + @override + Future update(int id, ApprovalStepInput input) async { + final response = await _api.patch>( + '$_basePath/$id', + data: input.toPayload(), + options: Options(responseType: ResponseType.json), + ); + final raw = response.data; + final data = + (raw?['data'] as Map?) ?? + (raw is Map ? raw : const {}); + return ApprovalStepRecordDto.fromJson(data).toEntity(); + } } diff --git a/lib/features/approvals/step/domain/entities/approval_step_input.dart b/lib/features/approvals/step/domain/entities/approval_step_input.dart new file mode 100644 index 0000000..5833f2b --- /dev/null +++ b/lib/features/approvals/step/domain/entities/approval_step_input.dart @@ -0,0 +1,61 @@ +/// 결재 단계 생성/수정 입력 모델 +/// +/// - 단계 순서, 승인자, 비고 등의 값을 API 페이로드로 직렬화한다. +/// - `approvalId`는 생성 시에만 필요하며 수정 시에는 null로 둘 수 있다. +class ApprovalStepInput { + ApprovalStepInput({ + this.approvalId, + required this.stepOrder, + required this.approverId, + this.statusId, + this.assignedAt, + this.decidedAt, + this.note, + }) : assert(stepOrder > 0, '단계 순서는 1 이상의 정수여야 합니다.'), + assert(approverId > 0, '승인자 ID는 양수여야 합니다.'); + + final int? approvalId; + final int stepOrder; + final int approverId; + final int? statusId; + final DateTime? assignedAt; + final DateTime? decidedAt; + final String? note; + + /// API 요청 페이로드를 구성한다. + Map toPayload() { + final payload = { + 'step_order': stepOrder, + 'approver_id': approverId, + if (statusId != null) 'status_id': statusId, + if (assignedAt != null) + 'assigned_at': assignedAt!.toUtc().toIso8601String(), + if (decidedAt != null) 'decided_at': decidedAt!.toUtc().toIso8601String(), + if (note != null && note!.trim().isNotEmpty) 'note': note, + }; + if (approvalId != null) { + payload['approval_id'] = approvalId; + } + return payload; + } + + ApprovalStepInput copyWith({ + int? approvalId, + int? stepOrder, + int? approverId, + int? statusId, + DateTime? assignedAt, + DateTime? decidedAt, + String? note, + }) { + return ApprovalStepInput( + approvalId: approvalId ?? this.approvalId, + stepOrder: stepOrder ?? this.stepOrder, + approverId: approverId ?? this.approverId, + statusId: statusId ?? this.statusId, + assignedAt: assignedAt ?? this.assignedAt, + decidedAt: decidedAt ?? this.decidedAt, + note: note ?? this.note, + ); + } +} diff --git a/lib/features/approvals/step/domain/repositories/approval_step_repository.dart b/lib/features/approvals/step/domain/repositories/approval_step_repository.dart index b307809..a08052a 100644 --- a/lib/features/approvals/step/domain/repositories/approval_step_repository.dart +++ b/lib/features/approvals/step/domain/repositories/approval_step_repository.dart @@ -1,5 +1,6 @@ import 'package:superport_v2/core/common/models/paginated_result.dart'; +import '../entities/approval_step_input.dart'; import '../entities/approval_step_record.dart'; abstract class ApprovalStepRepository { @@ -13,4 +14,10 @@ abstract class ApprovalStepRepository { }); Future fetchDetail(int id); + + /// 결재 단계를 생성한다. + Future create(ApprovalStepInput input); + + /// 결재 단계를 수정한다. + Future update(int id, ApprovalStepInput input); } diff --git a/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart b/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart index f442a06..8dd915f 100644 --- a/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart +++ b/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import '../../domain/entities/approval_step_input.dart'; import '../../domain/entities/approval_step_record.dart'; import '../../domain/repositories/approval_step_repository.dart'; @@ -12,6 +13,7 @@ class ApprovalStepController extends ChangeNotifier { PaginatedResult? _result; bool _isLoading = false; + bool _isSaving = false; String _query = ''; int? _statusId; int? _approverId; @@ -21,6 +23,7 @@ class ApprovalStepController extends ChangeNotifier { PaginatedResult? get result => _result; bool get isLoading => _isLoading; + bool get isSaving => _isSaving; String get query => _query; int? get statusId => _statusId; int? get approverId => _approverId; @@ -101,4 +104,43 @@ class ApprovalStepController extends ChangeNotifier { _approverId = null; notifyListeners(); } + + Future createStep(ApprovalStepInput input) async { + _isSaving = true; + _errorMessage = null; + notifyListeners(); + try { + final created = await _repository.create(input); + final nextPage = _result?.page ?? 1; + await fetch(page: nextPage); + return created; + } catch (e) { + _errorMessage = e.toString(); + return null; + } finally { + _isSaving = false; + notifyListeners(); + } + } + + Future updateStep( + int id, + ApprovalStepInput input, + ) async { + _isSaving = true; + _errorMessage = null; + notifyListeners(); + try { + final updated = await _repository.update(id, input); + final nextPage = _result?.page ?? 1; + await fetch(page: nextPage); + return updated; + } catch (e) { + _errorMessage = e.toString(); + return null; + } finally { + _isSaving = false; + notifyListeners(); + } + } } diff --git a/lib/features/approvals/step/presentation/pages/approval_step_page.dart b/lib/features/approvals/step/presentation/pages/approval_step_page.dart index 85edf74..6317b35 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -9,6 +9,7 @@ import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/spec_page.dart'; import '../controllers/approval_step_controller.dart'; +import '../../domain/entities/approval_step_input.dart'; import '../../domain/entities/approval_step_record.dart'; import '../../domain/repositories/approval_step_repository.dart'; @@ -141,6 +142,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { final selectedStatus = _controller.statusId ?? -1; final approverOptions = _buildApproverOptions(records); final selectedApprover = _controller.approverId ?? -1; + final isSaving = _controller.isSaving; return AppLayout( title: '결재 단계 관리', @@ -151,13 +153,19 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { AppBreadcrumbItem(label: '결재 단계'), ], actions: [ - Tooltip( - message: '결재 단계 생성은 정책 정리 후 제공됩니다.', - child: ShadButton( - onPressed: null, - leading: const Icon(lucide.LucideIcons.plus, size: 16), - child: const Text('단계 추가'), - ), + ShadButton( + key: const ValueKey('approval_step_create'), + onPressed: (_controller.isLoading || isSaving) + ? null + : _openCreateStepForm, + leading: isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(lucide.LucideIcons.plus, size: 16), + child: Text(isSaving ? '저장 중...' : '단계 추가'), ), ], toolbar: FilterBar( @@ -225,12 +233,16 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { ), ), ShadButton.outline( - onPressed: _controller.isLoading ? null : _applyFilters, + onPressed: (_controller.isLoading || isSaving) + ? null + : _applyFilters, child: const Text('검색 적용'), ), ShadButton.ghost( onPressed: - !_controller.isLoading && _controller.hasActiveFilters + !_controller.isLoading && + !isSaving && + _controller.hasActiveFilters ? _resetFilters : null, child: const Text('필터 초기화'), @@ -319,15 +331,35 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { ShadTableCell( child: Align( alignment: Alignment.centerRight, - child: ShadButton.outline( - key: ValueKey( - 'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}', - ), - size: ShadButtonSize.sm, - onPressed: step.id == null - ? null - : () => _openDetail(record), - child: const Text('상세'), + child: Wrap( + spacing: 8, + children: [ + ShadButton.outline( + key: ValueKey( + 'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}', + ), + size: ShadButtonSize.sm, + onPressed: + step.id == null || + _controller.isLoading || + isSaving + ? null + : () => _openDetail(record), + child: const Text('상세'), + ), + if (step.id != null) + ShadButton( + key: ValueKey( + 'step_edit_${step.id}_${step.stepOrder}', + ), + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || isSaving + ? null + : () => _openEditStepForm(record), + child: const Text('수정'), + ), + ], ), ), ), @@ -345,7 +377,9 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { ShadButton.outline( size: ShadButtonSize.sm, onPressed: - _controller.isLoading || currentPage <= 1 + _controller.isLoading || + isSaving || + currentPage <= 1 ? null : () => _controller.fetch( page: currentPage - 1, @@ -355,7 +389,10 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { const SizedBox(width: 8), ShadButton.outline( size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext + onPressed: + _controller.isLoading || + isSaving || + !hasNext ? null : () => _controller.fetch( page: currentPage + 1, @@ -413,6 +450,67 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { _searchFocus.requestFocus(); } + Future _openCreateStepForm() async { + final input = await showDialog( + context: context, + builder: (dialogContext) { + return _StepFormDialog( + title: '결재 단계 추가', + submitLabel: '저장', + isEditing: false, + ); + }, + ); + + if (!mounted || input == null) { + return; + } + + final created = await _controller.createStep(input); + if (!mounted || created == null) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('결재번호 ${created.approvalNo} 단계가 추가되었습니다.')), + ); + } + + Future _openEditStepForm(ApprovalStepRecord record) async { + final stepId = record.step.id; + if (stepId == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('저장되지 않은 단계는 수정할 수 없습니다.'))); + return; + } + + final input = await showDialog( + context: context, + builder: (dialogContext) { + return _StepFormDialog( + title: '결재 단계 수정', + submitLabel: '저장', + isEditing: true, + initialRecord: record, + ); + }, + ); + + if (!mounted || input == null) { + return; + } + + final updated = await _controller.updateStep(stepId, input); + if (!mounted || updated == null) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('결재번호 ${updated.approvalNo} 단계 정보를 수정했습니다.')), + ); + } + Future _openDetail(ApprovalStepRecord record) async { final stepId = record.step.id; if (stepId == null) { @@ -564,3 +662,263 @@ class _DetailRow extends StatelessWidget { ); } } + +class _StepFormDialog extends StatefulWidget { + const _StepFormDialog({ + required this.title, + required this.submitLabel, + required this.isEditing, + this.initialRecord, + }); + + final String title; + final String submitLabel; + final bool isEditing; + final ApprovalStepRecord? initialRecord; + + @override + State<_StepFormDialog> createState() => _StepFormDialogState(); +} + +class _StepFormDialogState extends State<_StepFormDialog> { + late final TextEditingController _approvalIdController; + late final TextEditingController _approvalNoController; + late final TextEditingController _stepOrderController; + late final TextEditingController _approverIdController; + late final TextEditingController _noteController; + Map _errors = const {}; + + @override + void initState() { + super.initState(); + final record = widget.initialRecord; + _approvalIdController = TextEditingController( + text: widget.isEditing && record != null + ? record.approvalId.toString() + : '', + ); + _approvalNoController = TextEditingController( + text: record?.approvalNo ?? '', + ); + _stepOrderController = TextEditingController( + text: record?.step.stepOrder.toString() ?? '', + ); + _approverIdController = TextEditingController( + text: record?.step.approver.id.toString() ?? '', + ); + _noteController = TextEditingController(text: record?.step.note ?? ''); + } + + @override + void dispose() { + _approvalIdController.dispose(); + _approvalNoController.dispose(); + _stepOrderController.dispose(); + _approverIdController.dispose(); + _noteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final materialTheme = Theme.of(context); + + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ShadCard( + title: Text(widget.title, style: theme.textTheme.h3), + footer: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: () => Navigator.of(context).pop(), + child: const Text('취소'), + ), + const SizedBox(width: 12), + ShadButton( + key: const ValueKey('step_form_submit'), + onPressed: _handleSubmit, + child: Text(widget.submitLabel), + ), + ], + ), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (!widget.isEditing) + _FormFieldBlock( + label: '결재 ID', + errorText: _errors['approvalId'], + child: ShadInput( + key: const ValueKey('step_form_approval_id'), + controller: _approvalIdController, + onChanged: (_) => _clearError('approvalId'), + ), + ) + else ...[ + _FormFieldBlock( + label: '결재 ID', + child: ShadInput( + controller: _approvalIdController, + readOnly: true, + ), + ), + const SizedBox(height: 16), + _FormFieldBlock( + label: '결재번호', + child: ShadInput( + controller: _approvalNoController, + readOnly: true, + ), + ), + ], + if (!widget.isEditing) const SizedBox(height: 16), + _FormFieldBlock( + label: '단계 순서', + errorText: _errors['stepOrder'], + child: ShadInput( + key: const ValueKey('step_form_step_order'), + controller: _stepOrderController, + onChanged: (_) => _clearError('stepOrder'), + ), + ), + const SizedBox(height: 16), + _FormFieldBlock( + label: '승인자 ID', + errorText: _errors['approverId'], + child: ShadInput( + key: const ValueKey('step_form_approver_id'), + controller: _approverIdController, + onChanged: (_) => _clearError('approverId'), + ), + ), + const SizedBox(height: 16), + _FormFieldBlock( + label: '비고', + helperText: '필요 시 단계에 대한 참고 내용을 남길 수 있습니다.', + child: ShadTextarea( + key: const ValueKey('step_form_note'), + controller: _noteController, + minHeight: 100, + maxHeight: 200, + ), + ), + if (_errors['form'] != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _errors['form']!, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _handleSubmit() { + final Map nextErrors = {}; + int? approvalId; + if (widget.isEditing) { + approvalId = widget.initialRecord?.approvalId; + } else { + approvalId = int.tryParse(_approvalIdController.text.trim()); + if (approvalId == null || approvalId <= 0) { + nextErrors['approvalId'] = '결재 ID를 1 이상의 숫자로 입력하세요.'; + } + } + + final stepOrder = int.tryParse(_stepOrderController.text.trim()); + if (stepOrder == null || stepOrder <= 0) { + nextErrors['stepOrder'] = '단계 순서를 1 이상의 숫자로 입력하세요.'; + } + + final approverId = int.tryParse(_approverIdController.text.trim()); + if (approverId == null || approverId <= 0) { + nextErrors['approverId'] = '승인자 ID를 1 이상의 숫자로 입력하세요.'; + } + + setState(() => _errors = nextErrors); + if (nextErrors.isNotEmpty) { + return; + } + + final note = _noteController.text.trim(); + final input = ApprovalStepInput( + approvalId: approvalId, + stepOrder: stepOrder!, + approverId: approverId!, + note: note.isEmpty ? null : note, + statusId: widget.initialRecord?.step.status.id, + ); + + Navigator.of(context).pop(input); + } + + void _clearError(String field) { + if (_errors[field] == null) { + return; + } + setState(() { + final updated = Map.from(_errors); + updated.remove(field); + _errors = updated; + }); + } +} + +class _FormFieldBlock extends StatelessWidget { + const _FormFieldBlock({ + required this.label, + this.errorText, + this.helperText, + required this.child, + }); + + final String label; + final Widget child; + final String? errorText; + final String? helperText; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final materialTheme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + child, + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + errorText!, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + if (helperText != null && helperText!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(helperText!, style: theme.textTheme.muted), + ), + ], + ); + } +} diff --git a/lib/injection_container.dart b/lib/injection_container.dart index f621b3f..c184672 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -25,6 +25,8 @@ import 'features/masters/uom/data/repositories/uom_repository_remote.dart'; import 'features/masters/uom/domain/repositories/uom_repository.dart'; import 'features/approvals/data/repositories/approval_repository_remote.dart'; import 'features/approvals/data/repositories/approval_template_repository_remote.dart'; +import 'features/approvals/history/data/repositories/approval_history_repository_remote.dart'; +import 'features/approvals/history/domain/repositories/approval_history_repository.dart'; import 'features/approvals/step/data/repositories/approval_step_repository_remote.dart'; import 'features/approvals/domain/repositories/approval_repository.dart'; import 'features/approvals/domain/repositories/approval_template_repository.dart'; @@ -107,4 +109,8 @@ Future initInjection({ sl.registerLazySingleton( () => ApprovalStepRepositoryRemote(apiClient: sl()), ); + + sl.registerLazySingleton( + () => ApprovalHistoryRepositoryRemote(apiClient: sl()), + ); } diff --git a/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart b/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart new file mode 100644 index 0000000..0fa8822 --- /dev/null +++ b/test/features/approvals/history/presentation/controllers/approval_history_controller_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart'; +import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart'; +import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.dart'; + +class _MockApprovalHistoryRepository extends Mock + implements ApprovalHistoryRepository {} + +void main() { + late ApprovalHistoryController controller; + late _MockApprovalHistoryRepository repository; + + final record = ApprovalHistoryRecord( + id: 1, + approvalId: 10, + approvalNo: 'APP-2024-0001', + stepOrder: 1, + action: ApprovalAction(id: 11, name: 'approve'), + fromStatus: ApprovalStatus(id: 1, name: '대기', color: null), + toStatus: ApprovalStatus(id: 2, name: '승인', color: null), + approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'), + actionAt: DateTime(2024, 4, 1, 9, 30), + note: '승인 완료', + ); + + PaginatedResult createResult( + List items, + ) { + return PaginatedResult( + items: items, + page: 1, + pageSize: 20, + total: items.length, + ); + } + + setUp(() { + repository = _MockApprovalHistoryRepository(); + controller = ApprovalHistoryController(repository: repository); + }); + + test('fetch 성공 시 결과를 갱신한다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + action: any(named: 'action'), + from: any(named: 'from'), + to: any(named: 'to'), + ), + ).thenAnswer((_) async => createResult([record])); + + await controller.fetch(); + + expect(controller.result?.items, isNotEmpty); + expect(controller.errorMessage, isNull); + verify( + () => repository.list( + page: 1, + pageSize: 20, + query: null, + action: null, + from: null, + to: null, + ), + ).called(1); + }); + + test('필터 적용 시 파라미터를 전달한다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + action: any(named: 'action'), + from: any(named: 'from'), + to: any(named: 'to'), + ), + ).thenAnswer((_) async => createResult([record])); + + controller.updateQuery('APP'); + controller.updateActionFilter(ApprovalHistoryActionFilter.approve); + controller.updateDateRange(DateTime(2024, 4, 1), DateTime(2024, 4, 30)); + + await controller.fetch(page: 2); + + verify( + () => repository.list( + page: 2, + pageSize: 20, + query: 'APP', + action: 'approve', + from: DateTime(2024, 4, 1), + to: DateTime(2024, 4, 30), + ), + ).called(1); + }); + + test('에러 발생 시 errorMessage에 저장한다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + action: any(named: 'action'), + from: any(named: 'from'), + to: any(named: 'to'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.errorMessage, isNotNull); + expect(controller.result, isNull); + }); + + test('clearFilters가 상태를 초기화한다', () { + controller.updateQuery('APP'); + controller.updateActionFilter(ApprovalHistoryActionFilter.comment); + controller.updateDateRange(DateTime(2024, 4, 1), DateTime(2024, 4, 5)); + + controller.clearFilters(); + + expect(controller.query, isEmpty); + expect(controller.actionFilter, ApprovalHistoryActionFilter.all); + expect(controller.from, isNull); + expect(controller.to, isNull); + }); +} diff --git a/test/features/approvals/history/presentation/pages/approval_history_page_test.dart b/test/features/approvals/history/presentation/pages/approval_history_page_test.dart new file mode 100644 index 0000000..a04fb94 --- /dev/null +++ b/test/features/approvals/history/presentation/pages/approval_history_page_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart'; +import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart'; +import 'package:superport_v2/features/approvals/history/presentation/pages/approval_history_page.dart'; + +class _MockApprovalHistoryRepository extends Mock + implements ApprovalHistoryRepository {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockApprovalHistoryRepository repository; + + final record = ApprovalHistoryRecord( + id: 1, + approvalId: 10, + approvalNo: 'APP-2024-0001', + stepOrder: 1, + action: ApprovalAction(id: 11, name: 'approve'), + fromStatus: ApprovalStatus(id: 1, name: '대기', color: null), + toStatus: ApprovalStatus(id: 2, name: '승인', color: null), + approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'), + actionAt: DateTime(2024, 4, 1, 9, 30), + note: '승인 완료', + ); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('플래그 Off 시 스펙 페이지를 노출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const ApprovalHistoryPage())); + await tester.pump(); + + expect(find.text('결재 이력 조회'), findsOneWidget); + expect(find.text('결재 단계별 변경 이력을 조회합니다.'), findsOneWidget); + }); + + testWidgets('이력 목록을 렌더링하고 검색 필터를 적용한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + repository = _MockApprovalHistoryRepository(); + GetIt.I.registerLazySingleton(() => repository); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + action: any(named: 'action'), + from: any(named: 'from'), + to: any(named: 'to'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [record], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await tester.pumpWidget(_buildApp(const ApprovalHistoryPage())); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.textContaining('APP-2024-0001'), findsOneWidget); + expect(find.text('승인 완료'), findsOneWidget); + + await tester.enterText(find.byType(ShadInput).first, 'APP-2024'); + await tester.tap(find.text('검색 적용')); + await tester.pump(); + + verify( + () => repository.list( + page: any(named: 'page'), + pageSize: 20, + query: 'APP-2024', + action: null, + from: null, + to: null, + ), + ).called(greaterThanOrEqualTo(1)); + }); +} diff --git a/test/features/approvals/step/presentation/controllers/approval_step_controller_test.dart b/test/features/approvals/step/presentation/controllers/approval_step_controller_test.dart index 74dd349..15bb1c1 100644 --- a/test/features/approvals/step/presentation/controllers/approval_step_controller_test.dart +++ b/test/features/approvals/step/presentation/controllers/approval_step_controller_test.dart @@ -3,6 +3,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_input.dart'; import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart'; import 'package:superport_v2/features/approvals/step/domain/repositories/approval_step_repository.dart'; import 'package:superport_v2/features/approvals/step/presentation/controllers/approval_step_controller.dart'; @@ -11,6 +12,12 @@ class _MockApprovalStepRepository extends Mock implements ApprovalStepRepository {} void main() { + setUpAll(() { + registerFallbackValue( + ApprovalStepInput(approvalId: 1, stepOrder: 1, approverId: 1), + ); + }); + late ApprovalStepController controller; late _MockApprovalStepRepository repository; @@ -142,4 +149,76 @@ void main() { expect(detail, isNull); expect(controller.errorMessage, isNotNull); }); + + test('createStep 성공 시 생성된 레코드를 반환한다', () async { + when(() => repository.create(any())).thenAnswer((_) async => sampleRecord); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + statusId: any(named: 'statusId'), + approverId: any(named: 'approverId'), + approvalId: any(named: 'approvalId'), + ), + ).thenAnswer((_) async => createResult([sampleRecord])); + + final result = await controller.createStep( + ApprovalStepInput(approvalId: 10, stepOrder: 1, approverId: 21), + ); + + expect(result, isNotNull); + expect(controller.errorMessage, isNull); + verify(() => repository.create(any())).called(1); + }); + + test('createStep 실패 시 null을 반환하고 에러를 기록한다', () async { + when(() => repository.create(any())).thenThrow(Exception('fail')); + + final result = await controller.createStep( + ApprovalStepInput(approvalId: 10, stepOrder: 1, approverId: 21), + ); + + expect(result, isNull); + expect(controller.errorMessage, isNotNull); + }); + + test('updateStep 성공 시 수정된 레코드를 반환한다', () async { + when( + () => repository.update(any(), any()), + ).thenAnswer((_) async => sampleRecord); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + statusId: any(named: 'statusId'), + approverId: any(named: 'approverId'), + approvalId: any(named: 'approvalId'), + ), + ).thenAnswer((_) async => createResult([sampleRecord])); + + final result = await controller.updateStep( + 100, + ApprovalStepInput(stepOrder: 2, approverId: 25, approvalId: 10), + ); + + expect(result, isNotNull); + expect(controller.errorMessage, isNull); + verify(() => repository.update(100, any())).called(1); + }); + + test('updateStep 실패 시 null을 반환한다', () async { + when(() => repository.update(any(), any())).thenThrow(Exception('fail')); + + final result = await controller.updateStep( + 100, + ApprovalStepInput(stepOrder: 2, approverId: 25, approvalId: 10), + ); + + expect(result, isNull); + expect(controller.errorMessage, isNotNull); + }); } diff --git a/test/features/approvals/step/presentation/pages/approval_step_page_test.dart b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart index 4965773..dd819ff 100644 --- a/test/features/approvals/step/presentation/pages/approval_step_page_test.dart +++ b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart @@ -7,6 +7,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_input.dart'; import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart'; import 'package:superport_v2/features/approvals/step/domain/repositories/approval_step_repository.dart'; import 'package:superport_v2/features/approvals/step/presentation/pages/approval_step_page.dart'; @@ -29,6 +30,12 @@ Widget _buildApp(Widget child) { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() { + registerFallbackValue( + ApprovalStepInput(approvalId: 1, stepOrder: 1, approverId: 1), + ); + }); + late _MockApprovalStepRepository repository; final record = ApprovalStepRecord( @@ -107,4 +114,153 @@ void main() { await tester.tap(find.text('닫기')); await tester.pumpAndSettle(); }); + + testWidgets('단계 추가 다이얼로그에서 저장을 호출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + repository = _MockApprovalStepRepository(); + GetIt.I.registerLazySingleton(() => repository); + + final createdRecord = ApprovalStepRecord( + approvalId: 12, + approvalNo: 'APP-2024-0012', + transactionNo: 'TRX-2024-012', + templateName: '입고 기본', + step: ApprovalStep( + id: 777, + stepOrder: 2, + approver: ApprovalApprover(id: 33, employeeNo: 'E033', name: '김승인2'), + status: ApprovalStatus(id: 1, name: '승인대기', color: null), + assignedAt: DateTime(2024, 4, 2, 9), + decidedAt: null, + note: '신규 단계', + ), + ); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + statusId: any(named: 'statusId'), + approverId: any(named: 'approverId'), + approvalId: any(named: 'approvalId'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [record], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + when(() => repository.create(any())).thenAnswer((_) async => createdRecord); + + await tester.pumpWidget(_buildApp(const ApprovalStepPage())); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('approval_step_create'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('step_form_approval_id')), + '12', + ); + await tester.enterText( + find.byKey(const ValueKey('step_form_step_order')), + '2', + ); + await tester.enterText( + find.byKey(const ValueKey('step_form_approver_id')), + '33', + ); + await tester.enterText( + find.byKey(const ValueKey('step_form_note')), + '신규 단계', + ); + + await tester.tap(find.byKey(const ValueKey('step_form_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + verify(() => repository.create(any())).called(1); + expect(find.text('결재번호 APP-2024-0012 단계가 추가되었습니다.'), findsOneWidget); + }); + + testWidgets('단계 수정 다이얼로그에서 저장을 호출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + repository = _MockApprovalStepRepository(); + GetIt.I.registerLazySingleton(() => repository); + + final updatedRecord = ApprovalStepRecord( + approvalId: record.approvalId, + approvalNo: record.approvalNo, + transactionNo: record.transactionNo, + templateName: record.templateName, + step: ApprovalStep( + id: record.step.id, + stepOrder: 2, + approver: ApprovalApprover(id: 30, employeeNo: 'E030', name: '박수정'), + status: record.step.status, + assignedAt: record.step.assignedAt, + decidedAt: record.step.decidedAt, + note: '수정됨', + ), + ); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + statusId: any(named: 'statusId'), + approverId: any(named: 'approverId'), + approvalId: any(named: 'approvalId'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [record], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + when( + () => repository.update(any(), any()), + ).thenAnswer((_) async => updatedRecord); + + await tester.pumpWidget(_buildApp(const ApprovalStepPage())); + await tester.pump(); + await tester.pumpAndSettle(); + + final editButtonFinder = find.byKey( + ValueKey('step_edit_${record.step.id}_${record.step.stepOrder}'), + ); + final editButton = tester.widget(editButtonFinder); + editButton.onPressed?.call(); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('step_form_step_order')), + '2', + ); + await tester.enterText( + find.byKey(const ValueKey('step_form_approver_id')), + '30', + ); + await tester.enterText(find.byKey(const ValueKey('step_form_note')), '수정됨'); + + await tester.tap(find.byKey(const ValueKey('step_form_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + verify(() => repository.update(record.step.id!, any())).called(1); + expect( + find.text('결재번호 ${record.approvalNo} 단계 정보를 수정했습니다.'), + findsOneWidget, + ); + }); }