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