diff --git a/AGENTS.md b/AGENTS.md index 4e261df..f391021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ Commits follow the existing Superport convention: Korean imperative summaries wi ## Architecture & Environment Notes Initialize environments via `.env.development` / `.env.production` and load them through `Environment.initialize()` before bootstrapping DI. New data sources should expose repository interfaces in `domain/` and rely on the shared `ApiClient` instance. Do not use mock data in the application; always call the real backend (staging/production as appropriate). If an endpoint is not available, mark the feature as disabled behind a feature flag rather than mocking. +- Frontend behaviour/data models must strictly follow the deployed backend contract. (프론트엔드는 백엔드 API 계약을 절대 우선으로 준수해야 하며, 누락된 기능은 백엔드 수정 요청 후 진행한다.) --- @@ -34,6 +35,12 @@ Initialize environments via `.env.development` / `.env.production` and load them --- +## Notification Policy + +- Every task completion must trigger a notification via the configured `notify.py` workflow so users are consistently alerted. + +--- + ## SRP & Clean Architecture Enforcement Apply these principles repo-wide. Use the checklist below during reviews. diff --git a/doc/backend_change_requests.md b/doc/backend_change_requests.md new file mode 100644 index 0000000..f3aad1a --- /dev/null +++ b/doc/backend_change_requests.md @@ -0,0 +1,56 @@ +# 백엔드 수정 요청서 (Master/Transaction API 확장) + +## 1. 배경 +- 프론트엔드에서 인벤토리/승인 플로우를 실데이터에 맞춰 구현하기 위해서는 백엔드가 스펙(`stock_approval_system_api_v4.md`)상의 모든 마스터와 재고 트랜잭션 API를 제공해야 한다. +- 현 시점에는 `vendors`, `uoms`, `transaction_types`, `transaction_statuses`, `approval_statuses`, `approval_actions`, `warehouses` 엔드포인트까지만 구현되어 있으며, Flutter 화면은 아직 mock 데이터를 사용 중이다. +- 백엔드 코드를 직접 수정할 수 없는 상황이므로, 필요한 변경 사항을 명확히 정리해 전담 팀/담당자에게 전달한다. + +## 2. 요청 범위 +### 2.1 기본 경로 정렬 +- 모든 REST 엔드포인트는 `/api/v1` prefix 하위로 노출되어야 하며, 기존 구현(vendors/uoms/transaction-types/transaction-statuses/approval-statuses/approval-actions/warehouses)도 동일한 경로를 유지해야 한다. +- OpenAPI/스펙 문서에는 버전 프리픽스가 명확히 표기되어야 하며, 변경 시 프론트엔드가 사용하는 베이스 URL(`Environment.baseUrl`)과 일치하도록 공지한다. + +### 2.2 마스터 데이터 API 확대 +- 대상 테이블: `customers`, `products`, `employees`, `groups`, `menus`, `group_menu_permissions`, `approval_templates`, `approval_steps`(정의), `zipcodes` 검색용 API 등 +- 요구 사항 + - `/api/v1/` 패턴으로 목록/상세/생성/수정/삭제/복구 CRUD 일관성 유지 + - 목록 API는 검색(q), 활성/비활성 필터, soft-delete 필터, 정렬(sort/order), 페이지네이션(page/page_size) 지원 + - 관계형 데이터는 `find_also_related` 패턴으로 DTO에 포함 (예: 고객→zipcode, 그룹→permissions, 직원→group) + - 프론트엔드 Remote Repository(`lib/features/masters/**/data/repositories`)와 엔티티 스키마를 맞추기 위해 스펙 필드명 그대로 응답 + +### 2.3 결재(Approval) 도메인 확장 +- 리소스: `/approvals`, `/approval-steps`, `/approval-histories`, `/approval-templates` +- 요구 사항 + - 리스트/상세 API는 `include=steps,histories` 등 프론트가 사용하는 확장 파라미터를 지원해야 한다. + - 단계 배정(`POST /approvals/{id}/steps`), 단계 재배치(`PATCH /approvals/{id}/steps`), 단계 액션 수행(`POST /approval-steps/{id}/actions`)을 스펙대로 구현 + - 승인 가능 여부 조회(`GET /approvals/{id}/can-proceed`) 및 복구(`/approvals/{id}/restore`) 포함 + - 응답에는 Domain DTO(`ApprovalDto`, `ApprovalActionDto` 등)에서 필요로 하는 필드가 누락되지 않도록 검증 + +### 2.4 재고 트랜잭션 API 설계 및 구현 +- 리소스: `stock_transactions` (입고/출고/대여), `transaction_lines`, `transaction_approvals` 등 스펙 정의 테이블 +- 요구 사항 + - 목록 필터: 상태, 창고, 고객/거래처, 기간(처리일/반납예정일 등), 포함(include=lines, approval_history 등) + - 상세 응답: 헤더 정보 + 라인아이템 + 승인 이력/로그 전달 + - 상태 전이/승인 플로우 API (`submit`, `approve`, `reject`, `cancel`)와 재고 처리 결과 반영 + - soft-delete 및 복구 정책 정의 (필요 시 논의) + - SeaORM 트랜잭션을 이용해 헤더/라인/로그 동시 저장 + +### 2.5 공통 고려사항 +- DTO/응답 구조는 `stock_approval_system_api_v4.md`와 동기화하고, 변경 시 문서도 업데이트 +- `script/run_api_tests.sh`에 각 리소스의 CRUD 및 상태 전이 스텝을 추가해 회귀 테스트 가능하도록 보완 +- 샘플 데이터(`migration/002_sample_data.sql`)는 필수 참조 데이터만 유지하고, 대량 더미는 옵션 플래그로 분리 + +## 3. 선행 작업 및 의존성 +- 데이터 모델 검증: 스펙과 현재 DB 스키마 일치 여부 확인, 필요한 경우 추가 마이그레이션 작성 +- 인증/권한: 그룹-메뉴 권한 매핑을 API 보호 미들웨어에 적용 (추후 프론트엔드 권한 제어와 연동) +- 로깅/관측성: 주요 재고 트랜잭션 이벤트를 tracing 로그로 남겨 운영 대응 + +## 4. 수용 기준 (Acceptance Criteria) +- 모든 신규/확장된 엔드포인트에 대해 Actix 라우트, 도메인 DTO, SeaORM 리포지토리, 에러 매핑이 완비되어야 한다. +- `cargo check`, `cargo test`, `script/run_api_tests.sh`가 통과해야 하며, 샘플 DB로 기본 CRUD 시나리오가 동작할 것. +- README `Next Steps` 섹션 업데이트와 변경된 API 스펙 커밋이 포함되어야 한다. + +## 5. 후속 조치 +- 본 문서 확인 후 백엔드 담당자가 작업 범위/일정을 산출 +- 작업 완료 시 프론트엔드 팀에 API mock 제거 및 실연동 착수 일정 공유 +- 필요 시 추가 논의 사항을 본 문서 하단에 코멘트 형태로 기록 diff --git a/doc/commenting_plan.md b/doc/backup/commenting_plan.md similarity index 100% rename from doc/commenting_plan.md rename to doc/backup/commenting_plan.md diff --git a/doc/core_commenting_plan.md b/doc/backup/core_commenting_plan.md similarity index 100% rename from doc/core_commenting_plan.md rename to doc/backup/core_commenting_plan.md diff --git a/doc/inventory_commenting_plan.md b/doc/backup/inventory_commenting_plan.md similarity index 100% rename from doc/inventory_commenting_plan.md rename to doc/backup/inventory_commenting_plan.md diff --git a/doc/test_commenting_plan.md b/doc/backup/test_commenting_plan.md similarity index 100% rename from doc/test_commenting_plan.md rename to doc/backup/test_commenting_plan.md diff --git a/doc/frontend_api_alignment_plan.md b/doc/frontend_api_alignment_plan.md new file mode 100644 index 0000000..0aef753 --- /dev/null +++ b/doc/frontend_api_alignment_plan.md @@ -0,0 +1,40 @@ +# Frontend API Alignment Plan + +## 1. 현황 요약 +- Environment `API_BASE_URL` 기본값은 `http://localhost:8080`이며 버전 prefix(`/api/v1`)는 포함되어 있지 않다. +- Master/Approval/Inventory 모듈 대부분이 `_basePath = '/'`로 정의되어 있어 실제 백엔드(`/api/v1/...`)와 경로가 불일치한다. +- 입고/출고/대여 화면은 `_mockRecords`, `Inventory*Catalog` 등 정적 데이터를 사용하고 있으며, 서버 응답 모델과 연결되어 있지 않다. +- 승인 도메인(approvals/steps/histories/templates)은 Repository/DTO가 준비되어 있으나 백엔드 미구현 상태라 기능 플래그로 비활성화되어 있다. + +## 2. 즉시 처리 가능한 정렬 작업 +1. **공통 경로 상수화** + - `lib/core/network/api_client.dart` 또는 별도 헬퍼에 `const apiV1 = '/api/v1';` 정의. + - 모든 Remote Repository의 `_basePath` 앞에 `${ApiRoutes.apiV1}` prefix 적용. + - `WarehouseRepositoryRemote`, `VendorRepositoryRemote`, `UomRepositoryRemote`, `TransactionTypeRepositoryRemote`, `TransactionStatusRepositoryRemote`, `ApprovalStatus/Action` 등 전체 점검. +2. **DI/환경 값 점검** + - `.env.*` 예시 파일에 `API_BASE_URL` 주석으로 "버전 prefix 없음" 명시. + - 필요 시 `Environment.baseUrl`에 `/api/v1`를 포함한 값을 직접 지정해도 되지만, 기존 백엔드 관례에 맞춰 prefix 상수 사용 권장. +3. **기 구현 백엔드 연동** + - 벤더/단위/거래유형/거래상태/결재상태/결재행위/창고 화면에서 `Feature_*` 플래그를 통해 API 호출 활성화. + - 응답 파싱 결과가 UI에 반영되는지 확인하고, 로딩/에러 핸들러 추가. +4. **테스트/검증** + - `flutter analyze`, `flutter test` 실행. + - 가짜 데이터 비활성화 후 빈 응답 대비 UI(Empty state) 정상 동작 확인. + +## 3. 백엔드 작업 의존 영역 +1. **고객/제품/직원/권한 등 마스터 확장** + - 백엔드 `/api/v1/customers`, `/products`, `/employees`, `/groups`, `/menus`, `/group-menu-permissions` 구현 후 Remote Repository 활성화. + - DTO 매핑 검증 및 리스트/폼 화면에 실데이터 연동. +2. **승인(Approvals) 플로우** + - `/approvals`, `/approval-steps`, `/approval-histories`, `/approval-templates` 엔드포인트 제공 시 기능 플래그 해제. + - Step assign/action API 응답 구조가 `ApprovalDto` 기대 필드(steps, actions, histories)를 포함하는지 확인. +3. **재고 트랜잭션 (입고/출고/대여)** + - `/stock-transactions` 및 관련 라인/승인 API가 준비되면 `_mockRecords`, `Inventory*Catalog` 제거. + - 목록 필터/페이지네이션을 API Query와 동기화하고, 상세 모달 입력/수정 흐름을 서버 모델로 전환. +4. **우편번호 검색** + - `/zipcodes`(검색) API 구현 시 `PostalSearchRepositoryRemote` 경로와 파라미터를 확인하고 UI를 연동. + +## 4. 릴리즈 절차 +- 기능별 Feature Flag를 단계적으로 해제하면서 QA 진행. +- 각 단계에서 `script/run_api_tests.sh`로 백엔드 검증 → 프론트 `flutter test` → 수동 시연 순으로 검증 체계 유지. +- API 변경 사항은 `CHANGELOG` 및 `doc/backend_change_requests.md`와 동기화해 추후 회고/인수에 활용. diff --git a/lib/features/approvals/data/dtos/approval_dto.dart b/lib/features/approvals/data/dtos/approval_dto.dart index 2cd28a2..9ee00ce 100644 --- a/lib/features/approvals/data/dtos/approval_dto.dart +++ b/lib/features/approvals/data/dtos/approval_dto.dart @@ -194,6 +194,7 @@ class ApprovalStepDto { required this.assignedAt, this.decidedAt, this.note, + this.isDeleted = false, }); final int? id; @@ -203,6 +204,7 @@ class ApprovalStepDto { final DateTime assignedAt; final DateTime? decidedAt; final String? note; + final bool isDeleted; factory ApprovalStepDto.fromJson(Map json) { return ApprovalStepDto( @@ -217,6 +219,10 @@ class ApprovalStepDto { assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(), decidedAt: _parseDate(json['decided_at']), note: json['note'] as String?, + isDeleted: + json['is_deleted'] as bool? ?? + (json['deleted_at'] != null || + (json['is_active'] is bool && !(json['is_active'] as bool))), ); } @@ -229,6 +235,7 @@ class ApprovalStepDto { assignedAt: assignedAt, decidedAt: decidedAt, note: note, + isDeleted: isDeleted, ); } diff --git a/lib/features/approvals/domain/entities/approval.dart b/lib/features/approvals/domain/entities/approval.dart index 273bf5b..be83c1c 100644 --- a/lib/features/approvals/domain/entities/approval.dart +++ b/lib/features/approvals/domain/entities/approval.dart @@ -103,6 +103,7 @@ class ApprovalStep { required this.assignedAt, this.decidedAt, this.note, + this.isDeleted = false, }); final int? id; @@ -112,6 +113,29 @@ class ApprovalStep { final DateTime assignedAt; final DateTime? decidedAt; final String? note; + final bool isDeleted; + + ApprovalStep copyWith({ + int? id, + int? stepOrder, + ApprovalApprover? approver, + ApprovalStatus? status, + DateTime? assignedAt, + DateTime? decidedAt, + String? note, + bool? isDeleted, + }) { + return ApprovalStep( + id: id ?? this.id, + stepOrder: stepOrder ?? this.stepOrder, + approver: approver ?? this.approver, + status: status ?? this.status, + assignedAt: assignedAt ?? this.assignedAt, + decidedAt: decidedAt ?? this.decidedAt, + note: note ?? this.note, + isDeleted: isDeleted ?? this.isDeleted, + ); + } } class ApprovalApprover { diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index e88b2ec..d6d4933 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -173,7 +173,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { actions: [ ShadButton( leading: const Icon(lucide.LucideIcons.plus, size: 16), - onPressed: () {}, + onPressed: _openCreateApprovalDialog, child: const Text('신규 결재'), ), ], @@ -357,6 +357,116 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { ); } + /// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다. + Future _openCreateApprovalDialog() async { + final transactionController = TextEditingController(); + final noteController = TextEditingController(); + var submitted = false; + + final shouldShowToast = await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + final shadTheme = ShadTheme.of(context); + final errorVisible = + submitted && transactionController.text.trim().isEmpty; + + return SuperportDialog( + title: '신규 결재 등록', + description: '트랜잭션 정보를 입력하면 API 연동 시 자동 제출이 지원됩니다.', + constraints: const BoxConstraints(maxWidth: 480), + actions: [ + ShadButton.ghost( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('닫기'), + ), + ShadButton( + key: const ValueKey('approval_create_submit'), + onPressed: () { + final trimmed = transactionController.text.trim(); + setState(() => submitted = true); + if (trimmed.isEmpty) { + return; + } + Navigator.of(dialogContext).pop(true); + }, + child: const Text('임시 저장'), + ), + ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text('트랜잭션 ID', style: shadTheme.textTheme.small), + const SizedBox(height: 8), + ShadInput( + key: const ValueKey('approval_create_transaction'), + controller: transactionController, + placeholder: const Text('예: 2404-TRX-001'), + onChanged: (_) => setState(() {}), + ), + if (errorVisible) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '트랜잭션 ID를 입력해야 결재 생성이 가능합니다.', + style: shadTheme.textTheme.small.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + const SizedBox(height: 16), + Text('비고 (선택)', style: shadTheme.textTheme.small), + const SizedBox(height: 8), + ShadTextarea( + key: const ValueKey('approval_create_note'), + controller: noteController, + minHeight: 120, + maxHeight: 220, + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: shadTheme.colorScheme.mutedForeground.withValues( + alpha: 0.08, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + 'API 연동 준비 중', + style: TextStyle(fontWeight: FontWeight.w600), + ), + SizedBox(height: 6), + Text( + '현재는 결재 생성 UI만 제공됩니다. 실제 저장은 백엔드 연동 이후 지원될 예정입니다.', + ), + ], + ), + ), + ], + ), + ); + }, + ); + }, + ); + + transactionController.dispose(); + noteController.dispose(); + + if (shouldShowToast == true && mounted) { + SuperportToast.info( + context, + '결재 생성은 API 연동 이후 지원될 예정입니다. 입력한 값은 실제로 저장되지 않았습니다.', + ); + } + } + void _applyFilters() { _controller.updateQuery(_searchController.text.trim()); if (_dateRange != null) { diff --git a/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart b/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart index 3a6c3f4..09f51d3 100644 --- a/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart +++ b/lib/features/approvals/step/data/repositories/approval_step_repository_remote.dart @@ -82,4 +82,24 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository { (raw is Map ? raw : const {}); return ApprovalStepRecordDto.fromJson(data).toEntity(); } + + /// 결재 단계를 비활성화한다. + @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 raw = response.data; + final data = + (raw?['data'] as Map?) ?? + (raw is Map ? raw : const {}); + return ApprovalStepRecordDto.fromJson(data).toEntity(); + } } diff --git a/lib/features/approvals/step/domain/repositories/approval_step_repository.dart b/lib/features/approvals/step/domain/repositories/approval_step_repository.dart index 845fc32..26a0da4 100644 --- a/lib/features/approvals/step/domain/repositories/approval_step_repository.dart +++ b/lib/features/approvals/step/domain/repositories/approval_step_repository.dart @@ -23,4 +23,10 @@ abstract class ApprovalStepRepository { /// 결재 단계를 수정한다. Future update(int id, ApprovalStepInput input); + + /// 결재 단계를 삭제(비활성화)한다. + Future delete(int id); + + /// 삭제된 결재 단계를 복구한다. + Future restore(int id); } diff --git a/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart b/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart index cca1b37..cb4c4f7 100644 --- a/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart +++ b/lib/features/approvals/step/presentation/controllers/approval_step_controller.dart @@ -158,4 +158,64 @@ class ApprovalStepController extends ChangeNotifier { notifyListeners(); } } + + /// 결재 단계를 삭제(비활성화)하고 목록 상태를 반영한다. + Future deleteStep(int id) async { + _isSaving = true; + _errorMessage = null; + notifyListeners(); + try { + await _repository.delete(id); + if (_result != null) { + final items = _result!.items + .map((record) { + final stepId = record.step.id; + if (stepId != null && stepId == id) { + return record.copyWith( + step: record.step.copyWith(isDeleted: true), + ); + } + return record; + }) + .toList(growable: false); + _result = _result!.copyWith(items: items); + } + return true; + } catch (e) { + _errorMessage = e.toString(); + return false; + } finally { + _isSaving = false; + notifyListeners(); + } + } + + /// 삭제된 결재 단계를 복구하고 최신 데이터를 반환한다. + Future restoreStep(int id) async { + _isSaving = true; + _errorMessage = null; + notifyListeners(); + try { + final record = await _repository.restore(id); + if (_result != null) { + final items = _result!.items + .map((item) { + final stepId = item.step.id; + if (stepId != null && stepId == id) { + return record; + } + return item; + }) + .toList(growable: false); + _result = _result!.copyWith(items: items); + } + return record; + } catch (e) { + _errorMessage = e.toString(); + return null; + } finally { + _isSaving = false; + notifyListeners(); + } + } } diff --git a/lib/features/approvals/step/presentation/pages/approval_step_page.dart b/lib/features/approvals/step/presentation/pages/approval_step_page.dart index 06072ae..627df11 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../core/constants/app_sections.dart'; +import '../../../../../core/permissions/permission_manager.dart'; import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/components/superport_dialog.dart'; @@ -14,6 +15,8 @@ import '../../domain/entities/approval_step_input.dart'; import '../../domain/entities/approval_step_record.dart'; import '../../domain/repositories/approval_step_repository.dart'; +const String _stepResourcePath = '/approvals/steps'; + /// 결재 단계 관리 진입 페이지. 기능 플래그에 따라 실제 화면 또는 준비중 화면을 노출한다. class ApprovalStepPage extends StatelessWidget { const ApprovalStepPage({super.key}); @@ -132,19 +135,23 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { AppBreadcrumbItem(label: '결재 단계'), ], actions: [ - ShadButton( - key: const ValueKey('approval_step_create'), - onPressed: (_controller.isLoading || isSaving) - ? null - : _openCreateStepForm, - leading: isSaving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(lucide.LucideIcons.plus, size: 16), - child: Text(isSaving ? '저장 중...' : '단계 추가'), + PermissionGate( + resource: _stepResourcePath, + action: PermissionAction.create, + child: ShadButton( + key: const ValueKey('approval_step_create'), + onPressed: (_controller.isLoading || isSaving) + ? null + : _openCreateStepForm, + leading: isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(lucide.LucideIcons.plus, size: 16), + child: Text(isSaving ? '저장 중...' : '단계 추가'), + ), ), ], toolbar: FilterBar( @@ -287,6 +294,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { }, children: records.map((record) { final step = record.step; + final isDeleted = step.isDeleted; return [ ShadTableCell( child: Text(step.id?.toString() ?? '-'), @@ -313,30 +321,78 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { child: Wrap( spacing: 8, children: [ - ShadButton.outline( - key: ValueKey( - 'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}', - ), - size: ShadButtonSize.sm, - onPressed: - step.id == null || - _controller.isLoading || - isSaving - ? null - : () => _openDetail(record), - child: const Text('상세'), - ), - if (step.id != null) - ShadButton( + PermissionGate( + resource: _stepResourcePath, + action: PermissionAction.view, + child: ShadButton.outline( key: ValueKey( - 'step_edit_${step.id}_${step.stepOrder}', + 'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}', ), size: ShadButtonSize.sm, onPressed: - _controller.isLoading || isSaving + step.id == null || + _controller.isLoading || + isSaving ? null - : () => _openEditStepForm(record), - child: const Text('수정'), + : () => _openDetail(record), + child: const Text('상세'), + ), + ), + if (step.id != null && !isDeleted) + PermissionGate( + resource: _stepResourcePath, + action: PermissionAction.edit, + child: ShadButton( + key: ValueKey( + 'step_edit_${step.id}_${step.stepOrder}', + ), + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || + isSaving + ? null + : () => + _openEditStepForm(record), + child: const Text('수정'), + ), + ), + if (step.id != null && !isDeleted) + PermissionGate( + resource: _stepResourcePath, + action: PermissionAction.delete, + child: ShadButton.destructive( + key: ValueKey( + 'step_delete_${step.id}_${step.stepOrder}', + ), + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || + isSaving + ? null + : () => _confirmDeleteStep( + record, + ), + child: const Text('삭제'), + ), + ), + if (step.id != null && isDeleted) + PermissionGate( + resource: _stepResourcePath, + action: PermissionAction.restore, + child: ShadButton.outline( + key: ValueKey( + 'step_restore_${step.id}_${step.stepOrder}', + ), + size: ShadButtonSize.sm, + onPressed: + _controller.isLoading || + isSaving + ? null + : () => _confirmRestoreStep( + record, + ), + child: const Text('복구'), + ), ), ], ), @@ -554,6 +610,92 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { ); } + Future _confirmDeleteStep(ApprovalStepRecord record) async { + final stepId = record.step.id; + if (stepId == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('저장되지 않은 단계는 삭제할 수 없습니다.'))); + return; + } + + final confirmed = await SuperportDialog.show( + context: context, + dialog: SuperportDialog( + title: '결재 단계 삭제', + description: + '결재번호 ${record.approvalNo}의 ${record.step.stepOrder}단계를 삭제하시겠습니까? 삭제 후 복구할 수 있습니다.', + actions: [ + ShadButton.ghost( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + ShadButton.destructive( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('삭제'), + ), + ], + ), + ); + + if (confirmed != true) { + return; + } + + final success = await _controller.deleteStep(stepId); + if (!mounted) { + return; + } + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('결재번호 ${record.approvalNo} 단계가 삭제되었습니다.')), + ); + } + } + + Future _confirmRestoreStep(ApprovalStepRecord record) async { + final stepId = record.step.id; + if (stepId == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('단계 식별자가 없어 복구할 수 없습니다.'))); + return; + } + + final confirmed = await SuperportDialog.show( + context: context, + dialog: SuperportDialog( + title: '결재 단계 복구', + description: + '결재번호 ${record.approvalNo}의 ${record.step.stepOrder}단계를 복구하시겠습니까?', + actions: [ + ShadButton.ghost( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + ShadButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('복구'), + ), + ], + ), + ); + + if (confirmed != true) { + return; + } + + final restored = await _controller.restoreStep(stepId); + if (!mounted) { + return; + } + if (restored != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('결재번호 ${restored.approvalNo} 단계가 복구되었습니다.')), + ); + } + } + String _formatDate(DateTime date) { return _dateFormat.format(date.toLocal()); } diff --git a/test/features/approvals/step/presentation/controllers/approval_step_controller_test.dart b/test/features/approvals/step/presentation/controllers/approval_step_controller_test.dart index 15bb1c1..4f2f975 100644 --- a/test/features/approvals/step/presentation/controllers/approval_step_controller_test.dart +++ b/test/features/approvals/step/presentation/controllers/approval_step_controller_test.dart @@ -221,4 +221,70 @@ void main() { expect(result, isNull); expect(controller.errorMessage, isNotNull); }); + + test('deleteStep 성공 시 단계가 비활성화된다', () async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + statusId: any(named: 'statusId'), + approverId: any(named: 'approverId'), + approvalId: any(named: 'approvalId'), + ), + ).thenAnswer((_) async => createResult([sampleRecord])); + + when(() => repository.delete(any())).thenAnswer((_) async {}); + + await controller.fetch(); + + final success = await controller.deleteStep(sampleRecord.step.id!); + + expect(success, isTrue); + expect(controller.result?.items.first.step.isDeleted, isTrue); + }); + + test('deleteStep 실패 시 false를 반환하고 에러를 기록한다', () async { + when(() => repository.delete(any())).thenThrow(Exception('fail')); + + final success = await controller.deleteStep(999); + + expect(success, isFalse); + expect(controller.errorMessage, isNotNull); + }); + + test('restoreStep 성공 시 단계가 활성화된다', () async { + final deletedRecord = sampleRecord.copyWith( + step: sampleRecord.step.copyWith(isDeleted: true), + ); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + statusId: any(named: 'statusId'), + approverId: any(named: 'approverId'), + approvalId: any(named: 'approvalId'), + ), + ).thenAnswer((_) async => createResult([deletedRecord])); + + when(() => repository.restore(any())).thenAnswer((_) async => sampleRecord); + + await controller.fetch(); + + final restored = await controller.restoreStep(sampleRecord.step.id!); + + expect(restored, isNotNull); + expect(controller.result?.items.first.step.isDeleted, isFalse); + }); + + test('restoreStep 실패 시 null을 반환하고 에러를 기록한다', () async { + when(() => repository.restore(any())).thenThrow(Exception('fail')); + + final restored = await controller.restoreStep(100); + + expect(restored, isNull); + expect(controller.errorMessage, isNotNull); + }); } diff --git a/test/features/approvals/step/presentation/pages/approval_step_page_test.dart b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart index d326c51..2380c66 100644 --- a/test/features/approvals/step/presentation/pages/approval_step_page_test.dart +++ b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart @@ -6,6 +6,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_input.dart'; import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart'; @@ -16,13 +17,19 @@ class _MockApprovalStepRepository extends Mock implements ApprovalStepRepository {} Widget _buildApp(Widget child) { + final manager = PermissionManager( + overrides: {'/approvals/steps': PermissionAction.values.toSet()}, + ); return MaterialApp( - home: ShadTheme( - data: ShadThemeData( - colorScheme: const ShadSlateColorScheme.light(), - brightness: Brightness.light, + home: PermissionScope( + manager: manager, + child: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), ), - child: Scaffold(body: child), ), ); } @@ -263,4 +270,92 @@ void main() { findsOneWidget, ); }); + + testWidgets('삭제 버튼 확인 후 저장소 삭제를 호출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + repository = _MockApprovalStepRepository(); + GetIt.I.registerLazySingleton(() => repository); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + statusId: any(named: 'statusId'), + approverId: any(named: 'approverId'), + approvalId: any(named: 'approvalId'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [record], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + when(() => repository.delete(any())).thenAnswer((_) async {}); + + await tester.pumpWidget(_buildApp(const ApprovalStepPage())); + await tester.pump(); + await tester.pumpAndSettle(); + + final deleteFinder = find.byKey(const ValueKey('step_delete_501_1')); + final deleteButton = tester.widget(deleteFinder); + deleteButton.onPressed?.call(); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ShadButton, '삭제').last); + await tester.pump(); + await tester.pumpAndSettle(); + + verify(() => repository.delete(501)).called(1); + }); + + testWidgets('복구 버튼 확인 후 저장소 복구를 호출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + repository = _MockApprovalStepRepository(); + GetIt.I.registerLazySingleton(() => repository); + + final deletedRecord = record.copyWith( + step: record.step.copyWith(isDeleted: true), + ); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + statusId: any(named: 'statusId'), + approverId: any(named: 'approverId'), + approvalId: any(named: 'approvalId'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [deletedRecord], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + when(() => repository.restore(any())).thenAnswer((_) async => record); + + await tester.pumpWidget(_buildApp(const ApprovalStepPage())); + await tester.pump(); + await tester.pumpAndSettle(); + + final restoreFinder = find.byKey(const ValueKey('step_restore_501_1')); + final restoreButton = tester.widget(restoreFinder); + restoreButton.onPressed?.call(); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ShadButton, '복구').last); + await tester.pump(); + await tester.pumpAndSettle(); + + verify(() => repository.restore(501)).called(1); + }); }