결재 템플릿 단계 적용 구현
- ApprovalTemplate 엔티티·DTO·원격 리포지토리 추가 - ApprovalController에 템플릿 로딩/적용 상태와 assignSteps 호출 연동 - ApprovalPage 단계 탭에 템플릿 선택 UI 및 적용 확인 다이얼로그 구현 - 템플릿 적용 단위 테스트와 IMPLEMENTATION_TASKS 현황 갱신
This commit is contained in:
292
lib/features/approvals/data/dtos/approval_dto.dart
Normal file
292
lib/features/approvals/data/dtos/approval_dto.dart
Normal file
@@ -0,0 +1,292 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import '../../domain/entities/approval.dart';
|
||||
|
||||
class ApprovalDto {
|
||||
ApprovalDto({
|
||||
this.id,
|
||||
required this.approvalNo,
|
||||
this.transactionNo,
|
||||
required this.status,
|
||||
this.currentStep,
|
||||
required this.requester,
|
||||
required this.requestedAt,
|
||||
this.decidedAt,
|
||||
this.note,
|
||||
this.isActive = true,
|
||||
this.isDeleted = false,
|
||||
this.steps = const [],
|
||||
this.histories = const [],
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final String approvalNo;
|
||||
final String? transactionNo;
|
||||
final ApprovalStatusDto status;
|
||||
final ApprovalStepDto? currentStep;
|
||||
final ApprovalRequesterDto requester;
|
||||
final DateTime requestedAt;
|
||||
final DateTime? decidedAt;
|
||||
final String? note;
|
||||
final bool isActive;
|
||||
final bool isDeleted;
|
||||
final List<ApprovalStepDto> steps;
|
||||
final List<ApprovalHistoryDto> histories;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
factory ApprovalDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalDto(
|
||||
id: json['id'] as int?,
|
||||
approvalNo: json['approval_no'] as String,
|
||||
transactionNo: json['transaction'] is Map<String, dynamic>
|
||||
? (json['transaction']['transaction_no'] as String?)
|
||||
: json['transaction_no'] as String?,
|
||||
status: ApprovalStatusDto.fromJson(
|
||||
(json['status'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
currentStep: json['current_step'] is Map<String, dynamic>
|
||||
? ApprovalStepDto.fromJson(
|
||||
json['current_step'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
requester: ApprovalRequesterDto.fromJson(
|
||||
(json['requester'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
requestedAt: _parseDate(json['requested_at']) ?? DateTime.now(),
|
||||
decidedAt: _parseDate(json['decided_at']),
|
||||
note: json['note'] as String?,
|
||||
isActive: (json['is_active'] as bool?) ?? true,
|
||||
isDeleted: (json['is_deleted'] as bool?) ?? false,
|
||||
steps: (json['steps'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ApprovalStepDto.fromJson)
|
||||
.toList(),
|
||||
histories: (json['histories'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ApprovalHistoryDto.fromJson)
|
||||
.toList(),
|
||||
createdAt: _parseDate(json['created_at']),
|
||||
updatedAt: _parseDate(json['updated_at']),
|
||||
);
|
||||
}
|
||||
|
||||
Approval toEntity() => Approval(
|
||||
id: id,
|
||||
approvalNo: approvalNo,
|
||||
transactionNo: transactionNo ?? '-',
|
||||
status: status.toEntity(),
|
||||
currentStep: currentStep?.toEntity(),
|
||||
requester: requester.toEntity(),
|
||||
requestedAt: requestedAt,
|
||||
decidedAt: decidedAt,
|
||||
note: note,
|
||||
isActive: isActive,
|
||||
isDeleted: isDeleted,
|
||||
steps: steps.map((e) => e.toEntity()).toList(),
|
||||
histories: histories.map((e) => e.toEntity()).toList(),
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
|
||||
static PaginatedResult<Approval> parsePaginated(Map<String, dynamic>? json) {
|
||||
final items = (json?['items'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ApprovalDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList();
|
||||
return PaginatedResult<Approval>(
|
||||
items: items,
|
||||
page: json?['page'] as int? ?? 1,
|
||||
pageSize: json?['page_size'] as int? ?? items.length,
|
||||
total: json?['total'] as int? ?? items.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ApprovalStatusDto {
|
||||
ApprovalStatusDto({required this.id, required this.name, this.color});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? color;
|
||||
|
||||
factory ApprovalStatusDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalStatusDto(
|
||||
id: json['id'] as int? ?? json['status_id'] as int? ?? 0,
|
||||
name: json['name'] as String? ?? json['status_name'] as String? ?? '-',
|
||||
color: json['color'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalStatus toEntity() => ApprovalStatus(id: id, name: name, color: color);
|
||||
}
|
||||
|
||||
class ApprovalRequesterDto {
|
||||
ApprovalRequesterDto({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
|
||||
factory ApprovalRequesterDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalRequesterDto(
|
||||
id: json['id'] as int? ?? json['employee_id'] as int? ?? 0,
|
||||
employeeNo: json['employee_no'] as String? ?? '-',
|
||||
name: json['name'] as String? ?? json['employee_name'] as String? ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalRequester toEntity() =>
|
||||
ApprovalRequester(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
|
||||
class ApprovalApproverDto {
|
||||
ApprovalApproverDto({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
|
||||
factory ApprovalApproverDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalApproverDto(
|
||||
id: json['id'] as int? ?? json['approver_id'] as int? ?? 0,
|
||||
employeeNo: json['employee_no'] as String? ?? '-',
|
||||
name: json['name'] as String? ?? json['employee_name'] as String? ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalApprover toEntity() =>
|
||||
ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
|
||||
class ApprovalStepDto {
|
||||
ApprovalStepDto({
|
||||
this.id,
|
||||
required this.stepOrder,
|
||||
required this.approver,
|
||||
required this.status,
|
||||
required this.assignedAt,
|
||||
this.decidedAt,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final int stepOrder;
|
||||
final ApprovalApproverDto approver;
|
||||
final ApprovalStatusDto status;
|
||||
final DateTime assignedAt;
|
||||
final DateTime? decidedAt;
|
||||
final String? note;
|
||||
|
||||
factory ApprovalStepDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalStepDto(
|
||||
id: json['id'] as int?,
|
||||
stepOrder: json['step_order'] as int? ?? 0,
|
||||
approver: ApprovalApproverDto.fromJson(
|
||||
(json['approver'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
status: ApprovalStatusDto.fromJson(
|
||||
(json['status'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
|
||||
decidedAt: _parseDate(json['decided_at']),
|
||||
note: json['note'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalStep toEntity() => ApprovalStep(
|
||||
id: id,
|
||||
stepOrder: stepOrder,
|
||||
approver: approver.toEntity(),
|
||||
status: status.toEntity(),
|
||||
assignedAt: assignedAt,
|
||||
decidedAt: decidedAt,
|
||||
note: note,
|
||||
);
|
||||
}
|
||||
|
||||
class ApprovalHistoryDto {
|
||||
ApprovalHistoryDto({
|
||||
this.id,
|
||||
required this.action,
|
||||
this.fromStatus,
|
||||
required this.toStatus,
|
||||
required this.approver,
|
||||
required this.actionAt,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final ApprovalActionDto action;
|
||||
final ApprovalStatusDto? fromStatus;
|
||||
final ApprovalStatusDto toStatus;
|
||||
final ApprovalApproverDto approver;
|
||||
final DateTime actionAt;
|
||||
final String? note;
|
||||
|
||||
factory ApprovalHistoryDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalHistoryDto(
|
||||
id: json['id'] as int?,
|
||||
action: ApprovalActionDto.fromJson(
|
||||
(json['action'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
fromStatus: json['from_status'] is Map<String, dynamic>
|
||||
? ApprovalStatusDto.fromJson(
|
||||
json['from_status'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
toStatus: ApprovalStatusDto.fromJson(
|
||||
(json['to_status'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
approver: ApprovalApproverDto.fromJson(
|
||||
(json['approver'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
actionAt: _parseDate(json['action_at']) ?? DateTime.now(),
|
||||
note: json['note'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalHistory toEntity() => ApprovalHistory(
|
||||
id: id,
|
||||
action: action.toEntity(),
|
||||
fromStatus: fromStatus?.toEntity(),
|
||||
toStatus: toStatus.toEntity(),
|
||||
approver: approver.toEntity(),
|
||||
actionAt: actionAt,
|
||||
note: note,
|
||||
);
|
||||
}
|
||||
|
||||
class ApprovalActionDto {
|
||||
ApprovalActionDto({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
factory ApprovalActionDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalActionDto(
|
||||
id: json['id'] as int? ?? json['action_id'] as int? ?? 0,
|
||||
name: json['name'] as String? ?? json['action_name'] as String? ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalAction toEntity() => ApprovalAction(id: id, name: name);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
149
lib/features/approvals/data/dtos/approval_template_dto.dart
Normal file
149
lib/features/approvals/data/dtos/approval_template_dto.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import '../../domain/entities/approval_template.dart';
|
||||
|
||||
class ApprovalTemplateDto {
|
||||
ApprovalTemplateDto({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
this.description,
|
||||
required this.isActive,
|
||||
this.createdBy,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.steps = const [],
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
final String? description;
|
||||
final bool isActive;
|
||||
final ApprovalTemplateAuthorDto? createdBy;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final List<ApprovalTemplateStepDto> steps;
|
||||
|
||||
factory ApprovalTemplateDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalTemplateDto(
|
||||
id: json['id'] as int? ?? 0,
|
||||
code: json['template_code'] as String? ?? json['code'] as String? ?? '-',
|
||||
name: json['template_name'] as String? ?? json['name'] as String? ?? '-',
|
||||
description: json['description'] as String?,
|
||||
isActive: (json['is_active'] as bool?) ?? true,
|
||||
createdBy: json['created_by'] is Map<String, dynamic>
|
||||
? ApprovalTemplateAuthorDto.fromJson(
|
||||
json['created_by'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
createdAt: _parseDate(json['created_at']),
|
||||
updatedAt: _parseDate(json['updated_at']),
|
||||
steps: (json['steps'] as List<dynamic>? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ApprovalTemplateStepDto.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalTemplate toEntity({bool includeSteps = true}) {
|
||||
return ApprovalTemplate(
|
||||
id: id,
|
||||
code: code,
|
||||
name: name,
|
||||
description: description,
|
||||
isActive: isActive,
|
||||
createdBy: createdBy?.toEntity(),
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
steps: includeSteps ? steps.map((e) => e.toEntity()).toList() : const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ApprovalTemplateAuthorDto {
|
||||
ApprovalTemplateAuthorDto({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
|
||||
factory ApprovalTemplateAuthorDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalTemplateAuthorDto(
|
||||
id: json['id'] as int? ?? json['employee_id'] as int? ?? 0,
|
||||
employeeNo: json['employee_no'] as String? ?? '-',
|
||||
name: json['employee_name'] as String? ?? json['name'] as String? ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalTemplateAuthor toEntity() {
|
||||
return ApprovalTemplateAuthor(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
}
|
||||
|
||||
class ApprovalTemplateStepDto {
|
||||
ApprovalTemplateStepDto({
|
||||
this.id,
|
||||
required this.stepOrder,
|
||||
required this.approver,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final int stepOrder;
|
||||
final ApprovalTemplateApproverDto approver;
|
||||
final String? note;
|
||||
|
||||
factory ApprovalTemplateStepDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalTemplateStepDto(
|
||||
id: json['id'] as int?,
|
||||
stepOrder: json['step_order'] as int? ?? 0,
|
||||
approver: ApprovalTemplateApproverDto.fromJson(
|
||||
(json['approver'] as Map<String, dynamic>? ?? const {}),
|
||||
),
|
||||
note: json['note'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalTemplateStep toEntity() {
|
||||
return ApprovalTemplateStep(
|
||||
id: id,
|
||||
stepOrder: stepOrder,
|
||||
approver: approver.toEntity(),
|
||||
note: note,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ApprovalTemplateApproverDto {
|
||||
ApprovalTemplateApproverDto({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
|
||||
factory ApprovalTemplateApproverDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalTemplateApproverDto(
|
||||
id: json['id'] as int? ?? json['approver_id'] as int? ?? 0,
|
||||
employeeNo: json['employee_no'] as String? ?? '-',
|
||||
name: json['employee_name'] as String? ?? json['name'] as String? ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalTemplateApprover toEntity() {
|
||||
return ApprovalTemplateApprover(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
}
|
||||
|
||||
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,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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
248
lib/features/approvals/domain/entities/approval.dart
Normal file
248
lib/features/approvals/domain/entities/approval.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
/// 결재(Approval) 엔티티
|
||||
///
|
||||
/// - 결재 기본 정보와 현재 단계, 라인(단계/이력) 데이터를 포함한다.
|
||||
/// - presentation/data 레이어 구현에 의존하지 않는다.
|
||||
class Approval {
|
||||
Approval({
|
||||
this.id,
|
||||
required this.approvalNo,
|
||||
required this.transactionNo,
|
||||
required this.status,
|
||||
this.currentStep,
|
||||
required this.requester,
|
||||
required this.requestedAt,
|
||||
this.decidedAt,
|
||||
this.note,
|
||||
this.isActive = true,
|
||||
this.isDeleted = false,
|
||||
this.steps = const [],
|
||||
this.histories = const [],
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final String approvalNo;
|
||||
final String transactionNo;
|
||||
final ApprovalStatus status;
|
||||
final ApprovalStep? currentStep;
|
||||
final ApprovalRequester requester;
|
||||
final DateTime requestedAt;
|
||||
final DateTime? decidedAt;
|
||||
final String? note;
|
||||
final bool isActive;
|
||||
final bool isDeleted;
|
||||
final List<ApprovalStep> steps;
|
||||
final List<ApprovalHistory> histories;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
Approval copyWith({
|
||||
int? id,
|
||||
String? approvalNo,
|
||||
String? transactionNo,
|
||||
ApprovalStatus? status,
|
||||
ApprovalStep? currentStep,
|
||||
ApprovalRequester? requester,
|
||||
DateTime? requestedAt,
|
||||
DateTime? decidedAt,
|
||||
String? note,
|
||||
bool? isActive,
|
||||
bool? isDeleted,
|
||||
List<ApprovalStep>? steps,
|
||||
List<ApprovalHistory>? histories,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return Approval(
|
||||
id: id ?? this.id,
|
||||
approvalNo: approvalNo ?? this.approvalNo,
|
||||
transactionNo: transactionNo ?? this.transactionNo,
|
||||
status: status ?? this.status,
|
||||
currentStep: currentStep ?? this.currentStep,
|
||||
requester: requester ?? this.requester,
|
||||
requestedAt: requestedAt ?? this.requestedAt,
|
||||
decidedAt: decidedAt ?? this.decidedAt,
|
||||
note: note ?? this.note,
|
||||
isActive: isActive ?? this.isActive,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
steps: steps ?? this.steps,
|
||||
histories: histories ?? this.histories,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ApprovalStatus {
|
||||
ApprovalStatus({required this.id, required this.name, this.color});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? color;
|
||||
}
|
||||
|
||||
class ApprovalRequester {
|
||||
ApprovalRequester({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class ApprovalStep {
|
||||
ApprovalStep({
|
||||
this.id,
|
||||
required this.stepOrder,
|
||||
required this.approver,
|
||||
required this.status,
|
||||
required this.assignedAt,
|
||||
this.decidedAt,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final int stepOrder;
|
||||
final ApprovalApprover approver;
|
||||
final ApprovalStatus status;
|
||||
final DateTime assignedAt;
|
||||
final DateTime? decidedAt;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
class ApprovalApprover {
|
||||
ApprovalApprover({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class ApprovalHistory {
|
||||
ApprovalHistory({
|
||||
this.id,
|
||||
required this.action,
|
||||
this.fromStatus,
|
||||
required this.toStatus,
|
||||
required this.approver,
|
||||
required this.actionAt,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final ApprovalAction action;
|
||||
final ApprovalStatus? fromStatus;
|
||||
final ApprovalStatus toStatus;
|
||||
final ApprovalApprover approver;
|
||||
final DateTime actionAt;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
class ApprovalAction {
|
||||
ApprovalAction({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 결재 단계에서 수행 가능한 행위 타입
|
||||
///
|
||||
/// - API `approval_actions` 테이블의 대표 코드와 매핑된다.
|
||||
/// - UI에서는 이 타입을 기반으로 표시 라벨과 권한을 제어한다.
|
||||
enum ApprovalStepActionType { approve, reject, comment }
|
||||
|
||||
extension ApprovalStepActionTypeX on ApprovalStepActionType {
|
||||
/// API 호출 시 사용되는 행위 코드
|
||||
String get code {
|
||||
switch (this) {
|
||||
case ApprovalStepActionType.approve:
|
||||
return 'approve';
|
||||
case ApprovalStepActionType.reject:
|
||||
return 'reject';
|
||||
case ApprovalStepActionType.comment:
|
||||
return 'comment';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 생성 입력 모델
|
||||
class ApprovalInput {
|
||||
ApprovalInput({required this.transactionId, this.note});
|
||||
|
||||
final int transactionId;
|
||||
final String? note;
|
||||
|
||||
Map<String, dynamic> toPayload() {
|
||||
return {'transaction_id': transactionId, 'note': note};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 단계 행위 입력 모델
|
||||
///
|
||||
/// - `POST /approval-steps/{id}/actions` 요청 바디를 구성한다.
|
||||
/// - `note`는 비고가 있을 때만 포함한다.
|
||||
class ApprovalStepActionInput {
|
||||
ApprovalStepActionInput({
|
||||
required this.stepId,
|
||||
required this.actionId,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int stepId;
|
||||
final int actionId;
|
||||
final String? note;
|
||||
|
||||
Map<String, dynamic> toPayload() {
|
||||
return {
|
||||
'id': stepId,
|
||||
'approval_action_id': actionId,
|
||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 단계를 일괄 등록/재배치하기 위한 입력 모델
|
||||
class ApprovalStepAssignmentInput {
|
||||
ApprovalStepAssignmentInput({
|
||||
required this.approvalId,
|
||||
required this.steps,
|
||||
});
|
||||
|
||||
final int approvalId;
|
||||
final List<ApprovalStepAssignmentItem> steps;
|
||||
|
||||
Map<String, dynamic> toPayload() {
|
||||
return {
|
||||
'id': approvalId,
|
||||
'steps': steps.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ApprovalStepAssignmentItem {
|
||||
ApprovalStepAssignmentItem({
|
||||
required this.stepOrder,
|
||||
required this.approverId,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int stepOrder;
|
||||
final int approverId;
|
||||
final String? note;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'step_order': stepOrder,
|
||||
'approver_id': approverId,
|
||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/// 결재 템플릿 엔티티
|
||||
///
|
||||
/// - 반복되는 결재 단계를 사전에 정의해두고 요청 시 불러온다.
|
||||
class ApprovalTemplate {
|
||||
ApprovalTemplate({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
this.description,
|
||||
required this.isActive,
|
||||
this.createdBy,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.steps = const [],
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
final String? description;
|
||||
final bool isActive;
|
||||
final ApprovalTemplateAuthor? createdBy;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final List<ApprovalTemplateStep> steps;
|
||||
}
|
||||
|
||||
class ApprovalTemplateAuthor {
|
||||
ApprovalTemplateAuthor({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class ApprovalTemplateStep {
|
||||
ApprovalTemplateStep({
|
||||
this.id,
|
||||
required this.stepOrder,
|
||||
required this.approver,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final int stepOrder;
|
||||
final ApprovalTemplateApprover approver;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
class ApprovalTemplateApprover {
|
||||
ApprovalTemplateApprover({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import '../entities/approval.dart';
|
||||
|
||||
abstract class ApprovalRepository {
|
||||
Future<PaginatedResult<Approval>> list({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? query,
|
||||
String? status,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
bool includeHistories = false,
|
||||
bool includeSteps = false,
|
||||
});
|
||||
|
||||
Future<Approval> fetchDetail(
|
||||
int id, {
|
||||
bool includeSteps = true,
|
||||
bool includeHistories = true,
|
||||
});
|
||||
|
||||
/// 활성화된 결재 행위(approve/reject/comment 등) 목록 조회
|
||||
Future<List<ApprovalAction>> listActions({bool activeOnly = true});
|
||||
|
||||
/// 결재 단계에 행위를 적용하고 최신 결재 정보를 반환
|
||||
Future<Approval> performStepAction(ApprovalStepActionInput input);
|
||||
|
||||
/// 결재 단계 일괄 생성/재배치
|
||||
Future<Approval> assignSteps(ApprovalStepAssignmentInput input);
|
||||
|
||||
Future<Approval> create(ApprovalInput input);
|
||||
|
||||
Future<Approval> update(int id, ApprovalInput input);
|
||||
|
||||
Future<void> delete(int id);
|
||||
|
||||
Future<Approval> restore(int id);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import '../entities/approval_template.dart';
|
||||
|
||||
abstract class ApprovalTemplateRepository {
|
||||
Future<List<ApprovalTemplate>> list({bool activeOnly = true});
|
||||
|
||||
Future<ApprovalTemplate> fetchDetail(int id, {bool includeSteps = true});
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../core/constants/app_sections.dart';
|
||||
import '../../../../../widgets/app_layout.dart';
|
||||
import '../../../../../widgets/components/coming_soon_card.dart';
|
||||
import '../../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
|
||||
class ApprovalHistoryPage extends StatelessWidget {
|
||||
@@ -7,41 +12,62 @@ class ApprovalHistoryPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SpecPage(
|
||||
title: '결재 이력 조회',
|
||||
summary: '결재 단계별 변경 이력을 조회합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '조회 테이블',
|
||||
description: '수정 없이 이력 리스트만 제공.',
|
||||
table: SpecTable(
|
||||
columns: [
|
||||
'번호',
|
||||
'결재ID',
|
||||
'단계ID',
|
||||
'승인자',
|
||||
'행위',
|
||||
'변경전상태',
|
||||
'변경후상태',
|
||||
'작업일시',
|
||||
'비고',
|
||||
],
|
||||
rows: [
|
||||
[
|
||||
'1',
|
||||
'APP-20240301-001',
|
||||
'STEP-1',
|
||||
'최관리',
|
||||
'승인',
|
||||
'승인대기',
|
||||
'승인완료',
|
||||
'2024-03-01 10:30',
|
||||
'-',
|
||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||
if (!enabled) {
|
||||
return const SpecPage(
|
||||
title: '결재 이력 조회',
|
||||
summary: '결재 단계별 변경 이력을 조회합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '조회 테이블',
|
||||
description: '수정 없이 이력 리스트만 제공.',
|
||||
table: SpecTable(
|
||||
columns: [
|
||||
'번호',
|
||||
'결재ID',
|
||||
'단계ID',
|
||||
'승인자',
|
||||
'행위',
|
||||
'변경전상태',
|
||||
'변경후상태',
|
||||
'작업일시',
|
||||
'비고',
|
||||
],
|
||||
],
|
||||
rows: [
|
||||
[
|
||||
'1',
|
||||
'APP-20240301-001',
|
||||
'STEP-1',
|
||||
'최관리',
|
||||
'승인',
|
||||
'승인대기',
|
||||
'승인완료',
|
||||
'2024-03-01 10:30',
|
||||
'-',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return AppLayout(
|
||||
title: '결재 이력 조회',
|
||||
subtitle: '결재 단계별 변경 기록을 확인할 수 있도록 준비 중입니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '결재', path: '/approvals/history'),
|
||||
AppBreadcrumbItem(label: '결재 이력'),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
children: const [Text('이력 검색 조건은 API 사양 확정 후 제공될 예정입니다.')],
|
||||
),
|
||||
child: const ComingSoonCard(
|
||||
title: '결재 이력 화면 구현 준비 중',
|
||||
description: '결재 단계 로그 API와 연동해 조건 검색 및 엑셀 내보내기를 제공할 예정입니다.',
|
||||
items: ['결재번호/승인자/행위 유형별 필터', '기간·상태 조건 조합 검색', '다운로드(Excel/PDF) 기능'],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_template.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
import '../../domain/repositories/approval_template_repository.dart';
|
||||
|
||||
enum ApprovalStatusFilter {
|
||||
all,
|
||||
pending,
|
||||
inProgress,
|
||||
onHold,
|
||||
approved,
|
||||
rejected,
|
||||
}
|
||||
|
||||
typedef DateRange = ({DateTime from, DateTime to});
|
||||
|
||||
const Map<ApprovalStepActionType, List<String>> _actionAliases = {
|
||||
ApprovalStepActionType.approve: ['approve', '승인'],
|
||||
ApprovalStepActionType.reject: ['reject', '반려'],
|
||||
ApprovalStepActionType.comment: ['comment', '코멘트', '의견'],
|
||||
};
|
||||
|
||||
/// 결재 목록 및 상세 화면 상태 컨트롤러
|
||||
///
|
||||
/// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다.
|
||||
/// - 승인/반려 등의 후속 액션은 추후 구현 시 추가한다.
|
||||
class ApprovalController extends ChangeNotifier {
|
||||
ApprovalController({
|
||||
required ApprovalRepository approvalRepository,
|
||||
required ApprovalTemplateRepository templateRepository,
|
||||
}) : _repository = approvalRepository,
|
||||
_templateRepository = templateRepository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
final ApprovalTemplateRepository _templateRepository;
|
||||
|
||||
PaginatedResult<Approval>? _result;
|
||||
Approval? _selected;
|
||||
bool _isLoadingList = false;
|
||||
bool _isLoadingDetail = false;
|
||||
bool _isLoadingActions = false;
|
||||
bool _isPerformingAction = false;
|
||||
int? _processingStepId;
|
||||
bool _isLoadingTemplates = false;
|
||||
bool _isApplyingTemplate = false;
|
||||
int? _applyingTemplateId;
|
||||
String? _errorMessage;
|
||||
String _query = '';
|
||||
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
|
||||
DateTime? _fromDate;
|
||||
DateTime? _toDate;
|
||||
List<ApprovalAction> _actions = const [];
|
||||
List<ApprovalTemplate> _templates = const [];
|
||||
|
||||
PaginatedResult<Approval>? get result => _result;
|
||||
Approval? get selected => _selected;
|
||||
bool get isLoadingList => _isLoadingList;
|
||||
bool get isLoadingDetail => _isLoadingDetail;
|
||||
bool get isLoadingActions => _isLoadingActions;
|
||||
bool get isPerformingAction => _isPerformingAction;
|
||||
int? get processingStepId => _processingStepId;
|
||||
String? get errorMessage => _errorMessage;
|
||||
String get query => _query;
|
||||
ApprovalStatusFilter get statusFilter => _statusFilter;
|
||||
DateTime? get fromDate => _fromDate;
|
||||
DateTime? get toDate => _toDate;
|
||||
List<ApprovalAction> get actionOptions => _actions;
|
||||
bool get hasActionOptions => _actions.isNotEmpty;
|
||||
List<ApprovalTemplate> get templates => _templates;
|
||||
bool get isLoadingTemplates => _isLoadingTemplates;
|
||||
bool get isApplyingTemplate => _isApplyingTemplate;
|
||||
int? get applyingTemplateId => _applyingTemplateId;
|
||||
|
||||
Future<void> fetch({int page = 1}) async {
|
||||
_isLoadingList = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final statusParam = switch (_statusFilter) {
|
||||
ApprovalStatusFilter.all => null,
|
||||
ApprovalStatusFilter.pending => 'pending',
|
||||
ApprovalStatusFilter.inProgress => 'in_progress',
|
||||
ApprovalStatusFilter.onHold => 'on_hold',
|
||||
ApprovalStatusFilter.approved => 'approved',
|
||||
ApprovalStatusFilter.rejected => 'rejected',
|
||||
};
|
||||
final response = await _repository.list(
|
||||
page: page,
|
||||
pageSize: _result?.pageSize ?? 20,
|
||||
query: _query.isEmpty ? null : _query,
|
||||
status: statusParam,
|
||||
from: _fromDate,
|
||||
to: _toDate,
|
||||
includeSteps: false,
|
||||
includeHistories: false,
|
||||
);
|
||||
_result = response;
|
||||
if (_selected != null) {
|
||||
final exists = response.items.any((item) => item.id == _selected?.id);
|
||||
if (!exists) {
|
||||
_selected = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
_isLoadingList = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadActionOptions({bool force = false}) async {
|
||||
if (_actions.isNotEmpty && !force) {
|
||||
return;
|
||||
}
|
||||
_isLoadingActions = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final items = await _repository.listActions(activeOnly: true);
|
||||
_actions = items;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
_isLoadingActions = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadTemplates({bool force = false}) async {
|
||||
if (_templates.isNotEmpty && !force) {
|
||||
return;
|
||||
}
|
||||
_isLoadingTemplates = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final items = await _templateRepository.list(activeOnly: true);
|
||||
_templates = items;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
_isLoadingTemplates = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectApproval(int id) async {
|
||||
_isLoadingDetail = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final detail = await _repository.fetchDetail(
|
||||
id,
|
||||
includeSteps: true,
|
||||
includeHistories: true,
|
||||
);
|
||||
_selected = detail;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
} finally {
|
||||
_isLoadingDetail = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearSelection() {
|
||||
_selected = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> performStepAction({
|
||||
required ApprovalStep step,
|
||||
required ApprovalStepActionType type,
|
||||
String? note,
|
||||
}) async {
|
||||
if (step.id == null) {
|
||||
_errorMessage = '단계 식별자가 없어 행위를 수행할 수 없습니다.';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
final action = _findActionByType(type);
|
||||
if (action == null) {
|
||||
_errorMessage = '사용 가능한 결재 행위를 찾을 수 없습니다.';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
_isPerformingAction = true;
|
||||
_processingStepId = step.id;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final sanitizedNote = note?.trim();
|
||||
final updated = await _repository.performStepAction(
|
||||
ApprovalStepActionInput(
|
||||
stepId: step.id!,
|
||||
actionId: action.id,
|
||||
note: sanitizedNote?.isEmpty ?? true ? null : sanitizedNote,
|
||||
),
|
||||
);
|
||||
_selected = updated;
|
||||
if (_result != null && updated.id != null) {
|
||||
final items = _result!.items
|
||||
.map((item) => item.id == updated.id ? updated : item)
|
||||
.toList();
|
||||
_result = _result!.copyWith(items: items);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
return false;
|
||||
} finally {
|
||||
_isPerformingAction = false;
|
||||
_processingStepId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> applyTemplate(int templateId) async {
|
||||
final approvalId = _selected?.id;
|
||||
if (approvalId == null) {
|
||||
_errorMessage = '선택된 결재가 없어 템플릿을 적용할 수 없습니다.';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
_isApplyingTemplate = true;
|
||||
_applyingTemplateId = templateId;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final template = await _templateRepository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (template.steps.isEmpty) {
|
||||
_errorMessage = '선택한 템플릿에 등록된 단계가 없습니다.';
|
||||
return false;
|
||||
}
|
||||
|
||||
final sortedSteps = List.of(template.steps)
|
||||
..sort((a, b) => a.stepOrder.compareTo(b.stepOrder));
|
||||
final input = ApprovalStepAssignmentInput(
|
||||
approvalId: approvalId,
|
||||
steps: sortedSteps
|
||||
.map(
|
||||
(step) => ApprovalStepAssignmentItem(
|
||||
stepOrder: step.stepOrder,
|
||||
approverId: step.approver.id,
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
final updated = await _repository.assignSteps(input);
|
||||
_selected = updated;
|
||||
if (_result != null && updated.id != null) {
|
||||
final items = _result!.items
|
||||
.map((item) => item.id == updated.id ? updated : item)
|
||||
.toList();
|
||||
_result = _result!.copyWith(items: items);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
return false;
|
||||
} finally {
|
||||
_isApplyingTemplate = false;
|
||||
_applyingTemplateId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void updateQuery(String value) {
|
||||
_query = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateStatusFilter(ApprovalStatusFilter filter) {
|
||||
_statusFilter = filter;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateDateRange(DateTime? from, DateTime? to) {
|
||||
_fromDate = from;
|
||||
_toDate = to;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearFilters() {
|
||||
_query = '';
|
||||
_statusFilter = ApprovalStatusFilter.all;
|
||||
_fromDate = null;
|
||||
_toDate = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
ApprovalAction? _findActionByType(ApprovalStepActionType type) {
|
||||
final aliases = _actionAliases[type] ?? [type.code];
|
||||
for (final action in _actions) {
|
||||
final normalized = action.name.toLowerCase();
|
||||
for (final alias in aliases) {
|
||||
if (normalized == alias.toLowerCase()) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
1466
lib/features/approvals/presentation/pages/approval_page.dart
Normal file
1466
lib/features/approvals/presentation/pages/approval_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,59 +1,12 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
import '../../../presentation/pages/approval_page.dart';
|
||||
|
||||
class ApprovalRequestPage extends StatelessWidget {
|
||||
const ApprovalRequestPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SpecPage(
|
||||
title: '결재 관리',
|
||||
summary: '결재 번호와 상태, 상신자를 확인하고 결재 플로우를 제어합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '입력 폼',
|
||||
items: [
|
||||
'트랜잭션번호 [Dropdown]',
|
||||
'결재번호 [자동생성]',
|
||||
'결재상태 [Dropdown]',
|
||||
'상신자 [자동]',
|
||||
'비고 [Text]',
|
||||
],
|
||||
),
|
||||
SpecSection(
|
||||
title: '수정 폼',
|
||||
items: ['결재번호 [ReadOnly]', '상신자 [ReadOnly]', '요청일시 [ReadOnly]'],
|
||||
),
|
||||
SpecSection(
|
||||
title: '테이블 리스트',
|
||||
description: '1행 예시',
|
||||
table: SpecTable(
|
||||
columns: [
|
||||
'번호',
|
||||
'결재번호',
|
||||
'트랜잭션번호',
|
||||
'상태',
|
||||
'상신자',
|
||||
'요청일시',
|
||||
'최종결정일시',
|
||||
'비고',
|
||||
],
|
||||
rows: [
|
||||
[
|
||||
'1',
|
||||
'APP-20240301-001',
|
||||
'IN-20240301-001',
|
||||
'승인대기',
|
||||
'홍길동',
|
||||
'2024-03-01 09:00',
|
||||
'-',
|
||||
'-',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return const ApprovalPage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../core/constants/app_sections.dart';
|
||||
import '../../../../../widgets/app_layout.dart';
|
||||
import '../../../../../widgets/components/coming_soon_card.dart';
|
||||
import '../../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
|
||||
class ApprovalStepPage extends StatelessWidget {
|
||||
@@ -7,44 +13,81 @@ class ApprovalStepPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SpecPage(
|
||||
title: '결재 단계 관리',
|
||||
summary: '결재 단계 순서와 승인자를 구성합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '입력 폼',
|
||||
items: [
|
||||
'결재ID [Dropdown]',
|
||||
'단계순서 [Number]',
|
||||
'승인자 [Dropdown]',
|
||||
'단계상태 [Dropdown]',
|
||||
'비고 [Text]',
|
||||
],
|
||||
),
|
||||
SpecSection(
|
||||
title: '수정 폼',
|
||||
items: ['결재ID [ReadOnly]', '단계순서 [ReadOnly]'],
|
||||
),
|
||||
SpecSection(
|
||||
title: '테이블 리스트',
|
||||
description: '1행 예시',
|
||||
table: SpecTable(
|
||||
columns: ['번호', '결재ID', '단계순서', '승인자', '상태', '배정일시', '결정일시', '비고'],
|
||||
rows: [
|
||||
[
|
||||
'1',
|
||||
'APP-20240301-001',
|
||||
'1',
|
||||
'최관리',
|
||||
'승인대기',
|
||||
'2024-03-01 09:00',
|
||||
'-',
|
||||
'-',
|
||||
],
|
||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||
if (!enabled) {
|
||||
return const SpecPage(
|
||||
title: '결재 단계 관리',
|
||||
summary: '결재 단계 순서와 승인자를 구성합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '입력 폼',
|
||||
items: [
|
||||
'결재ID [Dropdown]',
|
||||
'단계순서 [Number]',
|
||||
'승인자 [Dropdown]',
|
||||
'단계상태 [Dropdown]',
|
||||
'비고 [Text]',
|
||||
],
|
||||
),
|
||||
SpecSection(
|
||||
title: '수정 폼',
|
||||
items: ['결재ID [ReadOnly]', '단계순서 [ReadOnly]'],
|
||||
),
|
||||
SpecSection(
|
||||
title: '테이블 리스트',
|
||||
description: '1행 예시',
|
||||
table: SpecTable(
|
||||
columns: [
|
||||
'번호',
|
||||
'결재ID',
|
||||
'단계순서',
|
||||
'승인자',
|
||||
'상태',
|
||||
'배정일시',
|
||||
'결정일시',
|
||||
'비고',
|
||||
],
|
||||
rows: [
|
||||
[
|
||||
'1',
|
||||
'APP-20240301-001',
|
||||
'1',
|
||||
'최관리',
|
||||
'승인대기',
|
||||
'2024-03-01 09:00',
|
||||
'-',
|
||||
'-',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return AppLayout(
|
||||
title: '결재 단계 관리',
|
||||
subtitle: '결재 순서를 정의하고 승인자를 배정할 수 있도록 준비 중입니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '결재', path: '/approvals/steps'),
|
||||
AppBreadcrumbItem(label: '결재 단계'),
|
||||
],
|
||||
actions: [
|
||||
ShadButton(
|
||||
onPressed: null,
|
||||
leading: const Icon(LucideIcons.plus, size: 16),
|
||||
child: const Text('단계 추가'),
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
children: const [Text('필터 구성은 결재 단계 API 확정 후 제공될 예정입니다.')],
|
||||
),
|
||||
child: const ComingSoonCard(
|
||||
title: '결재 단계 화면 구현 준비 중',
|
||||
description: '결재 단계 CRUD와 템플릿 연동 요구사항을 정리하는 중입니다.',
|
||||
items: ['결재 요청별 단계 조회 및 정렬', '승인자 지정과 단계 상태 변경', '템플릿에서 단계 일괄 불러오기'],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../core/constants/app_sections.dart';
|
||||
import '../../../../../widgets/app_layout.dart';
|
||||
import '../../../../../widgets/components/coming_soon_card.dart';
|
||||
import '../../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
|
||||
class ApprovalTemplatePage extends StatelessWidget {
|
||||
@@ -7,45 +13,73 @@ class ApprovalTemplatePage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SpecPage(
|
||||
title: '결재 템플릿 관리',
|
||||
summary: '반복적인 결재 흐름을 템플릿으로 정의합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '입력 폼',
|
||||
items: [
|
||||
'템플릿코드 [Text]',
|
||||
'템플릿명 [Text]',
|
||||
'설명 [Text]',
|
||||
'작성자 [ReadOnly]',
|
||||
'사용여부 [Switch]',
|
||||
'비고 [Text]',
|
||||
'단계 추가: 순서 [Number], 승인자 [Dropdown]',
|
||||
],
|
||||
),
|
||||
SpecSection(
|
||||
title: '수정 폼',
|
||||
items: ['템플릿코드 [ReadOnly]', '작성자 [ReadOnly]'],
|
||||
),
|
||||
SpecSection(
|
||||
title: '테이블 리스트',
|
||||
description: '1행 예시',
|
||||
table: SpecTable(
|
||||
columns: ['번호', '템플릿코드', '템플릿명', '설명', '작성자', '사용여부', '변경일시'],
|
||||
rows: [
|
||||
[
|
||||
'1',
|
||||
'TEMP-001',
|
||||
'입고 기본 결재',
|
||||
'입고 처리 2단계 결재',
|
||||
'홍길동',
|
||||
'Y',
|
||||
'2024-03-01 10:00',
|
||||
],
|
||||
final enabled = Environment.flag('FEATURE_APPROVALS_ENABLED');
|
||||
if (!enabled) {
|
||||
return const SpecPage(
|
||||
title: '결재 템플릿 관리',
|
||||
summary: '반복적인 결재 흐름을 템플릿으로 정의합니다.',
|
||||
sections: [
|
||||
SpecSection(
|
||||
title: '입력 폼',
|
||||
items: [
|
||||
'템플릿코드 [Text]',
|
||||
'템플릿명 [Text]',
|
||||
'설명 [Text]',
|
||||
'작성자 [ReadOnly]',
|
||||
'사용여부 [Switch]',
|
||||
'비고 [Text]',
|
||||
'단계 추가: 순서 [Number], 승인자 [Dropdown]',
|
||||
],
|
||||
),
|
||||
SpecSection(
|
||||
title: '수정 폼',
|
||||
items: ['템플릿코드 [ReadOnly]', '작성자 [ReadOnly]'],
|
||||
),
|
||||
SpecSection(
|
||||
title: '테이블 리스트',
|
||||
description: '1행 예시',
|
||||
table: SpecTable(
|
||||
columns: ['번호', '템플릿코드', '템플릿명', '설명', '작성자', '사용여부', '변경일시'],
|
||||
rows: [
|
||||
[
|
||||
'1',
|
||||
'TEMP-001',
|
||||
'입고 기본 결재',
|
||||
'입고 처리 2단계 결재',
|
||||
'홍길동',
|
||||
'Y',
|
||||
'2024-03-01 10:00',
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return AppLayout(
|
||||
title: '결재 템플릿 관리',
|
||||
subtitle: '반복되는 결재 단계를 템플릿으로 구성할 수 있도록 준비 중입니다.',
|
||||
breadcrumbs: const [
|
||||
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
|
||||
AppBreadcrumbItem(label: '결재', path: '/approvals/templates'),
|
||||
AppBreadcrumbItem(label: '결재 템플릿'),
|
||||
],
|
||||
actions: [
|
||||
ShadButton(
|
||||
onPressed: null,
|
||||
leading: const Icon(LucideIcons.plus, size: 16),
|
||||
child: const Text('템플릿 생성'),
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
children: const [Text('템플릿 검색/필터 UI는 결재 요구사항 확정 후 제공됩니다.')],
|
||||
),
|
||||
child: const ComingSoonCard(
|
||||
title: '결재 템플릿 화면 구현 준비 중',
|
||||
description: '템플릿 헤더 정보와 단계 반복 입력을 다루는 UI를 설계하고 있습니다.',
|
||||
items: ['템플릿 목록 정렬 및 사용여부 토글', '단계 편집/추가/삭제 인터랙션', '템플릿 버전 관리 및 배포 전략'],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user