From 35b90026888e68ef16c00a9575a4bb356d1db769 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 25 Sep 2025 16:41:22 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=B0=EC=9E=AC=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/IMPLEMENTATION_TASKS.md | 2 +- .../data/dtos/approval_step_record_dto.dart | 76 +++ .../approval_step_repository_remote.dart | 51 ++ .../domain/entities/approval_step_record.dart | 36 ++ .../approval_step_repository.dart | 16 + .../controllers/approval_step_controller.dart | 94 ++++ .../pages/approval_step_page.dart | 464 +++++++++++++++++- lib/injection_container.dart | 6 + .../approval_step_controller_test.dart | 144 ++++++ .../pages/approval_step_page_test.dart | 116 +++++ 10 files changed, 981 insertions(+), 24 deletions(-) create mode 100644 lib/features/approvals/step/data/dtos/approval_step_record_dto.dart create mode 100644 lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart create mode 100644 lib/features/approvals/step/domain/entities/approval_step_record.dart create mode 100644 lib/features/approvals/step/domain/repositories/approval_step_repository.dart create mode 100644 lib/features/approvals/step/presentation/controllers/approval_step_controller.dart create mode 100644 test/features/approvals/step/presentation/controllers/approval_step_controller_test.dart create mode 100644 test/features/approvals/step/presentation/pages/approval_step_page_test.dart diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md index df42140..629371d 100644 --- a/doc/IMPLEMENTATION_TASKS.md +++ b/doc/IMPLEMENTATION_TASKS.md @@ -59,7 +59,7 @@ - [ ] 결재(`/approvals`): 목록/필터, 상세(개요/단계/이력 탭) (현황: `/approvals/requests` 라우트를 `ApprovalPage`로 연결하고 AppLayout/FilterBar·단계 행위·템플릿 적용까지 연동했으며 추가 액션/권한 제어는 후속 예정) - [x] 템플릿 불러오기: 단계 탭에서 템플릿 선택 UI(단계 리스트 반영) (현황: 템플릿 목록 로딩·선택·확인 다이얼로그·`assignSteps` 호출로 단계 일괄 적용까지 구현, 템플릿 CRUD 화면과 연동되어 최신 목록/단계 구성이 반영됨) - [x] 단계 행위: 승인/반려/코멘트 버튼(가능 여부 상태에 따라 비활성/툴팁) (현황: 단계 버튼·툴팁·행위 다이얼로그를 구현했고 `ApprovalRepository.performStepAction` 연동 완료, 권한 기반 노출/후속 알림은 TODO) -- [ ] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) (현황: feature flag On 시 AppLayout + 안내 카드 플레이스홀더 제공, CRUD/데이터 연동은 미구현) +- [ ] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) (현황: 단계 목록·필터·상세 다이얼로그까지 구현했으며 생성/수정 등의 CRUD 플로우는 후속 예정) - [ ] 이력(`/approval-histories`): 조회 전용 테이블 (현황: AppLayout 기반 안내 화면으로 전환, 실제 테이블/필터/다운로드는 미구현) - [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정) diff --git a/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart b/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart new file mode 100644 index 0000000..f0d43e3 --- /dev/null +++ b/lib/features/approvals/step/data/dtos/approval_step_record_dto.dart @@ -0,0 +1,76 @@ +import '../../../../core/common/models/paginated_result.dart'; +import '../../../domain/entities/approval.dart'; +import '../../data/dtos/approval_dto.dart'; +import '../../domain/entities/approval_step_record.dart'; + +class ApprovalStepRecordDto { + ApprovalStepRecordDto({ + required this.approvalId, + required this.approvalNo, + this.transactionNo, + this.templateName, + required this.step, + }); + + final int approvalId; + final String approvalNo; + final String? transactionNo; + final String? templateName; + final ApprovalStep step; + + factory ApprovalStepRecordDto.fromJson(Map json) { + final approvalData = json['approval'] as Map?; + 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 transactionNo = + json['transaction_no'] as String? ?? + approvalData?['transaction_no'] as String? ?? + approvalData?['transactionNo'] as String?; + final templateName = + json['template_name'] as String? ?? + approvalData?['template_name'] as String? ?? + approvalData?['templateName'] as String?; + + final step = ApprovalStepDto.fromJson(json).toEntity(); + + return ApprovalStepRecordDto( + approvalId: approvalId, + approvalNo: approvalNo, + transactionNo: transactionNo, + templateName: templateName, + step: step, + ); + } + + ApprovalStepRecord toEntity() { + return ApprovalStepRecord( + approvalId: approvalId, + approvalNo: approvalNo, + transactionNo: transactionNo, + templateName: templateName, + step: step, + ); + } + + static PaginatedResult parsePaginated( + Map? json, + ) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(ApprovalStepRecordDto.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, + ); + } +} 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 new file mode 100644 index 0000000..b10bd08 --- /dev/null +++ b/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart @@ -0,0 +1,51 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/common/models/paginated_result.dart'; +import '../../../../core/network/api_client.dart'; +import '../../domain/entities/approval_step_record.dart'; +import '../../domain/repositories/approval_step_repository.dart'; +import '../dtos/approval_step_record_dto.dart'; + +class ApprovalStepRepositoryRemote implements ApprovalStepRepository { + ApprovalStepRepositoryRemote({required ApiClient apiClient}) + : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '/approval-steps'; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? statusId, + int? approverId, + int? approvalId, + }) async { + final response = await _api.get>( + _basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (statusId != null) 'status_id': statusId, + if (approverId != null) 'approver_id': approverId, + if (approvalId != null) 'approval_id': approvalId, + }, + options: Options(responseType: ResponseType.json), + ); + + return ApprovalStepRecordDto.parsePaginated(response.data); + } + + @override + Future fetchDetail(int id) async { + final response = await _api.get>( + '$_basePath/$id', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? const {}; + return ApprovalStepRecordDto.fromJson(data).toEntity(); + } +} diff --git a/lib/features/approvals/step/domain/entities/approval_step_record.dart b/lib/features/approvals/step/domain/entities/approval_step_record.dart new file mode 100644 index 0000000..80b0232 --- /dev/null +++ b/lib/features/approvals/step/domain/entities/approval_step_record.dart @@ -0,0 +1,36 @@ +import '../../../domain/entities/approval.dart'; + +/// 결재 단계 레코드 +/// +/// - 개별 결재 요청의 특정 단계를 목록으로 조회할 때 사용한다. +class ApprovalStepRecord { + ApprovalStepRecord({ + required this.approvalId, + required this.approvalNo, + this.transactionNo, + this.templateName, + required this.step, + }); + + final int approvalId; + final String approvalNo; + final String? transactionNo; + final String? templateName; + final ApprovalStep step; + + ApprovalStepRecord copyWith({ + int? approvalId, + String? approvalNo, + String? transactionNo, + String? templateName, + ApprovalStep? step, + }) { + return ApprovalStepRecord( + approvalId: approvalId ?? this.approvalId, + approvalNo: approvalNo ?? this.approvalNo, + transactionNo: transactionNo ?? this.transactionNo, + templateName: templateName ?? this.templateName, + step: step ?? this.step, + ); + } +} diff --git a/lib/features/approvals/step/domain/repositories/approval_step_repository.dart b/lib/features/approvals/step/domain/repositories/approval_step_repository.dart new file mode 100644 index 0000000..b307809 --- /dev/null +++ b/lib/features/approvals/step/domain/repositories/approval_step_repository.dart @@ -0,0 +1,16 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../entities/approval_step_record.dart'; + +abstract class ApprovalStepRepository { + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? statusId, + int? approverId, + int? approvalId, + }); + + Future fetchDetail(int id); +} diff --git a/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart b/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart new file mode 100644 index 0000000..4396ea8 --- /dev/null +++ b/lib/features/approvals/step/presentation/controllers/approval_step_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_step_record.dart'; +import '../../domain/repositories/approval_step_repository.dart'; + +class ApprovalStepController extends ChangeNotifier { + ApprovalStepController({required ApprovalStepRepository repository}) + : _repository = repository; + + final ApprovalStepRepository _repository; + + PaginatedResult? _result; + bool _isLoading = false; + String _query = ''; + int? _statusId; + String? _errorMessage; + bool _isLoadingDetail = false; + ApprovalStepRecord? _selected; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + String get query => _query; + int? get statusId => _statusId; + String? get errorMessage => _errorMessage; + bool get isLoadingDetail => _isLoadingDetail; + ApprovalStepRecord? get selected => _selected; + + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + final sanitizedQuery = _query.trim(); + final response = await _repository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: sanitizedQuery.isEmpty ? null : sanitizedQuery, + statusId: _statusId, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateStatusId(int? value) { + _statusId = value; + notifyListeners(); + } + + Future fetchDetail(int id) async { + _isLoadingDetail = true; + _errorMessage = null; + notifyListeners(); + try { + final detail = await _repository.fetchDetail(id); + _selected = detail; + return detail; + } catch (e) { + _errorMessage = e.toString(); + return null; + } finally { + _isLoadingDetail = false; + notifyListeners(); + } + } + + void clearSelection() { + _selected = null; + notifyListeners(); + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + bool get hasActiveFilters => _query.trim().isNotEmpty || _statusId != null; + + void resetFilters() { + _query = ''; + _statusId = null; + 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 b2c79a1..4e3bd93 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -1,12 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.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 '../controllers/approval_step_controller.dart'; +import '../../domain/entities/approval_step_record.dart'; +import '../../domain/repositories/approval_step_repository.dart'; class ApprovalStepPage extends StatelessWidget { const ApprovalStepPage({super.key}); @@ -65,28 +70,441 @@ class ApprovalStepPage extends StatelessWidget { ); } - return AppLayout( - title: '결재 단계 관리', - subtitle: '결재 순서를 정의하고 승인자를 배정할 수 있도록 준비 중입니다.', - breadcrumbs: const [ - AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '결재', path: '/approvals/steps'), - AppBreadcrumbItem(label: '결재 단계'), - ], - actions: [ - ShadButton( - onPressed: null, - leading: const Icon(LucideIcons.plus, size: 16), - child: const Text('단계 추가'), - ), - ], - toolbar: FilterBar( - children: const [Text('필터 구성은 결재 단계 API 확정 후 제공될 예정입니다.')], - ), - child: const ComingSoonCard( - title: '결재 단계 화면 구현 준비 중', - description: '결재 단계 CRUD와 템플릿 연동 요구사항을 정리하는 중입니다.', - items: ['결재 요청별 단계 조회 및 정렬', '승인자 지정과 단계 상태 변경', '템플릿에서 단계 일괄 불러오기'], + return const _ApprovalStepEnabledPage(); + } +} + +class _ApprovalStepEnabledPage extends StatefulWidget { + const _ApprovalStepEnabledPage(); + + @override + State<_ApprovalStepEnabledPage> createState() => + _ApprovalStepEnabledPageState(); +} + +class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { + late final ApprovalStepController _controller; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + String? _lastError; + + @override + void initState() { + super.initState(); + _controller = ApprovalStepController( + repository: GetIt.I(), + )..addListener(_handleControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _controller.fetch(); + }); + } + + void _handleControllerUpdate() { + 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(_handleControllerUpdate); + _controller.dispose(); + _searchController.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final result = _controller.result; + final records = result?.items ?? const []; + final totalCount = result?.total ?? 0; + final currentPage = result?.page ?? 1; + final pageSize = result?.pageSize ?? records.length; + final totalPages = pageSize == 0 + ? 1 + : (totalCount / pageSize).ceil().clamp(1, 9999); + final hasNext = result == null + ? false + : (result.page * result.pageSize) < result.total; + final statusOptions = _buildStatusOptions(records); + final selectedStatus = _controller.statusId ?? -1; + + return AppLayout( + title: '결재 단계 관리', + subtitle: '결재 요청별 단계 현황을 조회하고 상세 정보를 확인합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '결재', path: '/approvals/steps'), + AppBreadcrumbItem(label: '결재 단계'), + ], + actions: [ + Tooltip( + message: '결재 단계 생성은 정책 정리 후 제공됩니다.', + child: ShadButton( + onPressed: null, + leading: const Icon(lucide.LucideIcons.plus, size: 16), + child: const Text('단계 추가'), + ), + ), + ], + toolbar: FilterBar( + children: [ + SizedBox( + width: 260, + child: ShadInput( + controller: _searchController, + focusNode: _searchFocus, + placeholder: const Text('결재번호, 승인자 검색'), + leading: const Icon(lucide.LucideIcons.search, size: 16), + onSubmitted: (_) => _applyFilters(), + ), + ), + if (statusOptions.length > 1) + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(selectedStatus), + initialValue: selectedStatus, + onChanged: (value) { + _controller.updateStatusId(value == -1 ? null : value); + }, + selectedOptionBuilder: (context, value) { + final option = statusOptions.firstWhere( + (element) => element.id == value, + orElse: () => _StatusOption(id: -1, name: '전체'), + ); + return Text(option.name); + }, + options: statusOptions + .map( + (opt) => ShadOption( + value: opt.id, + child: Text(opt.name), + ), + ) + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + ShadButton.ghost( + onPressed: + !_controller.isLoading && _controller.hasActiveFilters + ? _resetFilters + : null, + child: const Text('필터 초기화'), + ), + ], + ), + child: ShadCard( + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : records.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '조회된 결재 단계가 없습니다.', + style: theme.textTheme.muted, + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 480, + child: ShadTable.list( + header: + [ + 'ID', + '결재번호', + '단계순서', + '승인자', + '상태', + '배정일시', + '결정일시', + '동작', + ] + .map( + (label) => ShadTableCell.header( + child: Text(label), + ), + ) + .toList(), + columnSpanExtent: (index) { + switch (index) { + case 1: + return const FixedTableSpanExtent(160); + case 2: + return const FixedTableSpanExtent(100); + case 3: + return const FixedTableSpanExtent(150); + case 4: + return const FixedTableSpanExtent(120); + case 5: + case 6: + return const FixedTableSpanExtent(160); + case 7: + return const FixedTableSpanExtent(110); + default: + return const FixedTableSpanExtent(90); + } + }, + children: records.map((record) { + final step = record.step; + return [ + ShadTableCell( + child: Text(step.id?.toString() ?? '-'), + ), + ShadTableCell(child: Text(record.approvalNo)), + ShadTableCell(child: Text('${step.stepOrder}')), + ShadTableCell(child: Text(step.approver.name)), + ShadTableCell( + child: ShadBadge(child: Text(step.status.name)), + ), + ShadTableCell( + child: Text(_formatDate(step.assignedAt)), + ), + ShadTableCell( + child: Text( + step.decidedAt == null + ? '-' + : _formatDate(step.decidedAt!), + ), + ), + ShadTableCell( + child: Align( + alignment: Alignment.centerRight, + child: ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: step.id == null + ? null + : () => _openDetail(record), + child: const Text('상세'), + ), + ), + ), + ]; + }).toList(), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('총 $totalCount건', 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('다음'), + ), + const SizedBox(width: 12), + Text( + '페이지 $currentPage / $totalPages', + style: theme.textTheme.small, + ), + ], + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + List<_StatusOption> _buildStatusOptions(List records) { + final options = <_StatusOption>{}; + options.add(const _StatusOption(id: -1, name: '전체')); + for (final record in records) { + final status = record.step.status; + if (status.id != null) { + options.add(_StatusOption(id: status.id!, name: status.name)); + } + } + return options.toList()..sort((a, b) => a.id.compareTo(b.id)); + } + + Future _applyFilters() async { + _controller.updateQuery(_searchController.text.trim()); + await _controller.fetch(page: 1); + } + + Future _resetFilters() async { + _searchController.clear(); + _controller.resetFilters(); + await _controller.fetch(page: 1); + _searchFocus.requestFocus(); + } + + Future _openDetail(ApprovalStepRecord record) async { + final stepId = record.step.id; + if (stepId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('단계 식별자가 없어 상세 정보를 볼 수 없습니다.')), + ); + return; + } + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + final detail = await _controller.fetchDetail(stepId); + if (!mounted) return; + Navigator.of(context, rootNavigator: true).pop(); + if (detail == null) return; + await showDialog( + context: context, + builder: (dialogContext) { + final step = detail.step; + final theme = ShadTheme.of(dialogContext); + return Dialog( + insetPadding: const EdgeInsets.all(24), + clipBehavior: Clip.antiAlias, + child: ShadCard( + title: Text('결재 단계 상세', style: theme.textTheme.h3), + description: Text( + '결재번호 ${detail.approvalNo}', + style: theme.textTheme.muted, + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailRow(label: '단계 순서', value: '${step.stepOrder}'), + _DetailRow(label: '승인자', value: step.approver.name), + _DetailRow(label: '상태', value: step.status.name), + _DetailRow( + label: '배정일시', + value: _formatDate(step.assignedAt), + ), + _DetailRow( + label: '결정일시', + value: step.decidedAt == null + ? '-' + : _formatDate(step.decidedAt!), + ), + _DetailRow(label: '템플릿', value: detail.templateName ?? '-'), + _DetailRow( + label: '트랜잭션번호', + value: detail.transactionNo ?? '-', + ), + const SizedBox(height: 12), + Text( + '비고', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ShadTextarea( + initialValue: step.note ?? '', + readOnly: true, + minHeight: 80, + maxHeight: 200, + ), + ], + ), + ), + footer: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('닫기'), + ), + ], + ), + ), + ); + }, + ); + } + + String _formatDate(DateTime date) { + return _dateFormat.format(date.toLocal()); + } +} + +class _StatusOption { + const _StatusOption({required this.id, required this.name}); + + final int id; + final String name; + + @override + bool operator ==(Object other) { + return other is _StatusOption && other.id == id; + } + + @override + int get hashCode => id.hashCode; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded(child: Text(value, style: theme.textTheme.small)), + ], ), ); } diff --git a/lib/injection_container.dart b/lib/injection_container.dart index b52dba8..f621b3f 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -25,8 +25,10 @@ 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/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'; +import 'features/approvals/step/domain/repositories/approval_step_repository.dart'; /// 전역 DI 컨테이너 final GetIt sl = GetIt.instance; @@ -101,4 +103,8 @@ Future initInjection({ sl.registerLazySingleton( () => ApprovalTemplateRepositoryRemote(apiClient: sl()), ); + + sl.registerLazySingleton( + () => ApprovalStepRepositoryRemote(apiClient: sl()), + ); } 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 new file mode 100644 index 0000000..ec9e793 --- /dev/null +++ b/test/features/approvals/step/presentation/controllers/approval_step_controller_test.dart @@ -0,0 +1,144 @@ +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/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'; + +class _MockApprovalStepRepository extends Mock + implements ApprovalStepRepository {} + +void main() { + late ApprovalStepController controller; + late _MockApprovalStepRepository repository; + + final sampleRecord = ApprovalStepRecord( + approvalId: 10, + approvalNo: 'APP-2024-0001', + transactionNo: 'TRX-2024-01', + templateName: '입고 기본', + step: ApprovalStep( + id: 100, + stepOrder: 1, + approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'), + status: ApprovalStatus(id: 1, name: '승인대기', color: null), + assignedAt: DateTime(2024, 4, 1, 9), + decidedAt: null, + note: '확인 요청', + ), + ); + + PaginatedResult createResult( + List items, + ) { + return PaginatedResult( + items: items, + page: 1, + pageSize: 20, + total: items.length, + ); + } + + setUp(() { + repository = _MockApprovalStepRepository(); + controller = ApprovalStepController(repository: repository); + }); + + test('fetch 성공 시 결과를 갱신한다', () async { + 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])); + + await controller.fetch(); + + expect(controller.result?.items, isNotEmpty); + expect(controller.errorMessage, isNull); + verify( + () => repository.list( + page: 1, + pageSize: 20, + query: null, + statusId: null, + approverId: null, + approvalId: null, + ), + ).called(1); + }); + + test('에러 발생 시 errorMessage를 설정한다', () async { + 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'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.errorMessage, isNotNull); + expect(controller.result, isNull); + }); + + test('필터 갱신 후 fetch 시 파라미터에 반영한다', () async { + 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])); + + controller.updateQuery('APP-2024'); + controller.updateStatusId(2); + + await controller.fetch(page: 3); + + verify( + () => repository.list( + page: 3, + pageSize: 20, + query: 'APP-2024', + statusId: 2, + approverId: null, + approvalId: null, + ), + ).called(1); + }); + + test('fetchDetail 성공 시 selected가 설정된다', () async { + when( + () => repository.fetchDetail(any()), + ).thenAnswer((_) async => sampleRecord); + + final detail = await controller.fetchDetail(100); + + expect(detail, isNotNull); + expect(controller.selected, isNotNull); + verify(() => repository.fetchDetail(100)).called(1); + }); + + test('fetchDetail 실패 시 null을 반환한다', () async { + when(() => repository.fetchDetail(any())).thenThrow(Exception('fail')); + + final detail = await controller.fetchDetail(100); + + expect(detail, 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 new file mode 100644 index 0000000..629a904 --- /dev/null +++ b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart @@ -0,0 +1,116 @@ +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:two_dimensional_scrollables/two_dimensional_scrollables.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_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'; + +class _MockApprovalStepRepository extends Mock + implements ApprovalStepRepository {} + +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 _MockApprovalStepRepository repository; + + final record = ApprovalStepRecord( + approvalId: 10, + approvalNo: 'APP-2024-0001', + transactionNo: 'TRX-2024-001', + templateName: '입고 기본', + step: ApprovalStep( + id: 501, + stepOrder: 1, + approver: ApprovalApprover(id: 21, employeeNo: 'E001', name: '최승인'), + status: ApprovalStatus(id: 1, name: '승인대기', color: null), + assignedAt: DateTime(2024, 4, 1, 9), + decidedAt: null, + 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 ApprovalStepPage())); + await tester.pump(); + + expect(find.text('결재 단계 관리'), findsOneWidget); + expect(find.text('결재 단계 순서와 승인자를 구성합니다.'), findsOneWidget); + }); + + testWidgets('목록을 렌더링하고 상세 다이얼로그를 연다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + repository = _MockApprovalStepRepository(); + GetIt.I.registerLazySingleton(() => repository); + + 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.fetchDetail(any())).thenAnswer((_) async => record); + + await tester.pumpWidget(_buildApp(const ApprovalStepPage())); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('APP-2024-0001'), findsOneWidget); + expect(find.text('최승인'), findsOneWidget); + + await tester.dragUntilVisible( + find.widgetWithText(ShadButton, '상세'), + find.byType(TwoDimensionalScrollable), + const Offset(-200, 0), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ShadButton, '상세').first); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('결재 단계 상세'), findsOneWidget); + expect(find.text('검토 필요'), findsOneWidget); + verify(() => repository.fetchDetail(501)).called(1); + + await tester.tap(find.text('닫기')); + await tester.pumpAndSettle(); + }); +}