결재 템플릿 단계 적용 구현

- ApprovalTemplate 엔티티·DTO·원격 리포지토리 추가
- ApprovalController에 템플릿 로딩/적용 상태와 assignSteps 호출 연동
- ApprovalPage 단계 탭에 템플릿 선택 UI 및 적용 확인 다이얼로그 구현
- 템플릿 적용 단위 테스트와 IMPLEMENTATION_TASKS 현황 갱신
This commit is contained in:
JiWoong Sul
2025-09-25 00:21:12 +09:00
parent b6e50464d2
commit c3010965ad
63 changed files with 10179 additions and 1436 deletions

View File

@@ -0,0 +1,168 @@
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.dart';
import '../../domain/repositories/approval_repository.dart';
import '../dtos/approval_dto.dart';
class ApprovalRepositoryRemote implements ApprovalRepository {
ApprovalRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
final ApiClient _api;
static const _basePath = '/approvals';
@override
Future<PaginatedResult<Approval>> list({
int page = 1,
int pageSize = 20,
String? query,
String? status,
DateTime? from,
DateTime? to,
bool includeHistories = false,
bool includeSteps = false,
}) async {
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: {
'page': page,
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (status != null && status.isNotEmpty) 'status': status,
if (from != null) 'from': from.toIso8601String(),
if (to != null) 'to': to.toIso8601String(),
if (includeHistories) 'include_histories': true,
if (includeSteps) 'include_steps': true,
},
options: Options(responseType: ResponseType.json),
);
return ApprovalDto.parsePaginated(response.data ?? const {});
}
@override
Future<Approval> fetchDetail(
int id, {
bool includeSteps = true,
bool includeHistories = true,
}) async {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id',
query: {
if (includeSteps) 'include_steps': true,
if (includeHistories) 'include_histories': true,
},
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalDto.fromJson(data).toEntity();
}
@override
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
final response = await _api.get<Map<String, dynamic>>(
'/approval-actions',
query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true},
options: Options(responseType: ResponseType.json),
);
final items = (response.data?['items'] as List<dynamic>? ?? [])
.whereType<Map<String, dynamic>>()
.map(ApprovalActionDto.fromJson)
.map((dto) => dto.toEntity())
.toList();
return items;
}
@override
Future<Approval> performStepAction(ApprovalStepActionInput input) async {
final response = await _api.post<Map<String, dynamic>>(
'/approval-steps/${input.stepId}/actions',
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
final approvalJson = _extractApprovalFromActionResponse(
response.data ?? const <String, dynamic>{},
);
if (approvalJson == null) {
throw StateError('결재 단계 행위 응답에 결재 데이터가 없습니다.');
}
return ApprovalDto.fromJson(approvalJson).toEntity();
}
@override
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) async {
final response = await _api.post<Map<String, dynamic>>(
'/approvals/${input.approvalId}/steps',
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
final approvalJson = _extractApprovalFromActionResponse(
response.data ?? const <String, dynamic>{},
);
if (approvalJson == null) {
throw StateError('결재 단계 일괄 처리 응답에 결재 데이터가 없습니다.');
}
return ApprovalDto.fromJson(approvalJson).toEntity();
}
@override
Future<Approval> create(ApprovalInput input) async {
final response = await _api.post<Map<String, dynamic>>(
_basePath,
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalDto.fromJson(data).toEntity();
}
@override
Future<Approval> update(int id, ApprovalInput input) async {
final response = await _api.patch<Map<String, dynamic>>(
'$_basePath/$id',
data: input.toPayload(),
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalDto.fromJson(data).toEntity();
}
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
}
@override
Future<Approval> 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 ApprovalDto.fromJson(data).toEntity();
}
Map<String, dynamic>? _extractApprovalFromActionResponse(
Map<String, dynamic> body,
) {
final data = body['data'];
if (data is Map<String, dynamic>) {
if (data['approval'] is Map<String, dynamic>) {
return data['approval'] as Map<String, dynamic>;
}
if (data['approval_data'] is Map<String, dynamic>) {
return data['approval_data'] as Map<String, dynamic>;
}
final hasStatus =
data.containsKey('status') || data.containsKey('approval_status');
if (data.containsKey('approval_no') && hasStatus) {
return data;
}
}
if (body['approval'] is Map<String, dynamic>) {
return body['approval'] as Map<String, dynamic>;
}
return null;
}
}

View File

@@ -0,0 +1,46 @@
import 'package:dio/dio.dart';
import '../../../../core/network/api_client.dart';
import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_template_repository.dart';
import '../dtos/approval_template_dto.dart';
class ApprovalTemplateRepositoryRemote implements ApprovalTemplateRepository {
ApprovalTemplateRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
final ApiClient _api;
static const _basePath = '/approval-templates';
@override
Future<List<ApprovalTemplate>> list({bool activeOnly = true}) async {
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true},
options: Options(responseType: ResponseType.json),
);
final items = (response.data?['items'] as List<dynamic>? ?? [])
.whereType<Map<String, dynamic>>()
.map(ApprovalTemplateDto.fromJson)
.map((dto) => dto.toEntity(includeSteps: false))
.toList();
return items;
}
@override
Future<ApprovalTemplate> fetchDetail(
int id, {
bool includeSteps = true,
}) async {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id',
query: {if (includeSteps) 'include': 'steps'},
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};
return ApprovalTemplateDto.fromJson(
data,
).toEntity(includeSteps: includeSteps);
}
}