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:
JiWoong Sul
2025-10-31 01:05:39 +09:00
parent 259b056072
commit d76f765814
133 changed files with 13878 additions and 947 deletions

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

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

View File

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

View File

@@ -21,8 +21,8 @@ class ApprovalProceedStatusDto {
}
ApprovalProceedStatus toEntity() => ApprovalProceedStatus(
approvalId: approvalId,
canProceed: canProceed,
reason: reason,
);
approvalId: approvalId,
canProceed: canProceed,
reason: reason,
);
}

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

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

View File

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

View File

@@ -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을 추출한다.

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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('결재를 진행할 권한이 없습니다.');
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('결재를 진행할 권한이 없습니다.');
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export 'approval_step_configurator.dart';
export 'approval_step_row.dart';
export 'approval_template_picker.dart';

View File

@@ -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 = {

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

View File

@@ -0,0 +1 @@
export 'approval_ui_helpers.dart';

View File

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

View File

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

View File

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