From fa0bda5ea44226a5cfcff4a394c5d418e7008251 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 5 Nov 2025 17:05:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20API=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EB=94=94=EB=B2=84=EA=B7=B8=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs 폴더 문서를 최신 API 계약으로 갱신하고 가이드를 다듬었다\n- approvals data/presentation 레이어를 API v4 스펙에 맞춰 리팩터링했다\n- approver 자동완성 위젯을 신규 공유 레포지토리에 맞춰 교체하고 UX를 보강했다\n- inventory/rental 페이지 테이블 초기화 시 승인 기준 연동을 정비했다\n- 로그인 페이지 디버그 버튼을 tera/exa 계정으로 분리해 QA 로그인을 단순화했다\n- get_it 등록과 테스트 케이스를 신규 공유 리포지토리에 맞춰 업데이트했다 --- README.md | 2 +- doc/IMPLEMENTATION_TASKS.md | 2 +- doc/PRD_입출고_결재_v2.md | 4 +- doc/stock_approval_system_api_v4.md | 16 +- doc/stock_approval_system_spec_v4.md | 2 +- lib/core/network/api_routes.dart | 2 + .../permissions/permission_resources.dart | 3 +- .../approval_template_repository_remote.dart | 154 +++-- .../pages/approval_history_page.dart | 4 +- .../utils/approval_form_initializer.dart | 81 ++- .../widgets/approval_step_configurator.dart | 39 +- .../widgets/approval_step_row.dart | 32 +- .../approvals/shared/approver_catalog.dart | 148 ----- .../dtos/approval_approver_candidate_dto.dart | 53 ++ .../approval_approver_repository_remote.dart | 84 +++ .../entities/approval_approver_candidate.dart | 32 + .../approval_approver_repository.dart | 16 + .../widgets/approver_autocomplete_field.dart | 587 ++++++++++++------ .../pages/approval_template_page.dart | 65 +- .../presentation/pages/inbound_page.dart | 5 +- .../presentation/pages/outbound_page.dart | 5 +- .../presentation/pages/rental_page.dart | 5 +- .../login/presentation/pages/login_page.dart | 49 +- .../pages/group_permission_page.dart | 55 +- .../controllers/product_controller.dart | 45 +- lib/injection_container.dart | 5 + .../utils/approval_form_initializer_test.dart | 79 ++- .../pages/approval_template_page_test.dart | 73 +++ 28 files changed, 1102 insertions(+), 545 deletions(-) delete mode 100644 lib/features/approvals/shared/approver_catalog.dart create mode 100644 lib/features/approvals/shared/data/dtos/approval_approver_candidate_dto.dart create mode 100644 lib/features/approvals/shared/data/repositories/approval_approver_repository_remote.dart create mode 100644 lib/features/approvals/shared/domain/entities/approval_approver_candidate.dart create mode 100644 lib/features/approvals/shared/domain/repositories/approval_approver_repository.dart diff --git a/README.md b/README.md index b105802..f2682d8 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ flutter run -d chrome --web-renderer canvaskit --dart-define=ENV=development - 현재 연동된 주요 리소스 - `/customers`, `/vendors`, `/products`, `/uoms`, `/users`, `/groups`, `/menus`, `/group-menu-permissions` - `/warehouses`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions` - - `/approvals`, `/approval-steps`, `/approval-templates`, `/approval-histories` + - `/approvals`, `/approval-steps`, `/approval/templates`, `/approval-histories` - `/stock-transactions`(lines/customers 포함), `/reports/downloads` - `/zipcodes` (우편번호 검색) - API 응답 실패는 `Failure.describe()`를 통해 토스트/다이얼로그로 노출되며, 필드 검증 오류와 일반 메시지를 자동 병합한다. diff --git a/doc/IMPLEMENTATION_TASKS.md b/doc/IMPLEMENTATION_TASKS.md index 28520fd..e6da643 100644 --- a/doc/IMPLEMENTATION_TASKS.md +++ b/doc/IMPLEMENTATION_TASKS.md @@ -67,7 +67,7 @@ - [x] 단계 행위: 승인/반려/코멘트 버튼(가능 여부 상태에 따라 비활성/툴팁) (현황: 단계 버튼·툴팁·행위 다이얼로그를 구현했고 `ApprovalRepository.performStepAction` 연동 완료, 권한 기반 노출/후속 알림은 TODO) - [x] 단계 관리(`/approval-steps`): 목록/편집(신규/수정) (현황: 목록/필터 + 상세/신규/수정 모달 UI를 구현하고 컨트롤러에서 생성·수정 호출까지 연동, 삭제/권한 제어는 후속 예정) - [x] 이력(`/approval-histories`): 조회 전용 테이블 (현황: AppLayout 기반 필터·페이지네이션 테이블과 기간 선택/엑셀 비활성 버튼까지 구현, 다운로드 API 연동은 후속 예정) -- [x] 템플릿(`/approval-templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정) +- [x] 템플릿(`/approval/templates`): 목록/헤더+단계 반복 폼 (현황: AppLayout + FilterBar + 페이지네이션 테이블과 생성/수정/삭제/복구 플로우를 구현했고 단계 등록 API까지 연동 완료, 승인자 자동완성·권한 제어 등 추가 UX는 후속 예정) ### Approval Flow v2 (신규) - [ ] 입고/출고/대여 등록 폼에 결재 단계 구성 섹션 추가 (`ApprovalStepConfigurator` 모달/섹션, `ShadTable` 기반 리스트) diff --git a/doc/PRD_입출고_결재_v2.md b/doc/PRD_입출고_결재_v2.md index cb5b22b..31926ee 100644 --- a/doc/PRD_입출고_결재_v2.md +++ b/doc/PRD_입출고_결재_v2.md @@ -183,7 +183,7 @@ - 테이블 전용: 번호, 결재ID, 단계ID, 승인자, 행위, 변경전상태, 변경후상태, 작업일시, 비고. ### 5.16 결재 템플릿 관리 -- 라우트: `/approval-templates` +- 라우트: `/approval/templates` - 테이블: 번호, 템플릿코드, 템플릿명, 설명, 작성자, 사용여부, 변경일시. - 신규/수정: - 헤더: 템플릿코드[TXT], 템플릿명[TXT], 설명[TXT], 작성자[RO], 사용여부[SW], 비고[TXT]. @@ -523,7 +523,7 @@ - 생성: `POST /stock-transactions` 바디 내 헤더/라인/고객 배열 동시 전달 - 결재 상세: `GET /approvals/{id}?include=steps,histories` - 단계 행위: `POST /approval-steps/{id}/actions` with `approval_action_id` -- 결재 템플릿: `GET/POST/PATCH /approval-templates`, `POST/PATCH /approval-templates/{id}/steps` +- 결재 템플릿: `GET/POST/PATCH /approval/templates`, `POST/PATCH /approval/templates/{id}/steps` - 룩업: `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions` ## 20. 컴포넌트 매핑(shadcn_ui) diff --git a/doc/stock_approval_system_api_v4.md b/doc/stock_approval_system_api_v4.md index cbcacd6..af86f7f 100644 --- a/doc/stock_approval_system_api_v4.md +++ b/doc/stock_approval_system_api_v4.md @@ -1758,10 +1758,10 @@ --- ## 6. 결재 템플릿 API -리소스: `/approval-templates` +리소스: `/approval/templates` ### 6.1 목록 조회 -`GET /approval-templates?page=1` +`GET /approval/templates?page=1` ```json { "items": [ @@ -1788,7 +1788,7 @@ - `created_by`는 작성자의 `id`, `employee_id`, `name`을 포함하며 `include=` 파라미터 없이도 기본 반환된다. ### 6.2 단건 조회 -`GET /approval-templates/3001?include=steps` +`GET /approval/templates/3001?include=steps` ```json { "data": { @@ -1821,7 +1821,7 @@ ``` ### 6.3 생성·수정 -- `POST /approval-templates` +- `POST /approval/templates` ```json { "template_code": "AP_OUTBOUND", @@ -1832,7 +1832,7 @@ } ``` -- `POST /approval-templates/3002/steps` +- `POST /approval/templates/3002/steps` ```json { "id": 3002, @@ -1849,7 +1849,7 @@ } ``` -- `PATCH /approval-templates/3002` +- `PATCH /approval/templates/3002` ```json { "id": 3002, @@ -1858,7 +1858,7 @@ } ``` -- `PATCH /approval-templates/3002/steps` +- `PATCH /approval/templates/3002/steps` ```json { "id": 3002, @@ -1872,7 +1872,7 @@ } ``` -- 삭제/복구: `DELETE /approval-templates/{id}`, `POST /approval-templates/{id}/restore` +- 삭제/복구: `DELETE /approval/templates/{id}`, `POST /approval/templates/{id}/restore` --- diff --git a/doc/stock_approval_system_spec_v4.md b/doc/stock_approval_system_spec_v4.md index 8c7215e..fc1b732 100644 --- a/doc/stock_approval_system_spec_v4.md +++ b/doc/stock_approval_system_spec_v4.md @@ -132,7 +132,7 @@ zipcodes ||--o{ customers : addressed | created_at | 생성일시 | timestamp | - | now() | Y | | | | | updated_at | 변경일시 | timestamp | - | now() | Y | | | | -> API 기본 응답(`GET /approval-templates`, `GET /approval-templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다. +> API 기본 응답(`GET /approval/templates`, `GET /approval/templates/{id}`)은 작성자 요약(`created_by { id, employee_id, name }`)을 항상 포함하며, `include=created_by` 없이도 반환된다. --- diff --git a/lib/core/network/api_routes.dart b/lib/core/network/api_routes.dart index 92b1f46..0d9f537 100644 --- a/lib/core/network/api_routes.dart +++ b/lib/core/network/api_routes.dart @@ -13,6 +13,8 @@ class ApiRoutes { static const approvalHistory = '$apiV1/approval/history'; static const approvalActions = '$apiV1/approval-actions'; static const approvalDrafts = '$apiV1/approval-drafts'; + static const approvalTemplates = '$apiV1/approval/templates'; + static const approvalTemplatesLegacy = '$apiV1/approval-templates'; /// 결재 행위 전용 경로(`/approval/{action}`)를 반환한다. static String approvalAction(String action) { diff --git a/lib/core/permissions/permission_resources.dart b/lib/core/permissions/permission_resources.dart index 7f4cdbb..d9b1ccc 100644 --- a/lib/core/permissions/permission_resources.dart +++ b/lib/core/permissions/permission_resources.dart @@ -10,7 +10,7 @@ class PermissionResources { static const String approvals = '/approvals'; static const String approvalSteps = '/approval-steps'; static const String approvalHistories = '/approval-histories'; - static const String approvalTemplates = '/approval-templates'; + static const String approvalTemplates = '/approval/templates'; static const String groupMenuPermissions = '/group-menu-permissions'; static const String vendors = '/vendors'; static const String products = '/products'; @@ -39,6 +39,7 @@ class PermissionResources { '/approvals/histories': approvalHistories, '/approval-histories': approvalHistories, '/approvals/templates': approvalTemplates, + '/approval/templates': approvalTemplates, '/approval-templates': approvalTemplates, '/masters/group-permissions': groupMenuPermissions, '/group-menu-permissions': groupMenuPermissions, 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 a2b58e7..c676fc5 100644 --- a/lib/features/approvals/data/repositories/approval_template_repository_remote.dart +++ b/lib/features/approvals/data/repositories/approval_template_repository_remote.dart @@ -17,7 +17,10 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { final ApiClient _api; - static const _basePath = '${ApiRoutes.apiV1}/approval-templates'; + static const _basePaths = [ + ApiRoutes.approvalTemplates, + ApiRoutes.approvalTemplatesLegacy, + ]; /// 결재 템플릿 목록을 조회한다. 검색/활성 여부 필터를 지원한다. @override @@ -27,17 +30,19 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { String? query, bool? isActive, }) async { - final response = await _api.get>( - _basePath, - query: { - 'page': page, - 'page_size': pageSize, - if (query != null && query.isNotEmpty) 'q': query, - if (isActive != null) 'active': isActive, - }, - options: Options(responseType: ResponseType.json), - ); - return ApprovalTemplateDto.parsePaginated(response.data); + return _withTemplateRoute((basePath) async { + final response = await _api.get>( + basePath, + query: { + 'page': page, + 'page_size': pageSize, + if (query != null && query.isNotEmpty) 'q': query, + if (isActive != null) 'active': isActive, + }, + options: Options(responseType: ResponseType.json), + ); + return ApprovalTemplateDto.parsePaginated(response.data); + }); } /// 템플릿 상세 정보를 조회한다. 필요 시 단계 포함 여부를 지정한다. @@ -46,14 +51,17 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { int id, { bool includeSteps = true, }) async { - final response = await _api.get>( - '$_basePath/$id', - query: {if (includeSteps) 'include': 'steps'}, - options: Options(responseType: ResponseType.json), - ); - return ApprovalTemplateDto.fromJson( - _api.unwrapAsMap(response), - ).toEntity(includeSteps: includeSteps); + return _withTemplateRoute((basePath) async { + final path = ApiClient.buildPath(basePath, [id]); + final response = await _api.get>( + path, + query: {if (includeSteps) 'include': 'steps'}, + options: Options(responseType: ResponseType.json), + ); + return ApprovalTemplateDto.fromJson( + _api.unwrapAsMap(response), + ).toEntity(includeSteps: includeSteps); + }); } /// 템플릿을 생성하고 필요하면 단계까지 함께 등록한다. @@ -62,18 +70,20 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { ApprovalTemplateInput input, { List steps = const [], }) async { - final response = await _api.post>( - _basePath, - data: input.toCreatePayload(), - options: Options(responseType: ResponseType.json), - ); - final created = ApprovalTemplateDto.fromJson( - _api.unwrapAsMap(response), - ).toEntity(includeSteps: false); - if (steps.isNotEmpty) { - await _postSteps(created.id, steps); - } - return fetchDetail(created.id, includeSteps: true); + return _withTemplateRoute((basePath) async { + final response = await _api.post>( + basePath, + data: input.toCreatePayload(), + options: Options(responseType: ResponseType.json), + ); + final created = ApprovalTemplateDto.fromJson( + _api.unwrapAsMap(response), + ).toEntity(includeSteps: false); + if (steps.isNotEmpty) { + await _postSteps(created.id, steps, basePath: basePath); + } + return fetchDetail(created.id, includeSteps: true); + }); } /// 템플릿 기본 정보와 단계 구성을 수정한다. @@ -83,43 +93,54 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { 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); + return _withTemplateRoute((basePath) async { + final path = ApiClient.buildPath(basePath, [id]); + await _api.patch>( + path, + data: input.toUpdatePayload(id), + options: Options(responseType: ResponseType.json), + ); + if (steps != null) { + await _patchSteps(id, steps, basePath: basePath); + } + return fetchDetail(id, includeSteps: true); + }); } /// 템플릿을 삭제한다. @override Future delete(int id) async { - await _api.delete('$_basePath/$id'); + await _withTemplateRoute((basePath) async { + final path = ApiClient.buildPath(basePath, [id]); + await _api.delete(path); + }); } /// 삭제된 템플릿을 복구한다. @override Future restore(int id) async { - final response = await _api.post>( - '$_basePath/$id/restore', - options: Options(responseType: ResponseType.json), - ); - return ApprovalTemplateDto.fromJson( - _api.unwrapAsMap(response), - ).toEntity(includeSteps: false); + return _withTemplateRoute((basePath) async { + final path = ApiClient.buildPath(basePath, [id, 'restore']); + final response = await _api.post>( + path, + options: Options(responseType: ResponseType.json), + ); + return ApprovalTemplateDto.fromJson( + _api.unwrapAsMap(response), + ).toEntity(includeSteps: false); + }); } /// 템플릿 단계 전체를 신규로 등록한다. Future _postSteps( int templateId, - List steps, - ) async { + List steps, { + required String basePath, + }) async { if (steps.isEmpty) return; + final path = ApiClient.buildPath(basePath, [templateId, 'steps']); await _api.post>( - '$_basePath/$templateId/steps', + path, data: { 'id': templateId, 'steps': steps.map((step) => step.toJson(includeId: false)).toList(), @@ -131,10 +152,12 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { /// 템플릿 단계 정보를 부분 수정한다. Future _patchSteps( int templateId, - List steps, - ) async { + List steps, { + required String basePath, + }) async { + final path = ApiClient.buildPath(basePath, [templateId, 'steps']); await _api.patch>( - '$_basePath/$templateId/steps', + path, data: { 'id': templateId, 'steps': steps.map((step) => step.toJson()).toList(), @@ -142,4 +165,25 @@ class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository { options: Options(responseType: ResponseType.json), ); } + + Future _withTemplateRoute( + Future Function(String basePath) operation, + ) async { + DioException? lastNotFound; + for (final basePath in _basePaths) { + try { + return await operation(basePath); + } on DioException catch (error) { + if (error.response?.statusCode == 404) { + lastNotFound = error; + continue; + } + rethrow; + } + } + if (lastNotFound != null) { + throw lastNotFound; + } + throw StateError('템플릿 경로 후보가 정의되지 않았습니다.'); + } } 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 a2ec39c..1144816 100644 --- a/lib/features/approvals/history/presentation/pages/approval_history_page.dart +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -22,9 +22,9 @@ import '../../../domain/entities/approval_flow.dart'; import '../../../domain/repositories/approval_repository.dart'; import '../../../domain/usecases/recall_approval_use_case.dart'; import '../../../domain/usecases/resubmit_approval_use_case.dart'; +import '../../../shared/domain/entities/approval_approver_candidate.dart'; import '../../../shared/widgets/widgets.dart'; import '../../../shared/widgets/approver_autocomplete_field.dart'; -import '../../../shared/approver_catalog.dart'; import '../controllers/approval_history_controller.dart'; import '../widgets/approval_audit_log_table.dart'; import '../widgets/approval_flow_timeline.dart'; @@ -315,7 +315,7 @@ class _ApprovalHistoryEnabledPageState _refreshAuditForSelectedRecord(resetPage: true); } - void _handleAuditActorSelected(ApprovalApproverCatalogItem? item) { + void _handleAuditActorSelected(ApprovalApproverCandidate? item) { final selectedId = item?.id ?? int.tryParse(_auditActorIdController.text); _controller.updateAuditActor(selectedId); _refreshAuditForSelectedRecord(resetPage: true); diff --git a/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart b/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart index e0e8c74..de4748c 100644 --- a/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart +++ b/lib/features/approvals/request/presentation/utils/approval_form_initializer.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + +import 'package:get_it/get_it.dart'; + import '../../../domain/entities/approval.dart'; -import '../../../shared/approver_catalog.dart'; +import '../../../shared/domain/repositories/approval_approver_repository.dart'; import '../controllers/approval_request_controller.dart'; import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart'; @@ -10,12 +14,13 @@ class ApprovalFormInitializer { ApprovalFormInitializer._(); /// 결재 구성 컨트롤러에 기본값을 주입한다. - static void populate({ + static Future populate({ required ApprovalRequestController controller, Approval? existingApproval, StockTransactionApprovalInput? draft, ApprovalRequestParticipant? defaultRequester, - }) { + ApprovalApproverRepository? repository, + }) async { if (existingApproval != null) { _applyExistingApproval(controller, existingApproval); return; @@ -24,7 +29,11 @@ class ApprovalFormInitializer { controller.setRequester(defaultRequester); } if (draft != null) { - _applyDraft(controller, draft); + await _applyDraft( + controller, + draft, + repository ?? _resolveRepository(), + ); } } @@ -57,40 +66,62 @@ class ApprovalFormInitializer { } } - static void _applyDraft( + static Future _applyDraft( ApprovalRequestController controller, StockTransactionApprovalInput draft, - ) { - final requesterCatalog = ApprovalApproverCatalog.byId(draft.requestedById); - if (requesterCatalog != null) { - controller.setRequester( - ApprovalRequestParticipant( - id: requesterCatalog.id, - name: requesterCatalog.name, - employeeNo: requesterCatalog.employeeNo, - ), - ); + ApprovalApproverRepository? repository, + ) async { + final repo = repository; + if (repo == null) { + return; } - final steps = draft.steps - .map((step) { - final catalog = ApprovalApproverCatalog.byId(step.approverId); - if (catalog == null) { + + final requester = await _fetchParticipant(repo, draft.requestedById); + if (requester != null) { + controller.setRequester(requester); + } + + final futures = draft.steps + .map((step) async { + final participant = await _fetchParticipant(repo, step.approverId); + if (participant == null) { return null; } return ApprovalRequestStep( stepOrder: step.stepOrder, - approver: ApprovalRequestParticipant( - id: catalog.id, - name: catalog.name, - employeeNo: catalog.employeeNo, - ), + approver: participant, note: step.note, ); }) - .whereType() .toList(growable: false); + + final resolvedSteps = await Future.wait(futures); + final steps = resolvedSteps.whereType().toList(); if (steps.isNotEmpty) { controller.applyTemplateSteps(steps); } } + + static Future _fetchParticipant( + ApprovalApproverRepository repository, + int id, + ) async { + final candidate = await repository.fetchById(id); + if (candidate == null) { + return null; + } + return ApprovalRequestParticipant( + id: candidate.id, + name: candidate.name, + employeeNo: candidate.employeeNo, + ); + } + + static ApprovalApproverRepository? _resolveRepository() { + final getIt = GetIt.I; + if (!getIt.isRegistered()) { + return null; + } + return getIt(); + } } diff --git a/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart b/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart index b71a417..66dec84 100644 --- a/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart +++ b/lib/features/approvals/request/presentation/widgets/approval_step_configurator.dart @@ -4,7 +4,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../widgets/components/feedback.dart'; import '../../../../../widgets/components/superport_dialog.dart'; -import '../../../shared/approver_catalog.dart'; +import '../../../shared/domain/entities/approval_approver_candidate.dart'; import '../../../shared/widgets/approver_autocomplete_field.dart'; import '../controllers/approval_request_controller.dart'; import 'approval_step_row.dart'; @@ -435,7 +435,7 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> { } Future _openAddStepDialog() async { - ApprovalApproverCatalogItem? selected; + ApprovalApproverCandidate? selected; final idController = TextEditingController(); final result = await SuperportDialog.show( @@ -483,13 +483,19 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> { return; } - final participant = _resolveParticipant(selected, idController.text.trim()); - if (participant == null) { + final selection = selected; + if (selection == null) { SuperportToast.warning(context, '유효한 승인자를 선택해주세요.'); idController.dispose(); return; } + final participant = ApprovalRequestParticipant( + id: selection.id, + name: selection.name, + employeeNo: selection.employeeNo, + ); + final added = widget.controller.addStep(approver: participant); if (!added) { final message = widget.controller.errorMessage ?? '결재 단계를 추가하지 못했습니다.'; @@ -503,31 +509,6 @@ class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> { idController.dispose(); } - ApprovalRequestParticipant? _resolveParticipant( - ApprovalApproverCatalogItem? selected, - String manualInput, - ) { - if (selected != null) { - return ApprovalRequestParticipant( - id: selected.id, - name: selected.name, - employeeNo: selected.employeeNo, - ); - } - final manualId = int.tryParse(manualInput); - if (manualId == null) { - return null; - } - final match = ApprovalApproverCatalog.byId(manualId); - if (match == null) { - return null; - } - return ApprovalRequestParticipant( - id: match.id, - name: match.name, - employeeNo: match.employeeNo, - ); - } } class _InfoBadge extends StatelessWidget { diff --git a/lib/features/approvals/request/presentation/widgets/approval_step_row.dart b/lib/features/approvals/request/presentation/widgets/approval_step_row.dart index 83fb33d..cafa948 100644 --- a/lib/features/approvals/request/presentation/widgets/approval_step_row.dart +++ b/lib/features/approvals/request/presentation/widgets/approval_step_row.dart @@ -3,7 +3,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../widgets/components/feedback.dart'; -import '../../../shared/approver_catalog.dart'; +import '../../../shared/domain/entities/approval_approver_candidate.dart'; import '../../../shared/widgets/approver_autocomplete_field.dart'; import '../controllers/approval_request_controller.dart'; @@ -96,37 +96,23 @@ class _ApprovalStepRowState extends State { Future _handleApproverSelected( BuildContext context, - ApprovalApproverCatalogItem? item, + ApprovalApproverCandidate? candidate, ) async { if (widget.readOnly) { return; } ApprovalRequestParticipant? nextParticipant; - if (item != null) { + if (candidate != null) { nextParticipant = ApprovalRequestParticipant( - id: item.id, - name: item.name, - employeeNo: item.employeeNo, + id: candidate.id, + name: candidate.name, + employeeNo: candidate.employeeNo, ); } else { - final manualId = int.tryParse(_approverIdController.text.trim()); - if (manualId == null) { - SuperportToast.warning(context, '승인자를 다시 선택해주세요.'); - _restorePreviousApprover(); - return; - } - final catalogMatch = ApprovalApproverCatalog.byId(manualId); - if (catalogMatch == null) { - SuperportToast.warning(context, '등록되지 않은 승인자입니다.'); - _restorePreviousApprover(); - return; - } - nextParticipant = ApprovalRequestParticipant( - id: catalogMatch.id, - name: catalogMatch.name, - employeeNo: catalogMatch.employeeNo, - ); + SuperportToast.warning(context, '승인자를 다시 선택해주세요.'); + _restorePreviousApprover(); + return; } final updated = widget.controller.updateStep( diff --git a/lib/features/approvals/shared/approver_catalog.dart b/lib/features/approvals/shared/approver_catalog.dart deleted file mode 100644 index 4a134b4..0000000 --- a/lib/features/approvals/shared/approver_catalog.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'package:flutter/material.dart'; - -/// 결재 승인자(approver)를 자동완성으로 검색하기 위한 카탈로그 항목. -class ApprovalApproverCatalogItem { - const ApprovalApproverCatalogItem({ - required this.id, - required this.employeeNo, - required this.name, - required this.team, - }); - - final int id; - final String employeeNo; - final String name; - final String team; -} - -String _normalize(String value) { - return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9가-힣]'), ''); -} - -/// 결재용 승인자 카탈로그. -/// -/// - API 연동 전까지 고정된 데이터를 사용한다. -class ApprovalApproverCatalog { - static final List items = List.unmodifiable([ - const ApprovalApproverCatalogItem( - id: 101, - employeeNo: 'EMP101', - name: '이검토', - team: '물류운영팀', - ), - const ApprovalApproverCatalogItem( - id: 102, - employeeNo: 'EMP102', - name: '최검수', - team: '품질보증팀', - ), - const ApprovalApproverCatalogItem( - id: 103, - employeeNo: 'EMP103', - name: '문회수', - team: '품질보증팀', - ), - const ApprovalApproverCatalogItem( - id: 104, - employeeNo: 'EMP104', - name: '박팀장', - team: '운영혁신팀', - ), - const ApprovalApproverCatalogItem( - id: 105, - employeeNo: 'EMP105', - name: '정차장', - team: '구매팀', - ), - const ApprovalApproverCatalogItem( - id: 106, - employeeNo: 'EMP106', - name: '오승훈', - team: '영업지원팀', - ), - const ApprovalApproverCatalogItem( - id: 107, - employeeNo: 'EMP107', - name: '유컨펌', - team: '총무팀', - ), - const ApprovalApproverCatalogItem( - id: 108, - employeeNo: 'EMP108', - name: '문서결', - team: '경영기획팀', - ), - const ApprovalApproverCatalogItem( - id: 110, - employeeNo: 'EMP110', - name: '문검토', - team: '물류운영팀', - ), - const ApprovalApproverCatalogItem( - id: 120, - employeeNo: 'EMP120', - name: '신품질', - team: '품질관리팀', - ), - const ApprovalApproverCatalogItem( - id: 201, - employeeNo: 'EMP201', - name: '한임원', - team: '경영진', - ), - const ApprovalApproverCatalogItem( - id: 210, - employeeNo: 'EMP210', - name: '강팀장', - team: '물류운영팀', - ), - const ApprovalApproverCatalogItem( - id: 221, - employeeNo: 'EMP221', - name: '노부장', - team: '경영관리팀', - ), - ]); - - static final Map _byId = { - for (final item in items) item.id: item, - }; - - static final Map _byEmployeeNo = { - for (final item in items) item.employeeNo.toLowerCase(): item, - }; - - static ApprovalApproverCatalogItem? byId(int? id) => - id == null ? null : _byId[id]; - - static ApprovalApproverCatalogItem? byEmployeeNo(String? employeeNo) { - if (employeeNo == null) return null; - return _byEmployeeNo[employeeNo.toLowerCase()]; - } - - static List filter(String query) { - final normalized = _normalize(query); - if (normalized.isEmpty) { - return items.take(10).toList(); - } - final lower = query.toLowerCase(); - return [ - for (final item in items) - if (_normalize(item.name).contains(normalized) || - item.employeeNo.toLowerCase().contains(lower) || - item.team.toLowerCase().contains(lower) || - item.id.toString().contains(lower)) - item, - ]; - } -} - -/// 자동완성 추천이 없을 때 보여줄 위젯. -Widget buildEmptyApproverResult(TextTheme textTheme) { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text('일치하는 승인자를 찾지 못했습니다.', style: textTheme.bodySmall), - ), - ); -} diff --git a/lib/features/approvals/shared/data/dtos/approval_approver_candidate_dto.dart b/lib/features/approvals/shared/data/dtos/approval_approver_candidate_dto.dart new file mode 100644 index 0000000..9d71ab9 --- /dev/null +++ b/lib/features/approvals/shared/data/dtos/approval_approver_candidate_dto.dart @@ -0,0 +1,53 @@ +import 'package:superport_v2/core/common/utils/json_utils.dart'; + +import '../../domain/entities/approval_approver_candidate.dart'; + +/// 승인자 후보 응답을 파싱하는 DTO. +class ApprovalApproverCandidateDto { + ApprovalApproverCandidateDto({ + required this.id, + required this.employeeNo, + required this.name, + this.team, + this.email, + this.phone, + }); + + final int id; + final String employeeNo; + final String name; + final String? team; + final String? email; + final String? phone; + + /// JSON 응답에서 DTO를 생성한다. + factory ApprovalApproverCandidateDto.fromJson(Map json) { + final group = json['group'] is Map + ? json['group'] as Map + : null; + return ApprovalApproverCandidateDto( + id: json['id'] as int? ?? JsonUtils.readInt(json, 'user_id', fallback: 0), + employeeNo: json['employee_id'] as String? ?? + json['employee_no'] as String? ?? + '-', + name: json['name'] as String? ?? + json['employee_name'] as String? ?? + '-', + team: group?['group_name'] as String? ?? json['team'] as String?, + email: json['email'] as String?, + phone: json['phone'] as String? ?? json['mobile_no'] as String?, + ); + } + + /// DTO를 도메인 엔티티로 변환한다. + ApprovalApproverCandidate toEntity() { + return ApprovalApproverCandidate( + id: id, + employeeNo: employeeNo, + name: name, + team: team, + email: email, + phone: phone, + ); + } +} diff --git a/lib/features/approvals/shared/data/repositories/approval_approver_repository_remote.dart b/lib/features/approvals/shared/data/repositories/approval_approver_repository_remote.dart new file mode 100644 index 0000000..62751f1 --- /dev/null +++ b/lib/features/approvals/shared/data/repositories/approval_approver_repository_remote.dart @@ -0,0 +1,84 @@ +import 'package:dio/dio.dart'; + +import '../../../../../core/network/api_client.dart'; +import '../../../../../core/network/api_routes.dart'; +import '../../domain/entities/approval_approver_candidate.dart'; +import '../../domain/repositories/approval_approver_repository.dart'; +import '../dtos/approval_approver_candidate_dto.dart'; + +/// 승인자 자동완성용 원격 저장소 구현체. +class ApprovalApproverRepositoryRemote implements ApprovalApproverRepository { + ApprovalApproverRepositoryRemote({required ApiClient apiClient}) + : _api = apiClient; + + final ApiClient _api; + + static const _basePath = '${ApiRoutes.apiV1}/users'; + + @override + Future> search({ + required String keyword, + int limit = 20, + }) async { + final trimmed = keyword.trim(); + if (trimmed.isEmpty) { + return const []; + } + + final response = await _api.get>( + _basePath, + query: _buildQuery(limit: limit, keyword: trimmed), + options: Options(responseType: ResponseType.json), + ); + + return _mapCandidates(response.data); + } + + @override + Future fetchById(int id) async { + final response = await _api.get>( + '$_basePath/$id', + query: ApiClient.buildQuery(include: const ['group']), + options: Options(responseType: ResponseType.json), + ); + + final payload = _api.unwrapAsMap(response); + if (payload.isEmpty) { + return null; + } + return ApprovalApproverCandidateDto.fromJson(payload).toEntity(); + } + + @override + Future> listInitial({int limit = 20}) async { + final response = await _api.get>( + _basePath, + query: _buildQuery(limit: limit), + options: Options(responseType: ResponseType.json), + ); + + return _mapCandidates(response.data); + } + + Map _buildQuery({required int limit, String? keyword}) { + return ApiClient.buildQuery( + page: 1, + pageSize: limit, + q: keyword, + sort: 'name', + order: 'asc', + include: const ['group'], + filters: const {'is_active': true}, + ); + } + + List _mapCandidates( + Map? payload, + ) { + return (payload?['items'] as List? ?? const []) + .whereType>() + .map(ApprovalApproverCandidateDto.fromJson) + .map((dto) => dto.toEntity()) + .toList(growable: false); + } +} diff --git a/lib/features/approvals/shared/domain/entities/approval_approver_candidate.dart b/lib/features/approvals/shared/domain/entities/approval_approver_candidate.dart new file mode 100644 index 0000000..d6d8eab --- /dev/null +++ b/lib/features/approvals/shared/domain/entities/approval_approver_candidate.dart @@ -0,0 +1,32 @@ +/// 결재 승인자 자동완성에 사용되는 후보 정보. +class ApprovalApproverCandidate { + const ApprovalApproverCandidate({ + required this.id, + required this.employeeNo, + required this.name, + this.team, + this.email, + this.phone, + }); + + /// 승인자 고유 ID (users.id). + final int id; + + /// 사번 혹은 직원 식별자. + final String employeeNo; + + /// 직원 이름. + final String name; + + /// 소속 팀 또는 그룹명. + final String? team; + + /// 이메일 주소. + final String? email; + + /// 전화번호. + final String? phone; + + /// 리스트 등에서 표시할 기본 라벨을 반환한다. + String get displayLabel => '$name ($employeeNo)'; +} diff --git a/lib/features/approvals/shared/domain/repositories/approval_approver_repository.dart b/lib/features/approvals/shared/domain/repositories/approval_approver_repository.dart new file mode 100644 index 0000000..2273738 --- /dev/null +++ b/lib/features/approvals/shared/domain/repositories/approval_approver_repository.dart @@ -0,0 +1,16 @@ +import '../entities/approval_approver_candidate.dart'; + +/// 승인자 검색을 제공하는 저장소 인터페이스. +abstract class ApprovalApproverRepository { + /// 키워드로 승인자 후보를 검색한다. + Future> search({ + required String keyword, + int limit = 20, + }); + + /// ID로 승인자 정보를 조회한다. + Future fetchById(int id); + + /// 자동완성 드롭다운 초기 노출용 활성 승인자 목록을 조회한다. + Future> listInitial({int limit = 20}); +} diff --git a/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart b/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart index 6e01b10..227f8dc 100644 --- a/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart +++ b/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart @@ -1,12 +1,16 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -import '../approver_catalog.dart'; +import '../domain/entities/approval_approver_candidate.dart'; +import '../domain/repositories/approval_approver_repository.dart'; /// 승인자 자동완성 필드. /// -/// - 사용자가 이름/사번으로 검색하면 일치하는 승인자를 제안한다. -/// - 항목을 선택하면 `idController`에 승인자 ID가 채워진다. +/// - 이름/사번을 입력하면 API에서 승인자 후보를 검색한다. +/// - 항목 선택 시 `idController`에 승인자 ID를 기록한다. class ApprovalApproverAutocompleteField extends StatefulWidget { const ApprovalApproverAutocompleteField({ super.key, @@ -17,104 +21,309 @@ class ApprovalApproverAutocompleteField extends StatefulWidget { final TextEditingController idController; final String? hintText; - final void Function(ApprovalApproverCatalogItem?)? onSelected; + final void Function(ApprovalApproverCandidate?)? onSelected; @override State createState() => _ApprovalApproverAutocompleteFieldState(); } -/// 승인자 자동완성 필드의 내부 상태를 관리한다. class _ApprovalApproverAutocompleteFieldState extends State { - late final TextEditingController _textController; - late final FocusNode _focusNode; - ApprovalApproverCatalogItem? _selected; + static const _debounceDuration = Duration(milliseconds: 250); + static const _pageSize = 15; + + final TextEditingController _textController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final List _suggestions = []; + final List _initialSuggestions = []; + ApprovalApproverCandidate? _selected; + Timer? _debounce; + bool _isSearching = false; + bool _isLoadingInitial = false; + bool _initialLoaded = false; + bool _isApplyingText = false; + int _requestId = 0; + + ApprovalApproverRepository? get _repository => + GetIt.I.isRegistered() + ? GetIt.I() + : null; @override void initState() { super.initState(); - _textController = TextEditingController(); - _focusNode = FocusNode(); _focusNode.addListener(_handleFocusChange); - _syncFromId(); + _initializeFromId(); + unawaited(_prefetchInitialCandidates()); } - /// 외부에서 제공된 ID 값으로부터 표시 문자열을 동기화한다. - void _syncFromId() { - final idText = widget.idController.text.trim(); - final id = int.tryParse(idText); - final match = ApprovalApproverCatalog.byId(id); - if (match != null) { - _selected = match; - _textController.text = _displayLabel(match); - } else if (id != null) { - _selected = null; - _textController.text = '직접 입력: $id'; - } else { - _selected = null; - _textController.clear(); + @override + void didUpdateWidget(covariant ApprovalApproverAutocompleteField oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(widget.idController, oldWidget.idController)) { + _initializeFromId(); } } - /// 검색어에 매칭되는 승인자 목록을 반환한다. - Iterable _options(String query) { - return ApprovalApproverCatalog.filter(query); - } - - /// 특정 승인자를 선택했을 때 내부 상태와 콜백을 갱신한다. - void _handleSelected(ApprovalApproverCatalogItem item) { - setState(() { - _selected = item; - widget.idController.text = item.id.toString(); - _textController.text = _displayLabel(item); - widget.onSelected?.call(item); - }); - } - - /// 선택된 값을 초기화한다. - void _handleCleared() { - setState(() { - _selected = null; - widget.idController.clear(); - _textController.clear(); + Future _initializeFromId() async { + final idText = widget.idController.text.trim(); + final id = int.tryParse(idText); + if (id == null) { + setState(() { + _selected = null; + _setText(''); + }); + return; + } + final repository = _repository; + if (repository == null) { + return; + } + try { + final candidate = await repository.fetchById(id); + if (!mounted) { + return; + } + if (candidate == null) { + setState(() { + _selected = null; + _setText(''); + }); + widget.onSelected?.call(null); + return; + } + setState(() { + _selected = candidate; + }); + _ensureCandidateCached(candidate); + _setText(_displayLabel(candidate)); + widget.onSelected?.call(candidate); + } catch (_) { + // 조회 실패 시 기존 상태를 유지하되 텍스트를 비운다. + if (!mounted) { + return; + } + setState(() { + _selected = null; + _setText(''); + }); widget.onSelected?.call(null); + } + } + + Future _prefetchInitialCandidates() async { + if (_initialLoaded || _isLoadingInitial) { + return; + } + + final repository = _repository; + if (repository == null) { + return; + } + + setState(() { + _isLoadingInitial = true; + }); + + try { + final results = await repository.listInitial(limit: _pageSize); + if (!mounted) { + return; + } + setState(() { + _initialSuggestions.clear(); + _initialSuggestions.addAll(results); + if (_selected != null && + !_initialSuggestions.any((item) => item.id == _selected!.id)) { + _initialSuggestions.insert(0, _selected!); + } + if (_textController.text.trim().isEmpty) { + _suggestions.clear(); + _suggestions.addAll(_initialSuggestions); + } + _isLoadingInitial = false; + _initialLoaded = true; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _isLoadingInitial = false; + _initialLoaded = false; + }); + } + } + + void _handleFocusChange() { + if (_focusNode.hasFocus) { + if (!_initialLoaded && !_isLoadingInitial) { + unawaited(_prefetchInitialCandidates()); + } else if (_textController.text.trim().isEmpty && + _initialSuggestions.isNotEmpty) { + setState(() { + _suggestions.clear(); + _suggestions.addAll(_initialSuggestions); + }); + } + return; + } + unawaited(_confirmManualEntry(_textController.text)); + } + + void _scheduleSearch(String keyword) { + _debounce?.cancel(); + _debounce = Timer(_debounceDuration, () { + unawaited(_search(keyword)); }); } - String _displayLabel(ApprovalApproverCatalogItem item) { - return '${item.name} (${item.employeeNo}) · ${item.team}'; + Future _search(String keyword) async { + final repository = _repository; + final trimmed = keyword.trim(); + if (repository == null || trimmed.isEmpty) { + if (mounted) { + setState(() { + _suggestions.clear(); + _isSearching = false; + }); + } + return; + } + final request = ++_requestId; + setState(() { + _isSearching = true; + }); + try { + final results = await repository.search( + keyword: trimmed, + limit: _pageSize, + ); + if (!mounted || request != _requestId) { + return; + } + setState(() { + _suggestions.clear(); + _suggestions.addAll(results); + _isSearching = false; + }); + } catch (_) { + if (!mounted || request != _requestId) { + return; + } + setState(() { + _suggestions.clear(); + _isSearching = false; + }); + } } - /// 사용자가 직접 입력한 사번(ID)을 기반으로 값을 결정한다. - void _applyManualEntry(String value) { + Future _confirmManualEntry(String value) async { final trimmed = value.trim(); if (trimmed.isEmpty) { - _handleCleared(); + _clearSelection(); return; } final manualId = int.tryParse(trimmed.replaceAll(RegExp(r'[^0-9]'), '')); if (manualId == null) { return; } - final match = ApprovalApproverCatalog.byId(manualId); - if (match != null) { - _handleSelected(match); + final repository = _repository; + if (repository == null) { + return; + } + try { + final candidate = await repository.fetchById(manualId); + if (!mounted) { + return; + } + if (candidate == null) { + _clearSelection(); + return; + } + _applySelection(candidate); + } catch (_) { + if (!mounted) { + return; + } + _clearSelection(); + } + } + + void _applySelection(ApprovalApproverCandidate candidate) { + setState(() { + _selected = candidate; + widget.idController.text = candidate.id.toString(); + _isSearching = false; + }); + _ensureCandidateCached(candidate); + _setText(_displayLabel(candidate)); + widget.onSelected?.call(candidate); + } + + void _clearSelection({ + bool useInitialSuggestions = false, + bool resetText = true, + }) { + final hadSelection = + _selected != null || widget.idController.text.isNotEmpty; + setState(() { + _selected = null; + _isSearching = false; + _suggestions.clear(); + if (useInitialSuggestions && _initialSuggestions.isNotEmpty) { + _suggestions.addAll(_initialSuggestions); + } + }); + if (hadSelection) { + widget.idController.clear(); + } + widget.onSelected?.call(null); + if (resetText || useInitialSuggestions || hadSelection) { + _setText(''); + } + } + + String _displayLabel(ApprovalApproverCandidate candidate) { + final team = candidate.team?.trim(); + if (team == null || team.isEmpty) { + return '${candidate.name} (${candidate.employeeNo})'; + } + return '${candidate.name} (${candidate.employeeNo}) · $team'; + } + + void _ensureCandidateCached(ApprovalApproverCandidate candidate) { + if (!mounted) { + return; + } + final exists = _initialSuggestions.any((item) => item.id == candidate.id); + if (exists) { return; } setState(() { - _selected = null; - widget.idController.text = manualId.toString(); - _textController.text = '직접 입력: $manualId'; - widget.onSelected?.call(null); + _initialSuggestions.insert(0, candidate); + if (_textController.text.trim().isEmpty) { + _suggestions.clear(); + _suggestions.addAll(_initialSuggestions); + } }); } - /// 포커스가 해제될 때 수동 입력을 확정한다. - void _handleFocusChange() { - if (!_focusNode.hasFocus) { - _applyManualEntry(_textController.text); - } + void _setText(String value) { + _isApplyingText = true; + _textController.text = value; + _textController.selection = TextSelection.collapsed(offset: value.length); + _isApplyingText = false; + } + + @override + void dispose() { + _debounce?.cancel(); + _textController.dispose(); + _focusNode.removeListener(_handleFocusChange); + _focusNode.dispose(); + super.dispose(); } @override @@ -122,135 +331,124 @@ class _ApprovalApproverAutocompleteFieldState final theme = ShadTheme.of(context); return LayoutBuilder( builder: (context, constraints) { - return RawAutocomplete( + final maxWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : 360.0; + return RawAutocomplete( textEditingController: _textController, focusNode: _focusNode, optionsBuilder: (textEditingValue) { - final text = textEditingValue.text.trim(); - if (text.isEmpty) { - return const Iterable.empty(); - } - return _options(text); + return _suggestions; }, displayStringForOption: _displayLabel, - onSelected: _handleSelected, + onSelected: _applySelection, fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) { - return ShadInput( - controller: textController, - focusNode: focusNode, - placeholder: Text(widget.hintText ?? '승인자 이름 또는 사번 검색'), - onChanged: (value) { - if (value.isEmpty) { - _handleCleared(); - } else if (_selected != null && - value != _displayLabel(_selected!)) { - setState(() { - _selected = null; - widget.idController.clear(); - }); - } - }, - onSubmitted: (_) { - _applyManualEntry(textController.text); - onFieldSubmitted(); - }, - onPressedOutside: (event) { - // 드롭다운에서 항목을 고르기 전에 포커스를 잃지 않도록 한다. - focusNode.requestFocus(); - }, + assert(identical(textController, _textController)); + return Stack( + alignment: Alignment.centerRight, + children: [ + ShadInput( + controller: textController, + focusNode: focusNode, + placeholder: Text(widget.hintText ?? '승인자 이름 또는 사번 검색'), + onChanged: (value) { + if (_isApplyingText) { + return; + } + if (value.trim().isEmpty) { + _debounce?.cancel(); + _clearSelection(useInitialSuggestions: true); + if (!_initialLoaded && !_isLoadingInitial) { + unawaited(_prefetchInitialCandidates()); + } + } else { + _scheduleSearch(value); + } + }, + onSubmitted: (_) { + unawaited(_confirmManualEntry(textController.text)); + onFieldSubmitted(); + }, + onPressedOutside: (_) => focusNode.requestFocus(), + ), + if (_isSearching || + (_isLoadingInitial && + textController.text.trim().isEmpty)) + const Padding( + padding: EdgeInsets.only(right: 12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], ); }, optionsViewBuilder: (context, onSelected, options) { - if (options.isEmpty) { - return Listener( - onPointerDown: (_) { - if (!_focusNode.hasPrimaryFocus) { - _focusNode.requestFocus(); - } - }, - child: Align( - alignment: AlignmentDirectional.topStart, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth, - maxHeight: 220, - ), - child: Material( - elevation: 6, - color: theme.colorScheme.background, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: theme.colorScheme.border), - ), - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - '일치하는 승인자를 찾지 못했습니다.', - style: theme.textTheme.muted, - ), - ), - ), - ), + final isInitialLoadInProgress = + _isLoadingInitial && _textController.text.trim().isEmpty; + if (_isSearching || isInitialLoadInProgress) { + return _buildDropdownWrapper( + maxWidth: maxWidth, + maxHeight: 220, + theme: theme, + child: const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } - return Listener( - onPointerDown: (_) { - if (!_focusNode.hasPrimaryFocus) { - _focusNode.requestFocus(); - } - }, - child: Align( - alignment: AlignmentDirectional.topStart, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth, - maxHeight: 260, - ), - child: Material( - elevation: 6, - color: theme.colorScheme.background, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: theme.colorScheme.border), - ), - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 6), - itemCount: options.length, - itemBuilder: (context, index) { - final option = options.elementAt(index); - return InkWell( - onTap: () => onSelected(option), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${option.name} · ${option.team}', - style: theme.textTheme.p, - ), - const SizedBox(height: 4), - Text( - 'ID ${option.id} · ${option.employeeNo}', - style: theme.textTheme.muted.copyWith( - fontSize: 12, - ), - ), - ], - ), - ), - ); - }, - ), + if (options.isEmpty) { + final hasKeyword = _textController.text.trim().isNotEmpty; + final message = hasKeyword + ? '일치하는 승인자를 찾지 못했습니다.' + : '표시할 승인자가 없습니다.'; + return _buildDropdownWrapper( + maxWidth: maxWidth, + maxHeight: 220, + theme: theme, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text(message, style: theme.textTheme.muted), ), ), + ); + } + return _buildDropdownWrapper( + maxWidth: maxWidth, + maxHeight: 260, + theme: theme, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 6), + itemCount: options.length, + itemBuilder: (context, index) { + final option = options.elementAt(index); + return InkWell( + onTap: () => onSelected(option), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(option.name, style: theme.textTheme.p), + const SizedBox(height: 4), + Text( + _optionSubtitle(option), + style: theme.textTheme.muted.copyWith(fontSize: 12), + ), + ], + ), + ), + ); + }, ), ); }, @@ -259,12 +457,35 @@ class _ApprovalApproverAutocompleteFieldState ); } - @override - void dispose() { - _textController.dispose(); - _focusNode - ..removeListener(_handleFocusChange) - ..dispose(); - super.dispose(); + Widget _buildDropdownWrapper({ + required double maxWidth, + required double maxHeight, + required Widget child, + required ShadThemeData theme, + }) { + return Align( + alignment: AlignmentDirectional.topStart, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: maxHeight), + child: Material( + elevation: 6, + color: theme.colorScheme.background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: theme.colorScheme.border), + ), + child: child, + ), + ), + ); + } + + String _optionSubtitle(ApprovalApproverCandidate candidate) { + final team = candidate.team?.trim(); + final buffer = StringBuffer('ID ${candidate.id} · ${candidate.employeeNo}'); + if (team != null && team.isNotEmpty) { + buffer.write(' · $team'); + } + return buffer.toString(); } } 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 cc1019f..0de9c07 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../core/config/environment.dart'; @@ -15,6 +16,7 @@ import '../../../domain/entities/approval_template.dart'; import '../../../domain/repositories/approval_template_repository.dart'; import '../../../domain/usecases/apply_approval_template_use_case.dart'; import '../../../domain/usecases/save_approval_template_use_case.dart'; +import '../../../../auth/application/auth_service.dart'; import '../controllers/approval_template_controller.dart'; /// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다. @@ -69,7 +71,7 @@ class _ApprovalTemplateEnabledPageState late final ApprovalTemplateController _controller; final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocus = FocusNode(); - final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; static const _pageSizeOptions = [10, 20, 50]; @@ -354,6 +356,50 @@ class _ApprovalTemplateEnabledPageState _searchFocus.requestFocus(); } + String _generateTemplateCode() { + final authService = GetIt.I(); + final session = authService.session; + String normalizedEmployee = ''; + + final candidateValues = [ + session?.user.employeeNo, + session?.user.email, + session?.user.name, + ]; + for (final candidate in candidateValues) { + if (candidate == null) { + continue; + } + var source = candidate.trim(); + final atIndex = source.indexOf('@'); + if (atIndex > 0) { + source = source.substring(0, atIndex); + } + final normalized = source.toUpperCase().replaceAll( + RegExp(r'[^A-Z0-9]'), + '', + ); + if (normalized.isNotEmpty) { + normalizedEmployee = normalized; + break; + } + } + if (normalizedEmployee.isEmpty && session?.user.id != null) { + normalizedEmployee = session!.user.id.toString(); + } + + final suffixSource = normalizedEmployee.isEmpty + ? '0000' + : normalizedEmployee; + final suffix = suffixSource.length >= 4 + ? suffixSource.substring(suffixSource.length - 4) + : suffixSource.padLeft(4, '0'); + final timestamp = intl.DateFormat( + 'yyMMddHHmmssSSS', + ).format(DateTime.now().toUtc()); + return 'AP_TEMP_${suffix}_$timestamp'; + } + Future _openTemplatePreview(int templateId) async { showDialog( context: context, @@ -530,7 +576,9 @@ class _ApprovalTemplateEnabledPageState Future _openTemplateForm({ApprovalTemplate? template}) async { final isEdit = template != null; final existingTemplate = template; - final codeController = TextEditingController(text: template?.code ?? ''); + final codeController = TextEditingController( + text: isEdit ? existingTemplate!.code : _generateTemplateCode(), + ); final nameController = TextEditingController(text: template?.name ?? ''); final descriptionController = TextEditingController( text: template?.description ?? '', @@ -573,7 +621,7 @@ class _ApprovalTemplateEnabledPageState ) .toList(); final input = ApprovalTemplateInput( - code: isEdit ? existingTemplate?.code : codeValue, + code: isEdit ? existingTemplate!.code : codeValue, name: nameValue, description: descriptionController.text.trim().isEmpty ? null @@ -583,16 +631,11 @@ class _ApprovalTemplateEnabledPageState : noteController.text.trim(), isActive: statusNotifier.value, ); - if (isEdit && existingTemplate == null) { - modalSetState?.call(() => errorText = '템플릿 정보를 불러오지 못했습니다.'); - modalSetState?.call(() => isSaving = false); - return; - } modalSetState?.call(() => isSaving = true); - final success = isEdit && existingTemplate != null - ? await _controller.update(existingTemplate.id, input, stepInputs) + final success = isEdit + ? await _controller.update(existingTemplate!.id, input, stepInputs) : await _controller.create(input, stepInputs); if (success != null && mounted) { Navigator.of(context, rootNavigator: true).pop(true); @@ -622,6 +665,8 @@ class _ApprovalTemplateEnabledPageState label: '템플릿 코드', child: ShadInput( controller: codeController, + readOnly: true, + enabled: false, placeholder: const Text('예: AP_INBOUND'), ), ), diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index cf45ea2..fffd807 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -1515,12 +1515,15 @@ class _InboundPageState extends State { employeeNo: writer.employeeNo, ); }(); - ApprovalFormInitializer.populate( + await ApprovalFormInitializer.populate( controller: approvalController, existingApproval: initial?.raw?.approval, draft: _controller?.approvalDraft, defaultRequester: defaultRequester, ); + if (!mounted) { + return null; + } final writerController = TextEditingController( text: writerLabel(writerSelection), diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 10ba5a9..7ccebbb 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -1609,12 +1609,15 @@ class _OutboundPageState extends State { employeeNo: writer.employeeNo, ); }(); - ApprovalFormInitializer.populate( + await ApprovalFormInitializer.populate( controller: approvalController, existingApproval: initial?.raw?.approval, draft: _controller?.approvalDraft, defaultRequester: defaultRequester, ); + if (!mounted) { + return null; + } final writerController = TextEditingController( text: writerLabel(writerSelection), diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index d8ba81c..52a996d 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -1591,12 +1591,15 @@ class _RentalPageState extends State { employeeNo: writer.employeeNo, ); }(); - ApprovalFormInitializer.populate( + await ApprovalFormInitializer.populate( controller: approvalController, existingApproval: initial?.raw?.approval, draft: _controller?.approvalDraft, defaultRequester: defaultRequester, ); + if (!mounted) { + return null; + } final writerController = TextEditingController( text: writerLabel(writerSelection), diff --git a/lib/features/login/presentation/pages/login_page.dart b/lib/features/login/presentation/pages/login_page.dart index 56e09d3..1c7e1c9 100644 --- a/lib/features/login/presentation/pages/login_page.dart +++ b/lib/features/login/presentation/pages/login_page.dart @@ -263,11 +263,22 @@ class _LoginPageState extends State { ), if (kDebugMode) ...[ const SizedBox(height: 12), - // QA 요청: 테스트 로그인 버튼을 테마 색상과 굵은 서체로 강조하여 피드백 반영. + // QA 요청: 디버그 로그인 버튼을 테마 색상과 굵은 서체로 강조하여 피드백 반영. ShadButton.ghost( - onPressed: isLoading ? null : _handleTestLogin, + onPressed: isLoading ? null : _handleTeraLogin, child: Text( - '테스트 로그인', + 'tera로그인', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 8), + ShadButton.ghost( + onPressed: isLoading ? null : _handleExaLogin, + child: Text( + 'exa로그인', style: theme.textTheme.small.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, @@ -304,17 +315,37 @@ class _LoginPageState extends State { ); } - /// 디버그 모드에서 테스트 계정으로 실서버 로그인한다. - void _handleTestLogin() { + /// 디버그 모드에서 테라(Tera) 계정으로 실서버 로그인한다. + void _handleTeraLogin() { if (isLoading) { return; } - const testIdentifier = 'terabits'; - const testPassword = '123456'; + const teraIdentifier = 'terabits'; + const teraPassword = '123456'; - idController.text = testIdentifier; - passwordController.text = testPassword; + idController.text = teraIdentifier; + passwordController.text = teraPassword; + + setState(() { + rememberMe = false; + errorMessage = null; + }); + + _handleSubmit(); + } + + /// 디버그 모드에서 엑사(Exa) 계정으로 실서버 로그인한다. + void _handleExaLogin() { + if (isLoading) { + return; + } + + const exaIdentifier = 'exabits'; + const exaPassword = '123456'; + + idController.text = exaIdentifier; + passwordController.text = exaPassword; setState(() { rememberMe = false; diff --git a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart index 9ca246e..1e01f41 100644 --- a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart +++ b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart @@ -856,31 +856,36 @@ class _PermissionTable extends StatelessWidget { cells.add( ShadTableCell( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onEdit == null ? null : () => onEdit!(permission), - child: const Icon(LucideIcons.pencil, size: 16), - ), - const SizedBox(width: 8), - permission.isDeleted - ? ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onRestore == null - ? null - : () => onRestore!(permission), - child: const Icon(LucideIcons.history, size: 16), - ) - : ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onDelete == null - ? null - : () => onDelete!(permission), - child: const Icon(LucideIcons.trash2, size: 16), - ), - ], + child: Align( + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, + runSpacing: 6, + alignment: WrapAlignment.end, + runAlignment: WrapAlignment.end, + children: [ + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onEdit == null ? null : () => onEdit!(permission), + child: const Icon(LucideIcons.pencil, size: 16), + ), + permission.isDeleted + ? ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRestore == null + ? null + : () => onRestore!(permission), + child: const Icon(LucideIcons.history, size: 16), + ) + : ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onDelete == null + ? null + : () => onDelete!(permission), + child: const Icon(LucideIcons.trash2, size: 16), + ), + ], + ), ), ), ); diff --git a/lib/features/masters/product/presentation/controllers/product_controller.dart b/lib/features/masters/product/presentation/controllers/product_controller.dart index 9a9e931..2e0f096 100644 --- a/lib/features/masters/product/presentation/controllers/product_controller.dart +++ b/lib/features/masters/product/presentation/controllers/product_controller.dart @@ -42,6 +42,7 @@ class ProductController extends ChangeNotifier { List _vendorOptions = const []; List _uomOptions = const []; + bool _isDisposed = false; PaginatedResult? get result => _result; bool get isLoading => _isLoading; @@ -60,7 +61,7 @@ class ProductController extends ChangeNotifier { Future fetch({int page = 1}) async { _isLoading = true; _errorMessage = null; - notifyListeners(); + _notifySafely(); try { final previous = _result; final int resolvedPage; @@ -95,14 +96,14 @@ class ProductController extends ChangeNotifier { _errorMessage = failure.describe(); } finally { _isLoading = false; - notifyListeners(); + _notifySafely(); } } /// 필터/폼에서 사용할 공급업체와 단위 목록을 로드한다. Future loadLookups() async { _isLoadingLookups = true; - notifyListeners(); + _notifySafely(); try { debugPrint('[ProductController] 드롭다운 데이터 조회 시작'); final vendors = await fetchAllPaginatedItems( @@ -127,7 +128,7 @@ class ProductController extends ChangeNotifier { ); } finally { _isLoadingLookups = false; - notifyListeners(); + _notifySafely(); } } @@ -137,7 +138,7 @@ class ProductController extends ChangeNotifier { return; } _query = value; - notifyListeners(); + _notifySafely(); } /// 공급업체 필터를 변경한다. @@ -146,7 +147,7 @@ class ProductController extends ChangeNotifier { return; } _vendorFilter = vendorId; - notifyListeners(); + _notifySafely(); } /// 단위(UOM) 필터를 변경한다. @@ -155,7 +156,7 @@ class ProductController extends ChangeNotifier { return; } _uomFilter = uomId; - notifyListeners(); + _notifySafely(); } /// 사용 여부 필터를 변경한다. @@ -164,7 +165,7 @@ class ProductController extends ChangeNotifier { return; } _statusFilter = filter; - notifyListeners(); + _notifySafely(); } /// 페이지 크기를 변경한다. @@ -173,7 +174,7 @@ class ProductController extends ChangeNotifier { return; } _pageSize = size; - notifyListeners(); + _notifySafely(); } /// 제품을 생성한다. @@ -186,7 +187,7 @@ class ProductController extends ChangeNotifier { } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); - notifyListeners(); + _notifySafely(); return null; } finally { _setSubmitting(false); @@ -203,7 +204,7 @@ class ProductController extends ChangeNotifier { } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); - notifyListeners(); + _notifySafely(); return null; } finally { _setSubmitting(false); @@ -220,7 +221,7 @@ class ProductController extends ChangeNotifier { } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); - notifyListeners(); + _notifySafely(); return false; } finally { _setSubmitting(false); @@ -237,7 +238,7 @@ class ProductController extends ChangeNotifier { } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); - notifyListeners(); + _notifySafely(); return null; } finally { _setSubmitting(false); @@ -247,12 +248,26 @@ class ProductController extends ChangeNotifier { /// 에러 메시지를 초기화한다. void clearError() { _errorMessage = null; - notifyListeners(); + _notifySafely(); } /// 제출 상태 플래그를 갱신하고 리스너에 알린다. void _setSubmitting(bool value) { _isSubmitting = value; - notifyListeners(); + _notifySafely(); + } + + /// dispose 이후에는 알림을 중단하여 디버그 에러를 방지한다. + void _notifySafely() { + if (_isDisposed) { + return; + } + super.notifyListeners(); + } + + @override + void dispose() { + _isDisposed = true; + super.dispose(); } } diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 0a44ceb..f73d249 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -26,6 +26,8 @@ import 'features/approvals/domain/usecases/save_approval_template_use_case.dart' import 'features/approvals/domain/usecases/submit_approval_use_case.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/shared/data/repositories/approval_approver_repository_remote.dart'; +import 'features/approvals/shared/domain/repositories/approval_approver_repository.dart'; import 'features/approvals/step/data/repositories/approval_step_repository_remote.dart'; import 'features/approvals/step/domain/repositories/approval_step_repository.dart'; import 'features/auth/application/auth_service.dart'; @@ -187,6 +189,9 @@ void _registerApprovalDependencies() { ..registerLazySingleton( () => ApprovalDraftRepositoryRemote(apiClient: sl()), ) + ..registerLazySingleton( + () => ApprovalApproverRepositoryRemote(apiClient: sl()), + ) ..registerLazySingleton( () => SubmitApprovalUseCase(repository: sl()), ) diff --git a/test/features/approvals/request/presentation/utils/approval_form_initializer_test.dart b/test/features/approvals/request/presentation/utils/approval_form_initializer_test.dart index c2558ac..0646ee7 100644 --- a/test/features/approvals/request/presentation/utils/approval_form_initializer_test.dart +++ b/test/features/approvals/request/presentation/utils/approval_form_initializer_test.dart @@ -1,13 +1,30 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import 'package:superport_v2/features/approvals/request/presentation/controllers/approval_request_controller.dart'; import 'package:superport_v2/features/approvals/request/presentation/utils/approval_form_initializer.dart'; +import 'package:superport_v2/features/approvals/shared/domain/entities/approval_approver_candidate.dart'; +import 'package:superport_v2/features/approvals/shared/domain/repositories/approval_approver_repository.dart'; import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart'; void main() { + final getIt = GetIt.I; + + setUp(() { + if (getIt.isRegistered()) { + getIt.unregister(); + } + }); + + tearDown(() async { + if (getIt.isRegistered()) { + getIt.unregister(); + } + }); + group('ApprovalFormInitializer.populate', () { - test('초안 저장본이 있으면 상신자와 단계를 복구한다', () { + test('초안 저장본이 있으면 상신자와 단계를 복구한다', () async { final controller = ApprovalRequestController(); final draft = StockTransactionApprovalInput( requestedById: 101, @@ -17,7 +34,18 @@ void main() { ], ); - ApprovalFormInitializer.populate(controller: controller, draft: draft); + getIt.registerSingleton( + _FakeApproverRepository({ + 101: _candidate(id: 101, name: '요청자'), + 104: _candidate(id: 104, name: '1단계 승인자'), + 201: _candidate(id: 201, name: '최종 승인자'), + }), + ); + + await ApprovalFormInitializer.populate( + controller: controller, + draft: draft, + ); expect(controller.requester?.id, equals(101)); expect(controller.steps.length, equals(2)); @@ -25,7 +53,7 @@ void main() { expect(controller.steps.last.approver.id, equals(201)); }); - test('카탈로그에 없는 승인자는 복구 대상에서 제외한다', () { + test('조회 실패 시 해당 승인자는 복구 대상에서 제외한다', () async { final controller = ApprovalRequestController(); final draft = StockTransactionApprovalInput( requestedById: 101, @@ -35,10 +63,53 @@ void main() { ], ); - ApprovalFormInitializer.populate(controller: controller, draft: draft); + getIt.registerSingleton( + _FakeApproverRepository({ + 101: _candidate(id: 101, name: '요청자'), + 104: _candidate(id: 104, name: '1단계 승인자'), + }), + ); + + await ApprovalFormInitializer.populate( + controller: controller, + draft: draft, + ); expect(controller.steps.length, equals(1)); expect(controller.steps.first.approver.id, equals(104)); }); }); } + +ApprovalApproverCandidate _candidate({required int id, required String name}) { + return ApprovalApproverCandidate( + id: id, + employeeNo: 'EMP$id', + name: name, + team: '팀', + ); +} + +class _FakeApproverRepository implements ApprovalApproverRepository { + _FakeApproverRepository(this._candidates); + + final Map _candidates; + + @override + Future fetchById(int id) async { + return _candidates[id]; + } + + @override + Future> search({ + required String keyword, + int limit = 20, + }) async { + return _candidates.values.toList(growable: false); + } + + @override + Future> listInitial({int limit = 20}) async { + return _candidates.values.take(limit).toList(growable: false); + } +} diff --git a/test/features/approvals/template/presentation/pages/approval_template_page_test.dart b/test/features/approvals/template/presentation/pages/approval_template_page_test.dart index d52096a..ba7f63e 100644 --- a/test/features/approvals/template/presentation/pages/approval_template_page_test.dart +++ b/test/features/approvals/template/presentation/pages/approval_template_page_test.dart @@ -12,6 +12,11 @@ import 'package:superport_v2/features/approvals/domain/repositories/approval_tem import 'package:superport_v2/features/approvals/domain/usecases/apply_approval_template_use_case.dart'; import 'package:superport_v2/features/approvals/domain/usecases/save_approval_template_use_case.dart'; import 'package:superport_v2/features/approvals/template/presentation/pages/approval_template_page.dart'; +import 'package:superport_v2/features/approvals/shared/domain/entities/approval_approver_candidate.dart'; +import 'package:superport_v2/features/approvals/shared/domain/repositories/approval_approver_repository.dart'; +import 'package:superport_v2/features/auth/application/auth_service.dart'; +import 'package:superport_v2/features/auth/domain/entities/auth_session.dart'; +import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart'; class _MockApprovalTemplateRepository extends Mock implements ApprovalTemplateRepository {} @@ -23,6 +28,8 @@ class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {} class _FakeTemplateStepInput extends Fake implements ApprovalTemplateStepInput {} +class _MockAuthService extends Mock implements AuthService {} + Widget _buildApp(Widget child) { return MaterialApp( home: ShadTheme( @@ -82,6 +89,37 @@ void main() { approvalRepository: approvalRepository, ), ); + GetIt.I.registerLazySingleton( + () => _FakeApproverRepository({ + 33: ApprovalApproverCandidate( + id: 33, + employeeNo: 'EMP033', + name: '테스트 승인자', + team: 'QA팀', + ), + 21: ApprovalApproverCandidate( + id: 21, + employeeNo: 'E001', + name: '최승인', + team: '결재팀', + ), + }), + ); + final authService = _MockAuthService(); + when(() => authService.session).thenReturn( + AuthSession( + accessToken: 'test-access', + refreshToken: 'test-refresh', + expiresAt: DateTime(2099, 1, 1), + user: const AuthenticatedUser( + id: 99, + name: '테스트 사용자', + employeeNo: 'E999', + email: 'test@example.com', + ), + ), + ); + GetIt.I.registerSingleton(authService); }); ApprovalTemplate buildTemplate({bool isActive = true}) { @@ -333,3 +371,38 @@ void main() { }); }); } + +class _FakeApproverRepository implements ApprovalApproverRepository { + _FakeApproverRepository(this._candidates); + + final Map _candidates; + + @override + Future fetchById(int id) async { + return _candidates[id]; + } + + @override + Future> search({ + required String keyword, + int limit = 20, + }) async { + final lower = keyword.trim().toLowerCase(); + if (lower.isEmpty) { + return _candidates.values.toList(growable: false); + } + return _candidates.values + .where( + (candidate) => + candidate.name.toLowerCase().contains(lower) || + candidate.employeeNo.toLowerCase().contains(lower) || + candidate.id.toString().contains(lower), + ) + .toList(growable: false); + } + + @override + Future> listInitial({int limit = 20}) async { + return _candidates.values.take(limit).toList(growable: false); + } +}