feat(approvals): Approval Flow v2 프런트엔드 전면 개편
- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**) - ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화 - ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원 - Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영 - Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신 - SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리 - 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용 - Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가 - 실행: flutter analyze, flutter test
This commit is contained in:
156
lib/features/approvals/data/dtos/approval_audit_dto.dart
Normal file
156
lib/features/approvals/data/dtos/approval_audit_dto.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||
|
||||
import '../../domain/entities/approval.dart';
|
||||
import 'approval_step_dto.dart';
|
||||
|
||||
/// 결재 감사 로그(Audit) DTO.
|
||||
class ApprovalAuditDto {
|
||||
ApprovalAuditDto({
|
||||
this.id,
|
||||
required this.action,
|
||||
this.fromStatus,
|
||||
required this.toStatus,
|
||||
required this.actor,
|
||||
required this.actionAt,
|
||||
this.note,
|
||||
this.actionCode,
|
||||
this.payload,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final ApprovalActionDto action;
|
||||
final ApprovalStatusDto? fromStatus;
|
||||
final ApprovalStatusDto toStatus;
|
||||
final ApprovalApproverDto actor;
|
||||
final DateTime actionAt;
|
||||
final String? note;
|
||||
final String? actionCode;
|
||||
final Map<String, dynamic>? payload;
|
||||
|
||||
factory ApprovalAuditDto.fromJson(Map<String, dynamic> json) {
|
||||
final actionMap = {
|
||||
...?_asMap(json['action']),
|
||||
...?_asMap(json['approval_action']),
|
||||
};
|
||||
final fallbackActionId = json['approval_action_id'] ?? json['action_id'];
|
||||
if (fallbackActionId != null) {
|
||||
actionMap.putIfAbsent('id', () => fallbackActionId);
|
||||
}
|
||||
final fallbackActionName = _firstNonEmpty(<String?>[
|
||||
_readString(json, 'action_name'),
|
||||
_readString(json, 'approval_action_name'),
|
||||
]);
|
||||
if (fallbackActionName != null) {
|
||||
actionMap.putIfAbsent('name', () => fallbackActionName);
|
||||
}
|
||||
final rootActionCode = _firstNonEmpty(<String?>[
|
||||
_readString(json, 'action_code'),
|
||||
_readString(json, 'approval_action_code'),
|
||||
]);
|
||||
if (rootActionCode != null) {
|
||||
actionMap.putIfAbsent('code', () => rootActionCode);
|
||||
actionMap.putIfAbsent('action_code', () => rootActionCode);
|
||||
}
|
||||
final actionDto = ApprovalActionDto.fromJson(actionMap);
|
||||
final fromStatusMap = _asMap(json['from_status']);
|
||||
final toStatusMap = _asMap(json['to_status']) ?? const <String, dynamic>{};
|
||||
final actorMap =
|
||||
_asMap(json['actor']) ??
|
||||
_asMap(json['approver']) ??
|
||||
const <String, dynamic>{};
|
||||
final resolvedActionCode = rootActionCode ?? actionDto.code;
|
||||
|
||||
return ApprovalAuditDto(
|
||||
id: JsonUtils.readInt(json, 'id'),
|
||||
action: actionDto,
|
||||
fromStatus: fromStatusMap == null
|
||||
? null
|
||||
: ApprovalStatusDto.fromJson(fromStatusMap),
|
||||
toStatus: ApprovalStatusDto.fromJson(toStatusMap),
|
||||
actor: ApprovalApproverDto.fromJson(actorMap),
|
||||
actionAt: _parseDate(json['action_at']) ?? DateTime.now(),
|
||||
note: _readString(json, 'note'),
|
||||
actionCode: resolvedActionCode,
|
||||
payload: _asMap(
|
||||
json['payload'],
|
||||
)?.map((key, value) => MapEntry(key, value)),
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalHistory toEntity() => ApprovalHistory(
|
||||
id: id,
|
||||
action: action.toEntity(),
|
||||
fromStatus: fromStatus?.toEntity(),
|
||||
toStatus: toStatus.toEntity(),
|
||||
approver: actor.toEntity(),
|
||||
actionAt: actionAt,
|
||||
note: note,
|
||||
actionCode: actionCode,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
/// 결재 감사 로그 액션 DTO.
|
||||
class ApprovalActionDto {
|
||||
ApprovalActionDto({required this.id, required this.name, this.code});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? code;
|
||||
|
||||
factory ApprovalActionDto.fromJson(Map<String, dynamic> json) {
|
||||
if (json['action'] is Map<String, dynamic>) {
|
||||
return ApprovalActionDto.fromJson(json['action'] as Map<String, dynamic>);
|
||||
}
|
||||
final id = JsonUtils.readInt(json, 'id', fallback: 0);
|
||||
final name = _firstNonEmpty(<String?>[
|
||||
_readString(json, 'name'),
|
||||
_readString(json, 'action_name'),
|
||||
]);
|
||||
if (name == null) {
|
||||
throw const FormatException('결재 감사 로그 액션 이름이 누락되었습니다.');
|
||||
}
|
||||
final code = _firstNonEmpty(<String?>[
|
||||
_readString(json, 'code'),
|
||||
_readString(json, 'action_code'),
|
||||
]);
|
||||
return ApprovalActionDto(id: id, name: name, code: code);
|
||||
}
|
||||
|
||||
ApprovalAction toEntity() => ApprovalAction(id: id, name: name, code: code);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _asMap(dynamic value) =>
|
||||
value is Map<String, dynamic> ? value : null;
|
||||
|
||||
String? _readString(
|
||||
Map<String, dynamic>? source,
|
||||
String key, {
|
||||
String? fallback,
|
||||
}) {
|
||||
if (source == null) return fallback;
|
||||
final value = source[key];
|
||||
if (value is String) return value;
|
||||
if (value == null) return fallback;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
String? _firstNonEmpty(Iterable<String?> values) {
|
||||
for (final value in values) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
268
lib/features/approvals/data/dtos/approval_draft_dto.dart
Normal file
268
lib/features/approvals/data/dtos/approval_draft_dto.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||
|
||||
import '../../domain/entities/approval_draft.dart';
|
||||
|
||||
/// 결재 초안 단계 DTO.
|
||||
class ApprovalDraftStepDto {
|
||||
ApprovalDraftStepDto({
|
||||
required this.stepOrder,
|
||||
required this.approverId,
|
||||
this.approverRole,
|
||||
this.note,
|
||||
this.isOptional = false,
|
||||
});
|
||||
|
||||
final int stepOrder;
|
||||
final int approverId;
|
||||
final String? approverRole;
|
||||
final String? note;
|
||||
final bool isOptional;
|
||||
|
||||
factory ApprovalDraftStepDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalDraftStepDto(
|
||||
stepOrder: JsonUtils.readInt(json, 'step_order', fallback: 0),
|
||||
approverId: JsonUtils.readInt(json, 'approver_id', fallback: 0),
|
||||
approverRole: _readString(json['approver_role']),
|
||||
note: _readString(json['note']),
|
||||
isOptional: json['is_optional'] is bool
|
||||
? json['is_optional'] as bool
|
||||
: false,
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalDraftStep toEntity() => ApprovalDraftStep(
|
||||
stepOrder: stepOrder,
|
||||
approverId: approverId,
|
||||
approverRole: approverRole,
|
||||
note: note,
|
||||
isOptional: isOptional,
|
||||
);
|
||||
}
|
||||
|
||||
/// 결재 초안 페이로드 DTO.
|
||||
class ApprovalDraftPayloadDto {
|
||||
ApprovalDraftPayloadDto({
|
||||
this.title,
|
||||
this.summary,
|
||||
this.note,
|
||||
this.templateId,
|
||||
this.metadata,
|
||||
this.steps = const [],
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final String? note;
|
||||
final int? templateId;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final List<ApprovalDraftStepDto> steps;
|
||||
|
||||
factory ApprovalDraftPayloadDto.fromJson(Map<String, dynamic> json) {
|
||||
final steps = (json['steps'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ApprovalDraftStepDto.fromJson)
|
||||
.toList(growable: false);
|
||||
final metadata = json['metadata'] is Map<String, dynamic>
|
||||
? Map<String, dynamic>.from(json['metadata'] as Map)
|
||||
: null;
|
||||
return ApprovalDraftPayloadDto(
|
||||
title: _readString(json['title']),
|
||||
summary: _readString(json['summary']),
|
||||
note: _readString(json['note']),
|
||||
templateId: json['template_id'] as int?,
|
||||
metadata: metadata,
|
||||
steps: steps,
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalDraftPayload toEntity() => ApprovalDraftPayload(
|
||||
title: title,
|
||||
summary: summary,
|
||||
note: note,
|
||||
templateId: templateId,
|
||||
metadata: metadata,
|
||||
steps: steps.map((step) => step.toEntity()).toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
/// 결재 초안 요약 DTO.
|
||||
class ApprovalDraftSummaryDto {
|
||||
ApprovalDraftSummaryDto({
|
||||
required this.id,
|
||||
required this.requesterId,
|
||||
required this.status,
|
||||
required this.savedAt,
|
||||
this.requestId,
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
this.title,
|
||||
this.summary,
|
||||
this.expiresAt,
|
||||
this.sessionKey,
|
||||
this.stepCount = 0,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int requesterId;
|
||||
final ApprovalDraftStatus status;
|
||||
final DateTime savedAt;
|
||||
final int? requestId;
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final DateTime? expiresAt;
|
||||
final String? sessionKey;
|
||||
final int stepCount;
|
||||
|
||||
factory ApprovalDraftSummaryDto.fromJson(Map<String, dynamic> json) {
|
||||
final savedAtRaw = json['saved_at'];
|
||||
final expiresAtRaw = json['expires_at'];
|
||||
return ApprovalDraftSummaryDto(
|
||||
id: JsonUtils.readInt(json, 'id', fallback: 0),
|
||||
requesterId: JsonUtils.readInt(json, 'requester_id', fallback: 0),
|
||||
status: _parseStatus(_readString(json['status'])),
|
||||
savedAt: _parseDate(savedAtRaw) ?? DateTime.now().toUtc(),
|
||||
requestId: json['request_id'] as int?,
|
||||
transactionId: json['transaction_id'] as int?,
|
||||
templateId: json['template_id'] as int?,
|
||||
title: _readString(json['title']),
|
||||
summary: _readString(json['summary']),
|
||||
expiresAt: _parseDate(expiresAtRaw),
|
||||
sessionKey: _readString(json['session_key']),
|
||||
stepCount: JsonUtils.readInt(json, 'step_count', fallback: 0),
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalDraftSummary toEntity() => ApprovalDraftSummary(
|
||||
id: id,
|
||||
requesterId: requesterId,
|
||||
status: status,
|
||||
savedAt: savedAt,
|
||||
requestId: requestId,
|
||||
transactionId: transactionId,
|
||||
templateId: templateId,
|
||||
title: title,
|
||||
summary: summary,
|
||||
expiresAt: expiresAt,
|
||||
sessionKey: sessionKey,
|
||||
stepCount: stepCount,
|
||||
);
|
||||
}
|
||||
|
||||
/// 결재 초안 상세 DTO.
|
||||
class ApprovalDraftDetailDto {
|
||||
ApprovalDraftDetailDto({
|
||||
required this.id,
|
||||
required this.requesterId,
|
||||
required this.payload,
|
||||
required this.savedAt,
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
this.expiresAt,
|
||||
this.sessionKey,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int requesterId;
|
||||
final ApprovalDraftPayloadDto payload;
|
||||
final DateTime savedAt;
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final DateTime? expiresAt;
|
||||
final String? sessionKey;
|
||||
|
||||
factory ApprovalDraftDetailDto.fromJson(Map<String, dynamic> json) {
|
||||
final payloadMap = json['payload'] is Map<String, dynamic>
|
||||
? json['payload'] as Map<String, dynamic>
|
||||
: const <String, dynamic>{};
|
||||
return ApprovalDraftDetailDto(
|
||||
id: JsonUtils.readInt(json, 'id', fallback: 0),
|
||||
requesterId: JsonUtils.readInt(json, 'requester_id', fallback: 0),
|
||||
payload: ApprovalDraftPayloadDto.fromJson(payloadMap),
|
||||
savedAt: _parseDate(json['saved_at']) ?? DateTime.now().toUtc(),
|
||||
transactionId: json['transaction_id'] as int?,
|
||||
templateId: json['template_id'] as int?,
|
||||
expiresAt: _parseDate(json['expires_at']),
|
||||
sessionKey: _readString(json['session_key']),
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalDraftDetail toEntity() => ApprovalDraftDetail(
|
||||
id: id,
|
||||
requesterId: requesterId,
|
||||
payload: payload.toEntity(),
|
||||
savedAt: savedAt,
|
||||
transactionId: transactionId,
|
||||
templateId: templateId,
|
||||
expiresAt: expiresAt,
|
||||
sessionKey: sessionKey,
|
||||
);
|
||||
}
|
||||
|
||||
class ApprovalDraftDto {
|
||||
ApprovalDraftDto._();
|
||||
|
||||
static PaginatedResult<ApprovalDraftSummary> parsePaginated(
|
||||
Map<String, dynamic>? json,
|
||||
) {
|
||||
final items = JsonUtils.extractList(json)
|
||||
.map(ApprovalDraftSummaryDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList(growable: false);
|
||||
return PaginatedResult<ApprovalDraftSummary>(
|
||||
items: items,
|
||||
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||
);
|
||||
}
|
||||
|
||||
static ApprovalDraftDetail? parseDetail(Map<String, dynamic>? json) {
|
||||
if (json == null || json.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final map = JsonUtils.extractMap(json);
|
||||
if (map.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return ApprovalDraftDetailDto.fromJson(map).toEntity();
|
||||
}
|
||||
}
|
||||
|
||||
String? _readString(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is String) {
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
DateTime? _parseDate(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is DateTime) {
|
||||
return value.toUtc();
|
||||
}
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return DateTime.tryParse(value)?.toUtc();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ApprovalDraftStatus _parseStatus(String? value) {
|
||||
switch (value) {
|
||||
case 'expired':
|
||||
return ApprovalDraftStatus.expired;
|
||||
case 'archived':
|
||||
return ApprovalDraftStatus.archived;
|
||||
case 'active':
|
||||
default:
|
||||
return ApprovalDraftStatus.active;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||
|
||||
import '../../domain/entities/approval.dart';
|
||||
import 'approval_audit_dto.dart';
|
||||
import 'approval_step_dto.dart';
|
||||
|
||||
/// 결재 API 응답을 표현하는 DTO.
|
||||
///
|
||||
@@ -11,7 +13,9 @@ class ApprovalDto {
|
||||
ApprovalDto({
|
||||
this.id,
|
||||
required this.approvalNo,
|
||||
this.transactionId,
|
||||
this.transactionNo,
|
||||
this.transactionUpdatedAt,
|
||||
required this.status,
|
||||
this.currentStep,
|
||||
required this.requester,
|
||||
@@ -28,7 +32,9 @@ class ApprovalDto {
|
||||
|
||||
final int? id;
|
||||
final String approvalNo;
|
||||
final int? transactionId;
|
||||
final String? transactionNo;
|
||||
final DateTime? transactionUpdatedAt;
|
||||
final ApprovalStatusDto status;
|
||||
final ApprovalStepDto? currentStep;
|
||||
final ApprovalRequesterDto requester;
|
||||
@@ -38,7 +44,7 @@ class ApprovalDto {
|
||||
final bool isActive;
|
||||
final bool isDeleted;
|
||||
final List<ApprovalStepDto> steps;
|
||||
final List<ApprovalHistoryDto> histories;
|
||||
final List<ApprovalAuditDto> histories;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
@@ -51,7 +57,7 @@ class ApprovalDto {
|
||||
approvalEnvelope['status'],
|
||||
approvalEnvelope['approval_status'],
|
||||
]);
|
||||
final requesterMap = _firstNonEmptyMap([
|
||||
final rawRequesterMap = _firstNonEmptyMap([
|
||||
json['requester'],
|
||||
json['requested_by'],
|
||||
approvalEnvelope['requester'],
|
||||
@@ -86,14 +92,29 @@ class ApprovalDto {
|
||||
[json, transactionMap, approvalEnvelope, envelopeTransactionMap],
|
||||
const ['transaction_no', 'transactionNo'],
|
||||
);
|
||||
final transactionId =
|
||||
json['transaction_id'] as int? ??
|
||||
approvalEnvelope['transaction_id'] as int? ??
|
||||
transactionMap['id'] as int? ??
|
||||
envelopeTransactionMap['id'] as int?;
|
||||
final transactionUpdatedAt = _parseDate(
|
||||
transactionMap['updated_at'] ??
|
||||
envelopeTransactionMap['updated_at'] ??
|
||||
json['transaction_updated_at'] ??
|
||||
approvalEnvelope['transaction_updated_at'],
|
||||
);
|
||||
|
||||
return ApprovalDto(
|
||||
id: json['id'] as int? ?? approvalEnvelope['id'] as int?,
|
||||
approvalNo: approvalNo,
|
||||
transactionId: transactionId,
|
||||
transactionNo: transactionNo,
|
||||
transactionUpdatedAt: transactionUpdatedAt,
|
||||
status: ApprovalStatusDto.fromJson(statusMap),
|
||||
currentStep: currentStepDto,
|
||||
requester: ApprovalRequesterDto.fromJson(requesterMap),
|
||||
requester: ApprovalRequesterDto.fromJson(
|
||||
_resolveRequesterMap(json, approvalEnvelope, rawRequesterMap),
|
||||
),
|
||||
requestedAt:
|
||||
_parseDate(
|
||||
json['requested_at'] ?? approvalEnvelope['requested_at'],
|
||||
@@ -113,7 +134,7 @@ class ApprovalDto {
|
||||
false,
|
||||
steps: stepsSource.map(ApprovalStepDto.fromJson).toList(growable: false),
|
||||
histories: historiesSource
|
||||
.map(ApprovalHistoryDto.fromJson)
|
||||
.map(ApprovalAuditDto.fromJson)
|
||||
.toList(growable: false),
|
||||
createdAt: _parseDate(
|
||||
json['created_at'] ?? approvalEnvelope['created_at'],
|
||||
@@ -128,7 +149,9 @@ class ApprovalDto {
|
||||
Approval toEntity() => Approval(
|
||||
id: id,
|
||||
approvalNo: approvalNo,
|
||||
transactionId: transactionId,
|
||||
transactionNo: transactionNo ?? '-',
|
||||
transactionUpdatedAt: transactionUpdatedAt,
|
||||
status: status.toEntity(),
|
||||
currentStep: currentStep?.toEntity(),
|
||||
requester: requester.toEntity(),
|
||||
@@ -159,38 +182,6 @@ class ApprovalDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 상태(Status) DTO.
|
||||
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) {
|
||||
if (json['status'] is Map<String, dynamic>) {
|
||||
return ApprovalStatusDto.fromJson(json['status'] as Map<String, dynamic>);
|
||||
}
|
||||
return ApprovalStatusDto(
|
||||
id:
|
||||
json['id'] as int? ??
|
||||
json['status_id'] as int? ??
|
||||
json['approval_status_id'] as int? ??
|
||||
0,
|
||||
name:
|
||||
_readString(json['name']) ??
|
||||
_readString(json['status_name']) ??
|
||||
_readString(json['approval_status_name']) ??
|
||||
_readString(json['status']) ??
|
||||
'-',
|
||||
color: _readString(json['color']),
|
||||
);
|
||||
}
|
||||
|
||||
/// DTO를 [ApprovalStatus]로 변환한다.
|
||||
ApprovalStatus toEntity() => ApprovalStatus(id: id, name: name, color: color);
|
||||
}
|
||||
|
||||
/// 결재 요청자 DTO.
|
||||
class ApprovalRequesterDto {
|
||||
ApprovalRequesterDto({
|
||||
@@ -205,8 +196,11 @@ class ApprovalRequesterDto {
|
||||
|
||||
factory ApprovalRequesterDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalRequesterDto(
|
||||
id: json['id'] as int? ?? json['employee_id'] as int? ?? 0,
|
||||
employeeNo: _readString(json['employee_no']) ?? '-',
|
||||
id: JsonUtils.readInt(json, 'id', fallback: 0),
|
||||
employeeNo:
|
||||
_readString(json['employee_no']) ??
|
||||
_readString(json['employee_id']) ??
|
||||
'-',
|
||||
name:
|
||||
_readString(json['name']) ??
|
||||
_readString(json['employee_name']) ??
|
||||
@@ -219,194 +213,6 @@ class ApprovalRequesterDto {
|
||||
ApprovalRequester(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
|
||||
/// 결재 승인자 DTO.
|
||||
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: _readString(json['employee_no']) ?? '-',
|
||||
name:
|
||||
_readString(json['name']) ??
|
||||
_readString(json['employee_name']) ??
|
||||
'-',
|
||||
);
|
||||
}
|
||||
|
||||
/// DTO를 [ApprovalApprover]로 변환한다.
|
||||
ApprovalApprover toEntity() =>
|
||||
ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
|
||||
/// 결재 단계 DTO.
|
||||
class ApprovalStepDto {
|
||||
ApprovalStepDto({
|
||||
this.id,
|
||||
required this.stepOrder,
|
||||
required this.approver,
|
||||
required this.status,
|
||||
required this.assignedAt,
|
||||
this.decidedAt,
|
||||
this.note,
|
||||
this.isDeleted = false,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final int stepOrder;
|
||||
final ApprovalApproverDto approver;
|
||||
final ApprovalStatusDto status;
|
||||
final DateTime assignedAt;
|
||||
final DateTime? decidedAt;
|
||||
final String? note;
|
||||
final bool isDeleted;
|
||||
|
||||
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>? ??
|
||||
json['step_status'] as Map<String, dynamic>? ??
|
||||
json['approval_status'] as Map<String, dynamic>? ??
|
||||
const {}),
|
||||
),
|
||||
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
|
||||
decidedAt: _parseDate(json['decided_at']),
|
||||
note: _readString(json['note']),
|
||||
isDeleted:
|
||||
json['is_deleted'] as bool? ??
|
||||
(json['deleted_at'] != null ||
|
||||
(json['is_active'] is bool && !(json['is_active'] as bool))),
|
||||
);
|
||||
}
|
||||
|
||||
/// DTO를 [ApprovalStep]으로 변환한다.
|
||||
ApprovalStep toEntity() => ApprovalStep(
|
||||
id: id,
|
||||
stepOrder: stepOrder,
|
||||
approver: approver.toEntity(),
|
||||
status: status.toEntity(),
|
||||
assignedAt: assignedAt,
|
||||
decidedAt: decidedAt,
|
||||
note: note,
|
||||
isDeleted: isDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
/// 결재 이력 DTO.
|
||||
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) {
|
||||
final actionMap = _firstNonEmptyMap([
|
||||
json['action'],
|
||||
json['approval_action'],
|
||||
json['step_action'],
|
||||
]);
|
||||
final fromStatusMap = _firstNonEmptyMap([
|
||||
json['from_status'],
|
||||
json['fromStatus'],
|
||||
]);
|
||||
final toStatusMap = _firstNonEmptyMap([
|
||||
json['to_status'],
|
||||
json['toStatus'],
|
||||
]);
|
||||
final approverMap = _firstNonEmptyMap([json['approver'], json['employee']]);
|
||||
final fallbackAction = {
|
||||
'id': json['approval_action_id'] ?? json['action_id'],
|
||||
'name':
|
||||
_readString(json['approval_action_name']) ??
|
||||
_readString(json['action_name']) ??
|
||||
_readString(json['action']) ??
|
||||
'-',
|
||||
};
|
||||
|
||||
return ApprovalHistoryDto(
|
||||
id: json['id'] as int?,
|
||||
action: ApprovalActionDto.fromJson(
|
||||
actionMap.isEmpty ? fallbackAction : actionMap,
|
||||
),
|
||||
fromStatus: fromStatusMap.isEmpty
|
||||
? null
|
||||
: ApprovalStatusDto.fromJson(fromStatusMap),
|
||||
toStatus: ApprovalStatusDto.fromJson(toStatusMap),
|
||||
approver: ApprovalApproverDto.fromJson(approverMap),
|
||||
actionAt:
|
||||
_parseDate(json['action_at'] ?? json['actionAt']) ?? DateTime.now(),
|
||||
note: _readString(json['note']),
|
||||
);
|
||||
}
|
||||
|
||||
/// DTO를 [ApprovalHistory]로 변환한다.
|
||||
ApprovalHistory toEntity() => ApprovalHistory(
|
||||
id: id,
|
||||
action: action.toEntity(),
|
||||
fromStatus: fromStatus?.toEntity(),
|
||||
toStatus: toStatus.toEntity(),
|
||||
approver: approver.toEntity(),
|
||||
actionAt: actionAt,
|
||||
note: note,
|
||||
);
|
||||
}
|
||||
|
||||
/// 결재 행위(Action) DTO.
|
||||
class ApprovalActionDto {
|
||||
ApprovalActionDto({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
factory ApprovalActionDto.fromJson(Map<String, dynamic> json) {
|
||||
if (json['action'] is Map<String, dynamic>) {
|
||||
return ApprovalActionDto.fromJson(json['action'] as Map<String, dynamic>);
|
||||
}
|
||||
return ApprovalActionDto(
|
||||
id:
|
||||
json['id'] as int? ??
|
||||
json['action_id'] as int? ??
|
||||
json['approval_action_id'] as int? ??
|
||||
0,
|
||||
name:
|
||||
_readString(json['name']) ??
|
||||
_readString(json['action_name']) ??
|
||||
_readString(json['approval_action_name']) ??
|
||||
_readString(json['action']) ??
|
||||
'-',
|
||||
);
|
||||
}
|
||||
|
||||
/// DTO를 [ApprovalAction]으로 변환한다.
|
||||
ApprovalAction toEntity() => ApprovalAction(id: id, name: name);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _asListOfMap(dynamic value) {
|
||||
if (value is List) {
|
||||
return value.whereType<Map<String, dynamic>>().toList(growable: false);
|
||||
@@ -426,6 +232,60 @@ Map<String, dynamic> _firstNonEmptyMap(List<dynamic> candidates) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _resolveRequesterMap(
|
||||
Map<String, dynamic> root,
|
||||
Map<String, dynamic> envelope,
|
||||
Map<String, dynamic> candidate,
|
||||
) {
|
||||
if (candidate.isNotEmpty) {
|
||||
return candidate;
|
||||
}
|
||||
final resolved = <String, dynamic>{};
|
||||
final rootRequestedBy = _mapOrEmpty(root['requested_by']);
|
||||
if (rootRequestedBy.isNotEmpty) {
|
||||
resolved.addAll(rootRequestedBy);
|
||||
}
|
||||
final envelopeRequestedBy = _mapOrEmpty(envelope['requested_by']);
|
||||
if (resolved.isEmpty && envelopeRequestedBy.isNotEmpty) {
|
||||
resolved.addAll(envelopeRequestedBy);
|
||||
} else if (envelopeRequestedBy.isNotEmpty) {
|
||||
for (final entry in envelopeRequestedBy.entries) {
|
||||
resolved.putIfAbsent(entry.key, () => entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
final fallbackId = _pickInt(
|
||||
[resolved, rootRequestedBy, envelopeRequestedBy, root, envelope],
|
||||
const ['requester_id', 'requested_by_id', 'id'],
|
||||
);
|
||||
if (fallbackId != null) {
|
||||
resolved['id'] = fallbackId;
|
||||
}
|
||||
|
||||
final fallbackEmployeeNo = _pickString(
|
||||
[resolved, rootRequestedBy, envelopeRequestedBy, root, envelope],
|
||||
const [
|
||||
'employee_no',
|
||||
'employee_id',
|
||||
'requester_employee_no',
|
||||
'requested_by_employee_no',
|
||||
],
|
||||
);
|
||||
if (fallbackEmployeeNo != null) {
|
||||
resolved['employee_no'] = fallbackEmployeeNo;
|
||||
}
|
||||
|
||||
final fallbackName = _pickString(
|
||||
[resolved, rootRequestedBy, envelopeRequestedBy, root, envelope],
|
||||
const ['name', 'employee_name', 'requester_name', 'requested_by_name'],
|
||||
);
|
||||
if (fallbackName != null) {
|
||||
resolved['name'] = fallbackName;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
String? _pickString(List<dynamic> sources, List<String> keys) {
|
||||
for (final source in sources) {
|
||||
if (source is Map<String, dynamic>) {
|
||||
@@ -440,6 +300,29 @@ String? _pickString(List<dynamic> sources, List<String> keys) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int? _pickInt(List<dynamic> sources, List<String> keys) {
|
||||
for (final source in sources) {
|
||||
if (source is Map<String, dynamic>) {
|
||||
for (final key in keys) {
|
||||
final value = source[key];
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
if (value is String) {
|
||||
final parsed = int.tryParse(value);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 문자열/DateTime 입력을 DateTime으로 변환한다.
|
||||
DateTime? _parseDate(Object? value) {
|
||||
if (value == null) return null;
|
||||
|
||||
@@ -21,8 +21,8 @@ class ApprovalProceedStatusDto {
|
||||
}
|
||||
|
||||
ApprovalProceedStatus toEntity() => ApprovalProceedStatus(
|
||||
approvalId: approvalId,
|
||||
canProceed: canProceed,
|
||||
reason: reason,
|
||||
);
|
||||
approvalId: approvalId,
|
||||
canProceed: canProceed,
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
|
||||
254
lib/features/approvals/data/dtos/approval_request_dto.dart
Normal file
254
lib/features/approvals/data/dtos/approval_request_dto.dart
Normal file
@@ -0,0 +1,254 @@
|
||||
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||
|
||||
import '../../domain/entities/approval.dart';
|
||||
import 'approval_audit_dto.dart';
|
||||
|
||||
/// 결재 상신(Submit) 요청 DTO.
|
||||
class ApprovalSubmitRequestDto {
|
||||
ApprovalSubmitRequestDto({required this.approval, required this.steps});
|
||||
|
||||
final ApprovalCreatePayloadDto approval;
|
||||
final List<ApprovalStepInputDto> steps;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'approval': approval.toJson(),
|
||||
'steps': steps.map((e) => e.toJson()).toList(growable: false),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 재상신 요청 DTO.
|
||||
class ApprovalResubmitRequestDto {
|
||||
ApprovalResubmitRequestDto({
|
||||
required this.approvalId,
|
||||
required this.actorId,
|
||||
required this.steps,
|
||||
this.note,
|
||||
this.expectedUpdatedAt,
|
||||
this.transactionExpectedUpdatedAt,
|
||||
});
|
||||
|
||||
final int approvalId;
|
||||
final int actorId;
|
||||
final List<ApprovalStepInputDto> steps;
|
||||
final String? note;
|
||||
final DateTime? expectedUpdatedAt;
|
||||
final DateTime? transactionExpectedUpdatedAt;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final sanitizedNote = note?.trim();
|
||||
return {
|
||||
'approval_id': approvalId,
|
||||
'actor_id': actorId,
|
||||
'steps': steps.map((e) => e.toJson()).toList(growable: false),
|
||||
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
|
||||
'note': sanitizedNote,
|
||||
if (expectedUpdatedAt != null)
|
||||
'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(),
|
||||
if (transactionExpectedUpdatedAt != null)
|
||||
'transaction_expected_updated_at': transactionExpectedUpdatedAt!
|
||||
.toUtc()
|
||||
.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 승인/반려 요청 DTO.
|
||||
class ApprovalDecisionRequestDto {
|
||||
ApprovalDecisionRequestDto({
|
||||
required this.approvalId,
|
||||
required this.actorId,
|
||||
this.note,
|
||||
this.expectedUpdatedAt,
|
||||
});
|
||||
|
||||
final int approvalId;
|
||||
final int actorId;
|
||||
final String? note;
|
||||
final DateTime? expectedUpdatedAt;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final sanitizedNote = note?.trim();
|
||||
return {
|
||||
'approval_id': approvalId,
|
||||
'actor_id': actorId,
|
||||
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
|
||||
'note': sanitizedNote,
|
||||
if (expectedUpdatedAt != null)
|
||||
'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 회수 요청 DTO.
|
||||
class ApprovalRecallRequestDto {
|
||||
ApprovalRecallRequestDto({
|
||||
required this.approvalId,
|
||||
required this.actorId,
|
||||
this.note,
|
||||
this.expectedUpdatedAt,
|
||||
this.transactionExpectedUpdatedAt,
|
||||
});
|
||||
|
||||
final int approvalId;
|
||||
final int actorId;
|
||||
final String? note;
|
||||
final DateTime? expectedUpdatedAt;
|
||||
final DateTime? transactionExpectedUpdatedAt;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final sanitizedNote = note?.trim();
|
||||
return {
|
||||
'approval_id': approvalId,
|
||||
'actor_id': actorId,
|
||||
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
|
||||
'note': sanitizedNote,
|
||||
if (expectedUpdatedAt != null)
|
||||
'expected_updated_at': expectedUpdatedAt!.toUtc().toIso8601String(),
|
||||
if (transactionExpectedUpdatedAt != null)
|
||||
'transaction_expected_updated_at': transactionExpectedUpdatedAt!
|
||||
.toUtc()
|
||||
.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 본문 생성 DTO.
|
||||
class ApprovalCreatePayloadDto {
|
||||
ApprovalCreatePayloadDto({
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
required this.statusId,
|
||||
required this.requesterId,
|
||||
this.finalApproverId,
|
||||
this.requestedAt,
|
||||
this.decidedAt,
|
||||
this.cancelledAt,
|
||||
this.lastActionAt,
|
||||
this.title,
|
||||
this.summary,
|
||||
this.note,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final int statusId;
|
||||
final int requesterId;
|
||||
final int? finalApproverId;
|
||||
final DateTime? requestedAt;
|
||||
final DateTime? decidedAt;
|
||||
final DateTime? cancelledAt;
|
||||
final DateTime? lastActionAt;
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final String? note;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final sanitizedNote = note?.trim();
|
||||
final sanitizedTitle = title?.trim();
|
||||
final sanitizedSummary = summary?.trim();
|
||||
return {
|
||||
'transaction_id': transactionId,
|
||||
'template_id': templateId,
|
||||
'approval_status_id': statusId,
|
||||
'requested_by_id': requesterId,
|
||||
'final_approver_id': finalApproverId,
|
||||
if (requestedAt != null)
|
||||
'requested_at': requestedAt!.toUtc().toIso8601String(),
|
||||
if (decidedAt != null) 'decided_at': decidedAt!.toUtc().toIso8601String(),
|
||||
if (cancelledAt != null)
|
||||
'cancelled_at': cancelledAt!.toUtc().toIso8601String(),
|
||||
if (lastActionAt != null)
|
||||
'last_action_at': lastActionAt!.toUtc().toIso8601String(),
|
||||
if (sanitizedTitle != null && sanitizedTitle.isNotEmpty)
|
||||
'title': sanitizedTitle,
|
||||
if (sanitizedSummary != null && sanitizedSummary.isNotEmpty)
|
||||
'summary': sanitizedSummary,
|
||||
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
|
||||
'note': sanitizedNote,
|
||||
if (metadata != null && metadata!.isNotEmpty) 'metadata': metadata,
|
||||
};
|
||||
}
|
||||
|
||||
factory ApprovalCreatePayloadDto.fromSubmission(
|
||||
ApprovalSubmissionInput input,
|
||||
) {
|
||||
return ApprovalCreatePayloadDto(
|
||||
transactionId: input.transactionId,
|
||||
templateId: input.templateId,
|
||||
statusId: input.statusId,
|
||||
requesterId: input.requesterId,
|
||||
finalApproverId: input.finalApproverId,
|
||||
requestedAt: input.requestedAt,
|
||||
decidedAt: input.decidedAt,
|
||||
cancelledAt: input.cancelledAt,
|
||||
lastActionAt: input.lastActionAt,
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
note: input.note,
|
||||
metadata: input.metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 단계 생성 입력 DTO.
|
||||
class ApprovalStepInputDto {
|
||||
ApprovalStepInputDto({
|
||||
required this.stepOrder,
|
||||
required this.approverId,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int stepOrder;
|
||||
final int approverId;
|
||||
final String? note;
|
||||
|
||||
factory ApprovalStepInputDto.fromDomain(ApprovalStepAssignmentItem item) {
|
||||
return ApprovalStepInputDto(
|
||||
stepOrder: item.stepOrder,
|
||||
approverId: item.approverId,
|
||||
note: item.note,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final sanitizedNote = note?.trim();
|
||||
return {
|
||||
'step_order': stepOrder,
|
||||
'approver_id': approverId,
|
||||
if (sanitizedNote != null && sanitizedNote.isNotEmpty)
|
||||
'note': sanitizedNote,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 감사 로그 리스트 응답 DTO.
|
||||
class ApprovalAuditListDto {
|
||||
ApprovalAuditListDto({
|
||||
required this.items,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
final List<ApprovalAuditDto> items;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int total;
|
||||
|
||||
factory ApprovalAuditListDto.fromJson(Map<String, dynamic>? json) {
|
||||
final rawItems = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = rawItems
|
||||
.map((item) => ApprovalAuditDto.fromJson(item))
|
||||
.toList(growable: false);
|
||||
return ApprovalAuditListDto(
|
||||
items: items,
|
||||
page: JsonUtils.readInt(json, 'page', fallback: 1),
|
||||
pageSize: JsonUtils.readInt(json, 'page_size', fallback: items.length),
|
||||
total: JsonUtils.readInt(json, 'total', fallback: items.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
207
lib/features/approvals/data/dtos/approval_step_dto.dart
Normal file
207
lib/features/approvals/data/dtos/approval_step_dto.dart
Normal file
@@ -0,0 +1,207 @@
|
||||
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||
|
||||
import '../../domain/entities/approval.dart';
|
||||
|
||||
/// 결재 상태(summary) DTO.
|
||||
class ApprovalStatusDto {
|
||||
ApprovalStatusDto({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.color,
|
||||
bool? isBlockingNext,
|
||||
bool? isTerminal,
|
||||
}) : isBlockingNext = isBlockingNext ?? true,
|
||||
isTerminal = isTerminal ?? false;
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? color;
|
||||
final bool isBlockingNext;
|
||||
final bool isTerminal;
|
||||
|
||||
factory ApprovalStatusDto.fromJson(Map<String, dynamic> json) {
|
||||
if (json['status'] is Map<String, dynamic>) {
|
||||
return ApprovalStatusDto.fromJson(json['status'] as Map<String, dynamic>);
|
||||
}
|
||||
final resolvedName =
|
||||
_readString(json, 'name') ??
|
||||
_readString(json, 'status_name') ??
|
||||
_readString(json, 'statusName') ??
|
||||
'-';
|
||||
final rawColor =
|
||||
_readString(json, 'color') ??
|
||||
_readString(json, 'status_color') ??
|
||||
_readString(json, 'statusColor');
|
||||
return ApprovalStatusDto(
|
||||
id: JsonUtils.readInt(json, 'id', fallback: 0),
|
||||
name: resolvedName,
|
||||
color: rawColor,
|
||||
isBlockingNext:
|
||||
_readBool(json, 'is_blocking_next', fallback: true) ??
|
||||
_readBool(json, 'isBlockingNext', fallback: true) ??
|
||||
true,
|
||||
isTerminal:
|
||||
_readBool(json, 'is_terminal', fallback: false) ??
|
||||
_readBool(json, 'isTerminal', fallback: false) ??
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalStatus toEntity() => ApprovalStatus(
|
||||
id: id,
|
||||
name: name,
|
||||
color: color,
|
||||
isBlockingNext: isBlockingNext,
|
||||
isTerminal: isTerminal,
|
||||
);
|
||||
}
|
||||
|
||||
/// 결재 사용자 요약 DTO.
|
||||
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) {
|
||||
final employeeNo =
|
||||
_readString(json, 'employee_no') ??
|
||||
_readString(json, 'employee_id', fallback: '-');
|
||||
return ApprovalApproverDto(
|
||||
id: JsonUtils.readInt(json, 'id', fallback: 0),
|
||||
employeeNo: employeeNo ?? '-',
|
||||
name: _readString(json, 'name', fallback: '-') ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalApprover toEntity() =>
|
||||
ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
|
||||
/// 결재 단계(summary) DTO.
|
||||
class ApprovalStepDto {
|
||||
ApprovalStepDto({
|
||||
this.id,
|
||||
this.requestId,
|
||||
required this.stepOrder,
|
||||
this.templateStepId,
|
||||
this.approverRole,
|
||||
required this.approver,
|
||||
required this.status,
|
||||
this.assignedAt,
|
||||
this.decidedAt,
|
||||
this.actionAt,
|
||||
this.note,
|
||||
this.isDeleted = false,
|
||||
this.isOptional = false,
|
||||
this.escalationMinutes,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final int? requestId;
|
||||
final int stepOrder;
|
||||
final int? templateStepId;
|
||||
final String? approverRole;
|
||||
final ApprovalApproverDto approver;
|
||||
final ApprovalStatusDto status;
|
||||
final DateTime? assignedAt;
|
||||
final DateTime? decidedAt;
|
||||
final DateTime? actionAt;
|
||||
final String? note;
|
||||
final bool isDeleted;
|
||||
final bool isOptional;
|
||||
final int? escalationMinutes;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
factory ApprovalStepDto.fromJson(Map<String, dynamic> json) {
|
||||
final statusMap =
|
||||
_asMap(json['status']) ?? _asMap(json['step_status']) ?? const {};
|
||||
final approverMap = _asMap(json['approver']) ?? const {};
|
||||
|
||||
final assignedAt = _parseDate(json['assigned_at']);
|
||||
final decidedAt = _parseDate(json['decided_at']);
|
||||
final actionAt = _parseDate(json['action_at']);
|
||||
|
||||
return ApprovalStepDto(
|
||||
id: JsonUtils.readInt(json, 'id'),
|
||||
requestId: JsonUtils.readInt(json, 'request_id'),
|
||||
stepOrder: JsonUtils.readInt(json, 'step_order', fallback: 0),
|
||||
templateStepId: JsonUtils.readInt(json, 'template_step_id'),
|
||||
approverRole: _readString(json, 'approver_role'),
|
||||
approver: ApprovalApproverDto.fromJson(approverMap),
|
||||
status: ApprovalStatusDto.fromJson(statusMap),
|
||||
assignedAt: assignedAt,
|
||||
decidedAt: decidedAt,
|
||||
actionAt: actionAt,
|
||||
note: _readString(json, 'note'),
|
||||
isDeleted:
|
||||
_readBool(json, 'is_deleted') ??
|
||||
(json['deleted_at'] != null ||
|
||||
(json['is_active'] is bool && !(json['is_active'] as bool))),
|
||||
isOptional: _readBool(json, 'is_optional', fallback: false) ?? false,
|
||||
escalationMinutes: JsonUtils.readInt(json, 'escalation_minutes'),
|
||||
metadata: _asMap(
|
||||
json['metadata'],
|
||||
)?.map((key, value) => MapEntry(key, value)),
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalStep toEntity() => ApprovalStep(
|
||||
id: id,
|
||||
requestId: requestId,
|
||||
stepOrder: stepOrder,
|
||||
templateStepId: templateStepId,
|
||||
approverRole: approverRole,
|
||||
approver: approver.toEntity(),
|
||||
status: status.toEntity(),
|
||||
assignedAt: assignedAt ?? DateTime.now(),
|
||||
decidedAt: decidedAt,
|
||||
actionAt: actionAt,
|
||||
note: note,
|
||||
isDeleted: isDeleted,
|
||||
isOptional: isOptional,
|
||||
escalationMinutes: escalationMinutes,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
String? _readString(
|
||||
Map<String, dynamic>? source,
|
||||
String key, {
|
||||
String? fallback,
|
||||
}) {
|
||||
if (source == null) return fallback;
|
||||
final value = source[key];
|
||||
if (value is String) return value;
|
||||
if (value == null) return fallback;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
bool? _readBool(Map<String, dynamic>? source, String key, {bool? fallback}) {
|
||||
if (source == null) return fallback;
|
||||
final value = source[key];
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
if (value is String) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (normalized.isEmpty) return fallback;
|
||||
return ['1', 'y', 'yes', 'true'].contains(normalized);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _asMap(dynamic value) =>
|
||||
value is Map<String, dynamic> ? value : null;
|
||||
|
||||
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,78 @@
|
||||
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 'package:superport_v2/core/network/api_routes.dart';
|
||||
|
||||
import '../../domain/entities/approval_draft.dart';
|
||||
import '../../domain/repositories/approval_draft_repository.dart';
|
||||
import '../dtos/approval_draft_dto.dart';
|
||||
|
||||
/// 결재 초안을 원격 저장소로 관리한다.
|
||||
class ApprovalDraftRepositoryRemote implements ApprovalDraftRepository {
|
||||
ApprovalDraftRepositoryRemote({required ApiClient apiClient})
|
||||
: _api = apiClient;
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<ApprovalDraftSummary>> list(
|
||||
ApprovalDraftListFilter filter,
|
||||
) async {
|
||||
final query = ApiClient.buildQuery(
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
filters: {
|
||||
'requester_id': filter.requesterId,
|
||||
if (filter.transactionId != null)
|
||||
'transaction_id': filter.transactionId,
|
||||
if (filter.includeExpired) 'include_expired': filter.includeExpired,
|
||||
},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalDrafts,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDraftDto.parsePaginated(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalDraftDetail?> fetch({
|
||||
required int id,
|
||||
required int requesterId,
|
||||
}) async {
|
||||
final query = ApiClient.buildQuery(filters: {'requester_id': requesterId});
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiClient.buildPath(ApiRoutes.approvalDrafts, [id]),
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDraftDto.parseDetail(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalDraftDetail> save(ApprovalDraftSaveInput input) async {
|
||||
final payload = input.toJson();
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalDrafts,
|
||||
data: payload,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final detail = ApprovalDraftDto.parseDetail(response.data);
|
||||
if (detail == null) {
|
||||
throw const FormatException('초안 저장 응답이 비어 있습니다.');
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete({required int id, required int requesterId}) async {
|
||||
final query = ApiClient.buildQuery(filters: {'requester_id': requesterId});
|
||||
await _api.delete<void>(
|
||||
ApiClient.buildPath(ApiRoutes.approvalDrafts, [id]),
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import 'package:superport_v2/core/network/api_routes.dart';
|
||||
import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_proceed_status.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
import '../dtos/approval_audit_dto.dart';
|
||||
import '../dtos/approval_dto.dart';
|
||||
import '../dtos/approval_proceed_status_dto.dart';
|
||||
import '../dtos/approval_request_dto.dart';
|
||||
|
||||
/// 결재 API 엔드포인트를 호출하는 원격 저장소 구현체.
|
||||
///
|
||||
@@ -18,7 +20,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const _basePath = '${ApiRoutes.apiV1}/approvals';
|
||||
static const _basePath = ApiRoutes.approvals;
|
||||
|
||||
/// 결재 목록을 조회한다. 필터 조건이 없으면 최신순 페이지를 반환한다.
|
||||
@override
|
||||
@@ -28,26 +30,34 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
int? transactionId,
|
||||
int? approvalStatusId,
|
||||
int? requestedById,
|
||||
List<String>? statusCodes,
|
||||
bool includePending = false,
|
||||
bool includeHistories = false,
|
||||
bool includeSteps = false,
|
||||
}) async {
|
||||
final includeParts = <String>[];
|
||||
final includeParts = <String>['requested_by', 'transaction'];
|
||||
if (includeSteps) {
|
||||
includeParts.add('steps');
|
||||
}
|
||||
if (includeHistories) {
|
||||
includeParts.add('histories');
|
||||
}
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
final query = ApiClient.buildQuery(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
include: includeParts,
|
||||
filters: {
|
||||
if (transactionId != null) 'transaction_id': transactionId,
|
||||
if (approvalStatusId != null) 'approval_status_id': approvalStatusId,
|
||||
if (requestedById != null) 'requested_by_id': requestedById,
|
||||
if (includeParts.isNotEmpty) 'include': includeParts.join(','),
|
||||
if (statusCodes != null && statusCodes.isNotEmpty)
|
||||
'status': statusCodes,
|
||||
if (includePending) 'include_pending': includePending,
|
||||
},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.parsePaginated(response.data ?? const {});
|
||||
@@ -60,27 +70,114 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
bool includeSteps = true,
|
||||
bool includeHistories = true,
|
||||
}) async {
|
||||
final includeParts = <String>[];
|
||||
final includeParts = <String>['transaction', 'requested_by'];
|
||||
if (includeSteps) {
|
||||
includeParts.add('steps');
|
||||
}
|
||||
if (includeHistories) {
|
||||
includeParts.add('histories');
|
||||
}
|
||||
final query = ApiClient.buildQuery(include: includeParts);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
'$_basePath/$id',
|
||||
query: {if (includeParts.isNotEmpty) 'include': includeParts.join(',')},
|
||||
ApiClient.buildPath(_basePath, [id]),
|
||||
query: query.isEmpty ? null : query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> submit(ApprovalSubmissionInput input) async {
|
||||
final payload = ApprovalSubmitRequestDto(
|
||||
approval: ApprovalCreatePayloadDto.fromSubmission(input),
|
||||
steps: _mapSteps(input.steps),
|
||||
);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalAction('submit'),
|
||||
data: payload.toJson(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> resubmit(ApprovalResubmissionInput input) async {
|
||||
final payload = ApprovalResubmitRequestDto(
|
||||
approvalId: input.approvalId,
|
||||
actorId: input.actorId,
|
||||
steps: _mapSteps(input.submission.steps),
|
||||
note: input.note,
|
||||
expectedUpdatedAt: input.expectedUpdatedAt,
|
||||
transactionExpectedUpdatedAt: input.transactionExpectedUpdatedAt,
|
||||
);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalAction('resubmit'),
|
||||
data: payload.toJson(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> approve(ApprovalDecisionInput input) async {
|
||||
final payload = ApprovalDecisionRequestDto(
|
||||
approvalId: input.approvalId,
|
||||
actorId: input.actorId,
|
||||
note: input.note,
|
||||
expectedUpdatedAt: input.expectedUpdatedAt,
|
||||
);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalAction('approve'),
|
||||
data: payload.toJson(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> reject(ApprovalDecisionInput input) async {
|
||||
final payload = ApprovalDecisionRequestDto(
|
||||
approvalId: input.approvalId,
|
||||
actorId: input.actorId,
|
||||
note: input.note,
|
||||
expectedUpdatedAt: input.expectedUpdatedAt,
|
||||
);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalAction('reject'),
|
||||
data: payload.toJson(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> recall(ApprovalRecallInput input) async {
|
||||
final payload = ApprovalRecallRequestDto(
|
||||
approvalId: input.approvalId,
|
||||
actorId: input.actorId,
|
||||
note: input.note,
|
||||
expectedUpdatedAt: input.expectedUpdatedAt,
|
||||
transactionExpectedUpdatedAt: input.transactionExpectedUpdatedAt,
|
||||
);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalAction('recall'),
|
||||
data: payload.toJson(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
/// 활성화된 결재 행위 목록을 조회한다.
|
||||
@override
|
||||
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
|
||||
final query = ApiClient.buildQuery(
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
filters: {if (activeOnly) 'active': true},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
'${ApiRoutes.apiV1}/approval-actions',
|
||||
query: {'page': 1, 'page_size': 100, if (activeOnly) 'active': true},
|
||||
ApiRoutes.approvalActions,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final items = (response.data?['items'] as List<dynamic>? ?? [])
|
||||
@@ -91,11 +188,50 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
return items;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<ApprovalHistory>> listHistory({
|
||||
required int approvalId,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
int? actorId,
|
||||
int? approvalActionId,
|
||||
}) async {
|
||||
final query = ApiClient.buildQuery(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
filters: {
|
||||
'approval_id': approvalId,
|
||||
if (from != null) 'action_from': from,
|
||||
if (to != null) 'action_to': to,
|
||||
if (actorId != null) 'approver_id': actorId,
|
||||
if (approvalActionId != null) 'approval_action_id': approvalActionId,
|
||||
},
|
||||
);
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
ApiRoutes.approvalHistory,
|
||||
query: query,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final dto = ApprovalAuditListDto.fromJson(response.data ?? const {});
|
||||
return PaginatedResult<ApprovalHistory>(
|
||||
items: dto.items.map((e) => e.toEntity()).toList(growable: false),
|
||||
page: dto.page,
|
||||
pageSize: dto.pageSize,
|
||||
total: dto.total,
|
||||
);
|
||||
}
|
||||
|
||||
/// 결재 단계 행위를 수행하고 업데이트된 결재 정보를 반환한다.
|
||||
@override
|
||||
Future<Approval> performStepAction(ApprovalStepActionInput input) async {
|
||||
final path = ApiClient.buildPath(ApiRoutes.approvalSteps, [
|
||||
input.stepId,
|
||||
'actions',
|
||||
]);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'${ApiRoutes.apiV1}/approval-steps/${input.stepId}/actions',
|
||||
path,
|
||||
data: input.toPayload(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
@@ -111,8 +247,9 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
/// 결재 단계들을 일괄로 생성하거나 재배치한다.
|
||||
@override
|
||||
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) async {
|
||||
final path = ApiClient.buildPath(_basePath, [input.approvalId, 'steps']);
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'${ApiRoutes.apiV1}/approvals/${input.approvalId}/steps',
|
||||
path,
|
||||
data: input.toPayload(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
@@ -129,7 +266,7 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
@override
|
||||
Future<ApprovalProceedStatus> canProceed(int id) async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
'$_basePath/$id/can-proceed',
|
||||
ApiClient.buildPath(_basePath, [id, 'can-proceed']),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalProceedStatusDto.fromJson(
|
||||
@@ -145,34 +282,66 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
|
||||
data: input.toPayload(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
/// 결재 기본 정보를 수정한다.
|
||||
@override
|
||||
Future<Approval> update(ApprovalUpdateInput input) async {
|
||||
final response = await _api.patch<Map<String, dynamic>>(
|
||||
'$_basePath/${input.id}',
|
||||
ApiClient.buildPath(_basePath, [input.id]),
|
||||
data: input.toPayload(),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
/// 결재를 삭제(비활성화)한다.
|
||||
@override
|
||||
Future<void> delete(int id) async {
|
||||
await _api.delete<void>('$_basePath/$id');
|
||||
await _api.delete<void>(ApiClient.buildPath(_basePath, [id]));
|
||||
}
|
||||
|
||||
/// 삭제된 결재를 복구한다.
|
||||
@override
|
||||
Future<Approval> restore(int id) async {
|
||||
final response = await _api.post<Map<String, dynamic>>(
|
||||
'$_basePath/$id/restore',
|
||||
ApiClient.buildPath(_basePath, [id, 'restore']),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
return ApprovalDto.fromJson(_api.unwrapAsMap(response)).toEntity();
|
||||
return _mapApprovalFromResponse(response.data);
|
||||
}
|
||||
|
||||
/// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다.
|
||||
Approval _mapApprovalFromResponse(Map<String, dynamic>? body) {
|
||||
final payload = _extractApprovalPayload(body);
|
||||
if (payload.isEmpty) {
|
||||
throw StateError('결재 응답에 결재 데이터가 없습니다.');
|
||||
}
|
||||
return ApprovalDto.fromJson(payload).toEntity();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractApprovalPayload(Map<String, dynamic>? body) {
|
||||
if (body == null || body.isEmpty) {
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
final data = body['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
final approval = _selectApprovalPayload(data);
|
||||
if (approval != null) {
|
||||
return approval;
|
||||
}
|
||||
return Map<String, dynamic>.from(data);
|
||||
}
|
||||
final approval = _selectApprovalPayload(body);
|
||||
if (approval != null) {
|
||||
return approval;
|
||||
}
|
||||
return Map<String, dynamic>.from(body);
|
||||
}
|
||||
|
||||
List<ApprovalStepInputDto> _mapSteps(List<ApprovalStepAssignmentItem> items) {
|
||||
return items.map(ApprovalStepInputDto.fromDomain).toList(growable: false);
|
||||
}
|
||||
|
||||
/// 결재 단계/행위 응답에서 결재 객체 JSON을 추출한다.
|
||||
|
||||
@@ -6,7 +6,9 @@ class Approval {
|
||||
Approval({
|
||||
this.id,
|
||||
required this.approvalNo,
|
||||
this.transactionId,
|
||||
required this.transactionNo,
|
||||
this.transactionUpdatedAt,
|
||||
required this.status,
|
||||
this.currentStep,
|
||||
required this.requester,
|
||||
@@ -23,7 +25,9 @@ class Approval {
|
||||
|
||||
final int? id;
|
||||
final String approvalNo;
|
||||
final int? transactionId;
|
||||
final String transactionNo;
|
||||
final DateTime? transactionUpdatedAt;
|
||||
final ApprovalStatus status;
|
||||
final ApprovalStep? currentStep;
|
||||
final ApprovalRequester requester;
|
||||
@@ -40,7 +44,9 @@ class Approval {
|
||||
Approval copyWith({
|
||||
int? id,
|
||||
String? approvalNo,
|
||||
int? transactionId,
|
||||
String? transactionNo,
|
||||
DateTime? transactionUpdatedAt,
|
||||
ApprovalStatus? status,
|
||||
ApprovalStep? currentStep,
|
||||
ApprovalRequester? requester,
|
||||
@@ -57,7 +63,9 @@ class Approval {
|
||||
return Approval(
|
||||
id: id ?? this.id,
|
||||
approvalNo: approvalNo ?? this.approvalNo,
|
||||
transactionId: transactionId ?? this.transactionId,
|
||||
transactionNo: transactionNo ?? this.transactionNo,
|
||||
transactionUpdatedAt: transactionUpdatedAt ?? this.transactionUpdatedAt,
|
||||
status: status ?? this.status,
|
||||
currentStep: currentStep ?? this.currentStep,
|
||||
requester: requester ?? this.requester,
|
||||
@@ -75,11 +83,35 @@ class Approval {
|
||||
}
|
||||
|
||||
class ApprovalStatus {
|
||||
ApprovalStatus({required this.id, required this.name, this.color});
|
||||
ApprovalStatus({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.color,
|
||||
this.isBlockingNext = true,
|
||||
this.isTerminal = false,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? color;
|
||||
final bool isBlockingNext;
|
||||
final bool isTerminal;
|
||||
|
||||
ApprovalStatus copyWith({
|
||||
int? id,
|
||||
String? name,
|
||||
String? color,
|
||||
bool? isBlockingNext,
|
||||
bool? isTerminal,
|
||||
}) {
|
||||
return ApprovalStatus(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
color: color ?? this.color,
|
||||
isBlockingNext: isBlockingNext ?? this.isBlockingNext,
|
||||
isTerminal: isTerminal ?? this.isTerminal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ApprovalRequester {
|
||||
@@ -97,43 +129,71 @@ class ApprovalRequester {
|
||||
class ApprovalStep {
|
||||
ApprovalStep({
|
||||
this.id,
|
||||
this.requestId,
|
||||
required this.stepOrder,
|
||||
this.templateStepId,
|
||||
this.approverRole,
|
||||
required this.approver,
|
||||
required this.status,
|
||||
required this.assignedAt,
|
||||
this.decidedAt,
|
||||
this.note,
|
||||
this.isDeleted = false,
|
||||
this.actionAt,
|
||||
this.isOptional = false,
|
||||
this.escalationMinutes,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final int? requestId;
|
||||
final int stepOrder;
|
||||
final int? templateStepId;
|
||||
final String? approverRole;
|
||||
final ApprovalApprover approver;
|
||||
final ApprovalStatus status;
|
||||
final DateTime assignedAt;
|
||||
final DateTime? decidedAt;
|
||||
final String? note;
|
||||
final bool isDeleted;
|
||||
final DateTime? actionAt;
|
||||
final bool isOptional;
|
||||
final int? escalationMinutes;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
ApprovalStep copyWith({
|
||||
int? id,
|
||||
int? requestId,
|
||||
int? stepOrder,
|
||||
int? templateStepId,
|
||||
String? approverRole,
|
||||
ApprovalApprover? approver,
|
||||
ApprovalStatus? status,
|
||||
DateTime? assignedAt,
|
||||
DateTime? decidedAt,
|
||||
String? note,
|
||||
bool? isDeleted,
|
||||
DateTime? actionAt,
|
||||
bool? isOptional,
|
||||
int? escalationMinutes,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
return ApprovalStep(
|
||||
id: id ?? this.id,
|
||||
requestId: requestId ?? this.requestId,
|
||||
stepOrder: stepOrder ?? this.stepOrder,
|
||||
templateStepId: templateStepId ?? this.templateStepId,
|
||||
approverRole: approverRole ?? this.approverRole,
|
||||
approver: approver ?? this.approver,
|
||||
status: status ?? this.status,
|
||||
assignedAt: assignedAt ?? this.assignedAt,
|
||||
decidedAt: decidedAt ?? this.decidedAt,
|
||||
note: note ?? this.note,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
actionAt: actionAt ?? this.actionAt,
|
||||
isOptional: isOptional ?? this.isOptional,
|
||||
escalationMinutes: escalationMinutes ?? this.escalationMinutes,
|
||||
metadata: metadata ?? this.metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -159,6 +219,8 @@ class ApprovalHistory {
|
||||
required this.approver,
|
||||
required this.actionAt,
|
||||
this.note,
|
||||
this.actionCode,
|
||||
this.payload,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
@@ -168,13 +230,16 @@ class ApprovalHistory {
|
||||
final ApprovalApprover approver;
|
||||
final DateTime actionAt;
|
||||
final String? note;
|
||||
final String? actionCode;
|
||||
final Map<String, dynamic>? payload;
|
||||
}
|
||||
|
||||
class ApprovalAction {
|
||||
ApprovalAction({required this.id, required this.name});
|
||||
ApprovalAction({required this.id, required this.name, this.code});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? code;
|
||||
}
|
||||
|
||||
/// 결재 단계에서 수행 가능한 행위 타입
|
||||
@@ -300,3 +365,85 @@ class ApprovalStepAssignmentItem {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 상신 입력 모델.
|
||||
class ApprovalSubmissionInput {
|
||||
ApprovalSubmissionInput({
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
required this.statusId,
|
||||
required this.requesterId,
|
||||
this.finalApproverId,
|
||||
this.requestedAt,
|
||||
this.decidedAt,
|
||||
this.cancelledAt,
|
||||
this.lastActionAt,
|
||||
this.title,
|
||||
this.summary,
|
||||
this.note,
|
||||
this.metadata,
|
||||
this.steps = const [],
|
||||
});
|
||||
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final int statusId;
|
||||
final int requesterId;
|
||||
final int? finalApproverId;
|
||||
final DateTime? requestedAt;
|
||||
final DateTime? decidedAt;
|
||||
final DateTime? cancelledAt;
|
||||
final DateTime? lastActionAt;
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final String? note;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final List<ApprovalStepAssignmentItem> steps;
|
||||
}
|
||||
|
||||
/// 결재 승인/반려 입력 모델.
|
||||
class ApprovalDecisionInput {
|
||||
ApprovalDecisionInput({
|
||||
required this.approvalId,
|
||||
required this.actorId,
|
||||
this.note,
|
||||
this.expectedUpdatedAt,
|
||||
});
|
||||
|
||||
final int approvalId;
|
||||
final int actorId;
|
||||
final String? note;
|
||||
final DateTime? expectedUpdatedAt;
|
||||
}
|
||||
|
||||
/// 결재 회수 입력 모델.
|
||||
class ApprovalRecallInput extends ApprovalDecisionInput {
|
||||
ApprovalRecallInput({
|
||||
required super.approvalId,
|
||||
required super.actorId,
|
||||
super.note,
|
||||
super.expectedUpdatedAt,
|
||||
this.transactionExpectedUpdatedAt,
|
||||
});
|
||||
|
||||
final DateTime? transactionExpectedUpdatedAt;
|
||||
}
|
||||
|
||||
/// 결재 재상신 입력 모델.
|
||||
class ApprovalResubmissionInput {
|
||||
ApprovalResubmissionInput({
|
||||
required this.approvalId,
|
||||
required this.actorId,
|
||||
required this.submission,
|
||||
this.note,
|
||||
this.expectedUpdatedAt,
|
||||
this.transactionExpectedUpdatedAt,
|
||||
});
|
||||
|
||||
final int approvalId;
|
||||
final int actorId;
|
||||
final ApprovalSubmissionInput submission;
|
||||
final String? note;
|
||||
final DateTime? expectedUpdatedAt;
|
||||
final DateTime? transactionExpectedUpdatedAt;
|
||||
}
|
||||
|
||||
286
lib/features/approvals/domain/entities/approval_draft.dart
Normal file
286
lib/features/approvals/domain/entities/approval_draft.dart
Normal file
@@ -0,0 +1,286 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'approval.dart';
|
||||
|
||||
/// 결재 초안 상태를 표현하는 열거형.
|
||||
enum ApprovalDraftStatus { active, expired, archived }
|
||||
|
||||
/// 결재 초안 단계 정보를 나타낸다.
|
||||
class ApprovalDraftStep {
|
||||
ApprovalDraftStep({
|
||||
required this.stepOrder,
|
||||
required this.approverId,
|
||||
this.approverRole,
|
||||
this.note,
|
||||
this.isOptional = false,
|
||||
});
|
||||
|
||||
final int stepOrder;
|
||||
final int approverId;
|
||||
final String? approverRole;
|
||||
final String? note;
|
||||
final bool isOptional;
|
||||
|
||||
ApprovalStepAssignmentItem toAssignment() {
|
||||
return ApprovalStepAssignmentItem(
|
||||
stepOrder: stepOrder,
|
||||
approverId: approverId,
|
||||
note: note,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final trimmedNote = note?.trim();
|
||||
return {
|
||||
'step_order': stepOrder,
|
||||
'approver_id': approverId,
|
||||
if (approverRole != null && approverRole!.trim().isNotEmpty)
|
||||
'approver_role': approverRole,
|
||||
if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote,
|
||||
'is_optional': isOptional,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 초안 본문을 나타낸다.
|
||||
class ApprovalDraftPayload {
|
||||
ApprovalDraftPayload({
|
||||
this.title,
|
||||
this.summary,
|
||||
this.note,
|
||||
this.templateId,
|
||||
Map<String, dynamic>? metadata,
|
||||
List<ApprovalDraftStep>? steps,
|
||||
}) : metadata = metadata == null
|
||||
? null
|
||||
: Map.unmodifiable(Map<String, dynamic>.from(metadata)),
|
||||
_steps = steps == null
|
||||
? const []
|
||||
: List<ApprovalDraftStep>.unmodifiable(steps);
|
||||
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final String? note;
|
||||
final int? templateId;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final List<ApprovalDraftStep> _steps;
|
||||
|
||||
UnmodifiableListView<ApprovalDraftStep> get steps =>
|
||||
UnmodifiableListView(_steps);
|
||||
|
||||
List<ApprovalStepAssignmentItem> toAssignments() {
|
||||
return _steps.map((step) => step.toAssignment()).toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 초안 요약 정보를 담는다.
|
||||
class ApprovalDraftSummary {
|
||||
ApprovalDraftSummary({
|
||||
required this.id,
|
||||
required this.requesterId,
|
||||
required this.status,
|
||||
required this.savedAt,
|
||||
this.requestId,
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
this.title,
|
||||
this.summary,
|
||||
this.expiresAt,
|
||||
this.sessionKey,
|
||||
this.stepCount = 0,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int requesterId;
|
||||
final ApprovalDraftStatus status;
|
||||
final DateTime savedAt;
|
||||
final int? requestId;
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final DateTime? expiresAt;
|
||||
final String? sessionKey;
|
||||
final int stepCount;
|
||||
}
|
||||
|
||||
/// 결재 초안 상세 정보를 나타낸다.
|
||||
class ApprovalDraftDetail {
|
||||
ApprovalDraftDetail({
|
||||
required this.id,
|
||||
required this.requesterId,
|
||||
required this.payload,
|
||||
required this.savedAt,
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
this.expiresAt,
|
||||
this.sessionKey,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int requesterId;
|
||||
final ApprovalDraftPayload payload;
|
||||
final DateTime savedAt;
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final DateTime? expiresAt;
|
||||
final String? sessionKey;
|
||||
|
||||
Map<String, dynamic>? get sanitizedMetadata =>
|
||||
_stripClientState(payload.metadata);
|
||||
|
||||
ApprovalSubmissionInput toSubmissionInput({
|
||||
int? defaultStatusId,
|
||||
int? transactionIdOverride,
|
||||
}) {
|
||||
final statusId = _extractStatusId(payload.metadata) ?? defaultStatusId ?? 0;
|
||||
final assignments = payload.toAssignments();
|
||||
final cleanedMetadata = _stripClientState(payload.metadata);
|
||||
return ApprovalSubmissionInput(
|
||||
transactionId: transactionIdOverride ?? transactionId,
|
||||
templateId: payload.templateId ?? templateId,
|
||||
statusId: statusId,
|
||||
requesterId: requesterId,
|
||||
finalApproverId: assignments.isEmpty ? null : assignments.last.approverId,
|
||||
title: payload.title,
|
||||
summary: payload.summary,
|
||||
note: payload.note,
|
||||
metadata: cleanedMetadata,
|
||||
steps: assignments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 초안 목록 필터.
|
||||
class ApprovalDraftListFilter {
|
||||
const ApprovalDraftListFilter({
|
||||
required this.requesterId,
|
||||
this.page = 1,
|
||||
this.pageSize = 20,
|
||||
this.transactionId,
|
||||
this.includeExpired = false,
|
||||
}) : assert(page > 0, 'page는 1 이상이어야 합니다.');
|
||||
|
||||
final int requesterId;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int? transactionId;
|
||||
final bool includeExpired;
|
||||
|
||||
Map<String, dynamic> toQuery() {
|
||||
return {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
'requester_id': requesterId,
|
||||
if (transactionId != null) 'transaction_id': transactionId,
|
||||
if (includeExpired) 'include_expired': includeExpired,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 초안 저장 입력 모델.
|
||||
class ApprovalDraftSaveInput {
|
||||
ApprovalDraftSaveInput({
|
||||
required this.requesterId,
|
||||
required List<ApprovalDraftStep> steps,
|
||||
this.requestId,
|
||||
this.transactionId,
|
||||
this.templateId,
|
||||
this.title,
|
||||
this.summary,
|
||||
this.note,
|
||||
Map<String, dynamic>? metadata,
|
||||
this.sessionKey,
|
||||
this.statusId,
|
||||
}) : metadata = metadata == null
|
||||
? null
|
||||
: Map.unmodifiable(Map<String, dynamic>.from(metadata)),
|
||||
steps = List<ApprovalDraftStep>.unmodifiable(steps);
|
||||
|
||||
final int requesterId;
|
||||
final List<ApprovalDraftStep> steps;
|
||||
final int? requestId;
|
||||
final int? transactionId;
|
||||
final int? templateId;
|
||||
final String? title;
|
||||
final String? summary;
|
||||
final String? note;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final String? sessionKey;
|
||||
final int? statusId;
|
||||
|
||||
bool get hasSteps => steps.isNotEmpty;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final payload = <String, dynamic>{
|
||||
'requester_id': requesterId,
|
||||
if (requestId != null) 'request_id': requestId,
|
||||
if (transactionId != null) 'transaction_id': transactionId,
|
||||
if (templateId != null) 'template_id': templateId,
|
||||
if (title != null && title!.trim().isNotEmpty) 'title': title,
|
||||
if (summary != null && summary!.trim().isNotEmpty) 'summary': summary,
|
||||
if (note != null && note!.trim().isNotEmpty) 'note': note,
|
||||
if (sessionKey != null && sessionKey!.trim().isNotEmpty)
|
||||
'session_key': sessionKey,
|
||||
};
|
||||
final mergedMetadata = _mergeStatus(source: metadata, statusId: statusId);
|
||||
if (mergedMetadata != null && mergedMetadata.isNotEmpty) {
|
||||
payload['metadata'] = mergedMetadata;
|
||||
}
|
||||
payload['steps'] = steps
|
||||
.map((step) => step.toJson())
|
||||
.toList(growable: false);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
const _clientStateKey = '_client_state';
|
||||
const _statusKey = 'status_id';
|
||||
|
||||
Map<String, dynamic>? _mergeStatus({
|
||||
Map<String, dynamic>? source,
|
||||
int? statusId,
|
||||
}) {
|
||||
if (statusId == null) {
|
||||
return source;
|
||||
}
|
||||
final merged = source == null
|
||||
? <String, dynamic>{}
|
||||
: Map<String, dynamic>.from(source);
|
||||
final client = merged[_clientStateKey];
|
||||
final state = client is Map<String, dynamic>
|
||||
? Map<String, dynamic>.from(client)
|
||||
: <String, dynamic>{};
|
||||
state[_statusKey] = statusId;
|
||||
merged[_clientStateKey] = state;
|
||||
return merged;
|
||||
}
|
||||
|
||||
int? _extractStatusId(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null || metadata.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final client = metadata[_clientStateKey];
|
||||
if (client is Map<String, dynamic>) {
|
||||
final value = client[_statusKey];
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is String) {
|
||||
return int.tryParse(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _stripClientState(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null || metadata.isEmpty) {
|
||||
return metadata;
|
||||
}
|
||||
if (!metadata.containsKey(_clientStateKey)) {
|
||||
return metadata;
|
||||
}
|
||||
final cloned = Map<String, dynamic>.from(metadata);
|
||||
cloned.remove(_clientStateKey);
|
||||
return cloned.isEmpty ? null : cloned;
|
||||
}
|
||||
167
lib/features/approvals/domain/entities/approval_flow.dart
Normal file
167
lib/features/approvals/domain/entities/approval_flow.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import '../entities/approval.dart';
|
||||
|
||||
/// 결재 흐름(Approval Flow)을 표현하는 도메인 엔티티.
|
||||
///
|
||||
/// - 상신자, 최종 승인자, 단계 목록, 이력, 상태 요약을 한 번에 제공한다.
|
||||
/// - presentation 레이어에서는 이 엔티티만 의존해 UI를 구성한다.
|
||||
class ApprovalFlow {
|
||||
ApprovalFlow({
|
||||
required Approval approval,
|
||||
ApprovalApprover? finalApprover,
|
||||
ApprovalFlowStatusSummary? statusSummary,
|
||||
}) : _approval = approval,
|
||||
finalApprover = finalApprover ?? _inferFinalApprover(approval.steps),
|
||||
statusSummary =
|
||||
statusSummary ??
|
||||
ApprovalFlowStatusSummary.from(
|
||||
status: approval.status,
|
||||
steps: approval.steps,
|
||||
currentStep: approval.currentStep,
|
||||
),
|
||||
_steps = List<ApprovalStep>.unmodifiable(approval.steps),
|
||||
_histories = List<ApprovalHistory>.unmodifiable(approval.histories);
|
||||
|
||||
/// 결재 원본 데이터
|
||||
final Approval _approval;
|
||||
|
||||
/// 결재 단계 목록
|
||||
final List<ApprovalStep> _steps;
|
||||
|
||||
/// 결재 이력 목록
|
||||
final List<ApprovalHistory> _histories;
|
||||
|
||||
/// 최종 승인자 정보 (단계 목록 기반 추론 결과)
|
||||
final ApprovalApprover? finalApprover;
|
||||
|
||||
/// 결재 상태 요약 정보
|
||||
final ApprovalFlowStatusSummary statusSummary;
|
||||
|
||||
/// 원본 결재 엔티티에 접근한다.
|
||||
Approval get approval => _approval;
|
||||
|
||||
/// 결재 식별자(ID)
|
||||
int? get id => _approval.id;
|
||||
|
||||
/// 결재 번호(APP-YYYYMMDDNNNN 형식)
|
||||
String get approvalNo => _approval.approvalNo;
|
||||
|
||||
/// 연동된 전표 번호
|
||||
String get transactionNo => _approval.transactionNo;
|
||||
|
||||
/// 연동된 전표 ID
|
||||
int? get transactionId => _approval.transactionId;
|
||||
|
||||
/// 연동된 전표 최신 수정 시각
|
||||
DateTime? get transactionUpdatedAt => _approval.transactionUpdatedAt;
|
||||
|
||||
/// 현재 결재 상태
|
||||
ApprovalStatus get status => _approval.status;
|
||||
|
||||
/// 현재 진행 중인 단계 정보
|
||||
ApprovalStep? get currentStep => _approval.currentStep;
|
||||
|
||||
/// 상신자 정보
|
||||
ApprovalRequester get requester => _approval.requester;
|
||||
|
||||
/// 상신 일시
|
||||
DateTime get requestedAt => _approval.requestedAt;
|
||||
|
||||
/// 최종 결정 일시
|
||||
DateTime? get decidedAt => _approval.decidedAt;
|
||||
|
||||
/// 결재 메모
|
||||
String? get note => _approval.note;
|
||||
|
||||
/// 생성 일시
|
||||
DateTime? get createdAt => _approval.createdAt;
|
||||
|
||||
/// 변경 일시
|
||||
DateTime? get updatedAt => _approval.updatedAt;
|
||||
|
||||
/// 단계 목록을 반환한다.
|
||||
List<ApprovalStep> get steps => _steps;
|
||||
|
||||
/// 이력 목록을 반환한다.
|
||||
List<ApprovalHistory> get histories => _histories;
|
||||
|
||||
/// [Approval] 엔티티에서 [ApprovalFlow]를 생성하는 팩토리.
|
||||
factory ApprovalFlow.fromApproval(Approval approval) =>
|
||||
ApprovalFlow(approval: approval);
|
||||
|
||||
static ApprovalApprover? _inferFinalApprover(List<ApprovalStep> steps) {
|
||||
if (steps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final sorted = List<ApprovalStep>.from(steps)
|
||||
..sort((a, b) => a.stepOrder.compareTo(b.stepOrder));
|
||||
return sorted.last.approver;
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 상태 요약 정보.
|
||||
///
|
||||
/// - 전체 단계 수, 완료 단계 수, 대기 단계 수, 현재 단계 순번을 제공한다.
|
||||
class ApprovalFlowStatusSummary {
|
||||
ApprovalFlowStatusSummary({
|
||||
required this.status,
|
||||
required this.totalSteps,
|
||||
required this.completedSteps,
|
||||
required this.pendingSteps,
|
||||
this.currentStepOrder,
|
||||
});
|
||||
|
||||
/// 전체 결재 상태
|
||||
final ApprovalStatus status;
|
||||
|
||||
/// 총 단계 수
|
||||
final int totalSteps;
|
||||
|
||||
/// 완료된 단계 수
|
||||
final int completedSteps;
|
||||
|
||||
/// 대기 중인 단계 수
|
||||
final int pendingSteps;
|
||||
|
||||
/// 현재 진행 중인 단계 순번 (없으면 null)
|
||||
final int? currentStepOrder;
|
||||
|
||||
/// 완료율(%)을 정수로 반환한다.
|
||||
int get completionRate {
|
||||
if (totalSteps <= 0) {
|
||||
return 0;
|
||||
}
|
||||
final ratio = (completedSteps / totalSteps) * 100;
|
||||
return ratio.isFinite ? ratio.round() : 0;
|
||||
}
|
||||
|
||||
/// 결재 상태와 단계 목록을 기반으로 요약 정보를 생성한다.
|
||||
factory ApprovalFlowStatusSummary.from({
|
||||
required ApprovalStatus status,
|
||||
required List<ApprovalStep> steps,
|
||||
ApprovalStep? currentStep,
|
||||
}) {
|
||||
final total = steps.length;
|
||||
final completed = steps.where((step) => step.decidedAt != null).length;
|
||||
final pending = total - completed;
|
||||
final currentOrder = currentStep?.stepOrder ?? _findCurrentStepOrder(steps);
|
||||
return ApprovalFlowStatusSummary(
|
||||
status: status,
|
||||
totalSteps: total,
|
||||
completedSteps: completed,
|
||||
pendingSteps: pending < 0 ? 0 : pending,
|
||||
currentStepOrder: currentOrder,
|
||||
);
|
||||
}
|
||||
|
||||
static int? _findCurrentStepOrder(List<ApprovalStep> steps) {
|
||||
for (final step in steps) {
|
||||
if (step.decidedAt == null) {
|
||||
return step.stepOrder;
|
||||
}
|
||||
}
|
||||
if (steps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return steps.last.stepOrder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import '../entities/approval_draft.dart';
|
||||
|
||||
/// 결재 초안 저장소 인터페이스.
|
||||
abstract class ApprovalDraftRepository {
|
||||
Future<PaginatedResult<ApprovalDraftSummary>> list(
|
||||
ApprovalDraftListFilter filter,
|
||||
);
|
||||
|
||||
Future<ApprovalDraftDetail?> fetch({
|
||||
required int id,
|
||||
required int requesterId,
|
||||
});
|
||||
|
||||
Future<ApprovalDraftDetail> save(ApprovalDraftSaveInput input);
|
||||
|
||||
Future<void> delete({required int id, required int requesterId});
|
||||
}
|
||||
@@ -14,6 +14,8 @@ abstract class ApprovalRepository {
|
||||
int? transactionId,
|
||||
int? approvalStatusId,
|
||||
int? requestedById,
|
||||
List<String>? statusCodes,
|
||||
bool includePending = false,
|
||||
bool includeHistories = false,
|
||||
bool includeSteps = false,
|
||||
});
|
||||
@@ -25,6 +27,32 @@ abstract class ApprovalRepository {
|
||||
bool includeHistories = true,
|
||||
});
|
||||
|
||||
/// 결재를 상신한다.
|
||||
Future<Approval> submit(ApprovalSubmissionInput input);
|
||||
|
||||
/// 결재를 재상신한다.
|
||||
Future<Approval> resubmit(ApprovalResubmissionInput input);
|
||||
|
||||
/// 결재를 승인한다.
|
||||
Future<Approval> approve(ApprovalDecisionInput input);
|
||||
|
||||
/// 결재를 반려한다.
|
||||
Future<Approval> reject(ApprovalDecisionInput input);
|
||||
|
||||
/// 결재를 회수한다.
|
||||
Future<Approval> recall(ApprovalRecallInput input);
|
||||
|
||||
/// 결재 감사 로그를 조회한다.
|
||||
Future<PaginatedResult<ApprovalHistory>> listHistory({
|
||||
required int approvalId,
|
||||
int page,
|
||||
int pageSize,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
int? actorId,
|
||||
int? approvalActionId,
|
||||
});
|
||||
|
||||
/// 활성화된 결재 행위(approve/reject/comment 등) 목록 조회
|
||||
Future<List<ApprovalAction>> listActions({bool activeOnly = true});
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../entities/approval_template.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
import '../repositories/approval_template_repository.dart';
|
||||
|
||||
/// 결재 템플릿을 결재 요청에 적용하는 유즈케이스.
|
||||
///
|
||||
/// - 템플릿 단계를 정렬해 [ApprovalStepAssignmentInput]으로 변환한 뒤 저장소에 위임한다.
|
||||
class ApplyApprovalTemplateUseCase {
|
||||
ApplyApprovalTemplateUseCase({
|
||||
required ApprovalTemplateRepository templateRepository,
|
||||
required ApprovalRepository approvalRepository,
|
||||
}) : _templateRepository = templateRepository,
|
||||
_approvalRepository = approvalRepository;
|
||||
|
||||
final ApprovalTemplateRepository _templateRepository;
|
||||
final ApprovalRepository _approvalRepository;
|
||||
|
||||
/// [templateId]에 해당하는 템플릿을 [approvalId] 결재에 적용한다.
|
||||
///
|
||||
/// 템플릿에 단계가 없으면 [StateError]를 던진다.
|
||||
Future<ApprovalFlow> call({
|
||||
required int approvalId,
|
||||
required int templateId,
|
||||
}) async {
|
||||
final template = await _templateRepository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (template.steps.isEmpty) {
|
||||
throw StateError('단계가 없는 결재 템플릿은 적용할 수 없습니다.');
|
||||
}
|
||||
final steps = _mapTemplateSteps(template);
|
||||
final assignment = ApprovalStepAssignmentInput(
|
||||
approvalId: approvalId,
|
||||
steps: steps,
|
||||
);
|
||||
final approval = await _approvalRepository.assignSteps(assignment);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
|
||||
List<ApprovalStepAssignmentItem> _mapTemplateSteps(
|
||||
ApprovalTemplate template,
|
||||
) {
|
||||
final sorted = List<ApprovalTemplateStep>.of(template.steps)
|
||||
..sort((a, b) => a.stepOrder.compareTo(b.stepOrder));
|
||||
return sorted
|
||||
.map(
|
||||
(step) => ApprovalStepAssignmentItem(
|
||||
stepOrder: step.stepOrder,
|
||||
approverId: step.approver.id,
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
|
||||
/// 결재를 승인하는 유즈케이스.
|
||||
///
|
||||
/// - 승인자는 [ApprovalDecisionInput]을 통해 필요한 정보를 전달한다.
|
||||
class ApproveApprovalUseCase {
|
||||
ApproveApprovalUseCase({required ApprovalRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
|
||||
/// 결재를 승인하고 최신 [ApprovalFlow]를 반환한다.
|
||||
Future<ApprovalFlow> call(ApprovalDecisionInput input) async {
|
||||
await _ensureCanProceed(input.approvalId);
|
||||
final approval = await _repository.approve(input);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
|
||||
/// 결재 단계 진행 권한을 사전 확인한다.
|
||||
Future<void> _ensureCanProceed(int approvalId) async {
|
||||
final status = await _repository.canProceed(approvalId);
|
||||
if (status.canProceed) {
|
||||
return;
|
||||
}
|
||||
final reason = status.reason?.trim();
|
||||
if (reason != null && reason.isNotEmpty) {
|
||||
throw StateError(reason);
|
||||
}
|
||||
throw StateError('결재를 진행할 권한이 없습니다.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../repositories/approval_draft_repository.dart';
|
||||
|
||||
/// 결재 초안을 삭제하는 유즈케이스.
|
||||
class DeleteApprovalDraftUseCase {
|
||||
DeleteApprovalDraftUseCase({required ApprovalDraftRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalDraftRepository _repository;
|
||||
|
||||
Future<void> call({required int id, required int requesterId}) {
|
||||
return _repository.delete(id: id, requesterId: requesterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../entities/approval_draft.dart';
|
||||
import '../repositories/approval_draft_repository.dart';
|
||||
|
||||
/// 결재 초안 상세를 조회하는 유즈케이스.
|
||||
class GetApprovalDraftUseCase {
|
||||
GetApprovalDraftUseCase({required ApprovalDraftRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalDraftRepository _repository;
|
||||
|
||||
Future<ApprovalDraftDetail?> call({
|
||||
required int id,
|
||||
required int requesterId,
|
||||
}) {
|
||||
return _repository.fetch(id: id, requesterId: requesterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
|
||||
import '../entities/approval_draft.dart';
|
||||
import '../repositories/approval_draft_repository.dart';
|
||||
|
||||
/// 결재 초안 목록을 조회하는 유즈케이스.
|
||||
class ListApprovalDraftsUseCase {
|
||||
ListApprovalDraftsUseCase({required ApprovalDraftRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalDraftRepository _repository;
|
||||
|
||||
Future<PaginatedResult<ApprovalDraftSummary>> call(
|
||||
ApprovalDraftListFilter filter,
|
||||
) {
|
||||
return _repository.list(filter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
|
||||
/// 결재를 회수(recall)하는 유즈케이스.
|
||||
///
|
||||
/// - 회수 가능 여부는 별도의 선행 검증으로 확인해야 한다.
|
||||
class RecallApprovalUseCase {
|
||||
RecallApprovalUseCase({required ApprovalRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
|
||||
/// 결재를 회수하고 최신 [ApprovalFlow]를 반환한다.
|
||||
Future<ApprovalFlow> call(ApprovalRecallInput input) async {
|
||||
final approval = await _repository.recall(input);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
|
||||
/// 결재를 반려하는 유즈케이스.
|
||||
///
|
||||
/// - 반려 사유 및 코멘트는 [ApprovalDecisionInput.note]로 전달한다.
|
||||
class RejectApprovalUseCase {
|
||||
RejectApprovalUseCase({required ApprovalRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
|
||||
/// 결재를 반려하고 최신 [ApprovalFlow]를 반환한다.
|
||||
Future<ApprovalFlow> call(ApprovalDecisionInput input) async {
|
||||
await _ensureCanProceed(input.approvalId);
|
||||
final approval = await _repository.reject(input);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
|
||||
/// 결재 단계 진행 권한을 사전 확인한다.
|
||||
Future<void> _ensureCanProceed(int approvalId) async {
|
||||
final status = await _repository.canProceed(approvalId);
|
||||
if (status.canProceed) {
|
||||
return;
|
||||
}
|
||||
final reason = status.reason?.trim();
|
||||
if (reason != null && reason.isNotEmpty) {
|
||||
throw StateError(reason);
|
||||
}
|
||||
throw StateError('결재를 진행할 권한이 없습니다.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
|
||||
/// 결재를 재상신(resubmit)하는 유즈케이스.
|
||||
///
|
||||
/// - 재상신 시 수정된 단계 정보와 메모를 함께 전달한다.
|
||||
class ResubmitApprovalUseCase {
|
||||
ResubmitApprovalUseCase({required ApprovalRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
|
||||
/// 결재를 재상신하고 최신 [ApprovalFlow]를 반환한다.
|
||||
Future<ApprovalFlow> call(ApprovalResubmissionInput input) async {
|
||||
final approval = await _repository.resubmit(input);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../entities/approval_draft.dart';
|
||||
import '../repositories/approval_draft_repository.dart';
|
||||
|
||||
/// 결재 초안을 서버에 저장하는 유즈케이스.
|
||||
class SaveApprovalDraftUseCase {
|
||||
SaveApprovalDraftUseCase({required ApprovalDraftRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalDraftRepository _repository;
|
||||
|
||||
Future<ApprovalDraftDetail> call(ApprovalDraftSaveInput input) {
|
||||
return _repository.save(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import '../entities/approval_template.dart';
|
||||
import '../repositories/approval_template_repository.dart';
|
||||
|
||||
/// 결재 템플릿을 생성/수정하는 유즈케이스.
|
||||
///
|
||||
/// - [templateId]가 null이면 신규 생성, 값이 있으면 수정으로 처리한다.
|
||||
class SaveApprovalTemplateUseCase {
|
||||
SaveApprovalTemplateUseCase({required ApprovalTemplateRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalTemplateRepository _repository;
|
||||
|
||||
/// 템플릿을 저장하고 최신 [ApprovalTemplate]을 반환한다.
|
||||
Future<ApprovalTemplate> call({
|
||||
int? templateId,
|
||||
required ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput>? steps,
|
||||
}) {
|
||||
if (templateId == null) {
|
||||
return _repository.create(input, steps: steps ?? const []);
|
||||
}
|
||||
return _repository.update(templateId, input, steps: steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../entities/approval.dart';
|
||||
import '../entities/approval_flow.dart';
|
||||
import '../repositories/approval_repository.dart';
|
||||
|
||||
/// 결재를 상신(submit)하는 유즈케이스.
|
||||
///
|
||||
/// - 입력 파라미터는 [ApprovalSubmissionInput]을 사용한다.
|
||||
class SubmitApprovalUseCase {
|
||||
SubmitApprovalUseCase({required ApprovalRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
|
||||
/// 결재를 상신하고 갱신된 [ApprovalFlow]를 반환한다.
|
||||
Future<ApprovalFlow> call(ApprovalSubmissionInput input) async {
|
||||
final approval = await _repository.submit(input);
|
||||
return ApprovalFlow.fromApproval(approval);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
|
||||
import 'package:superport_v2/features/approvals/data/dtos/approval_audit_dto.dart';
|
||||
import 'package:superport_v2/features/approvals/data/dtos/approval_step_dto.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||
|
||||
import '../../domain/entities/approval_history_record.dart';
|
||||
|
||||
@@ -2,29 +2,62 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/network/failure.dart';
|
||||
|
||||
import '../../../domain/entities/approval.dart';
|
||||
import '../../../domain/entities/approval_flow.dart';
|
||||
import '../../../domain/repositories/approval_repository.dart';
|
||||
import '../../../domain/usecases/recall_approval_use_case.dart';
|
||||
import '../../../domain/usecases/resubmit_approval_use_case.dart';
|
||||
import '../../domain/entities/approval_history_record.dart';
|
||||
import '../../domain/repositories/approval_history_repository.dart';
|
||||
|
||||
/// 결재 이력에서 필터링 가능한 행위 타입.
|
||||
enum ApprovalHistoryActionFilter { all, approve, reject, comment }
|
||||
|
||||
/// 결재 이력 화면 탭 종류.
|
||||
enum ApprovalHistoryTab { flow, audit }
|
||||
|
||||
/// 결재 이력 화면의 목록/필터 상태를 관리하는 컨트롤러.
|
||||
///
|
||||
/// 기간, 검색어, 행위 타입에 따라 목록을 조회하고 페이지 사이즈를 조절한다.
|
||||
class ApprovalHistoryController extends ChangeNotifier {
|
||||
ApprovalHistoryController({required ApprovalHistoryRepository repository})
|
||||
: _repository = repository;
|
||||
ApprovalHistoryController({
|
||||
required ApprovalHistoryRepository repository,
|
||||
ApprovalRepository? approvalRepository,
|
||||
RecallApprovalUseCase? recallUseCase,
|
||||
ResubmitApprovalUseCase? resubmitUseCase,
|
||||
}) : _repository = repository,
|
||||
_approvalRepository = approvalRepository,
|
||||
_recallUseCase = recallUseCase,
|
||||
_resubmitUseCase = resubmitUseCase;
|
||||
|
||||
final ApprovalHistoryRepository _repository;
|
||||
final ApprovalRepository? _approvalRepository;
|
||||
final RecallApprovalUseCase? _recallUseCase;
|
||||
final ResubmitApprovalUseCase? _resubmitUseCase;
|
||||
final Map<int, ApprovalFlow> _flowCache = <int, ApprovalFlow>{};
|
||||
|
||||
PaginatedResult<ApprovalHistoryRecord>? _result;
|
||||
PaginatedResult<ApprovalHistory>? _auditResult;
|
||||
bool _isLoading = false;
|
||||
bool _isLoadingAudit = false;
|
||||
bool _isPerformingAction = false;
|
||||
bool _isLoadingFlow = false;
|
||||
ApprovalHistoryTab _activeTab = ApprovalHistoryTab.flow;
|
||||
String _query = '';
|
||||
ApprovalHistoryActionFilter _actionFilter = ApprovalHistoryActionFilter.all;
|
||||
DateTime? _from;
|
||||
DateTime? _to;
|
||||
String? _errorMessage;
|
||||
int _pageSize = 20;
|
||||
int _auditPageSize = 20;
|
||||
int? _selectedApprovalId;
|
||||
ApprovalFlow? _selectedFlow;
|
||||
int? _auditActorId;
|
||||
String? _auditActionCode;
|
||||
DateTime? _auditFrom;
|
||||
DateTime? _auditTo;
|
||||
final Map<String, ApprovalAction> _auditActions = <String, ApprovalAction>{};
|
||||
bool _isSelectionForbidden = false;
|
||||
|
||||
PaginatedResult<ApprovalHistoryRecord>? get result => _result;
|
||||
bool get isLoading => _isLoading;
|
||||
@@ -34,6 +67,30 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
DateTime? get to => _to;
|
||||
String? get errorMessage => _errorMessage;
|
||||
int get pageSize => _result?.pageSize ?? _pageSize;
|
||||
PaginatedResult<ApprovalHistory>? get auditResult => _auditResult;
|
||||
bool get isLoadingAudit => _isLoadingAudit;
|
||||
bool get isPerformingAction => _isPerformingAction;
|
||||
ApprovalHistoryTab get activeTab => _activeTab;
|
||||
int get auditPageSize => _auditResult?.pageSize ?? _auditPageSize;
|
||||
int? get selectedApprovalId => _selectedApprovalId;
|
||||
bool get hasAuditSelection => _selectedApprovalId != null;
|
||||
bool get hasAuditResults => _auditResult?.items.isNotEmpty ?? false;
|
||||
bool get isLoadingFlow => _isLoadingFlow;
|
||||
ApprovalFlow? get selectedFlow => _selectedFlow;
|
||||
int? get auditActorId => _auditActorId;
|
||||
String? get auditActionCode => _auditActionCode;
|
||||
DateTime? get auditFrom => _auditFrom;
|
||||
DateTime? get auditTo => _auditTo;
|
||||
List<ApprovalAction> get auditActions {
|
||||
if (_auditActions.isEmpty) {
|
||||
return const <ApprovalAction>[];
|
||||
}
|
||||
final items = _auditActions.values.toList(growable: false);
|
||||
items.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return items;
|
||||
}
|
||||
|
||||
bool get isSelectionForbidden => _isSelectionForbidden;
|
||||
|
||||
/// 현재 필터 조건에 맞춰 결재 이력 목록을 불러온다.
|
||||
///
|
||||
@@ -43,17 +100,7 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final previous = _result;
|
||||
final int resolvedPage;
|
||||
if (page < 1) {
|
||||
resolvedPage = 1;
|
||||
} else if (previous != null && previous.pageSize > 0) {
|
||||
final calculated = (previous.total / previous.pageSize).ceil();
|
||||
final maxPage = calculated < 1 ? 1 : calculated;
|
||||
resolvedPage = page > maxPage ? maxPage : page;
|
||||
} else {
|
||||
resolvedPage = page;
|
||||
}
|
||||
final resolvedPage = _resolvePage(page, _result);
|
||||
final action = switch (_actionFilter) {
|
||||
ApprovalHistoryActionFilter.all => null,
|
||||
ApprovalHistoryActionFilter.approve => 'approve',
|
||||
@@ -80,6 +127,125 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 지정한 결재의 감사 로그를 조회한다.
|
||||
Future<void> fetchAuditLogs({required int approvalId, int page = 1}) async {
|
||||
final approvalRepository = _approvalRepository;
|
||||
if (approvalRepository == null) {
|
||||
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
|
||||
}
|
||||
_isLoadingAudit = true;
|
||||
_errorMessage = null;
|
||||
_selectedApprovalId = approvalId;
|
||||
notifyListeners();
|
||||
try {
|
||||
final resolvedPage = _resolvePage(page, _auditResult);
|
||||
final response = await approvalRepository.listHistory(
|
||||
approvalId: approvalId,
|
||||
page: resolvedPage,
|
||||
pageSize: auditPageSize,
|
||||
from: _auditFrom ?? _from,
|
||||
to: _auditTo ?? _to,
|
||||
actorId: _auditActorId,
|
||||
approvalActionId: _resolveAuditActionId(),
|
||||
);
|
||||
_auditResult = response;
|
||||
_auditPageSize = response.pageSize;
|
||||
if (response.items.isNotEmpty) {
|
||||
final actionMap = <String, ApprovalAction>{};
|
||||
for (final log in response.items) {
|
||||
final code = log.action.code?.trim();
|
||||
if (code == null || code.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
actionMap.putIfAbsent(code, () => log.action);
|
||||
}
|
||||
if (actionMap.isNotEmpty) {
|
||||
_auditActions
|
||||
..clear()
|
||||
..addAll(actionMap);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
if (failure.statusCode == 403) {
|
||||
_isSelectionForbidden = true;
|
||||
_auditResult = null;
|
||||
}
|
||||
} finally {
|
||||
_isLoadingAudit = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 상세와 타임라인 정보를 조회해 선택 상태를 갱신한다.
|
||||
Future<void> loadApprovalFlow(int approvalId, {bool force = false}) async {
|
||||
final approvalRepository = _approvalRepository;
|
||||
if (approvalRepository == null) {
|
||||
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
|
||||
}
|
||||
if (!force && _flowCache.containsKey(approvalId)) {
|
||||
_selectedApprovalId = approvalId;
|
||||
_selectedFlow = _flowCache[approvalId];
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_isLoadingFlow = true;
|
||||
_errorMessage = null;
|
||||
_selectedApprovalId = approvalId;
|
||||
_isSelectionForbidden = false;
|
||||
_selectedFlow = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final detail = await approvalRepository.fetchDetail(
|
||||
approvalId,
|
||||
includeSteps: true,
|
||||
includeHistories: true,
|
||||
);
|
||||
final flow = ApprovalFlow.fromApproval(detail);
|
||||
_flowCache[approvalId] = flow;
|
||||
_selectedFlow = flow;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
if (failure.statusCode == 403) {
|
||||
_isSelectionForbidden = true;
|
||||
_selectedFlow = null;
|
||||
_auditResult = null;
|
||||
}
|
||||
} finally {
|
||||
_isLoadingFlow = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 선택된 결재 흐름을 최신 상태로 갱신한다.
|
||||
Future<ApprovalFlow?> refreshFlow(int approvalId) async {
|
||||
final approvalRepository = _approvalRepository;
|
||||
if (approvalRepository == null) {
|
||||
throw StateError('ApprovalRepository가 주입되지 않았습니다.');
|
||||
}
|
||||
try {
|
||||
final detail = await approvalRepository.fetchDetail(
|
||||
approvalId,
|
||||
includeSteps: true,
|
||||
includeHistories: true,
|
||||
);
|
||||
final flow = ApprovalFlow.fromApproval(detail);
|
||||
_flowCache[approvalId] = flow;
|
||||
if (_selectedApprovalId == approvalId) {
|
||||
_selectedFlow = flow;
|
||||
notifyListeners();
|
||||
}
|
||||
return flow;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 검색어를 업데이트해 다음 조회 시 적용될 수 있도록 한다.
|
||||
void updateQuery(String value) {
|
||||
_query = value;
|
||||
@@ -99,6 +265,82 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 활성 탭을 변경한다.
|
||||
void updateActiveTab(ApprovalHistoryTab tab) {
|
||||
if (_activeTab == tab) {
|
||||
return;
|
||||
}
|
||||
_activeTab = tab;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 감사 로그 페이지 사이즈를 변경한다.
|
||||
void updateAuditPageSize(int value) {
|
||||
if (value <= 0) {
|
||||
return;
|
||||
}
|
||||
_auditPageSize = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 감사 로그 행위자를 필터링한다.
|
||||
void updateAuditActor(int? actorId) {
|
||||
final normalized = actorId != null && actorId > 0 ? actorId : null;
|
||||
if (_auditActorId == normalized) {
|
||||
return;
|
||||
}
|
||||
_auditActorId = normalized;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 감사 로그 행위 타입을 필터링한다.
|
||||
void updateAuditAction(String? actionCode) {
|
||||
final normalized = actionCode?.trim();
|
||||
if (normalized != null && normalized.isEmpty) {
|
||||
_auditActionCode = null;
|
||||
} else {
|
||||
if (_auditActionCode == normalized) {
|
||||
return;
|
||||
}
|
||||
_auditActionCode = normalized;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 감사 로그 기간 필터를 설정한다.
|
||||
void updateAuditDateRange(DateTime? from, DateTime? to) {
|
||||
DateTime? normalizedFrom = from;
|
||||
DateTime? normalizedTo = to;
|
||||
if (normalizedFrom != null && normalizedTo != null) {
|
||||
if (normalizedFrom.isAfter(normalizedTo)) {
|
||||
final temp = normalizedFrom;
|
||||
normalizedFrom = normalizedTo;
|
||||
normalizedTo = temp;
|
||||
}
|
||||
}
|
||||
if (_auditFrom == normalizedFrom && _auditTo == normalizedTo) {
|
||||
return;
|
||||
}
|
||||
_auditFrom = normalizedFrom;
|
||||
_auditTo = normalizedTo;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 감사 로그 필터를 초기화한다.
|
||||
void clearAuditFilters() {
|
||||
if (_auditActorId == null &&
|
||||
(_auditActionCode == null || _auditActionCode!.isEmpty) &&
|
||||
_auditFrom == null &&
|
||||
_auditTo == null) {
|
||||
return;
|
||||
}
|
||||
_auditActorId = null;
|
||||
_auditActionCode = null;
|
||||
_auditFrom = null;
|
||||
_auditTo = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 검색어/행위/기간 필터를 초기화한다.
|
||||
void clearFilters() {
|
||||
_query = '';
|
||||
@@ -108,6 +350,20 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 감사 로그 선택 상태를 초기화한다.
|
||||
void clearAuditSelection() {
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
/// 선택된 결재의 감사 로그를 새로고침한다.
|
||||
Future<void> refreshAudit() async {
|
||||
final approvalId = _selectedApprovalId;
|
||||
if (approvalId == null) {
|
||||
return;
|
||||
}
|
||||
await fetchAuditLogs(approvalId: approvalId, page: _auditResult?.page ?? 1);
|
||||
}
|
||||
|
||||
/// 축적된 오류 메시지를 초기화한다.
|
||||
void clearError() {
|
||||
_errorMessage = null;
|
||||
@@ -123,9 +379,124 @@ class ApprovalHistoryController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재를 회수한다.
|
||||
Future<ApprovalFlow?> recallApproval(ApprovalRecallInput input) async {
|
||||
final useCase = _recallUseCase;
|
||||
if (useCase == null) {
|
||||
throw StateError('RecallApprovalUseCase가 주입되지 않았습니다.');
|
||||
}
|
||||
_isPerformingAction = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final flow = await useCase.call(input);
|
||||
final targetId = flow.approval.id ?? input.approvalId;
|
||||
await _refreshAfterAction(targetId, flow: flow);
|
||||
return flow;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} finally {
|
||||
_isPerformingAction = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재를 재상신한다.
|
||||
Future<ApprovalFlow?> resubmitApproval(
|
||||
ApprovalResubmissionInput input,
|
||||
) async {
|
||||
final useCase = _resubmitUseCase;
|
||||
if (useCase == null) {
|
||||
throw StateError('ResubmitApprovalUseCase가 주입되지 않았습니다.');
|
||||
}
|
||||
_isPerformingAction = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final flow = await useCase.call(input);
|
||||
final targetId = flow.approval.id ?? input.approvalId;
|
||||
await _refreshAfterAction(targetId, flow: flow);
|
||||
return flow;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} finally {
|
||||
_isPerformingAction = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool get hasActiveFilters =>
|
||||
_query.trim().isNotEmpty ||
|
||||
_actionFilter != ApprovalHistoryActionFilter.all ||
|
||||
_from != null ||
|
||||
_to != null;
|
||||
|
||||
bool get hasActiveAuditFilters =>
|
||||
(_auditActorId ?? 0) > 0 ||
|
||||
(_auditActionCode != null && _auditActionCode!.trim().isNotEmpty) ||
|
||||
_auditFrom != null ||
|
||||
_auditTo != null;
|
||||
|
||||
int? _resolveAuditActionId() {
|
||||
final code = _auditActionCode?.trim();
|
||||
if (code == null || code.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final action = _auditActions[code];
|
||||
return action?.id;
|
||||
}
|
||||
|
||||
/// 현재 선택 상태와 캐시를 초기화한다.
|
||||
void clearSelection() {
|
||||
if (_selectedApprovalId == null &&
|
||||
_auditResult == null &&
|
||||
_selectedFlow == null) {
|
||||
return;
|
||||
}
|
||||
_selectedApprovalId = null;
|
||||
_selectedFlow = null;
|
||||
_auditResult = null;
|
||||
_isSelectionForbidden = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int _resolvePage(int requested, PaginatedResult<dynamic>? current) {
|
||||
if (requested < 1) {
|
||||
return 1;
|
||||
}
|
||||
if (current != null && current.pageSize > 0) {
|
||||
final calculated = (current.total / current.pageSize).ceil();
|
||||
final maxPage = calculated < 1 ? 1 : calculated;
|
||||
return requested > maxPage ? maxPage : requested;
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
Future<void> _refreshAfterAction(int approvalId, {ApprovalFlow? flow}) async {
|
||||
await fetch(page: _result?.page ?? 1);
|
||||
if (flow != null) {
|
||||
_flowCache[approvalId] = flow;
|
||||
if (_selectedApprovalId == approvalId) {
|
||||
_selectedFlow = flow;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
if (_selectedApprovalId == approvalId) {
|
||||
if (flow == null && _approvalRepository != null) {
|
||||
await loadApprovalFlow(approvalId, force: true);
|
||||
}
|
||||
if (_approvalRepository != null) {
|
||||
await fetchAuditLogs(
|
||||
approvalId: approvalId,
|
||||
page: _auditResult?.page ?? 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../approvals/domain/entities/approval.dart';
|
||||
import '../../../shared/widgets/approval_ui_helpers.dart';
|
||||
import '../../../../../widgets/components/superport_table.dart';
|
||||
|
||||
/// 결재 감사 로그를 표 형태로 렌더링하는 위젯.
|
||||
class ApprovalAuditLogTable extends StatelessWidget {
|
||||
const ApprovalAuditLogTable({
|
||||
super.key,
|
||||
required this.logs,
|
||||
required this.dateFormat,
|
||||
this.pagination,
|
||||
this.onPageChange,
|
||||
this.onPageSizeChange,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
/// 감사 로그 목록.
|
||||
final List<ApprovalHistory> logs;
|
||||
|
||||
/// 날짜 포맷터.
|
||||
final DateFormat dateFormat;
|
||||
|
||||
/// 페이지네이션 상태.
|
||||
final SuperportTablePagination? pagination;
|
||||
|
||||
/// 페이지 변경 콜백.
|
||||
final ValueChanged<int>? onPageChange;
|
||||
|
||||
/// 페이지 크기 변경 콜백.
|
||||
final ValueChanged<int>? onPageSizeChange;
|
||||
|
||||
/// 로딩 여부.
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
if (logs.isEmpty) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Center(
|
||||
child: Text('선택한 결재의 감사 로그가 없습니다.', style: theme.textTheme.muted),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SuperportTable(
|
||||
columns: const [
|
||||
Text('행위'),
|
||||
Text('변경 상태'),
|
||||
Text('승인자'),
|
||||
Text('메모'),
|
||||
Text('일시'),
|
||||
],
|
||||
rows: logs.map((log) {
|
||||
final statusLabel = _buildStatusLabel(log);
|
||||
final timestamp = dateFormat.format(log.actionAt.toLocal());
|
||||
return [
|
||||
ShadBadge.outline(child: Text(log.action.name)),
|
||||
ApprovalStatusBadge(label: statusLabel, colorHex: log.toStatus.color),
|
||||
ApprovalApproverCell(
|
||||
name: log.approver.name,
|
||||
employeeNo: log.approver.employeeNo,
|
||||
),
|
||||
ApprovalNoteTooltip(note: log.note),
|
||||
Text(timestamp),
|
||||
];
|
||||
}).toList(),
|
||||
rowHeight: 68,
|
||||
maxHeight: 420,
|
||||
columnSpanExtent: (index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return const FixedTableSpanExtent(120);
|
||||
case 2:
|
||||
return const FixedTableSpanExtent(220);
|
||||
case 3:
|
||||
return const FixedTableSpanExtent(220);
|
||||
case 4:
|
||||
return const FixedTableSpanExtent(160);
|
||||
default:
|
||||
return const FixedTableSpanExtent(140);
|
||||
}
|
||||
},
|
||||
pagination: pagination,
|
||||
onPageChange: onPageChange,
|
||||
onPageSizeChange: onPageSizeChange,
|
||||
isLoading: isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
String _buildStatusLabel(ApprovalHistory log) {
|
||||
final from = log.fromStatus?.name ?? '시작';
|
||||
final to = log.toStatus.name;
|
||||
return '$from → $to';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../approvals/domain/entities/approval.dart';
|
||||
import '../../../../approvals/domain/entities/approval_flow.dart';
|
||||
import '../../../shared/widgets/approval_ui_helpers.dart';
|
||||
|
||||
/// 결재 흐름의 상태 변화를 타임라인으로 표현하는 위젯.
|
||||
class ApprovalFlowTimeline extends StatelessWidget {
|
||||
const ApprovalFlowTimeline({
|
||||
super.key,
|
||||
required this.flow,
|
||||
required this.dateFormat,
|
||||
});
|
||||
|
||||
/// 표시할 결재 흐름.
|
||||
final ApprovalFlow flow;
|
||||
|
||||
/// 일시 포맷터.
|
||||
final DateFormat dateFormat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final histories = List<ApprovalHistory>.from(flow.histories)
|
||||
..sort((a, b) => a.actionAt.compareTo(b.actionAt));
|
||||
final summary = flow.statusSummary;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSummary(theme, summary),
|
||||
const SizedBox(height: 16),
|
||||
if (histories.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondary.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text('결재 상태 변경 이력이 없습니다.', style: theme.textTheme.muted),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
for (var index = 0; index < histories.length; index++)
|
||||
_TimelineEntry(
|
||||
history: histories[index],
|
||||
isFirst: index == 0,
|
||||
isLast: index == histories.length - 1,
|
||||
dateFormat: dateFormat,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummary(ShadThemeData theme, ApprovalFlowStatusSummary summary) {
|
||||
final requester = flow.requester;
|
||||
final finalApprover = flow.finalApprover;
|
||||
final status = flow.status;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ApprovalStatusBadge(label: status.name, colorHex: status.color),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'총 ${summary.totalSteps}단계 · 완료 ${summary.completedSteps} · 대기 ${summary.pendingSteps}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'상신자',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${requester.name} (${requester.employeeNo})',
|
||||
style: theme.textTheme.p,
|
||||
),
|
||||
if (finalApprover != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'최종 승인자',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${finalApprover.name} (${finalApprover.employeeNo})',
|
||||
style: theme.textTheme.p,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'결재번호 ${flow.approvalNo}',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineEntry extends StatelessWidget {
|
||||
const _TimelineEntry({
|
||||
required this.history,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
required this.dateFormat,
|
||||
});
|
||||
|
||||
final ApprovalHistory history;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final DateFormat dateFormat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final fromStatus = history.fromStatus?.name ?? '시작';
|
||||
final toStatus = history.toStatus.name;
|
||||
final timestamp = dateFormat.format(history.actionAt.toLocal());
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: isFirst ? 0 : 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTimelineIndicator(theme),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
history.action.name,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(timestamp, style: theme.textTheme.muted),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('$fromStatus → $toStatus', style: theme.textTheme.small),
|
||||
const SizedBox(height: 12),
|
||||
ApprovalApproverCell(
|
||||
name: history.approver.name,
|
||||
employeeNo: history.approver.employeeNo,
|
||||
),
|
||||
if (history.note?.trim().isNotEmpty == true) ...[
|
||||
const SizedBox(height: 12),
|
||||
ApprovalNoteTooltip(note: history.note),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimelineIndicator(ShadThemeData theme) {
|
||||
final primary = theme.colorScheme.primary;
|
||||
return SizedBox(
|
||||
width: 20,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
Container(
|
||||
width: 2,
|
||||
height: 40,
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
decoration: BoxDecoration(color: primary.withValues(alpha: 0.4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:superport_v2/core/network/failure.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
@@ -6,13 +8,18 @@ import 'package:superport_v2/core/common/utils/pagination_utils.dart';
|
||||
import '../../../inventory/lookups/domain/entities/lookup_item.dart';
|
||||
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||
import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_draft.dart';
|
||||
import '../../domain/entities/approval_proceed_status.dart';
|
||||
import '../../domain/entities/approval_template.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
import '../../domain/repositories/approval_template_repository.dart';
|
||||
import '../../domain/usecases/get_approval_draft_use_case.dart';
|
||||
import '../../domain/usecases/list_approval_drafts_use_case.dart';
|
||||
import '../../domain/usecases/save_approval_draft_use_case.dart';
|
||||
|
||||
enum ApprovalStatusFilter {
|
||||
all,
|
||||
draft,
|
||||
pending,
|
||||
inProgress,
|
||||
onHold,
|
||||
@@ -29,6 +36,7 @@ const Map<ApprovalStepActionType, List<String>> _actionAliases = {
|
||||
};
|
||||
|
||||
const Map<ApprovalStatusFilter, String> _defaultStatusCodes = {
|
||||
ApprovalStatusFilter.draft: 'draft',
|
||||
ApprovalStatusFilter.pending: 'pending',
|
||||
ApprovalStatusFilter.inProgress: 'in_progress',
|
||||
ApprovalStatusFilter.onHold: 'on_hold',
|
||||
@@ -36,6 +44,12 @@ const Map<ApprovalStatusFilter, String> _defaultStatusCodes = {
|
||||
ApprovalStatusFilter.rejected: 'rejected',
|
||||
};
|
||||
|
||||
const List<String> _pendingFallbackStatusCodes = [
|
||||
'draft',
|
||||
'submitted',
|
||||
'in_progress',
|
||||
];
|
||||
|
||||
/// 결재 목록 및 상세 화면 상태 컨트롤러
|
||||
///
|
||||
/// - 목록 조회/필터 상태와 선택된 결재 상세 데이터를 관리한다.
|
||||
@@ -45,13 +59,22 @@ class ApprovalController extends ChangeNotifier {
|
||||
required ApprovalRepository approvalRepository,
|
||||
required ApprovalTemplateRepository templateRepository,
|
||||
InventoryLookupRepository? lookupRepository,
|
||||
SaveApprovalDraftUseCase? saveDraftUseCase,
|
||||
GetApprovalDraftUseCase? getDraftUseCase,
|
||||
ListApprovalDraftsUseCase? listDraftsUseCase,
|
||||
}) : _repository = approvalRepository,
|
||||
_templateRepository = templateRepository,
|
||||
_lookupRepository = lookupRepository;
|
||||
_lookupRepository = lookupRepository,
|
||||
_saveDraftUseCase = saveDraftUseCase,
|
||||
_getDraftUseCase = getDraftUseCase,
|
||||
_listDraftsUseCase = listDraftsUseCase;
|
||||
|
||||
final ApprovalRepository _repository;
|
||||
final ApprovalTemplateRepository _templateRepository;
|
||||
final InventoryLookupRepository? _lookupRepository;
|
||||
final SaveApprovalDraftUseCase? _saveDraftUseCase;
|
||||
final GetApprovalDraftUseCase? _getDraftUseCase;
|
||||
final ListApprovalDraftsUseCase? _listDraftsUseCase;
|
||||
|
||||
PaginatedResult<Approval>? _result;
|
||||
Approval? _selected;
|
||||
@@ -65,6 +88,7 @@ class ApprovalController extends ChangeNotifier {
|
||||
bool _isApplyingTemplate = false;
|
||||
int? _applyingTemplateId;
|
||||
ApprovalProceedStatus? _proceedStatus;
|
||||
ApprovalSubmissionInput? _submissionDraft;
|
||||
String? _errorMessage;
|
||||
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
|
||||
int? _transactionIdFilter;
|
||||
@@ -111,6 +135,9 @@ class ApprovalController extends ChangeNotifier {
|
||||
return reason;
|
||||
}
|
||||
|
||||
ApprovalSubmissionInput? get submissionDraft => _submissionDraft;
|
||||
bool get hasSubmissionDraft => _submissionDraft != null;
|
||||
|
||||
List<LookupItem> get approvalStatusOptions => _statusOptions;
|
||||
|
||||
int? get defaultApprovalStatusId {
|
||||
@@ -163,12 +190,15 @@ class ApprovalController extends ChangeNotifier {
|
||||
resolvedPage = page;
|
||||
}
|
||||
final statusId = _statusIdFor(_statusFilter);
|
||||
final statusCodes = _statusCodesFor(_statusFilter);
|
||||
final response = await _repository.list(
|
||||
page: resolvedPage,
|
||||
pageSize: _result?.pageSize ?? 20,
|
||||
transactionId: _transactionIdFilter,
|
||||
approvalStatusId: statusId,
|
||||
requestedById: _requestedById,
|
||||
statusCodes: statusCodes.isEmpty ? null : statusCodes,
|
||||
includePending: _statusFilter == ApprovalStatusFilter.all,
|
||||
includeSteps: false,
|
||||
includeHistories: false,
|
||||
);
|
||||
@@ -255,7 +285,7 @@ class ApprovalController extends ChangeNotifier {
|
||||
|
||||
String statusLabel(ApprovalStatusFilter filter) {
|
||||
if (filter == ApprovalStatusFilter.all) {
|
||||
return '전체 상태';
|
||||
return '전체 상태 (임시저장·진행 포함)';
|
||||
}
|
||||
final code = _statusCodeFor(filter);
|
||||
if (code != null) {
|
||||
@@ -271,6 +301,7 @@ class ApprovalController extends ChangeNotifier {
|
||||
ApprovalStatusFilter.onHold => '보류',
|
||||
ApprovalStatusFilter.approved => '승인완료',
|
||||
ApprovalStatusFilter.rejected => '반려',
|
||||
ApprovalStatusFilter.draft => '임시저장',
|
||||
ApprovalStatusFilter.all => '전체 상태',
|
||||
};
|
||||
}
|
||||
@@ -295,6 +326,22 @@ class ApprovalController extends ChangeNotifier {
|
||||
return lookup?.id;
|
||||
}
|
||||
|
||||
List<String> _statusCodesFor(ApprovalStatusFilter filter) {
|
||||
if (filter == ApprovalStatusFilter.all) {
|
||||
return const <String>[];
|
||||
}
|
||||
final code = _statusCodeFor(filter);
|
||||
if (filter == ApprovalStatusFilter.pending) {
|
||||
if (code == null || code.toLowerCase() == 'pending') {
|
||||
return List<String>.unmodifiable(_pendingFallbackStatusCodes);
|
||||
}
|
||||
}
|
||||
if (code == null || code.isEmpty) {
|
||||
return const <String>[];
|
||||
}
|
||||
return List<String>.unmodifiable(<String>[code]);
|
||||
}
|
||||
|
||||
/// 활성화된 결재 템플릿 목록을 조회해 캐싱한다.
|
||||
///
|
||||
/// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다.
|
||||
@@ -362,6 +409,141 @@ class ApprovalController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재 상신 초안을 보관한다.
|
||||
void cacheSubmissionDraft(ApprovalSubmissionInput draft) {
|
||||
_submissionDraft = draft;
|
||||
notifyListeners();
|
||||
_persistSubmissionDraft(draft);
|
||||
}
|
||||
|
||||
/// 저장된 결재 상신 초안을 반환하고 초기화한다.
|
||||
ApprovalSubmissionInput? consumeSubmissionDraft() {
|
||||
final draft = _submissionDraft;
|
||||
if (draft == null) {
|
||||
return null;
|
||||
}
|
||||
_submissionDraft = null;
|
||||
notifyListeners();
|
||||
return draft;
|
||||
}
|
||||
|
||||
/// 결재 상신 초안을 초기화한다.
|
||||
void clearSubmissionDraft() {
|
||||
if (_submissionDraft == null) {
|
||||
return;
|
||||
}
|
||||
_submissionDraft = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<ApprovalSubmissionInput?> restoreSubmissionDraft({
|
||||
required int requesterId,
|
||||
int? transactionId,
|
||||
}) async {
|
||||
final listUseCase = _listDraftsUseCase;
|
||||
final getUseCase = _getDraftUseCase;
|
||||
if (listUseCase == null || getUseCase == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final filter = ApprovalDraftListFilter(
|
||||
requesterId: requesterId,
|
||||
transactionId: transactionId,
|
||||
pageSize: 10,
|
||||
);
|
||||
final result = await listUseCase.call(filter);
|
||||
if (result.items.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final sessionKey = _submissionSessionKey(requesterId);
|
||||
final summary = result.items.firstWhere(
|
||||
(item) => item.sessionKey == sessionKey,
|
||||
orElse: () => result.items.first,
|
||||
);
|
||||
final detail = await getUseCase.call(
|
||||
id: summary.id,
|
||||
requesterId: requesterId,
|
||||
);
|
||||
if (detail == null) {
|
||||
return null;
|
||||
}
|
||||
final submission = detail.toSubmissionInput(
|
||||
defaultStatusId: _defaultSubmissionStatusId(),
|
||||
transactionIdOverride: transactionId ?? detail.transactionId,
|
||||
);
|
||||
_submissionDraft = submission;
|
||||
notifyListeners();
|
||||
return submission;
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('[ApprovalController] 초안 복구 실패: $error\n$stackTrace');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _persistSubmissionDraft(ApprovalSubmissionInput draft) {
|
||||
final useCase = _saveDraftUseCase;
|
||||
if (useCase == null) {
|
||||
return;
|
||||
}
|
||||
if (draft.steps.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final input = _buildSubmissionDraftInput(draft);
|
||||
if (!input.hasSteps) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
Future<void>(() async {
|
||||
try {
|
||||
await useCase.call(input);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('[ApprovalController] 초안 저장 실패: $error\n$stackTrace');
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
ApprovalDraftSaveInput _buildSubmissionDraftInput(
|
||||
ApprovalSubmissionInput draft,
|
||||
) {
|
||||
final steps = draft.steps
|
||||
.map(
|
||||
(step) => ApprovalDraftStep(
|
||||
stepOrder: step.stepOrder,
|
||||
approverId: step.approverId,
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
return ApprovalDraftSaveInput(
|
||||
requesterId: draft.requesterId,
|
||||
transactionId: draft.transactionId,
|
||||
templateId: draft.templateId,
|
||||
title: draft.title,
|
||||
summary: draft.summary,
|
||||
note: draft.note,
|
||||
metadata: draft.metadata,
|
||||
sessionKey: _submissionSessionKey(draft.requesterId),
|
||||
statusId: draft.statusId,
|
||||
steps: steps,
|
||||
);
|
||||
}
|
||||
|
||||
int? _defaultSubmissionStatusId() {
|
||||
final pendingId = _statusIdFor(ApprovalStatusFilter.pending);
|
||||
if (pendingId != null && pendingId > 0) {
|
||||
return pendingId;
|
||||
}
|
||||
final draftId = _statusIdFor(ApprovalStatusFilter.draft);
|
||||
if (draftId != null && draftId > 0) {
|
||||
return draftId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _submissionSessionKey(int requesterId) =>
|
||||
'approval_submission_$requesterId';
|
||||
|
||||
/// 결재를 생성하고 목록/상세 상태를 최신화한다.
|
||||
Future<Approval?> createApproval(ApprovalCreateInput input) async {
|
||||
_setSubmitting(true);
|
||||
|
||||
@@ -19,6 +19,9 @@ import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_template.dart';
|
||||
import '../../domain/repositories/approval_repository.dart';
|
||||
import '../../domain/repositories/approval_template_repository.dart';
|
||||
import '../../domain/usecases/get_approval_draft_use_case.dart';
|
||||
import '../../domain/usecases/list_approval_drafts_use_case.dart';
|
||||
import '../../domain/usecases/save_approval_draft_use_case.dart';
|
||||
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||
import '../../../inventory/shared/widgets/employee_autocomplete_field.dart';
|
||||
import '../controllers/approval_controller.dart';
|
||||
@@ -98,6 +101,15 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>()
|
||||
? GetIt.I<InventoryLookupRepository>()
|
||||
: null,
|
||||
saveDraftUseCase: GetIt.I.isRegistered<SaveApprovalDraftUseCase>()
|
||||
? GetIt.I<SaveApprovalDraftUseCase>()
|
||||
: null,
|
||||
getDraftUseCase: GetIt.I.isRegistered<GetApprovalDraftUseCase>()
|
||||
? GetIt.I<GetApprovalDraftUseCase>()
|
||||
: null,
|
||||
listDraftsUseCase: GetIt.I.isRegistered<ListApprovalDraftsUseCase>()
|
||||
? GetIt.I<ListApprovalDraftsUseCase>()
|
||||
: null,
|
||||
)..addListener(_handleControllerUpdate);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await Future.wait([
|
||||
@@ -307,24 +319,28 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: ShadSelect<ApprovalStatusFilter>(
|
||||
key: ValueKey(_controller.statusFilter),
|
||||
initialValue: _controller.statusFilter,
|
||||
selectedOptionBuilder: (context, value) =>
|
||||
Text(_statusLabel(value)),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
_controller.updateStatusFilter(value);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
options: ApprovalStatusFilter.values
|
||||
.map(
|
||||
(filter) => ShadOption(
|
||||
value: filter,
|
||||
child: Text(_statusLabel(filter)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Tooltip(
|
||||
message: '전체 상태 선택 시 임시저장·상신·진행중 결재까지 함께 조회합니다.',
|
||||
waitDuration: const Duration(milliseconds: 200),
|
||||
child: ShadSelect<ApprovalStatusFilter>(
|
||||
key: ValueKey(_controller.statusFilter),
|
||||
initialValue: _controller.statusFilter,
|
||||
selectedOptionBuilder: (context, value) =>
|
||||
Text(_statusLabel(value)),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
_controller.updateStatusFilter(value);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
options: ApprovalStatusFilter.values
|
||||
.map(
|
||||
(filter) => ShadOption(
|
||||
value: filter,
|
||||
child: Text(_statusLabel(filter)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -875,6 +891,11 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
style: shadTheme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_dialogDescription(type),
|
||||
style: shadTheme.textTheme.muted,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('비고', style: shadTheme.textTheme.small),
|
||||
const SizedBox(height: 8),
|
||||
ShadTextarea(
|
||||
@@ -950,11 +971,11 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
String _dialogTitle(ApprovalStepActionType type) {
|
||||
switch (type) {
|
||||
case ApprovalStepActionType.approve:
|
||||
return '단계 승인';
|
||||
return '결재 단계 승인';
|
||||
case ApprovalStepActionType.reject:
|
||||
return '단계 반려';
|
||||
return '결재 단계 반려';
|
||||
case ApprovalStepActionType.comment:
|
||||
return '코멘트 등록';
|
||||
return '결재 단계 코멘트';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -979,6 +1000,17 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
return '코멘트를 등록했습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
String _dialogDescription(ApprovalStepActionType type) {
|
||||
switch (type) {
|
||||
case ApprovalStepActionType.approve:
|
||||
return '승인하면 다음 단계로 진행합니다. 필요 시 비고를 남길 수 있습니다.';
|
||||
case ApprovalStepActionType.reject:
|
||||
return '반려 사유를 입력해 단계를 반려합니다. 비고는 선택 사항입니다.';
|
||||
case ApprovalStepActionType.comment:
|
||||
return '코멘트를 등록하면 결재 참여자에게 공유됩니다. 비고 입력이 필요합니다.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ApprovalTable extends StatelessWidget {
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../../approvals/domain/entities/approval.dart';
|
||||
import '../../../../approvals/domain/entities/approval_flow.dart';
|
||||
import '../../../../approvals/domain/entities/approval_template.dart';
|
||||
import '../../../../approvals/domain/repositories/approval_template_repository.dart';
|
||||
import '../../../../approvals/domain/usecases/apply_approval_template_use_case.dart';
|
||||
import '../../../../approvals/domain/usecases/save_approval_template_use_case.dart';
|
||||
import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||
|
||||
/// 결재 요청 화면에서 사용하는 참가자 요약 정보.
|
||||
///
|
||||
/// - 상신자(requester)와 승인자(approver)에 공통으로 적용한다.
|
||||
class ApprovalRequestParticipant {
|
||||
const ApprovalRequestParticipant({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.employeeNo,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String employeeNo;
|
||||
|
||||
/// [ApprovalRequester]로 변환한다.
|
||||
ApprovalRequester toRequester() {
|
||||
return ApprovalRequester(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
|
||||
/// [ApprovalApprover]로 변환한다.
|
||||
ApprovalApprover toApprover() {
|
||||
return ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 요청 단계 상태를 표현한다.
|
||||
class ApprovalRequestStep {
|
||||
const ApprovalRequestStep({
|
||||
required this.stepOrder,
|
||||
required this.approver,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int stepOrder;
|
||||
final ApprovalRequestParticipant approver;
|
||||
final String? note;
|
||||
|
||||
int get approverId => approver.id;
|
||||
|
||||
ApprovalRequestStep copyWith({
|
||||
int? stepOrder,
|
||||
ApprovalRequestParticipant? approver,
|
||||
String? note,
|
||||
}) {
|
||||
return ApprovalRequestStep(
|
||||
stepOrder: stepOrder ?? this.stepOrder,
|
||||
approver: approver ?? this.approver,
|
||||
note: note ?? this.note,
|
||||
);
|
||||
}
|
||||
|
||||
/// 도메인 계층에서 사용하는 [ApprovalStepAssignmentItem]으로 변환한다.
|
||||
ApprovalStepAssignmentItem toAssignmentItem() {
|
||||
return ApprovalStepAssignmentItem(
|
||||
stepOrder: stepOrder,
|
||||
approverId: approver.id,
|
||||
note: note,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 템플릿 버전 정보를 보관한다.
|
||||
class ApprovalTemplateSnapshot {
|
||||
const ApprovalTemplateSnapshot({
|
||||
required this.templateId,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final int templateId;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
ApprovalTemplateSnapshot copyWith({int? templateId, DateTime? updatedAt}) {
|
||||
return ApprovalTemplateSnapshot(
|
||||
templateId: templateId ?? this.templateId,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 요청 상태를 관리하고 검증/전송 모델로 변환하는 컨트롤러.
|
||||
///
|
||||
/// - 최대 98단계까지 결재 단계를 추가할 수 있으며, 승인자 중복을 방지한다.
|
||||
/// - 마지막 단계 승인자가 최종 승인자로 자동 바인딩된다.
|
||||
/// - 템플릿을 적용/변경할 때 버전 정보를 기록해 추후 비교에 활용한다.
|
||||
class ApprovalRequestController extends ChangeNotifier {
|
||||
ApprovalRequestController({
|
||||
int maxSteps = 98,
|
||||
ApprovalTemplateRepository? templateRepository,
|
||||
SaveApprovalTemplateUseCase? saveTemplateUseCase,
|
||||
ApplyApprovalTemplateUseCase? applyTemplateUseCase,
|
||||
}) : assert(maxSteps > 0, 'maxSteps는 1 이상이어야 합니다.'),
|
||||
_maxSteps = maxSteps,
|
||||
_templateRepository = templateRepository,
|
||||
_saveTemplateUseCase = saveTemplateUseCase,
|
||||
_applyTemplateUseCase = applyTemplateUseCase;
|
||||
|
||||
static const int defaultMaxSteps = 98;
|
||||
|
||||
final int _maxSteps;
|
||||
final ApprovalTemplateRepository? _templateRepository;
|
||||
final SaveApprovalTemplateUseCase? _saveTemplateUseCase;
|
||||
final ApplyApprovalTemplateUseCase? _applyTemplateUseCase;
|
||||
|
||||
ApprovalRequestParticipant? _requester;
|
||||
final List<ApprovalRequestStep> _steps = <ApprovalRequestStep>[];
|
||||
ApprovalTemplateSnapshot? _templateSnapshot;
|
||||
bool _isDirty = false;
|
||||
String? _errorMessage;
|
||||
bool _isApplyingTemplate = false;
|
||||
|
||||
ApprovalRequestParticipant? get requester => _requester;
|
||||
List<ApprovalRequestStep> get steps => List.unmodifiable(_steps);
|
||||
int get maxSteps => _maxSteps;
|
||||
bool get hasReachedStepLimit => _steps.length >= _maxSteps;
|
||||
bool get hasDuplicateApprover =>
|
||||
_steps.map((step) => step.approverId).toSet().length != _steps.length;
|
||||
bool get hasRequesterConflict {
|
||||
final requester = _requester;
|
||||
if (requester == null) {
|
||||
return false;
|
||||
}
|
||||
return _steps.any((step) => step.approverId == requester.id);
|
||||
}
|
||||
|
||||
int get totalSteps => _steps.length;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get isDirty => _isDirty;
|
||||
bool get isApplyingTemplate => _isApplyingTemplate;
|
||||
ApprovalTemplateSnapshot? get templateSnapshot => _templateSnapshot;
|
||||
|
||||
ApprovalRequestParticipant? get finalApprover {
|
||||
if (_steps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _steps.last.approver;
|
||||
}
|
||||
|
||||
int? get finalApproverId => finalApprover?.id;
|
||||
|
||||
/// 상신자를 설정한다.
|
||||
void setRequester(ApprovalRequestParticipant? participant) {
|
||||
if (_requester == participant) {
|
||||
return;
|
||||
}
|
||||
_requester = participant;
|
||||
if (participant != null &&
|
||||
_steps.any((step) => step.approverId == participant.id)) {
|
||||
_markDirty();
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
_isDirty = true;
|
||||
_clearError();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재 단계를 추가한다.
|
||||
///
|
||||
/// - 최대 단계 수를 초과하거나 중복 승인자를 추가하면 false를 반환한다.
|
||||
bool addStep({required ApprovalRequestParticipant approver, String? note}) {
|
||||
if (hasReachedStepLimit) {
|
||||
_setError('결재 단계는 최대 $_maxSteps개까지 추가할 수 있습니다.');
|
||||
return false;
|
||||
}
|
||||
final duplicated = _steps.any((step) => step.approverId == approver.id);
|
||||
if (_conflictsWithRequester(approver)) {
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
if (duplicated) {
|
||||
_setError('동일한 승인자는 한 번만 추가할 수 있습니다.');
|
||||
return false;
|
||||
}
|
||||
_clearError();
|
||||
final step = ApprovalRequestStep(
|
||||
stepOrder: _steps.length + 1,
|
||||
approver: approver,
|
||||
note: note,
|
||||
);
|
||||
_steps.add(step);
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 지정된 위치의 결재 단계를 제거한다.
|
||||
void removeStepAt(int index) {
|
||||
if (index < 0 || index >= _steps.length) {
|
||||
return;
|
||||
}
|
||||
_clearError();
|
||||
_steps.removeAt(index);
|
||||
_reassignStepOrders();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재 단계의 순서를 이동한다.
|
||||
void moveStep(int oldIndex, int newIndex) {
|
||||
if (oldIndex < 0 ||
|
||||
oldIndex >= _steps.length ||
|
||||
newIndex < 0 ||
|
||||
newIndex >= _steps.length ||
|
||||
oldIndex == newIndex) {
|
||||
return;
|
||||
}
|
||||
_clearError();
|
||||
final step = _steps.removeAt(oldIndex);
|
||||
_steps.insert(newIndex, step);
|
||||
_reassignStepOrders();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재 단계를 수정한다.
|
||||
///
|
||||
/// - 승인자를 변경할 경우 중복 여부를 검사한다.
|
||||
bool updateStep(
|
||||
int index, {
|
||||
ApprovalRequestParticipant? approver,
|
||||
String? note,
|
||||
}) {
|
||||
if (index < 0 || index >= _steps.length) {
|
||||
return false;
|
||||
}
|
||||
final current = _steps[index];
|
||||
final nextApprover = approver ?? current.approver;
|
||||
final duplicated =
|
||||
approver != null &&
|
||||
_steps.asMap().entries.any(
|
||||
(entry) =>
|
||||
entry.key != index && entry.value.approverId == nextApprover.id,
|
||||
);
|
||||
if (_conflictsWithRequester(nextApprover)) {
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
if (duplicated) {
|
||||
_setError('동일한 승인자는 한 번만 추가할 수 있습니다.');
|
||||
return false;
|
||||
}
|
||||
_clearError();
|
||||
_steps[index] = current.copyWith(approver: approver, note: note);
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 최종 승인자를 지정한다.
|
||||
///
|
||||
/// - 단계가 없으면 새로운 마지막 단계를 추가한다.
|
||||
/// - 이미 존재하는 경우 마지막 단계만 해당 승인자로 교체한다.
|
||||
bool setFinalApprover(ApprovalRequestParticipant approver, {String? note}) {
|
||||
if (_conflictsWithRequester(approver)) {
|
||||
_setError('최종 승인자는 상신자와 다른 사람이어야 합니다.');
|
||||
return false;
|
||||
}
|
||||
if (_steps.isEmpty) {
|
||||
return addStep(approver: approver, note: note);
|
||||
}
|
||||
final duplicateOtherIndex = _steps
|
||||
.sublist(0, _steps.length - 1)
|
||||
.any((step) => step.approverId == approver.id);
|
||||
if (duplicateOtherIndex) {
|
||||
_setError('최종 승인자는 다른 단계와 중복될 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
final lastIndex = _steps.length - 1;
|
||||
final last = _steps[lastIndex];
|
||||
_steps[lastIndex] = last.copyWith(
|
||||
approver: approver,
|
||||
note: note ?? last.note,
|
||||
);
|
||||
_clearError();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 템플릿 단계를 그대로 적용한다.
|
||||
void applyTemplateSteps(List<ApprovalRequestStep> steps) {
|
||||
if (_requester != null &&
|
||||
steps.any((step) => step.approverId == _requester!.id)) {
|
||||
_markDirty();
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
_steps
|
||||
..clear()
|
||||
..addAll(steps);
|
||||
_reassignStepOrders();
|
||||
_clearError();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 템플릿 스냅샷을 기록한다.
|
||||
void setTemplateSnapshot(ApprovalTemplateSnapshot? snapshot) {
|
||||
_templateSnapshot = snapshot;
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 템플릿 버전이 최신인지 간단히 확인한다.
|
||||
bool isTemplateUpToDate(DateTime? serverUpdatedAt) {
|
||||
final snapshot = _templateSnapshot;
|
||||
if (snapshot == null) {
|
||||
return true;
|
||||
}
|
||||
if (snapshot.updatedAt == null || serverUpdatedAt == null) {
|
||||
return true;
|
||||
}
|
||||
return !snapshot.updatedAt!.isBefore(serverUpdatedAt);
|
||||
}
|
||||
|
||||
/// 현재 상태로부터 결재 상신 입력 모델을 생성한다.
|
||||
ApprovalSubmissionInput buildSubmissionInput({
|
||||
int? transactionId,
|
||||
int? templateId,
|
||||
required int statusId,
|
||||
DateTime? requestedAt,
|
||||
DateTime? decidedAt,
|
||||
DateTime? cancelledAt,
|
||||
DateTime? lastActionAt,
|
||||
String? title,
|
||||
String? summary,
|
||||
String? note,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
final requester = _ensureRequester();
|
||||
final steps = _ensureSteps();
|
||||
return ApprovalSubmissionInput(
|
||||
transactionId: transactionId,
|
||||
templateId: templateId ?? _templateSnapshot?.templateId,
|
||||
statusId: statusId,
|
||||
requesterId: requester.id,
|
||||
finalApproverId: steps.isEmpty ? null : steps.last.approverId,
|
||||
requestedAt: requestedAt,
|
||||
decidedAt: decidedAt,
|
||||
cancelledAt: cancelledAt,
|
||||
lastActionAt: lastActionAt,
|
||||
title: title,
|
||||
summary: summary,
|
||||
note: note,
|
||||
metadata: metadata,
|
||||
steps: steps.map((step) => step.toAssignmentItem()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 재고 전표 결재 입력 모델로 변환한다.
|
||||
StockTransactionApprovalInput buildTransactionApprovalInput({
|
||||
int? approvalStatusId,
|
||||
int? templateId,
|
||||
DateTime? requestedAt,
|
||||
DateTime? decidedAt,
|
||||
DateTime? cancelledAt,
|
||||
DateTime? lastActionAt,
|
||||
String? title,
|
||||
String? summary,
|
||||
String? note,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
final requester = _ensureRequester();
|
||||
final steps = _ensureSteps();
|
||||
return StockTransactionApprovalInput(
|
||||
requestedById: requester.id,
|
||||
approvalStatusId: approvalStatusId,
|
||||
templateId: templateId ?? _templateSnapshot?.templateId,
|
||||
finalApproverId: steps.isEmpty ? null : steps.last.approverId,
|
||||
requestedAt: requestedAt,
|
||||
decidedAt: decidedAt,
|
||||
cancelledAt: cancelledAt,
|
||||
lastActionAt: lastActionAt,
|
||||
title: title,
|
||||
summary: summary,
|
||||
note: note,
|
||||
metadata: metadata,
|
||||
steps: steps.map((step) => step.toAssignmentItem()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 현재 상태를 초기화한다.
|
||||
void clear() {
|
||||
_requester = null;
|
||||
_steps.clear();
|
||||
_templateSnapshot = null;
|
||||
_errorMessage = null;
|
||||
_isDirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 템플릿을 저장 후 상태를 갱신한다.
|
||||
///
|
||||
/// 외부에서 저장 유즈케이스를 주입한 경우에만 동작한다.
|
||||
Future<ApprovalTemplate?> saveTemplate({
|
||||
int? templateId,
|
||||
required ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput>? steps,
|
||||
}) async {
|
||||
final useCase = _saveTemplateUseCase;
|
||||
if (useCase == null) {
|
||||
throw StateError('SaveApprovalTemplateUseCase가 주입되지 않았습니다.');
|
||||
}
|
||||
final template = await useCase.call(
|
||||
templateId: templateId,
|
||||
input: input,
|
||||
steps: steps,
|
||||
);
|
||||
_templateSnapshot = ApprovalTemplateSnapshot(
|
||||
templateId: template.id,
|
||||
updatedAt: template.updatedAt,
|
||||
);
|
||||
_clearError();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return template;
|
||||
}
|
||||
|
||||
/// 템플릿을 적용해 결재 단계를 갱신한다.
|
||||
///
|
||||
/// - 템플릿 저장소와 Apply 유즈케이스가 모두 주입된 경우에만 지원한다.
|
||||
Future<ApprovalFlow?> applyTemplate({
|
||||
required int approvalId,
|
||||
required int templateId,
|
||||
}) async {
|
||||
final repository = _templateRepository;
|
||||
final useCase = _applyTemplateUseCase;
|
||||
if (repository == null || useCase == null) {
|
||||
throw StateError('템플릿 적용을 위한 의존성이 주입되지 않았습니다.');
|
||||
}
|
||||
_isApplyingTemplate = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final template = await repository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (template.steps.isEmpty) {
|
||||
throw StateError('단계가 없는 템플릿은 적용할 수 없습니다.');
|
||||
}
|
||||
final flow = await useCase.call(
|
||||
approvalId: approvalId,
|
||||
templateId: templateId,
|
||||
);
|
||||
_templateSnapshot = ApprovalTemplateSnapshot(
|
||||
templateId: template.id,
|
||||
updatedAt: template.updatedAt,
|
||||
);
|
||||
final steps = template.steps
|
||||
.map(
|
||||
(step) => ApprovalRequestStep(
|
||||
stepOrder: step.stepOrder,
|
||||
approver: ApprovalRequestParticipant(
|
||||
id: step.approver.id,
|
||||
name: step.approver.name,
|
||||
employeeNo: step.approver.employeeNo,
|
||||
),
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
applyTemplateSteps(steps);
|
||||
return flow;
|
||||
} finally {
|
||||
_isApplyingTemplate = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _setError(String message) {
|
||||
_errorMessage = message;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _clearError() {
|
||||
_errorMessage = null;
|
||||
}
|
||||
|
||||
void _reassignStepOrders() {
|
||||
for (var index = 0; index < _steps.length; index++) {
|
||||
final current = _steps[index];
|
||||
_steps[index] = current.copyWith(stepOrder: index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
List<ApprovalRequestStep> _ensureSteps() {
|
||||
if (_steps.isEmpty) {
|
||||
throw StateError('최소 한 개 이상의 결재 단계를 추가해야 합니다.');
|
||||
}
|
||||
if (hasDuplicateApprover) {
|
||||
throw StateError('동일한 승인자가 중복되어 있습니다.');
|
||||
}
|
||||
final requester = _requester;
|
||||
if (requester != null) {
|
||||
for (var index = 0; index < _steps.length; index++) {
|
||||
final step = _steps[index];
|
||||
if (step.approverId != requester.id) {
|
||||
continue;
|
||||
}
|
||||
if (index == _steps.length - 1) {
|
||||
throw StateError('최종 승인자는 상신자와 다른 사람이어야 합니다.');
|
||||
}
|
||||
throw StateError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
return List<ApprovalRequestStep>.unmodifiable(_steps);
|
||||
}
|
||||
|
||||
ApprovalRequestParticipant _ensureRequester() {
|
||||
final requester = _requester;
|
||||
if (requester == null) {
|
||||
throw StateError('상신자를 선택해야 합니다.');
|
||||
}
|
||||
return requester;
|
||||
}
|
||||
|
||||
void _markDirty() {
|
||||
_isDirty = true;
|
||||
}
|
||||
|
||||
bool _conflictsWithRequester(ApprovalRequestParticipant participant) {
|
||||
final requester = _requester;
|
||||
if (requester == null) {
|
||||
return false;
|
||||
}
|
||||
return requester.id == participant.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import '../../../domain/entities/approval.dart';
|
||||
import '../../../shared/approver_catalog.dart';
|
||||
import '../controllers/approval_request_controller.dart';
|
||||
import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||
|
||||
/// 재고 전표 결재 섹션에서 공통으로 사용하는 초기화 유틸리티.
|
||||
///
|
||||
/// - 기존 결재 정보 또는 저장된 초안을 기반으로 단계/상신자를 세팅한다.
|
||||
class ApprovalFormInitializer {
|
||||
ApprovalFormInitializer._();
|
||||
|
||||
/// 결재 구성 컨트롤러에 기본값을 주입한다.
|
||||
static void populate({
|
||||
required ApprovalRequestController controller,
|
||||
Approval? existingApproval,
|
||||
StockTransactionApprovalInput? draft,
|
||||
ApprovalRequestParticipant? defaultRequester,
|
||||
}) {
|
||||
if (existingApproval != null) {
|
||||
_applyExistingApproval(controller, existingApproval);
|
||||
return;
|
||||
}
|
||||
if (defaultRequester != null) {
|
||||
controller.setRequester(defaultRequester);
|
||||
}
|
||||
if (draft != null) {
|
||||
_applyDraft(controller, draft);
|
||||
}
|
||||
}
|
||||
|
||||
static void _applyExistingApproval(
|
||||
ApprovalRequestController controller,
|
||||
Approval approval,
|
||||
) {
|
||||
controller.setRequester(
|
||||
ApprovalRequestParticipant(
|
||||
id: approval.requester.id,
|
||||
name: approval.requester.name,
|
||||
employeeNo: approval.requester.employeeNo,
|
||||
),
|
||||
);
|
||||
final steps = approval.steps
|
||||
.map(
|
||||
(step) => ApprovalRequestStep(
|
||||
stepOrder: step.stepOrder,
|
||||
approver: ApprovalRequestParticipant(
|
||||
id: step.approver.id,
|
||||
name: step.approver.name,
|
||||
employeeNo: step.approver.employeeNo,
|
||||
),
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
if (steps.isNotEmpty) {
|
||||
controller.applyTemplateSteps(steps);
|
||||
}
|
||||
}
|
||||
|
||||
static void _applyDraft(
|
||||
ApprovalRequestController controller,
|
||||
StockTransactionApprovalInput draft,
|
||||
) {
|
||||
final requesterCatalog = ApprovalApproverCatalog.byId(draft.requestedById);
|
||||
if (requesterCatalog != null) {
|
||||
controller.setRequester(
|
||||
ApprovalRequestParticipant(
|
||||
id: requesterCatalog.id,
|
||||
name: requesterCatalog.name,
|
||||
employeeNo: requesterCatalog.employeeNo,
|
||||
),
|
||||
);
|
||||
}
|
||||
final steps = draft.steps
|
||||
.map((step) {
|
||||
final catalog = ApprovalApproverCatalog.byId(step.approverId);
|
||||
if (catalog == null) {
|
||||
return null;
|
||||
}
|
||||
return ApprovalRequestStep(
|
||||
stepOrder: step.stepOrder,
|
||||
approver: ApprovalRequestParticipant(
|
||||
id: catalog.id,
|
||||
name: catalog.name,
|
||||
employeeNo: catalog.employeeNo,
|
||||
),
|
||||
note: step.note,
|
||||
);
|
||||
})
|
||||
.whereType<ApprovalRequestStep>()
|
||||
.toList(growable: false);
|
||||
if (steps.isNotEmpty) {
|
||||
controller.applyTemplateSteps(steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../widgets/components/feedback.dart';
|
||||
import '../../../../../widgets/components/superport_dialog.dart';
|
||||
import '../../../shared/approver_catalog.dart';
|
||||
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||
import '../controllers/approval_request_controller.dart';
|
||||
import 'approval_step_row.dart';
|
||||
import 'approval_template_picker.dart';
|
||||
|
||||
/// 결재 단계 구성을 요약하고 모달을 통해 편집할 수 있는 UI 섹션.
|
||||
class ApprovalStepConfigurator extends StatefulWidget {
|
||||
const ApprovalStepConfigurator({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
/// 결재 단계 상태를 제어하는 컨트롤러.
|
||||
final ApprovalRequestController controller;
|
||||
|
||||
/// 읽기 전용 모드 여부.
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
State<ApprovalStepConfigurator> createState() =>
|
||||
_ApprovalStepConfiguratorState();
|
||||
}
|
||||
|
||||
class _ApprovalStepConfiguratorState extends State<ApprovalStepConfigurator> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final steps = widget.controller.steps;
|
||||
final requester = widget.controller.requester;
|
||||
final finalApprover = widget.controller.finalApprover;
|
||||
final templateSnapshot = widget.controller.templateSnapshot;
|
||||
|
||||
return ShadCard(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'결재 단계 구성',
|
||||
style: theme.textTheme.h4.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'상신자, 중간 승인자, 최종 승인자를 정의하고 템플릿으로 저장할 수 있습니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: widget.readOnly
|
||||
? null
|
||||
: () => _openConfiguratorDialog(context),
|
||||
leading: const Icon(lucide.LucideIcons.settings2, size: 16),
|
||||
child: const Text('단계 구성 편집'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_InfoBadge(
|
||||
icon: lucide.LucideIcons.user,
|
||||
label: '상신자',
|
||||
value: requester?.name ?? '미지정',
|
||||
),
|
||||
_InfoBadge(
|
||||
icon: lucide.LucideIcons.badgeCheck,
|
||||
label: '최종 승인자',
|
||||
value: finalApprover?.name ?? '미지정',
|
||||
),
|
||||
_InfoBadge(
|
||||
icon: lucide.LucideIcons.listOrdered,
|
||||
label: '총 단계',
|
||||
value: '${steps.length}개',
|
||||
),
|
||||
if (templateSnapshot != null)
|
||||
_InfoBadge(
|
||||
icon: lucide.LucideIcons.bookmarkCheck,
|
||||
label: '적용 템플릿',
|
||||
value: '#${templateSnapshot.templateId}',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (steps.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 20,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Text(
|
||||
'등록된 결재 단계가 없습니다. 단계 구성 편집을 눌러 승인자를 추가하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
..._buildStepSummary(theme, steps),
|
||||
if (steps.length > 4)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'+ ${steps.length - 4}개 단계 더 있음',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.controller.errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.controller.errorMessage!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildStepSummary(
|
||||
ShadThemeData theme,
|
||||
List<ApprovalRequestStep> steps,
|
||||
) {
|
||||
final limit = steps.length > 4 ? 4 : steps.length;
|
||||
return [
|
||||
for (var index = 0; index < limit; index++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: index == 0 ? 0 : 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: theme.colorScheme.secondary.withValues(alpha: 0.12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${steps[index].stepOrder}',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${steps[index].approver.name} · ${steps[index].approver.employeeNo}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _openConfiguratorDialog(BuildContext context) {
|
||||
return SuperportDialog.show<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
dialog: SuperportDialog(
|
||||
title: '결재 단계 구성',
|
||||
description: '승인자 목록을 편집하고 템플릿을 적용하거나 저장합니다.',
|
||||
child: _ConfiguratorDialogBody(
|
||||
controller: widget.controller,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfiguratorDialogBody extends StatefulWidget {
|
||||
const _ConfiguratorDialogBody({
|
||||
required this.controller,
|
||||
required this.readOnly,
|
||||
});
|
||||
|
||||
final ApprovalRequestController controller;
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
State<_ConfiguratorDialogBody> createState() =>
|
||||
_ConfiguratorDialogBodyState();
|
||||
}
|
||||
|
||||
class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final steps = widget.controller.steps;
|
||||
final duplicates = _collectDuplicateApproverIds(steps);
|
||||
final isApplyingTemplate = widget.controller.isApplyingTemplate;
|
||||
final hasReachedLimit = widget.controller.hasReachedStepLimit;
|
||||
final requester = widget.controller.requester;
|
||||
final hasRequesterConflict = widget.controller.hasRequesterConflict;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'상신자',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Text(
|
||||
requester == null
|
||||
? '상신자가 아직 지정되지 않았습니다.'
|
||||
: '${requester.name} · ${requester.employeeNo}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
IgnorePointer(
|
||||
ignoring: widget.readOnly,
|
||||
child: Opacity(
|
||||
opacity: widget.readOnly ? 0.6 : 1,
|
||||
child: ApprovalTemplatePicker(
|
||||
controller: widget.controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'결재 단계 목록',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (isApplyingTemplate) ...[
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'템플릿을 적용하는 중입니다...',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
ShadButton.outline(
|
||||
onPressed: widget.readOnly || hasReachedLimit
|
||||
? null
|
||||
: _openAddStepDialog,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(lucide.LucideIcons.plus, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('단계 추가'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (steps.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Text(
|
||||
'결재 단계를 추가해 주세요. 마지막 단계가 자동으로 최종 승인자로 사용됩니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: steps.length,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final step = steps[index];
|
||||
final isFinal = index == steps.length - 1;
|
||||
return ApprovalStepRow(
|
||||
key: ValueKey('approval_step_row_$index'),
|
||||
controller: widget.controller,
|
||||
step: step,
|
||||
index: index,
|
||||
isFinal: isFinal,
|
||||
readOnly: widget.readOnly,
|
||||
hasDuplicateApprover: duplicates.contains(
|
||||
step.approverId,
|
||||
),
|
||||
isRequesterConflict: requester?.id == step.approverId,
|
||||
onRemove: widget.readOnly
|
||||
? null
|
||||
: () => widget.controller.removeStepAt(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (steps.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'각 단계 오른쪽 화살표 버튼으로 순서를 조정할 수 있습니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
if (duplicates.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'동일한 승인자가 중복된 단계가 있습니다. 승인자를 조정해 주세요.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasRequesterConflict)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'상신자와 동일한 승인자는 구성에 포함될 수 없습니다. 다른 승인자를 선택하세요.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasReachedLimit)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'결재 단계는 최대 ${widget.controller.maxSteps}개까지 추가할 수 있습니다.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.controller.errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.controller.errorMessage!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Set<int> _collectDuplicateApproverIds(List<ApprovalRequestStep> steps) {
|
||||
final seen = <int>{};
|
||||
final duplicates = <int>{};
|
||||
for (final step in steps) {
|
||||
final id = step.approverId;
|
||||
if (!seen.add(id)) {
|
||||
duplicates.add(id);
|
||||
}
|
||||
}
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
Future<void> _openAddStepDialog() async {
|
||||
ApprovalApproverCatalogItem? selected;
|
||||
final idController = TextEditingController();
|
||||
|
||||
final result = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
dialog: SuperportDialog(
|
||||
title: '결재 단계 추가',
|
||||
description: '승인자를 검색해 새로운 결재 단계를 추가합니다.',
|
||||
primaryAction: ShadButton(
|
||||
onPressed: () => Navigator.of(context, rootNavigator: true).pop(true),
|
||||
child: const Text('추가'),
|
||||
),
|
||||
secondaryAction: ShadButton.ghost(
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('승인자'),
|
||||
const SizedBox(height: 8),
|
||||
ApprovalApproverAutocompleteField(
|
||||
idController: idController,
|
||||
onSelected: (item) => selected = item,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'상신자와 중복되지 않도록 다른 승인자를 선택해야 합니다.',
|
||||
style: ShadTheme.of(context).textTheme.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
idController.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result != true) {
|
||||
idController.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final participant = _resolveParticipant(selected, idController.text.trim());
|
||||
if (participant == null) {
|
||||
SuperportToast.warning(context, '유효한 승인자를 선택해주세요.');
|
||||
idController.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final added = widget.controller.addStep(approver: participant);
|
||||
if (!added) {
|
||||
final message = widget.controller.errorMessage ?? '결재 단계를 추가하지 못했습니다.';
|
||||
SuperportToast.error(context, message);
|
||||
} else {
|
||||
SuperportToast.success(
|
||||
context,
|
||||
'"${participant.name}" 님을 단계 ${widget.controller.totalSteps}에 추가했습니다.',
|
||||
);
|
||||
}
|
||||
idController.dispose();
|
||||
}
|
||||
|
||||
ApprovalRequestParticipant? _resolveParticipant(
|
||||
ApprovalApproverCatalogItem? selected,
|
||||
String manualInput,
|
||||
) {
|
||||
if (selected != null) {
|
||||
return ApprovalRequestParticipant(
|
||||
id: selected.id,
|
||||
name: selected.name,
|
||||
employeeNo: selected.employeeNo,
|
||||
);
|
||||
}
|
||||
final manualId = int.tryParse(manualInput);
|
||||
if (manualId == null) {
|
||||
return null;
|
||||
}
|
||||
final match = ApprovalApproverCatalog.byId(manualId);
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
return ApprovalRequestParticipant(
|
||||
id: match.id,
|
||||
name: match.name,
|
||||
employeeNo: match.employeeNo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoBadge extends StatelessWidget {
|
||||
const _InfoBadge({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
color: theme.colorScheme.muted.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: theme.colorScheme.mutedForeground),
|
||||
const SizedBox(width: 6),
|
||||
Text('$label: $value', style: theme.textTheme.small),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../widgets/components/feedback.dart';
|
||||
import '../../../shared/approver_catalog.dart';
|
||||
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||
import '../controllers/approval_request_controller.dart';
|
||||
|
||||
/// 결재 단계 테이블에서 단일 행을 편집하기 위한 위젯.
|
||||
///
|
||||
/// - 순번, 승인자 자동완성, 역할/메모 입력, 삭제 버튼을 한 번에 제공한다.
|
||||
class ApprovalStepRow extends StatefulWidget {
|
||||
const ApprovalStepRow({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.step,
|
||||
required this.index,
|
||||
this.onRemove,
|
||||
this.isFinal = false,
|
||||
this.hasDuplicateApprover = false,
|
||||
this.isRequesterConflict = false,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
/// 결재 단계 상태를 관리하는 컨트롤러.
|
||||
final ApprovalRequestController controller;
|
||||
|
||||
/// 현재 행에 해당하는 단계 데이터.
|
||||
final ApprovalRequestStep step;
|
||||
|
||||
/// 행 인덱스(0-base).
|
||||
final int index;
|
||||
|
||||
/// 행 삭제 시 실행할 콜백.
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
/// 마지막 단계(최종 승인자)인지 여부.
|
||||
final bool isFinal;
|
||||
|
||||
/// 승인자 중복 오류가 있는지 여부.
|
||||
final bool hasDuplicateApprover;
|
||||
|
||||
/// 상신자와 중복되는 승인자인지 여부.
|
||||
final bool isRequesterConflict;
|
||||
|
||||
/// 읽기 전용 모드 여부.
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
State<ApprovalStepRow> createState() => _ApprovalStepRowState();
|
||||
}
|
||||
|
||||
class _ApprovalStepRowState extends State<ApprovalStepRow> {
|
||||
late final TextEditingController _approverIdController;
|
||||
late final TextEditingController _noteController;
|
||||
late ApprovalRequestParticipant _currentApprover;
|
||||
int _fieldVersion = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentApprover = widget.step.approver;
|
||||
_approverIdController = TextEditingController(
|
||||
text: widget.step.approverId.toString(),
|
||||
);
|
||||
_noteController = TextEditingController(text: widget.step.note ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ApprovalStepRow oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.step.approverId != widget.step.approverId) {
|
||||
_currentApprover = widget.step.approver;
|
||||
_approverIdController.text = widget.step.approverId.toString();
|
||||
_refreshAutocompleteField();
|
||||
}
|
||||
if (oldWidget.step.note != widget.step.note &&
|
||||
widget.step.note != _noteController.text) {
|
||||
_noteController.text = widget.step.note ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_approverIdController.dispose();
|
||||
_noteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _refreshAutocompleteField() {
|
||||
setState(() {
|
||||
_fieldVersion += 1;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleApproverSelected(
|
||||
BuildContext context,
|
||||
ApprovalApproverCatalogItem? item,
|
||||
) async {
|
||||
if (widget.readOnly) {
|
||||
return;
|
||||
}
|
||||
ApprovalRequestParticipant? nextParticipant;
|
||||
|
||||
if (item != null) {
|
||||
nextParticipant = ApprovalRequestParticipant(
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
employeeNo: item.employeeNo,
|
||||
);
|
||||
} else {
|
||||
final manualId = int.tryParse(_approverIdController.text.trim());
|
||||
if (manualId == null) {
|
||||
SuperportToast.warning(context, '승인자를 다시 선택해주세요.');
|
||||
_restorePreviousApprover();
|
||||
return;
|
||||
}
|
||||
final catalogMatch = ApprovalApproverCatalog.byId(manualId);
|
||||
if (catalogMatch == null) {
|
||||
SuperportToast.warning(context, '등록되지 않은 승인자입니다.');
|
||||
_restorePreviousApprover();
|
||||
return;
|
||||
}
|
||||
nextParticipant = ApprovalRequestParticipant(
|
||||
id: catalogMatch.id,
|
||||
name: catalogMatch.name,
|
||||
employeeNo: catalogMatch.employeeNo,
|
||||
);
|
||||
}
|
||||
|
||||
final updated = widget.controller.updateStep(
|
||||
widget.index,
|
||||
approver: nextParticipant,
|
||||
);
|
||||
if (!updated) {
|
||||
SuperportToast.error(
|
||||
context,
|
||||
widget.controller.errorMessage ?? '승인자 변경에 실패했습니다.',
|
||||
);
|
||||
_restorePreviousApprover();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_currentApprover = nextParticipant!;
|
||||
_approverIdController.text = nextParticipant.id.toString();
|
||||
});
|
||||
if (widget.hasDuplicateApprover) {
|
||||
SuperportToast.warning(context, '동일한 승인자가 존재하지 않도록 구성해주세요.');
|
||||
} else {
|
||||
SuperportToast.info(
|
||||
context,
|
||||
'단계 ${widget.step.stepOrder} 승인자를 ${nextParticipant.name} 님으로 변경했습니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _restorePreviousApprover() {
|
||||
_approverIdController.text = _currentApprover.id.toString();
|
||||
_refreshAutocompleteField();
|
||||
}
|
||||
|
||||
void _handleNoteChanged(String value) {
|
||||
if (widget.readOnly) {
|
||||
return;
|
||||
}
|
||||
final trimmed = value.trim();
|
||||
widget.controller.updateStep(
|
||||
widget.index,
|
||||
note: trimmed.isEmpty ? null : trimmed,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMove(int offset) {
|
||||
if (widget.readOnly) {
|
||||
return;
|
||||
}
|
||||
final targetIndex = widget.index + offset;
|
||||
final total = widget.controller.totalSteps;
|
||||
if (targetIndex < 0 || targetIndex >= total) {
|
||||
return;
|
||||
}
|
||||
widget.controller.moveStep(widget.index, targetIndex);
|
||||
final direction = offset < 0 ? '위로' : '아래로';
|
||||
SuperportToast.info(context, '결재 단계 순서를 $direction 조정했습니다.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final hasError = widget.hasDuplicateApprover || widget.isRequesterConflict;
|
||||
final borderColor = hasError
|
||||
? theme.colorScheme.destructive
|
||||
: theme.colorScheme.border;
|
||||
final badgeColor = hasError
|
||||
? theme.colorScheme.destructive
|
||||
: theme.colorScheme.secondary;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor, width: 1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_StepBadge(order: widget.step.stepOrder, badgeColor: badgeColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.isFinal ? '최종 승인자' : '승인자',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
IgnorePointer(
|
||||
ignoring: widget.readOnly,
|
||||
child: Opacity(
|
||||
opacity: widget.readOnly ? 0.6 : 1,
|
||||
child: ApprovalApproverAutocompleteField(
|
||||
key: ValueKey(
|
||||
'approver_field_${widget.index}_$_fieldVersion',
|
||||
),
|
||||
idController: _approverIdController,
|
||||
onSelected: (item) =>
|
||||
_handleApproverSelected(context, item),
|
||||
hintText: '승인자 이름 또는 사번 검색',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'역할/메모',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ShadInput(
|
||||
controller: _noteController,
|
||||
onChanged: _handleNoteChanged,
|
||||
enabled: !widget.readOnly,
|
||||
placeholder: const Text('예: 팀장 승인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: widget.readOnly || widget.index == 0
|
||||
? null
|
||||
: () => _handleMove(-1),
|
||||
tooltip: '위로 이동',
|
||||
icon: const Icon(lucide.LucideIcons.chevronUp, size: 18),
|
||||
),
|
||||
IconButton(
|
||||
onPressed:
|
||||
widget.readOnly ||
|
||||
widget.index >= widget.controller.totalSteps - 1
|
||||
? null
|
||||
: () => _handleMove(1),
|
||||
tooltip: '아래로 이동',
|
||||
icon: const Icon(
|
||||
lucide.LucideIcons.chevronDown,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: widget.readOnly || widget.onRemove == null
|
||||
? null
|
||||
: widget.onRemove,
|
||||
tooltip: '단계 삭제',
|
||||
icon: Icon(
|
||||
lucide.LucideIcons.trash2,
|
||||
color: widget.readOnly || widget.onRemove == null
|
||||
? theme.colorScheme.mutedForeground
|
||||
: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
lucide.LucideIcons.triangleAlert,
|
||||
size: 16,
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.hasDuplicateApprover
|
||||
? '동일한 승인자가 중복되어 있습니다. 다른 승인자를 선택해주세요.'
|
||||
: '상신자는 승인자로 지정할 수 없습니다. 다른 승인자를 선택해주세요.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.isFinal && !hasError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
lucide.LucideIcons.circleCheck,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'마지막 단계가 최종 승인자로 처리됩니다.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepBadge extends StatelessWidget {
|
||||
const _StepBadge({required this.order, required this.badgeColor});
|
||||
|
||||
final int order;
|
||||
final Color badgeColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: badgeColor.withValues(alpha: 0.4)),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
order.toString(),
|
||||
style: theme.textTheme.large.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: badgeColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../core/network/failure.dart';
|
||||
import '../../../../../widgets/components/feedback.dart';
|
||||
import '../../../domain/entities/approval_template.dart';
|
||||
import '../../../domain/repositories/approval_template_repository.dart';
|
||||
import '../../../domain/usecases/save_approval_template_use_case.dart';
|
||||
import '../controllers/approval_request_controller.dart';
|
||||
|
||||
/// 템플릿을 불러오고 적용/저장할 수 있는 결재 템플릿 선택 위젯.
|
||||
class ApprovalTemplatePicker extends StatefulWidget {
|
||||
const ApprovalTemplatePicker({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.repository,
|
||||
this.saveUseCase,
|
||||
this.onTemplateApplied,
|
||||
this.onTemplatesChanged,
|
||||
});
|
||||
|
||||
/// 결재 단계 상태를 제어하는 컨트롤러.
|
||||
final ApprovalRequestController controller;
|
||||
|
||||
/// 템플릿 저장소. 지정하지 않으면 [GetIt]에서 조회한다.
|
||||
final ApprovalTemplateRepository? repository;
|
||||
|
||||
/// 템플릿 저장 유즈케이스. 지정하지 않으면 [GetIt]에서 조회한다.
|
||||
final SaveApprovalTemplateUseCase? saveUseCase;
|
||||
|
||||
/// 템플릿 적용이 완료됐을 때 호출되는 콜백.
|
||||
final void Function(ApprovalTemplate template)? onTemplateApplied;
|
||||
|
||||
/// 템플릿 목록이 갱신됐을 때 호출되는 콜백.
|
||||
final void Function(List<ApprovalTemplate> templates)? onTemplatesChanged;
|
||||
|
||||
@override
|
||||
State<ApprovalTemplatePicker> createState() => _ApprovalTemplatePickerState();
|
||||
}
|
||||
|
||||
class _ApprovalTemplatePickerState extends State<ApprovalTemplatePicker> {
|
||||
List<ApprovalTemplate> _templates = const <ApprovalTemplate>[];
|
||||
int? _selectedTemplateId;
|
||||
bool _isLoading = false;
|
||||
bool _isSaving = false;
|
||||
String? _error;
|
||||
|
||||
ApprovalTemplateRepository? get _repository =>
|
||||
widget.repository ??
|
||||
(GetIt.I.isRegistered<ApprovalTemplateRepository>()
|
||||
? GetIt.I<ApprovalTemplateRepository>()
|
||||
: null);
|
||||
|
||||
SaveApprovalTemplateUseCase? get _saveUseCase =>
|
||||
widget.saveUseCase ??
|
||||
(GetIt.I.isRegistered<SaveApprovalTemplateUseCase>()
|
||||
? GetIt.I<SaveApprovalTemplateUseCase>()
|
||||
: null);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedTemplateId = widget.controller.templateSnapshot?.templateId;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadTemplates();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ApprovalTemplatePicker oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.controller.templateSnapshot?.templateId !=
|
||||
oldWidget.controller.templateSnapshot?.templateId) {
|
||||
setState(() {
|
||||
_selectedTemplateId = widget.controller.templateSnapshot?.templateId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTemplates() async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
setState(() {
|
||||
_error = '결재 템플릿 저장소가 등록되지 않아 목록을 불러올 수 없습니다.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final result = await repository.list(
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
isActive: true,
|
||||
);
|
||||
setState(() {
|
||||
_templates = result.items;
|
||||
if (_selectedTemplateId != null &&
|
||||
!_templates.any((template) => template.id == _selectedTemplateId)) {
|
||||
_selectedTemplateId = null;
|
||||
}
|
||||
});
|
||||
widget.onTemplatesChanged?.call(result.items);
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
setState(() {
|
||||
_error = failure.describe();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyTemplate(BuildContext context) async {
|
||||
final repository = _repository;
|
||||
final templateId = _selectedTemplateId;
|
||||
if (repository == null || templateId == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final detail = await repository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (detail.steps.isEmpty) {
|
||||
throw StateError('단계가 없는 템플릿은 적용할 수 없습니다.');
|
||||
}
|
||||
final steps = detail.steps
|
||||
.map(
|
||||
(step) => ApprovalRequestStep(
|
||||
stepOrder: step.stepOrder,
|
||||
approver: ApprovalRequestParticipant(
|
||||
id: step.approver.id,
|
||||
name: step.approver.name,
|
||||
employeeNo: step.approver.employeeNo,
|
||||
),
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
widget.controller.applyTemplateSteps(steps);
|
||||
widget.controller.setTemplateSnapshot(
|
||||
ApprovalTemplateSnapshot(
|
||||
templateId: detail.id,
|
||||
updatedAt: detail.updatedAt,
|
||||
),
|
||||
);
|
||||
widget.onTemplateApplied?.call(detail);
|
||||
SuperportToast.success(context, '템플릿 "${detail.name}"을(를) 적용했습니다.');
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = failure.describe();
|
||||
});
|
||||
if (context.mounted) {
|
||||
SuperportToast.error(context, failure.describe());
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openTemplatePreview(BuildContext context) async {
|
||||
final repository = _repository;
|
||||
final templateId = _selectedTemplateId;
|
||||
if (repository == null || templateId == null) {
|
||||
SuperportToast.info(context, '미리볼 템플릿을 먼저 선택하세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final detail = await repository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'템플릿 미리보기',
|
||||
style: theme.textTheme.h4.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(detail.name, style: theme.textTheme.small),
|
||||
if (detail.description?.isNotEmpty ?? false) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(detail.description!, style: theme.textTheme.muted),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (detail.steps.isEmpty)
|
||||
Text('등록된 단계가 없습니다.', style: theme.textTheme.muted)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
for (final step in detail.steps) ...[
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: theme.colorScheme.secondary
|
||||
.withValues(alpha: 0.12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
step.stepOrder.toString(),
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
step.approver.name,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'사번 ${step.approver.employeeNo}',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
if (step.note?.isNotEmpty ?? false)
|
||||
Text(
|
||||
step.note!,
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
SuperportToast.error(context, failure.describe());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openSaveTemplateDialog(BuildContext context) async {
|
||||
final steps = widget.controller.steps;
|
||||
if (steps.isEmpty) {
|
||||
SuperportToast.warning(context, '저장할 결재 단계가 없습니다.');
|
||||
return;
|
||||
}
|
||||
final saveUseCase = _saveUseCase;
|
||||
if (saveUseCase == null) {
|
||||
SuperportToast.error(context, '템플릿 저장 유즈케이스가 등록되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final nameController = TextEditingController();
|
||||
final codeController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
final noteController = TextEditingController();
|
||||
String? errorText;
|
||||
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: !_isSaving,
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
return StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
Future<void> handleSubmit() async {
|
||||
if (_isSaving) return;
|
||||
final nameText = nameController.text.trim();
|
||||
if (nameText.isEmpty) {
|
||||
setModalState(() {
|
||||
errorText = '템플릿명을 입력해주세요.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
final stepInputs = steps
|
||||
.map(
|
||||
(step) => ApprovalTemplateStepInput(
|
||||
stepOrder: step.stepOrder,
|
||||
approverId: step.approverId,
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final input = ApprovalTemplateInput(
|
||||
code: codeController.text.trim().isEmpty
|
||||
? null
|
||||
: codeController.text.trim(),
|
||||
name: nameText,
|
||||
description: descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: descriptionController.text.trim(),
|
||||
note: noteController.text.trim().isEmpty
|
||||
? null
|
||||
: noteController.text.trim(),
|
||||
isActive: true,
|
||||
);
|
||||
setModalState(() {
|
||||
_isSaving = true;
|
||||
errorText = null;
|
||||
});
|
||||
try {
|
||||
final template = await saveUseCase.call(
|
||||
templateId: null,
|
||||
input: input,
|
||||
steps: stepInputs,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(dialogContext).pop(true);
|
||||
if (!context.mounted) return;
|
||||
SuperportToast.success(
|
||||
context,
|
||||
'템플릿 "${template.name}"을(를) 저장했습니다.',
|
||||
);
|
||||
widget.controller.setTemplateSnapshot(
|
||||
ApprovalTemplateSnapshot(
|
||||
templateId: template.id,
|
||||
updatedAt: template.updatedAt,
|
||||
),
|
||||
);
|
||||
await _loadTemplates();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedTemplateId = template.id;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
setModalState(() {
|
||||
_isSaving = false;
|
||||
errorText = failure.describe();
|
||||
});
|
||||
if (context.mounted) {
|
||||
SuperportToast.error(context, failure.describe());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'템플릿으로 저장',
|
||||
style: theme.textTheme.h4.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadInput(
|
||||
controller: nameController,
|
||||
placeholder: const Text('템플릿명 (필수)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadInput(
|
||||
controller: codeController,
|
||||
placeholder: const Text('템플릿 코드 (선택)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadTextarea(
|
||||
controller: descriptionController,
|
||||
minHeight: 80,
|
||||
maxHeight: 160,
|
||||
placeholder: const Text('설명 (선택)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadTextarea(
|
||||
controller: noteController,
|
||||
minHeight: 80,
|
||||
maxHeight: 160,
|
||||
placeholder: const Text('비고/안내 문구 (선택)'),
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isSaving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
onPressed: _isSaving ? null : handleSubmit,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
nameController.dispose();
|
||||
codeController.dispose();
|
||||
descriptionController.dispose();
|
||||
noteController.dispose();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final snapshot = widget.controller.templateSnapshot;
|
||||
ApprovalTemplate? selectedTemplate;
|
||||
for (final template in _templates) {
|
||||
if (template.id == _selectedTemplateId) {
|
||||
selectedTemplate = template;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final isUpToDate = selectedTemplate == null
|
||||
? true
|
||||
: widget.controller.isTemplateUpToDate(selectedTemplate.updatedAt);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadSelect<int?>(
|
||||
placeholder: const Text('템플릿 선택'),
|
||||
initialValue: selectedTemplate?.id,
|
||||
enabled: !_isLoading,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedTemplateId = value;
|
||||
});
|
||||
},
|
||||
selectedOptionBuilder: (context, value) {
|
||||
if (value == null) {
|
||||
return const Text('템플릿 선택');
|
||||
}
|
||||
ApprovalTemplate? match;
|
||||
for (final template in _templates) {
|
||||
if (template.id == value) {
|
||||
match = template;
|
||||
break;
|
||||
}
|
||||
}
|
||||
match ??= selectedTemplate;
|
||||
return Text(match?.name ?? '템플릿 선택');
|
||||
},
|
||||
options: _templates
|
||||
.map(
|
||||
(template) => ShadOption<int?>(
|
||||
value: template.id,
|
||||
child: Text(template.name),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton.outline(
|
||||
onPressed: _isLoading ? null : () => _loadTemplates(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(lucide.LucideIcons.refreshCw, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text('새로고침'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton.ghost(
|
||||
onPressed: () => _openTemplatePreview(context),
|
||||
child: const Text('미리보기'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
onPressed: (_isLoading || _selectedTemplateId == null)
|
||||
? null
|
||||
: () => _applyTemplate(context),
|
||||
child: _isLoading && _selectedTemplateId != null
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('템플릿 적용'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
onPressed: _isSaving
|
||||
? null
|
||||
: () => _openSaveTemplateDialog(context),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(lucide.LucideIcons.save, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text('현재 단계를 템플릿으로 저장'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (snapshot != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'선택됨: #${snapshot.templateId}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_error!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
] else if (selectedTemplate != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
isUpToDate
|
||||
? '템플릿 "${selectedTemplate.name}"이(가) 적용 대기 중입니다.'
|
||||
: '템플릿 "${selectedTemplate.name}"이(가) 서버 버전과 달라져 재적용이 필요합니다.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: isUpToDate
|
||||
? theme.colorScheme.mutedForeground
|
||||
: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'approval_step_configurator.dart';
|
||||
export 'approval_step_row.dart';
|
||||
export 'approval_template_picker.dart';
|
||||
@@ -27,31 +27,31 @@ class ApprovalApproverCatalog {
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 101,
|
||||
employeeNo: 'EMP101',
|
||||
name: '김결재',
|
||||
name: '이검토',
|
||||
team: '물류운영팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 102,
|
||||
employeeNo: 'EMP102',
|
||||
name: '박승인',
|
||||
team: '재무팀',
|
||||
name: '최검수',
|
||||
team: '품질보증팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 103,
|
||||
employeeNo: 'EMP103',
|
||||
name: '이반려',
|
||||
name: '문회수',
|
||||
team: '품질보증팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 104,
|
||||
employeeNo: 'EMP104',
|
||||
name: '최리뷰',
|
||||
name: '박팀장',
|
||||
team: '운영혁신팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 105,
|
||||
employeeNo: 'EMP105',
|
||||
name: '정검토',
|
||||
name: '정차장',
|
||||
team: '구매팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
@@ -72,6 +72,36 @@ class ApprovalApproverCatalog {
|
||||
name: '문서결',
|
||||
team: '경영기획팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 110,
|
||||
employeeNo: 'EMP110',
|
||||
name: '문검토',
|
||||
team: '물류운영팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 120,
|
||||
employeeNo: 'EMP120',
|
||||
name: '신품질',
|
||||
team: '품질관리팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 201,
|
||||
employeeNo: 'EMP201',
|
||||
name: '한임원',
|
||||
team: '경영진',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 210,
|
||||
employeeNo: 'EMP210',
|
||||
name: '강팀장',
|
||||
team: '물류운영팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 221,
|
||||
employeeNo: 'EMP221',
|
||||
name: '노부장',
|
||||
team: '경영관리팀',
|
||||
),
|
||||
]);
|
||||
|
||||
static final Map<int, ApprovalApproverCatalogItem> _byId = {
|
||||
|
||||
220
lib/features/approvals/shared/widgets/approval_ui_helpers.dart
Normal file
220
lib/features/approvals/shared/widgets/approval_ui_helpers.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// 결재 승인자를 테이블/다이얼로그에서 일관되게 표시하기 위한 셀 위젯.
|
||||
///
|
||||
/// - 아바타에 이름 이니셜을 렌더링하고, 이름/사번/부가 설명을 함께 노출한다.
|
||||
class ApprovalApproverCell extends StatelessWidget {
|
||||
const ApprovalApproverCell({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.employeeNo,
|
||||
this.subtitle,
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
this.avatarSize = 32,
|
||||
});
|
||||
|
||||
/// 승인자 이름.
|
||||
final String name;
|
||||
|
||||
/// 승인자 사번.
|
||||
final String employeeNo;
|
||||
|
||||
/// 이름 아래에 노출할 부가 설명.
|
||||
final String? subtitle;
|
||||
|
||||
/// 아바타 배경색. 지정하지 않으면 테마 보조색을 사용한다.
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// 텍스트 색상. 지정하지 않으면 테마 기본 텍스트 색을 사용한다.
|
||||
final Color? textColor;
|
||||
|
||||
/// 아바타 지름.
|
||||
final double avatarSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final baseColor = backgroundColor ?? theme.colorScheme.secondary;
|
||||
final labelColor = textColor ?? theme.colorScheme.foreground;
|
||||
final avatarTextColor = theme.colorScheme.secondaryForeground;
|
||||
final initials = _buildInitials(name);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: avatarSize / 2,
|
||||
backgroundColor: baseColor.withValues(alpha: 0.14),
|
||||
foregroundColor: avatarTextColor,
|
||||
child: Text(
|
||||
initials,
|
||||
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: labelColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
employeeNo,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
if (subtitle != null && subtitle!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.muted.copyWith(
|
||||
fontSize: (theme.textTheme.muted.fontSize ?? 12).clamp(
|
||||
11,
|
||||
13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _buildInitials(String value) {
|
||||
final segments = value.trim().split(RegExp(r'[\s\u00A0]+'));
|
||||
if (segments.length == 1) {
|
||||
final entry = segments.first;
|
||||
if (entry.length <= 2) {
|
||||
return entry.toUpperCase();
|
||||
}
|
||||
return entry.substring(0, 2).toUpperCase();
|
||||
}
|
||||
return (segments[0].isNotEmpty ? segments[0][0] : '') +
|
||||
(segments[1].isNotEmpty ? segments[1][0] : '');
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 상태를 배경색/테두리와 함께 표시하는 배지 위젯.
|
||||
class ApprovalStatusBadge extends StatelessWidget {
|
||||
const ApprovalStatusBadge({super.key, required this.label, this.colorHex});
|
||||
|
||||
/// 상태 라벨.
|
||||
final String label;
|
||||
|
||||
/// 백엔드에서 내려오는 HEX 문자열(예: `#12ABFF`).
|
||||
final String? colorHex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final baseColor = _resolveColor(colorHex, theme.colorScheme.primary);
|
||||
final background = baseColor.withValues(alpha: 0.12);
|
||||
final borderColor = baseColor.withValues(alpha: 0.36);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: baseColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _resolveColor(String? value, Color fallback) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return fallback;
|
||||
}
|
||||
var hex = value.trim();
|
||||
if (hex.startsWith('#')) {
|
||||
hex = hex.substring(1);
|
||||
}
|
||||
if (hex.length == 6) {
|
||||
hex = 'FF$hex';
|
||||
}
|
||||
final parsed = int.tryParse(hex, radix: 16);
|
||||
if (parsed == null) {
|
||||
return fallback;
|
||||
}
|
||||
return Color(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 메모를 아이콘과 함께 툴팁으로 노출하는 위젯.
|
||||
class ApprovalNoteTooltip extends StatelessWidget {
|
||||
const ApprovalNoteTooltip({
|
||||
super.key,
|
||||
required this.note,
|
||||
this.placeholder = '-',
|
||||
this.maxWidth = 220,
|
||||
});
|
||||
|
||||
/// 메모 본문. 비어 있으면 [placeholder]를 표시한다.
|
||||
final String? note;
|
||||
|
||||
/// 메모가 없을 때 대체로 표시할 텍스트.
|
||||
final String placeholder;
|
||||
|
||||
/// 한 줄로 보여줄 때의 최대 너비.
|
||||
final double maxWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final trimmed = note?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) {
|
||||
return Text(placeholder, style: theme.textTheme.muted);
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
message: trimmed,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
lucide.LucideIcons.stickyNote,
|
||||
size: 16,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
trimmed,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/approvals/shared/widgets/widgets.dart
Normal file
1
lib/features/approvals/shared/widgets/widgets.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'approval_ui_helpers.dart';
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
|
||||
import 'package:superport_v2/features/approvals/data/dtos/approval_step_dto.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||
|
||||
import '../../domain/entities/approval_step_record.dart';
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/network/failure.dart';
|
||||
|
||||
import '../../../domain/entities/approval_flow.dart';
|
||||
import '../../../domain/entities/approval_template.dart';
|
||||
import '../../../domain/repositories/approval_template_repository.dart';
|
||||
import '../../../domain/usecases/apply_approval_template_use_case.dart';
|
||||
import '../../../domain/usecases/save_approval_template_use_case.dart';
|
||||
|
||||
/// 결재 템플릿 목록에서 사용할 상태 필터.
|
||||
enum ApprovalTemplateStatusFilter { all, activeOnly, inactiveOnly }
|
||||
@@ -12,14 +17,25 @@ enum ApprovalTemplateStatusFilter { all, activeOnly, inactiveOnly }
|
||||
///
|
||||
/// - 목록/검색/필터 상태와 생성·수정·삭제 요청을 관리한다.
|
||||
class ApprovalTemplateController extends ChangeNotifier {
|
||||
ApprovalTemplateController({required ApprovalTemplateRepository repository})
|
||||
: _repository = repository;
|
||||
ApprovalTemplateController({
|
||||
required ApprovalTemplateRepository repository,
|
||||
SaveApprovalTemplateUseCase? saveTemplateUseCase,
|
||||
ApplyApprovalTemplateUseCase? applyTemplateUseCase,
|
||||
}) : _repository = repository,
|
||||
_saveTemplateUseCase = saveTemplateUseCase,
|
||||
_applyTemplateUseCase = applyTemplateUseCase;
|
||||
|
||||
final ApprovalTemplateRepository _repository;
|
||||
final SaveApprovalTemplateUseCase? _saveTemplateUseCase;
|
||||
final ApplyApprovalTemplateUseCase? _applyTemplateUseCase;
|
||||
final Map<int, DateTime?> _templateVersions = <int, DateTime?>{};
|
||||
final Map<int, List<ApprovalTemplateStep>> _templateStepSummaries =
|
||||
<int, List<ApprovalTemplateStep>>{};
|
||||
|
||||
PaginatedResult<ApprovalTemplate>? _result;
|
||||
bool _isLoading = false;
|
||||
bool _isSubmitting = false;
|
||||
bool _isApplyingTemplate = false;
|
||||
String _query = '';
|
||||
ApprovalTemplateStatusFilter _statusFilter = ApprovalTemplateStatusFilter.all;
|
||||
String? _errorMessage;
|
||||
@@ -32,6 +48,37 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
ApprovalTemplateStatusFilter get statusFilter => _statusFilter;
|
||||
String? get errorMessage => _errorMessage;
|
||||
int get pageSize => _result?.pageSize ?? _pageSize;
|
||||
bool get isApplyingTemplate => _isApplyingTemplate;
|
||||
UnmodifiableMapView<int, DateTime?> get templateVersions =>
|
||||
UnmodifiableMapView(_templateVersions);
|
||||
UnmodifiableMapView<int, List<ApprovalTemplateStep>>
|
||||
get templateStepSummaries => UnmodifiableMapView(_templateStepSummaries);
|
||||
|
||||
/// 캐시된 템플릿 버전 정보를 반환한다.
|
||||
DateTime? versionOf(int templateId) => _templateVersions[templateId];
|
||||
|
||||
/// 캐시된 단계 요약을 반환한다.
|
||||
List<ApprovalTemplateStep>? stepSummaryOf(int templateId) =>
|
||||
_templateStepSummaries[templateId];
|
||||
|
||||
/// 단계 요약이 없으면 상세를 조회해 캐시한다.
|
||||
Future<List<ApprovalTemplateStep>?> ensureStepSummary(int templateId) async {
|
||||
final cached = _templateStepSummaries[templateId];
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
return cached;
|
||||
}
|
||||
final detail = await fetchDetail(templateId);
|
||||
return detail?.steps;
|
||||
}
|
||||
|
||||
/// 서버 업데이트 일시와 비교해 로컬 버전이 뒤처졌는지 확인한다.
|
||||
bool isTemplateStale(int templateId, DateTime? remoteUpdatedAt) {
|
||||
final local = _templateVersions[templateId];
|
||||
if (local == null || remoteUpdatedAt == null) {
|
||||
return false;
|
||||
}
|
||||
return local.isBefore(remoteUpdatedAt);
|
||||
}
|
||||
|
||||
/// 템플릿 목록을 조회해 캐시에 저장한다.
|
||||
///
|
||||
@@ -66,6 +113,10 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
);
|
||||
_result = response;
|
||||
_pageSize = response.pageSize;
|
||||
_recordTemplateVersions(response.items);
|
||||
for (final template in response.items) {
|
||||
_cacheTemplateSteps(template);
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
@@ -95,6 +146,9 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
try {
|
||||
final detail = await _repository.fetchDetail(id, includeSteps: true);
|
||||
_recordTemplateVersion(detail);
|
||||
_cacheTemplateSteps(detail);
|
||||
notifyListeners();
|
||||
return detail;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
@@ -108,20 +162,13 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
Future<ApprovalTemplate?> create(
|
||||
ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput> steps,
|
||||
) async {
|
||||
_setSubmitting(true);
|
||||
try {
|
||||
final created = await _repository.create(input, steps: steps);
|
||||
await fetch(page: 1);
|
||||
return created;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} finally {
|
||||
_setSubmitting(false);
|
||||
}
|
||||
) {
|
||||
return _saveTemplate(
|
||||
templateId: null,
|
||||
input: input,
|
||||
steps: steps,
|
||||
refreshPage: 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// 기존 템플릿을 수정하고 현재 페이지를 유지한 채 목록을 다시 가져온다.
|
||||
@@ -129,20 +176,28 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
int id,
|
||||
ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput>? steps,
|
||||
) async {
|
||||
_setSubmitting(true);
|
||||
try {
|
||||
final updated = await _repository.update(id, input, steps: steps);
|
||||
await fetch(page: _result?.page ?? 1);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} finally {
|
||||
_setSubmitting(false);
|
||||
}
|
||||
) {
|
||||
return _saveTemplate(
|
||||
templateId: id,
|
||||
input: input,
|
||||
steps: steps,
|
||||
refreshPage: _result?.page ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// 템플릿을 저장(create/update)하는 공통 진입점.
|
||||
Future<ApprovalTemplate?> save({
|
||||
int? templateId,
|
||||
required ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput>? steps,
|
||||
}) {
|
||||
final refreshPage = templateId == null ? 1 : _result?.page ?? 1;
|
||||
return _saveTemplate(
|
||||
templateId: templateId,
|
||||
input: input,
|
||||
steps: steps,
|
||||
refreshPage: refreshPage,
|
||||
);
|
||||
}
|
||||
|
||||
/// 템플릿을 삭제(비활성화)한 뒤 목록을 재조회한다.
|
||||
@@ -167,6 +222,7 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
_setSubmitting(true);
|
||||
try {
|
||||
final restored = await _repository.restore(id);
|
||||
_recordTemplateVersion(restored);
|
||||
await fetch(page: _result?.page ?? 1);
|
||||
return restored;
|
||||
} catch (error) {
|
||||
@@ -179,6 +235,44 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 템플릿을 지정한 결재에 적용한다.
|
||||
Future<ApprovalFlow?> applyToApproval({
|
||||
required int approvalId,
|
||||
required int templateId,
|
||||
}) async {
|
||||
final useCase = _applyTemplateUseCase;
|
||||
if (useCase == null) {
|
||||
throw StateError('ApplyApprovalTemplateUseCase가 주입되지 않았습니다.');
|
||||
}
|
||||
_errorMessage = null;
|
||||
_isApplyingTemplate = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final flow = await useCase.call(
|
||||
approvalId: approvalId,
|
||||
templateId: templateId,
|
||||
);
|
||||
try {
|
||||
final template = await _repository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: false,
|
||||
);
|
||||
_recordTemplateVersion(template);
|
||||
} catch (_) {
|
||||
// 최신 템플릿 버전 조회 실패는 무시한다.
|
||||
}
|
||||
return flow;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} finally {
|
||||
_isApplyingTemplate = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 오류 메시지를 초기화한다.
|
||||
void clearError() {
|
||||
_errorMessage = null;
|
||||
@@ -210,4 +304,61 @@ class ApprovalTemplateController extends ChangeNotifier {
|
||||
_isSubmitting = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<ApprovalTemplate?> _saveTemplate({
|
||||
int? templateId,
|
||||
required ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput>? steps,
|
||||
required int refreshPage,
|
||||
}) async {
|
||||
_errorMessage = null;
|
||||
_setSubmitting(true);
|
||||
try {
|
||||
final template = await _performSave(templateId, input, steps);
|
||||
_recordTemplateVersion(template);
|
||||
await fetch(page: refreshPage);
|
||||
return template;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
notifyListeners();
|
||||
return null;
|
||||
} finally {
|
||||
_setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApprovalTemplate> _performSave(
|
||||
int? templateId,
|
||||
ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput>? steps,
|
||||
) {
|
||||
final useCase = _saveTemplateUseCase;
|
||||
if (useCase != null) {
|
||||
return useCase.call(templateId: templateId, input: input, steps: steps);
|
||||
}
|
||||
if (templateId == null) {
|
||||
return _repository.create(input, steps: steps ?? const []);
|
||||
}
|
||||
return _repository.update(templateId, input, steps: steps);
|
||||
}
|
||||
|
||||
void _recordTemplateVersions(Iterable<ApprovalTemplate> templates) {
|
||||
for (final template in templates) {
|
||||
_recordTemplateVersion(template);
|
||||
}
|
||||
}
|
||||
|
||||
void _recordTemplateVersion(ApprovalTemplate template) {
|
||||
_templateVersions[template.id] = template.updatedAt;
|
||||
}
|
||||
|
||||
void _cacheTemplateSteps(ApprovalTemplate template) {
|
||||
if (template.steps.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_templateStepSummaries[template.id] = List<ApprovalTemplateStep>.from(
|
||||
template.steps,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import '../../../../../widgets/components/feature_disabled_placeholder.dart';
|
||||
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||
import '../../../domain/entities/approval_template.dart';
|
||||
import '../../../domain/repositories/approval_template_repository.dart';
|
||||
import '../../../domain/usecases/apply_approval_template_use_case.dart';
|
||||
import '../../../domain/usecases/save_approval_template_use_case.dart';
|
||||
import '../controllers/approval_template_controller.dart';
|
||||
|
||||
/// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다.
|
||||
@@ -76,6 +78,8 @@ class _ApprovalTemplateEnabledPageState
|
||||
super.initState();
|
||||
_controller = ApprovalTemplateController(
|
||||
repository: GetIt.I<ApprovalTemplateRepository>(),
|
||||
saveTemplateUseCase: GetIt.I<SaveApprovalTemplateUseCase>(),
|
||||
applyTemplateUseCase: GetIt.I<ApplyApprovalTemplateUseCase>(),
|
||||
)..addListener(_handleControllerUpdate);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await _controller.fetch();
|
||||
@@ -208,6 +212,7 @@ class _ApprovalTemplateEnabledPageState
|
||||
ShadTableCell.header(child: Text('ID')),
|
||||
ShadTableCell.header(child: Text('템플릿코드')),
|
||||
ShadTableCell.header(child: Text('템플릿명')),
|
||||
ShadTableCell.header(child: Text('결재 단계 요약')),
|
||||
ShadTableCell.header(child: Text('설명')),
|
||||
ShadTableCell.header(child: Text('사용')),
|
||||
ShadTableCell.header(child: Text('변경일시')),
|
||||
@@ -218,6 +223,13 @@ class _ApprovalTemplateEnabledPageState
|
||||
ShadTableCell(child: Text('${template.id}')),
|
||||
ShadTableCell(child: Text(template.code)),
|
||||
ShadTableCell(child: Text(template.name)),
|
||||
ShadTableCell(
|
||||
child: _TemplateStepSummaryCell(
|
||||
key: ValueKey('template_steps_${template.id}'),
|
||||
controller: _controller,
|
||||
template: template,
|
||||
),
|
||||
),
|
||||
ShadTableCell(
|
||||
child: Text(
|
||||
template.description?.isNotEmpty == true
|
||||
@@ -243,7 +255,17 @@ class _ApprovalTemplateEnabledPageState
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
key: ValueKey(
|
||||
'template_preview_${template.id}',
|
||||
),
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: () =>
|
||||
_openTemplatePreview(template.id),
|
||||
child: const Text('보기'),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
key: ValueKey(
|
||||
'template_edit_${template.id}',
|
||||
@@ -274,20 +296,26 @@ class _ApprovalTemplateEnabledPageState
|
||||
),
|
||||
];
|
||||
}).toList(),
|
||||
rowHeight: 56,
|
||||
maxHeight: 480,
|
||||
rowHeight: 58,
|
||||
maxHeight: 520,
|
||||
columnSpanExtent: (index) {
|
||||
switch (index) {
|
||||
case 2:
|
||||
return const FixedTableSpanExtent(220);
|
||||
case 3:
|
||||
return const FixedTableSpanExtent(260);
|
||||
case 4:
|
||||
return const FixedTableSpanExtent(100);
|
||||
case 5:
|
||||
return const FixedTableSpanExtent(180);
|
||||
case 6:
|
||||
case 0:
|
||||
return const FixedTableSpanExtent(80);
|
||||
case 1:
|
||||
return const FixedTableSpanExtent(160);
|
||||
case 2:
|
||||
return const FixedTableSpanExtent(200);
|
||||
case 3:
|
||||
return const FixedTableSpanExtent(300);
|
||||
case 4:
|
||||
return const FixedTableSpanExtent(220);
|
||||
case 5:
|
||||
return const FixedTableSpanExtent(100);
|
||||
case 6:
|
||||
return const FixedTableSpanExtent(180);
|
||||
case 7:
|
||||
return const FixedTableSpanExtent(220);
|
||||
default:
|
||||
return const FixedTableSpanExtent(140);
|
||||
}
|
||||
@@ -326,6 +354,99 @@ class _ApprovalTemplateEnabledPageState
|
||||
_searchFocus.requestFocus();
|
||||
}
|
||||
|
||||
Future<void> _openTemplatePreview(int templateId) async {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
final detail = await _controller.fetchDetail(templateId);
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (detail == null) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
const SnackBar(content: Text('템플릿 정보를 불러오지 못했습니다. 다시 시도하세요.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final theme = ShadTheme.of(context);
|
||||
await SuperportDialog.show<void>(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: detail.name,
|
||||
description: detail.description,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 540),
|
||||
child: detail.steps.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final step in detail.steps) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: theme.colorScheme.secondary.withValues(
|
||||
alpha: 0.12,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${step.stepOrder}',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
step.approver.name,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'사번 ${step.approver.employeeNo}',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
if (step.note?.isNotEmpty ?? false)
|
||||
Text(
|
||||
step.note!,
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openEditTemplate(ApprovalTemplate template) async {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
@@ -700,6 +821,96 @@ class _ApprovalTemplateEnabledPageState
|
||||
}
|
||||
}
|
||||
|
||||
class _TemplateStepSummaryCell extends StatefulWidget {
|
||||
const _TemplateStepSummaryCell({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.template,
|
||||
});
|
||||
|
||||
final ApprovalTemplateController controller;
|
||||
final ApprovalTemplate template;
|
||||
|
||||
@override
|
||||
State<_TemplateStepSummaryCell> createState() =>
|
||||
_TemplateStepSummaryCellState();
|
||||
}
|
||||
|
||||
class _TemplateStepSummaryCellState extends State<_TemplateStepSummaryCell> {
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _loadSummary() async {
|
||||
if (_isLoading) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
try {
|
||||
await widget.controller.ensureStepSummary(widget.template.id);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final steps =
|
||||
widget.controller.stepSummaryOf(widget.template.id) ??
|
||||
widget.template.steps;
|
||||
if (steps.isEmpty) {
|
||||
return ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _isLoading ? null : _loadSummary,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('단계 불러오기'),
|
||||
);
|
||||
}
|
||||
|
||||
final displaySteps = steps.take(3).toList();
|
||||
final overflow = steps.length - displaySteps.length;
|
||||
final summaryText = steps
|
||||
.map((step) => '${step.stepOrder}. ${step.approver.name}')
|
||||
.join(' → ');
|
||||
|
||||
return Tooltip(
|
||||
message: summaryText,
|
||||
preferBelow: false,
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
for (final step in displaySteps)
|
||||
ShadBadge(
|
||||
child: Text(
|
||||
'${step.stepOrder}. ${step.approver.name}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
if (overflow > 0)
|
||||
ShadBadge.outline(
|
||||
child: Text('+$overflow', style: theme.textTheme.small),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormField extends StatelessWidget {
|
||||
const _FormField({required this.label, required this.child});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user