From 1fbed565b7d98f8c9fc97984b8b96445dc0f6877 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 25 Sep 2025 00:42:25 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=B0=EC=9E=AC=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20CRUD=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=A0=95=EC=8B=9D=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/IMPLEMENTATION_TASKS.md | 6 +- .../data/dtos/approval_template_dto.dart | 23 + .../approval_template_repository_remote.dart | 103 ++- .../domain/entities/approval_template.dart | 97 ++ .../approval_template_repository.dart | 24 +- .../controllers/approval_controller.dart | 8 +- .../approval_template_controller.dart | 167 ++++ .../pages/approval_template_page.dart | 829 +++++++++++++++++- .../controllers/approval_controller_test.dart | 58 +- .../approval_template_controller_test.dart | 188 ++++ 10 files changed, 1450 insertions(+), 53 deletions(-) create mode 100644 lib/features/approvals/template/presentation/controllers/approval_template_controller.dart create mode 100644 test/features/approvals/template/presentation/controllers/approval_template_controller_test.dart diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md index 4c19f94..e656b5a 100644 --- a/doc/IMPLEMENTATION_TASKS.md +++ b/doc/IMPLEMENTATION_TASKS.md @@ -57,11 +57,11 @@ ## 7) 결재(UI) - [ ] 결재(`/approvals`): 목록/필터, 상세(개요/단계/이력 탭) (현황: `/approvals/requests` 라우트를 `ApprovalPage`로 연결하고 AppLayout/FilterBar·단계 행위·템플릿 적용까지 연동했으며 추가 액션/권한 제어는 후속 예정) -- [x] 템플릿 불러오기: 단계 탭에서 템플릿 선택 UI(단계 리스트 반영) (현황: 템플릿 목록 로딩·선택·확인 다이얼로그·`assignSteps` 호출로 단계 일괄 적용까지 구현, 템플릿 CRUD는 미구현) +- [x] 템플릿 불러오기: 단계 탭에서 템플릿 선택 UI(단계 리스트 반영) (현황: 템플릿 목록 로딩·선택·확인 다이얼로그·`assignSteps` 호출로 단계 일괄 적용까지 구현, 템플릿 CRUD 화면과 연동되어 최신 목록/단계 구성이 반영됨) - [x] 단계 행위: 승인/반려/코멘트 버튼(가능 여부 상태에 따라 비활성/툴팁) (현황: 단계 버튼·툴팁·행위 다이얼로그를 구현했고 `ApprovalRepository.performStepAction` 연동 완료, 권한 기반 노출/후속 알림은 TODO) - [ ] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) (현황: feature flag On 시 AppLayout + 안내 카드 플레이스홀더 제공, CRUD/데이터 연동은 미구현) - [ ] 이력(`/approval-histories`): 조회 전용 테이블 (현황: AppLayout 기반 안내 화면으로 전환, 실제 테이블/필터/다운로드는 미구현) -- [ ] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout/FilterBar를 갖춘 플레이스홀더만 존재, 템플릿 CRUD 로직 미구현) +- [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정) ## 8) 우편번호 검색 모달(UI) - [ ] 입력: 검색어 텍스트 (현황: `/utilities/postal-search` 라우트가 SpecPage만 노출, 실제 모달 위젯/상호작용 없음) @@ -87,7 +87,7 @@ - [ ] 각 화면 API 연결: - 입고/출고/대여: 목록/상세/생성/수정/삭제/복구 + include/필터/정렬/페이지네이션 (현황: `ShadTable`에 Mock 데이터 하드코딩, 리포지토리/DTO 부재) - 마스터: vendors/products/warehouses/customers/employees/menus/groups/group-permissions(+ 일괄 저장) (현황: 모든 마스터 화면이 `ApiClient` 기반 리포지토리로 CRUD/삭제·복구까지 호출하도록 작성되어 있으나 실제 엔드포인트 유효성 검증 필요) - - 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 (현황: 리포지토리 스켈레톤만 존재하며 UI는 AppLayout 기반으로 정리됐지만 실제 API 연동과 액션 처리는 미구현) + - 결재: approvals(+steps/histories), actions, can-proceed, 템플릿 CRUD/단계 배치 (현황: 템플릿 DTO/리포지토리/컨트롤러를 구현해 CRUD·단계 등록까지 API 연동이 완료됐고 나머지 결재 목록/이력/권한 제어는 진행 중) - 우편번호: `GET /zipcodes?...` (현황: 리포지토리/네트워킹 미구현) - 보고서: 다운로드 엔드포인트 연동(제공 시) (현황: 보고서 화면은 AppLayout 플레이스홀더 상태, API 연동과 다운로드 흐름 미구현) diff --git a/lib/features/approvals/data/dtos/approval_template_dto.dart b/lib/features/approvals/data/dtos/approval_template_dto.dart index 42ea3a5..e9be3f3 100644 --- a/lib/features/approvals/data/dtos/approval_template_dto.dart +++ b/lib/features/approvals/data/dtos/approval_template_dto.dart @@ -1,3 +1,5 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + import '../../domain/entities/approval_template.dart'; class ApprovalTemplateDto { @@ -7,6 +9,7 @@ class ApprovalTemplateDto { required this.name, this.description, required this.isActive, + this.note, this.createdBy, this.createdAt, this.updatedAt, @@ -18,6 +21,7 @@ class ApprovalTemplateDto { final String name; final String? description; final bool isActive; + final String? note; final ApprovalTemplateAuthorDto? createdBy; final DateTime? createdAt; final DateTime? updatedAt; @@ -29,6 +33,7 @@ class ApprovalTemplateDto { code: json['template_code'] as String? ?? json['code'] as String? ?? '-', name: json['template_name'] as String? ?? json['name'] as String? ?? '-', description: json['description'] as String?, + note: json['note'] as String?, isActive: (json['is_active'] as bool?) ?? true, createdBy: json['created_by'] is Map ? ApprovalTemplateAuthorDto.fromJson( @@ -50,6 +55,7 @@ class ApprovalTemplateDto { code: code, name: name, description: description, + note: note, isActive: isActive, createdBy: createdBy?.toEntity(), createdAt: createdAt, @@ -57,6 +63,23 @@ class ApprovalTemplateDto { steps: includeSteps ? steps.map((e) => e.toEntity()).toList() : const [], ); } + + static PaginatedResult parsePaginated( + Map? json, { + bool includeSteps = false, + }) { + final items = (json?['items'] as List? ?? []) + .whereType>() + .map(ApprovalTemplateDto.fromJson) + .map((dto) => dto.toEntity(includeSteps: includeSteps)) + .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, + ); + } } class ApprovalTemplateAuthorDto { diff --git a/lib/features/approvals/data/repositories/approval_template_repository_remote.dart b/lib/features/approvals/data/repositories/approval_template_repository_remote.dart index d3a9298..abf126e 100644 --- a/lib/features/approvals/data/repositories/approval_template_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_template_repository_remote.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; +import '../../../../core/common/models/paginated_result.dart'; import '../../../../core/network/api_client.dart'; import '../../domain/entities/approval_template.dart'; import '../../domain/repositories/approval_template_repository.dart'; @@ -14,18 +15,23 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { static const _basePath = '/approval-templates'; @override - Future> list({bool activeOnly = true}) async { + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isActive, + }) async { final response = await _api.get>( _basePath, - query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true}, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (isActive != null) 'active': isActive, + }, options: Options(responseType: ResponseType.json), ); - final items = (response.data?['items'] as List? ?? []) - .whereType>() - .map(ApprovalTemplateDto.fromJson) - .map((dto) => dto.toEntity(includeSteps: false)) - .toList(); - return items; + return ApprovalTemplateDto.parsePaginated(response.data); } @override @@ -43,4 +49,85 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { data, ).toEntity(includeSteps: includeSteps); } + + @override + Future create( + ApprovalTemplateInput input, { + List steps = const [], + }) async { + final response = await _api.post>( + _basePath, + data: input.toCreatePayload(), + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + final created = ApprovalTemplateDto.fromJson( + data, + ).toEntity(includeSteps: false); + if (steps.isNotEmpty) { + await _postSteps(created.id, steps); + } + return fetchDetail(created.id, includeSteps: true); + } + + @override + Future update( + int id, + ApprovalTemplateInput input, { + List? steps, + }) async { + await _api.patch>( + '$_basePath/$id', + data: input.toUpdatePayload(id), + options: Options(responseType: ResponseType.json), + ); + if (steps != null) { + await _patchSteps(id, steps); + } + return fetchDetail(id, includeSteps: true); + } + + @override + Future delete(int id) async { + await _api.delete('$_basePath/$id'); + } + + @override + Future restore(int id) async { + final response = await _api.post>( + '$_basePath/$id/restore', + options: Options(responseType: ResponseType.json), + ); + final data = (response.data?['data'] as Map?) ?? {}; + return ApprovalTemplateDto.fromJson(data).toEntity(includeSteps: false); + } + + Future _postSteps( + int templateId, + List steps, + ) async { + if (steps.isEmpty) return; + await _api.post>( + '$_basePath/$templateId/steps', + data: { + 'id': templateId, + 'steps': steps.map((step) => step.toJson(includeId: false)).toList(), + }, + options: Options(responseType: ResponseType.json), + ); + } + + Future _patchSteps( + int templateId, + List steps, + ) async { + await _api.patch>( + '$_basePath/$templateId/steps', + data: { + 'id': templateId, + 'steps': steps.map((step) => step.toJson()).toList(), + }, + options: Options(responseType: ResponseType.json), + ); + } } diff --git a/lib/features/approvals/domain/entities/approval_template.dart b/lib/features/approvals/domain/entities/approval_template.dart index 00fd067..cb06cb7 100644 --- a/lib/features/approvals/domain/entities/approval_template.dart +++ b/lib/features/approvals/domain/entities/approval_template.dart @@ -7,6 +7,7 @@ class ApprovalTemplate { required this.code, required this.name, this.description, + this.note, required this.isActive, this.createdBy, this.createdAt, @@ -18,11 +19,37 @@ class ApprovalTemplate { final String code; final String name; final String? description; + final String? note; final bool isActive; final ApprovalTemplateAuthor? createdBy; final DateTime? createdAt; final DateTime? updatedAt; final List steps; + + ApprovalTemplate copyWith({ + String? code, + String? name, + String? description, + String? note, + bool? isActive, + ApprovalTemplateAuthor? createdBy, + DateTime? createdAt, + DateTime? updatedAt, + List? steps, + }) { + return ApprovalTemplate( + id: id, + code: code ?? this.code, + name: name ?? this.name, + description: description ?? this.description, + note: note ?? this.note, + isActive: isActive ?? this.isActive, + createdBy: createdBy ?? this.createdBy, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + steps: steps ?? this.steps, + ); + } } class ApprovalTemplateAuthor { @@ -62,3 +89,73 @@ class ApprovalTemplateApprover { final String employeeNo; final String name; } + +/// 결재 템플릿 생성/수정 입력 모델 +class ApprovalTemplateInput { + ApprovalTemplateInput({ + this.code, + required this.name, + this.description, + this.note, + this.isActive = true, + this.createdById, + }); + + final String? code; + final String name; + final String? description; + final String? note; + final bool isActive; + final int? createdById; + + Map toCreatePayload() { + return { + if (code != null) 'template_code': code, + 'template_name': name, + if (description != null && description!.trim().isNotEmpty) + 'description': description, + if (note != null && note!.trim().isNotEmpty) 'note': note, + 'is_active': isActive, + if (createdById != null) 'created_by_id': createdById, + }; + } + + Map toUpdatePayload(int id) { + final payload = { + 'id': id, + 'template_name': name, + 'is_active': isActive, + }; + if (description != null && description!.trim().isNotEmpty) { + payload['description'] = description; + } + if (note != null && note!.trim().isNotEmpty) { + payload['note'] = note; + } + return payload; + } +} + +/// 템플릿 단계 입력 모델 +class ApprovalTemplateStepInput { + ApprovalTemplateStepInput({ + this.id, + required this.stepOrder, + required this.approverId, + this.note, + }); + + final int? id; + final int stepOrder; + final int approverId; + final String? note; + + Map toJson({bool includeId = true}) { + return { + if (includeId && id != null) 'id': id, + 'step_order': stepOrder, + 'approver_id': approverId, + if (note != null && note!.trim().isNotEmpty) 'note': note, + }; + } +} diff --git a/lib/features/approvals/domain/repositories/approval_template_repository.dart b/lib/features/approvals/domain/repositories/approval_template_repository.dart index e21e2c0..cfade0a 100644 --- a/lib/features/approvals/domain/repositories/approval_template_repository.dart +++ b/lib/features/approvals/domain/repositories/approval_template_repository.dart @@ -1,7 +1,29 @@ +import 'package:superport_v2/core/common/models/paginated_result.dart'; + import '../entities/approval_template.dart'; abstract class ApprovalTemplateRepository { - Future> list({bool activeOnly = true}); + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isActive, + }); Future fetchDetail(int id, {bool includeSteps = true}); + + Future create( + ApprovalTemplateInput input, { + List steps = const [], + }); + + Future update( + int id, + ApprovalTemplateInput input, { + List? steps, + }); + + Future delete(int id); + + Future restore(int id); } diff --git a/lib/features/approvals/presentation/controllers/approval_controller.dart b/lib/features/approvals/presentation/controllers/approval_controller.dart index 3627e5d..40b64e8 100644 --- a/lib/features/approvals/presentation/controllers/approval_controller.dart +++ b/lib/features/approvals/presentation/controllers/approval_controller.dart @@ -138,8 +138,12 @@ class ApprovalController extends ChangeNotifier { _errorMessage = null; notifyListeners(); try { - final items = await _templateRepository.list(activeOnly: true); - _templates = items; + final result = await _templateRepository.list( + page: 1, + pageSize: 100, + isActive: true, + ); + _templates = result.items; } catch (e) { _errorMessage = e.toString(); } finally { diff --git a/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart b/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart new file mode 100644 index 0000000..415010a --- /dev/null +++ b/lib/features/approvals/template/presentation/controllers/approval_template_controller.dart @@ -0,0 +1,167 @@ +import 'package:flutter/foundation.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; + +import '../../../domain/entities/approval_template.dart'; +import '../../../domain/repositories/approval_template_repository.dart'; + +enum ApprovalTemplateStatusFilter { all, activeOnly, inactiveOnly } + +/// 결재 템플릿 화면 상태 컨트롤러 +/// +/// - 목록/검색/필터 상태와 생성·수정·삭제 요청을 관리한다. +class ApprovalTemplateController extends ChangeNotifier { + ApprovalTemplateController({required ApprovalTemplateRepository repository}) + : _repository = repository; + + final ApprovalTemplateRepository _repository; + + PaginatedResult? _result; + bool _isLoading = false; + bool _isSubmitting = false; + String _query = ''; + ApprovalTemplateStatusFilter _statusFilter = ApprovalTemplateStatusFilter.all; + String? _errorMessage; + + PaginatedResult? get result => _result; + bool get isLoading => _isLoading; + bool get isSubmitting => _isSubmitting; + String get query => _query; + ApprovalTemplateStatusFilter get statusFilter => _statusFilter; + String? get errorMessage => _errorMessage; + + Future fetch({int page = 1}) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + try { + final sanitizedQuery = _query.trim(); + final isActive = switch (_statusFilter) { + ApprovalTemplateStatusFilter.all => null, + ApprovalTemplateStatusFilter.activeOnly => true, + ApprovalTemplateStatusFilter.inactiveOnly => false, + }; + final response = await _repository.list( + page: page, + pageSize: _result?.pageSize ?? 20, + query: sanitizedQuery.isEmpty ? null : sanitizedQuery, + isActive: isActive, + ); + _result = response; + } catch (e) { + _errorMessage = e.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void updateQuery(String value) { + _query = value; + notifyListeners(); + } + + void updateStatusFilter(ApprovalTemplateStatusFilter filter) { + _statusFilter = filter; + notifyListeners(); + } + + Future fetchDetail(int id) async { + _errorMessage = null; + notifyListeners(); + try { + final detail = await _repository.fetchDetail(id, includeSteps: true); + return detail; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } + } + + Future create( + ApprovalTemplateInput input, + List steps, + ) async { + _setSubmitting(true); + try { + final created = await _repository.create(input, steps: steps); + await fetch(page: 1); + return created; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future update( + int id, + ApprovalTemplateInput input, + List? steps, + ) async { + _setSubmitting(true); + try { + final updated = await _repository.update(id, input, steps: steps); + await fetch(page: _result?.page ?? 1); + return updated; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + Future delete(int id) async { + _setSubmitting(true); + try { + await _repository.delete(id); + await fetch(page: _result?.page ?? 1); + return true; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return false; + } finally { + _setSubmitting(false); + } + } + + Future restore(int id) async { + _setSubmitting(true); + try { + final restored = await _repository.restore(id); + await fetch(page: _result?.page ?? 1); + return restored; + } catch (e) { + _errorMessage = e.toString(); + notifyListeners(); + return null; + } finally { + _setSubmitting(false); + } + } + + void clearError() { + _errorMessage = null; + notifyListeners(); + } + + bool get hasActiveFilters => + _query.trim().isNotEmpty || + _statusFilter != ApprovalTemplateStatusFilter.all; + + void resetFilters() { + _query = ''; + _statusFilter = ApprovalTemplateStatusFilter.all; + notifyListeners(); + } + + void _setSubmitting(bool value) { + _isSubmitting = value; + notifyListeners(); + } +} diff --git a/lib/features/approvals/template/presentation/pages/approval_template_page.dart b/lib/features/approvals/template/presentation/pages/approval_template_page.dart index dcfc32a..aff763c 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -1,12 +1,18 @@ 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/components/superport_dialog.dart'; import '../../../../../widgets/spec_page.dart'; +import '../../../domain/entities/approval_template.dart'; +import '../../../domain/repositories/approval_template_repository.dart'; +import '../controllers/approval_template_controller.dart'; class ApprovalTemplatePage extends StatelessWidget { const ApprovalTemplatePage({super.key}); @@ -17,7 +23,7 @@ class ApprovalTemplatePage extends StatelessWidget { if (!enabled) { return const SpecPage( title: '결재 템플릿 관리', - summary: '반복적인 결재 흐름을 템플릿으로 정의합니다.', + summary: '반복되는 결재 단계를 템플릿으로 구성합니다.', sections: [ SpecSection( title: '입력 폼', @@ -25,10 +31,9 @@ class ApprovalTemplatePage extends StatelessWidget { '템플릿코드 [Text]', '템플릿명 [Text]', '설명 [Text]', - '작성자 [ReadOnly]', '사용여부 [Switch]', - '비고 [Text]', - '단계 추가: 순서 [Number], 승인자 [Dropdown]', + '비고 [Textarea]', + '단계 추가: 순서 [Number], 승인자ID [Number]', ], ), SpecSection( @@ -39,16 +44,15 @@ class ApprovalTemplatePage extends StatelessWidget { title: '테이블 리스트', description: '1행 예시', table: SpecTable( - columns: ['번호', '템플릿코드', '템플릿명', '설명', '작성자', '사용여부', '변경일시'], + columns: ['번호', '템플릿코드', '템플릿명', '설명', '사용여부', '변경일시'], rows: [ [ '1', - 'TEMP-001', - '입고 기본 결재', - '입고 처리 2단계 결재', - '홍길동', + 'AP_INBOUND', + '입고 결재 기본', + '입고 2단계', 'Y', - '2024-03-01 10:00', + '2024-04-01 09:00', ], ], ), @@ -57,29 +61,796 @@ class ApprovalTemplatePage extends StatelessWidget { ); } - return AppLayout( - title: '결재 템플릿 관리', - subtitle: '반복되는 결재 단계를 템플릿으로 구성할 수 있도록 준비 중입니다.', - breadcrumbs: const [ - AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), - AppBreadcrumbItem(label: '결재', path: '/approvals/templates'), - AppBreadcrumbItem(label: '결재 템플릿'), - ], + return const _ApprovalTemplateEnabledPage(); + } +} + +class _ApprovalTemplateEnabledPage extends StatefulWidget { + const _ApprovalTemplateEnabledPage(); + + @override + State<_ApprovalTemplateEnabledPage> createState() => + _ApprovalTemplateEnabledPageState(); +} + +class _ApprovalTemplateEnabledPageState + extends State<_ApprovalTemplateEnabledPage> { + late final ApprovalTemplateController _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 = ApprovalTemplateController( + 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 templates = 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; + + final showReset = + _searchController.text.trim().isNotEmpty || + _controller.statusFilter != ApprovalTemplateStatusFilter.all; + + return AppLayout( + title: '결재 템플릿 관리', + subtitle: '결재 단계 구성을 템플릿으로 저장하고 재사용합니다.', + breadcrumbs: const [ + AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath), + AppBreadcrumbItem(label: '결재', path: '/approvals/templates'), + AppBreadcrumbItem(label: '결재 템플릿'), + ], + actions: [ + ShadButton( + leading: const Icon(lucide.LucideIcons.plus, size: 16), + onPressed: _controller.isSubmitting + ? null + : () => _openTemplateForm(), + 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(), + ), + ), + SizedBox( + width: 180, + child: ShadSelect( + key: ValueKey(_controller.statusFilter), + initialValue: _controller.statusFilter, + selectedOptionBuilder: (context, value) => + Text(_statusLabel(value)), + onChanged: (value) { + if (value == null) return; + _controller.updateStatusFilter(value); + }, + options: ApprovalTemplateStatusFilter.values + .map( + (filter) => ShadOption( + value: filter, + child: Text(_statusLabel(filter)), + ), + ) + .toList(), + ), + ), + ShadButton.outline( + onPressed: _controller.isLoading ? null : _applyFilters, + child: const Text('검색 적용'), + ), + ShadButton.ghost( + onPressed: !_controller.isLoading && showReset + ? _resetFilters + : null, + child: const Text('필터 초기화'), + ), + ], + ), + child: ShadCard( + child: _controller.isLoading + ? const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ) + : templates.isEmpty + ? Padding( + padding: const EdgeInsets.all(32), + child: Text( + '등록된 결재 템플릿이 없습니다.', + style: theme.textTheme.muted, + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ShadTable.list( + header: + ['ID', '템플릿코드', '템플릿명', '설명', '사용', '변경일시', '동작'] + .map( + (e) => ShadTableCell.header(child: Text(e)), + ) + .toList(), + columnSpanExtent: (index) { + switch (index) { + case 2: + return const FixedTableSpanExtent(220); + case 3: + return const FixedTableSpanExtent(260); + case 4: + return const FixedTableSpanExtent(100); + case 5: + return const FixedTableSpanExtent(180); + case 6: + return const FixedTableSpanExtent(160); + default: + return const FixedTableSpanExtent(140); + } + }, + children: templates.map((template) { + return [ + ShadTableCell(child: Text('${template.id}')), + ShadTableCell(child: Text(template.code)), + ShadTableCell(child: Text(template.name)), + ShadTableCell( + child: Text( + template.description?.isNotEmpty == true + ? template.description! + : '-', + ), + ), + ShadTableCell( + child: ShadBadge( + variant: template.isActive + ? ShadBadgeVariant.defaultVariant + : ShadBadgeVariant.outline, + child: Text(template.isActive ? '사용' : '미사용'), + ), + ), + ShadTableCell( + child: Text( + template.updatedAt == null + ? '-' + : _dateFormat.format( + template.updatedAt!.toLocal(), + ), + ), + ), + ShadTableCell( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ShadButton.ghost( + key: ValueKey( + 'template_edit_${template.id}', + ), + size: ShadButtonSize.sm, + onPressed: _controller.isSubmitting + ? null + : () => _openEditTemplate(template), + child: const Text('수정'), + ), + const SizedBox(width: 8), + template.isActive + ? ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isSubmitting + ? null + : () => + _confirmDelete(template), + child: const Text('삭제'), + ) + : ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: _controller.isSubmitting + ? null + : () => + _confirmRestore(template), + 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, + ), + ], + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + void _applyFilters() { + _controller.updateQuery(_searchController.text.trim()); + _controller.fetch(page: 1); + } + + void _resetFilters() { + _searchController.clear(); + _controller.resetFilters(); + _controller.fetch(page: 1); + _searchFocus.requestFocus(); + } + + Future _openEditTemplate(ApprovalTemplate template) async { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + final detail = await _controller.fetchDetail(template.id); + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + if (!mounted || detail == null) { + return; + } + final success = await _openTemplateForm(template: detail); + if (!mounted || success != true) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('템플릿 "${detail.name}"을(를) 수정했습니다.'))); + } + + Future _confirmDelete(ApprovalTemplate template) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('템플릿 삭제'), + content: Text( + '"${template.name}" 템플릿을 삭제하시겠습니까?\n삭제 시 템플릿은 미사용 상태로 전환됩니다.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('삭제'), + ), + ], + ); + }, + ); + if (confirmed != true) return; + final ok = await _controller.delete(template.id); + if (!mounted || !ok) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('템플릿 "${template.name}"을(를) 삭제했습니다.')), + ); + } + + Future _confirmRestore(ApprovalTemplate template) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('템플릿 복구'), + content: Text('"${template.name}" 템플릿을 복구하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('취소'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('복구'), + ), + ], + ); + }, + ); + if (confirmed != true) return; + final restored = await _controller.restore(template.id); + if (!mounted || restored == null) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('템플릿 "${restored.name}"을(를) 복구했습니다.')), + ); + } + + Future _openTemplateForm({ApprovalTemplate? template}) async { + final isEdit = template != null; + final codeController = TextEditingController(text: template?.code ?? ''); + final nameController = TextEditingController(text: template?.name ?? ''); + final descriptionController = TextEditingController( + text: template?.description ?? '', + ); + final noteController = TextEditingController(text: template?.note ?? ''); + final steps = _buildStepFields(template); + final statusNotifier = ValueNotifier(template?.isActive ?? true); + bool isSaving = false; + String? errorText; + + final result = await showSuperportDialog( + context: context, + title: isEdit ? '템플릿 수정' : '템플릿 생성', + barrierDismissible: !isSaving, + body: StatefulBuilder( + builder: (dialogContext, setModalState) { + final theme = ShadTheme.of(dialogContext); + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!isEdit) + _FormField( + label: '템플릿 코드', + child: ShadInput( + controller: codeController, + placeholder: const Text('예: AP_INBOUND'), + ), + ), + _FormField( + label: '템플릿명', + child: ShadInput(controller: nameController), + ), + _FormField( + label: '설명', + child: ShadTextarea( + controller: descriptionController, + minLines: 2, + maxLines: 4, + ), + ), + _FormField( + label: '사용 여부', + child: ValueListenableBuilder( + valueListenable: statusNotifier, + builder: (_, value, __) { + return Row( + children: [ + ShadSwitch( + value: value, + onChanged: isSaving + ? null + : (next) => statusNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ); + }, + ), + ), + _FormField( + label: '비고', + child: ShadTextarea( + controller: noteController, + minLines: 2, + maxLines: 4, + ), + ), + const SizedBox(height: 16), + Text( + '결재 단계', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Column( + children: [ + for (var index = 0; index < steps.length; index++) + _StepEditorRow( + key: ValueKey('step_field_$index'), + field: steps[index], + index: index, + isEdit: isEdit, + onRemove: steps.length <= 1 || isSaving + ? null + : () { + setModalState(() { + final removed = steps.removeAt(index); + removed.dispose(); + }); + }, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: ShadButton.outline( + onPressed: isSaving + ? null + : () { + setModalState(() { + steps.add( + _TemplateStepField.create( + order: steps.length + 1, + ), + ); + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(lucide.LucideIcons.plus, size: 16), + SizedBox(width: 8), + Text('단계 추가'), + ], + ), + ), + ), + ], + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + errorText!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + ); + }, + ), actions: [ + ShadButton.ghost( + onPressed: isSaving ? null : () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), ShadButton( - onPressed: null, - leading: const Icon(LucideIcons.plus, size: 16), - child: const Text('템플릿 생성'), + onPressed: isSaving + ? null + : () async { + final codeValue = codeController.text.trim(); + final nameValue = nameController.text.trim(); + if (!isEdit && codeValue.isEmpty) { + setModalState(() => errorText = '템플릿 코드를 입력하세요.'); + return; + } + if (nameValue.isEmpty) { + setModalState(() => errorText = '템플릿명을 입력하세요.'); + return; + } + final validation = _validateSteps(steps); + if (validation != null) { + setModalState(() => errorText = validation); + return; + } + setModalState(() => errorText = null); + final stepInputs = steps + .map( + (field) => ApprovalTemplateStepInput( + id: field.id, + stepOrder: int.parse( + field.orderController.text.trim(), + ), + approverId: int.parse( + field.approverController.text.trim(), + ), + note: field.noteController.text.trim().isEmpty + ? null + : field.noteController.text.trim(), + ), + ) + .toList(); + final input = ApprovalTemplateInput( + code: isEdit ? template!.code : codeValue, + name: nameValue, + description: descriptionController.text.trim().isEmpty + ? null + : descriptionController.text.trim(), + note: noteController.text.trim().isEmpty + ? null + : noteController.text.trim(), + isActive: statusNotifier.value, + ); + setModalState(() => isSaving = true); + final success = isEdit + ? await _controller.update( + template!.id, + input, + stepInputs, + ) + : await _controller.create(input, stepInputs); + if (success != null && mounted) { + Navigator.of(context).pop(true); + } else { + setModalState(() => isSaving = false); + } + }, + child: isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isEdit ? '수정 완료' : '생성 완료'), ), ], - toolbar: FilterBar( - children: const [Text('템플릿 검색/필터 UI는 결재 요구사항 확정 후 제공됩니다.')], - ), - child: const ComingSoonCard( - title: '결재 템플릿 화면 구현 준비 중', - description: '템플릿 헤더 정보와 단계 반복 입력을 다루는 UI를 설계하고 있습니다.', - items: ['템플릿 목록 정렬 및 사용여부 토글', '단계 편집/추가/삭제 인터랙션', '템플릿 버전 관리 및 배포 전략'], + ); + + final createdName = nameController.text.trim(); + + for (final field in steps) { + field.dispose(); + } + codeController.dispose(); + nameController.dispose(); + descriptionController.dispose(); + noteController.dispose(); + statusNotifier.dispose(); + + if (result == true && mounted && template == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('템플릿 "$createdName"을 생성했습니다.'))); + } + return result; + } + + String? _validateSteps(List<_TemplateStepField> fields) { + if (fields.isEmpty) { + return '최소 1개의 결재 단계를 등록하세요.'; + } + for (var index = 0; index < fields.length; index++) { + final field = fields[index]; + final orderText = field.orderController.text.trim(); + final approverText = field.approverController.text.trim(); + final order = int.tryParse(orderText); + final approver = int.tryParse(approverText); + if (order == null || order <= 0) { + return '${index + 1}번째 단계의 순서를 올바르게 입력하세요.'; + } + if (approver == null || approver <= 0) { + return '${index + 1}번째 단계의 승인자ID를 올바르게 입력하세요.'; + } + } + return null; + } + + List<_TemplateStepField> _buildStepFields(ApprovalTemplate? template) { + if (template == null || template.steps.isEmpty) { + return [_TemplateStepField.create(order: 1)]; + } + return template.steps + .map( + (step) => _TemplateStepField( + id: step.id, + orderController: TextEditingController( + text: step.stepOrder.toString(), + ), + approverController: TextEditingController( + text: step.approver.id.toString(), + ), + noteController: TextEditingController(text: step.note ?? ''), + ), + ) + .toList(); + } + + String _statusLabel(ApprovalTemplateStatusFilter filter) { + switch (filter) { + case ApprovalTemplateStatusFilter.all: + return '전체'; + case ApprovalTemplateStatusFilter.activeOnly: + return '사용만'; + case ApprovalTemplateStatusFilter.inactiveOnly: + return '미사용만'; + } + } +} + +class _FormField extends StatelessWidget { + const _FormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + child, + ], ), ); } } + +class _StepEditorRow extends StatelessWidget { + const _StepEditorRow({ + super.key, + required this.field, + required this.index, + required this.isEdit, + required this.onRemove, + }); + + final _TemplateStepField field; + final int index; + final bool isEdit; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.border.withOpacity(0.6)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadInput( + controller: field.orderController, + keyboardType: TextInputType.number, + placeholder: const Text('단계 순서'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ShadInput( + controller: field.approverController, + keyboardType: TextInputType.number, + placeholder: const Text('승인자 ID'), + ), + ), + const SizedBox(width: 12), + if (onRemove != null) + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRemove, + child: const Icon(lucide.LucideIcons.trash2, size: 16), + ), + ], + ), + const SizedBox(height: 8), + ShadTextarea( + controller: field.noteController, + minLines: 1, + maxLines: 3, + placeholder: const Text('비고 (선택)'), + ), + if (isEdit && field.id != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text('단계 ID: ${field.id}', style: theme.textTheme.small), + ), + ], + ), + ); + } +} + +class _TemplateStepField { + _TemplateStepField({ + this.id, + required this.orderController, + required this.approverController, + required this.noteController, + }); + + final int? id; + final TextEditingController orderController; + final TextEditingController approverController; + final TextEditingController noteController; + + void dispose() { + orderController.dispose(); + approverController.dispose(); + noteController.dispose(); + } + + factory _TemplateStepField.create({required int order}) { + return _TemplateStepField( + orderController: TextEditingController(text: order.toString()), + approverController: TextEditingController(), + noteController: TextEditingController(), + ); + } +} diff --git a/test/features/approvals/presentation/controllers/approval_controller_test.dart b/test/features/approvals/presentation/controllers/approval_controller_test.dart index ae7cf8f..fb87570 100644 --- a/test/features/approvals/presentation/controllers/approval_controller_test.dart +++ b/test/features/approvals/presentation/controllers/approval_controller_test.dart @@ -207,11 +207,27 @@ void main() { group('loadTemplates', () { test('템플릿 목록을 불러오고 캐시한다', () async { when( - () => templateRepository.list(activeOnly: any(named: 'activeOnly')), + () => templateRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), ).thenAnswer( - (_) async => [ - ApprovalTemplate(id: 1, code: 'TEMP', name: '기본 템플릿', isActive: true), - ], + (_) async => PaginatedResult( + items: [ + ApprovalTemplate( + id: 1, + code: 'TEMP', + name: '기본 템플릿', + isActive: true, + steps: const [], + ), + ], + page: 1, + pageSize: 20, + total: 1, + ), ); await controller.loadTemplates(force: true); @@ -224,13 +240,23 @@ void main() { await controller.loadTemplates(); verifyNever( - () => templateRepository.list(activeOnly: any(named: 'activeOnly')), + () => templateRepository.list( + page: 1, + pageSize: 100, + query: null, + isActive: true, + ), ); }); test('에러 발생 시 errorMessage 설정', () async { when( - () => templateRepository.list(activeOnly: any(named: 'activeOnly')), + () => templateRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), ).thenThrow(Exception('template fail')); await controller.loadTemplates(force: true); @@ -375,6 +401,22 @@ void main() { ], ); + when( + () => templateRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [template], + page: 1, + pageSize: 20, + total: 1, + ), + ); + when( () => templateRepository.fetchDetail( any(), @@ -382,10 +424,6 @@ void main() { ), ).thenAnswer((_) async => template); - when( - () => templateRepository.list(activeOnly: any(named: 'activeOnly')), - ).thenAnswer((_) async => [template]); - when( () => repository.list( page: any(named: 'page'), diff --git a/test/features/approvals/template/presentation/controllers/approval_template_controller_test.dart b/test/features/approvals/template/presentation/controllers/approval_template_controller_test.dart new file mode 100644 index 0000000..f753f1b --- /dev/null +++ b/test/features/approvals/template/presentation/controllers/approval_template_controller_test.dart @@ -0,0 +1,188 @@ +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_template.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/template/presentation/controllers/approval_template_controller.dart'; + +class _MockApprovalTemplateRepository extends Mock + implements ApprovalTemplateRepository {} + +class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {} + +class _FakeStepInput extends Fake implements ApprovalTemplateStepInput {} + +void main() { + late ApprovalTemplateController controller; + late _MockApprovalTemplateRepository repository; + + final sampleTemplate = ApprovalTemplate( + id: 1, + code: 'AP_INBOUND', + name: '입고 결재 기본', + description: '입고 2단계', + note: '기본 템플릿', + isActive: true, + createdBy: null, + createdAt: DateTime(2024, 4, 1, 9), + updatedAt: DateTime(2024, 4, 2, 9), + steps: const [], + ); + + PaginatedResult createResult(List items) { + return PaginatedResult( + items: items, + page: 1, + pageSize: 20, + total: items.length, + ); + } + + setUpAll(() { + registerFallbackValue(_FakeTemplateInput()); + registerFallbackValue(_FakeStepInput()); + registerFallbackValue([]); + }); + + setUp(() { + repository = _MockApprovalTemplateRepository(); + controller = ApprovalTemplateController(repository: repository); + }); + + group('fetch', () { + setUp(() { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult([sampleTemplate])); + }); + + test('목록을 조회한다', () async { + await controller.fetch(); + + expect(controller.result?.items, isNotEmpty); + expect(controller.errorMessage, isNull); + }); + + test('필터를 전달한다', () async { + controller.updateQuery('AP_'); + controller.updateStatusFilter(ApprovalTemplateStatusFilter.inactiveOnly); + + await controller.fetch(page: 2); + + verify( + () => repository.list( + page: 2, + pageSize: 20, + query: 'AP_', + isActive: false, + ), + ).called(1); + }); + + test('에러 발생 시 errorMessage 설정', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenThrow(Exception('fail')); + + await controller.fetch(); + + expect(controller.errorMessage, isNotNull); + }); + }); + + test('create 성공 시 목록 갱신', () async { + when( + () => repository.create(any(), steps: any(named: 'steps')), + ).thenAnswer((_) async => sampleTemplate); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult([sampleTemplate])); + + final created = await controller.create( + ApprovalTemplateInput(code: 'AP_INBOUND', name: '입고 결재 기본'), + [ApprovalTemplateStepInput(stepOrder: 1, approverId: 10)], + ); + + expect(created, isNotNull); + verify( + () => repository.create(any(), steps: any(named: 'steps')), + ).called(1); + }); + + test('update 성공 시 현재 페이지 갱신', () async { + when( + () => repository.update(any(), any(), steps: any(named: 'steps')), + ).thenAnswer((_) async => sampleTemplate); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult([sampleTemplate])); + + controller.updateQuery('AP'); + await controller.update(1, ApprovalTemplateInput(name: '입고 결재 수정'), [ + ApprovalTemplateStepInput(stepOrder: 1, approverId: 33), + ]); + + verify( + () => repository.update(any(), any(), steps: any(named: 'steps')), + ).called(1); + }); + + test('delete 후 목록을 새로고침한다', () async { + when(() => repository.delete(any())).thenAnswer((_) async {}); + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult([])); + + final result = await controller.delete(1); + + expect(result, isTrue); + verify(() => repository.delete(1)).called(1); + }); + + test('restore 성공 시 템플릿을 반환한다', () async { + when( + () => repository.restore(any()), + ).thenAnswer((_) async => sampleTemplate); + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer((_) async => createResult([sampleTemplate])); + + final restored = await controller.restore(1); + + expect(restored, isNotNull); + verify(() => repository.restore(1)).called(1); + }); +}