결재 템플릿 단계 적용 구현

- 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,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;
}

View 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;
}

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);
}
}

View 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,
};
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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});
}

View File

@@ -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) 기능'],
),
);
}
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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();
}
}

View File

@@ -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: ['결재 요청별 단계 조회 및 정렬', '승인자 지정과 단계 상태 변경', '템플릿에서 단계 일괄 불러오기'],
),
);
}
}

View File

@@ -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: ['템플릿 목록 정렬 및 사용여부 토글', '단계 편집/추가/삭제 인터랙션', '템플릿 버전 관리 및 배포 전략'],
),
);
}
}