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