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:
@@ -0,0 +1,538 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../../approvals/domain/entities/approval.dart';
|
||||
import '../../../../approvals/domain/entities/approval_flow.dart';
|
||||
import '../../../../approvals/domain/entities/approval_template.dart';
|
||||
import '../../../../approvals/domain/repositories/approval_template_repository.dart';
|
||||
import '../../../../approvals/domain/usecases/apply_approval_template_use_case.dart';
|
||||
import '../../../../approvals/domain/usecases/save_approval_template_use_case.dart';
|
||||
import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||
|
||||
/// 결재 요청 화면에서 사용하는 참가자 요약 정보.
|
||||
///
|
||||
/// - 상신자(requester)와 승인자(approver)에 공통으로 적용한다.
|
||||
class ApprovalRequestParticipant {
|
||||
const ApprovalRequestParticipant({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.employeeNo,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String employeeNo;
|
||||
|
||||
/// [ApprovalRequester]로 변환한다.
|
||||
ApprovalRequester toRequester() {
|
||||
return ApprovalRequester(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
|
||||
/// [ApprovalApprover]로 변환한다.
|
||||
ApprovalApprover toApprover() {
|
||||
return ApprovalApprover(id: id, employeeNo: employeeNo, name: name);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 요청 단계 상태를 표현한다.
|
||||
class ApprovalRequestStep {
|
||||
const ApprovalRequestStep({
|
||||
required this.stepOrder,
|
||||
required this.approver,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int stepOrder;
|
||||
final ApprovalRequestParticipant approver;
|
||||
final String? note;
|
||||
|
||||
int get approverId => approver.id;
|
||||
|
||||
ApprovalRequestStep copyWith({
|
||||
int? stepOrder,
|
||||
ApprovalRequestParticipant? approver,
|
||||
String? note,
|
||||
}) {
|
||||
return ApprovalRequestStep(
|
||||
stepOrder: stepOrder ?? this.stepOrder,
|
||||
approver: approver ?? this.approver,
|
||||
note: note ?? this.note,
|
||||
);
|
||||
}
|
||||
|
||||
/// 도메인 계층에서 사용하는 [ApprovalStepAssignmentItem]으로 변환한다.
|
||||
ApprovalStepAssignmentItem toAssignmentItem() {
|
||||
return ApprovalStepAssignmentItem(
|
||||
stepOrder: stepOrder,
|
||||
approverId: approver.id,
|
||||
note: note,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 템플릿 버전 정보를 보관한다.
|
||||
class ApprovalTemplateSnapshot {
|
||||
const ApprovalTemplateSnapshot({
|
||||
required this.templateId,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final int templateId;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
ApprovalTemplateSnapshot copyWith({int? templateId, DateTime? updatedAt}) {
|
||||
return ApprovalTemplateSnapshot(
|
||||
templateId: templateId ?? this.templateId,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 요청 상태를 관리하고 검증/전송 모델로 변환하는 컨트롤러.
|
||||
///
|
||||
/// - 최대 98단계까지 결재 단계를 추가할 수 있으며, 승인자 중복을 방지한다.
|
||||
/// - 마지막 단계 승인자가 최종 승인자로 자동 바인딩된다.
|
||||
/// - 템플릿을 적용/변경할 때 버전 정보를 기록해 추후 비교에 활용한다.
|
||||
class ApprovalRequestController extends ChangeNotifier {
|
||||
ApprovalRequestController({
|
||||
int maxSteps = 98,
|
||||
ApprovalTemplateRepository? templateRepository,
|
||||
SaveApprovalTemplateUseCase? saveTemplateUseCase,
|
||||
ApplyApprovalTemplateUseCase? applyTemplateUseCase,
|
||||
}) : assert(maxSteps > 0, 'maxSteps는 1 이상이어야 합니다.'),
|
||||
_maxSteps = maxSteps,
|
||||
_templateRepository = templateRepository,
|
||||
_saveTemplateUseCase = saveTemplateUseCase,
|
||||
_applyTemplateUseCase = applyTemplateUseCase;
|
||||
|
||||
static const int defaultMaxSteps = 98;
|
||||
|
||||
final int _maxSteps;
|
||||
final ApprovalTemplateRepository? _templateRepository;
|
||||
final SaveApprovalTemplateUseCase? _saveTemplateUseCase;
|
||||
final ApplyApprovalTemplateUseCase? _applyTemplateUseCase;
|
||||
|
||||
ApprovalRequestParticipant? _requester;
|
||||
final List<ApprovalRequestStep> _steps = <ApprovalRequestStep>[];
|
||||
ApprovalTemplateSnapshot? _templateSnapshot;
|
||||
bool _isDirty = false;
|
||||
String? _errorMessage;
|
||||
bool _isApplyingTemplate = false;
|
||||
|
||||
ApprovalRequestParticipant? get requester => _requester;
|
||||
List<ApprovalRequestStep> get steps => List.unmodifiable(_steps);
|
||||
int get maxSteps => _maxSteps;
|
||||
bool get hasReachedStepLimit => _steps.length >= _maxSteps;
|
||||
bool get hasDuplicateApprover =>
|
||||
_steps.map((step) => step.approverId).toSet().length != _steps.length;
|
||||
bool get hasRequesterConflict {
|
||||
final requester = _requester;
|
||||
if (requester == null) {
|
||||
return false;
|
||||
}
|
||||
return _steps.any((step) => step.approverId == requester.id);
|
||||
}
|
||||
|
||||
int get totalSteps => _steps.length;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get isDirty => _isDirty;
|
||||
bool get isApplyingTemplate => _isApplyingTemplate;
|
||||
ApprovalTemplateSnapshot? get templateSnapshot => _templateSnapshot;
|
||||
|
||||
ApprovalRequestParticipant? get finalApprover {
|
||||
if (_steps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _steps.last.approver;
|
||||
}
|
||||
|
||||
int? get finalApproverId => finalApprover?.id;
|
||||
|
||||
/// 상신자를 설정한다.
|
||||
void setRequester(ApprovalRequestParticipant? participant) {
|
||||
if (_requester == participant) {
|
||||
return;
|
||||
}
|
||||
_requester = participant;
|
||||
if (participant != null &&
|
||||
_steps.any((step) => step.approverId == participant.id)) {
|
||||
_markDirty();
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
_isDirty = true;
|
||||
_clearError();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재 단계를 추가한다.
|
||||
///
|
||||
/// - 최대 단계 수를 초과하거나 중복 승인자를 추가하면 false를 반환한다.
|
||||
bool addStep({required ApprovalRequestParticipant approver, String? note}) {
|
||||
if (hasReachedStepLimit) {
|
||||
_setError('결재 단계는 최대 $_maxSteps개까지 추가할 수 있습니다.');
|
||||
return false;
|
||||
}
|
||||
final duplicated = _steps.any((step) => step.approverId == approver.id);
|
||||
if (_conflictsWithRequester(approver)) {
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
if (duplicated) {
|
||||
_setError('동일한 승인자는 한 번만 추가할 수 있습니다.');
|
||||
return false;
|
||||
}
|
||||
_clearError();
|
||||
final step = ApprovalRequestStep(
|
||||
stepOrder: _steps.length + 1,
|
||||
approver: approver,
|
||||
note: note,
|
||||
);
|
||||
_steps.add(step);
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 지정된 위치의 결재 단계를 제거한다.
|
||||
void removeStepAt(int index) {
|
||||
if (index < 0 || index >= _steps.length) {
|
||||
return;
|
||||
}
|
||||
_clearError();
|
||||
_steps.removeAt(index);
|
||||
_reassignStepOrders();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재 단계의 순서를 이동한다.
|
||||
void moveStep(int oldIndex, int newIndex) {
|
||||
if (oldIndex < 0 ||
|
||||
oldIndex >= _steps.length ||
|
||||
newIndex < 0 ||
|
||||
newIndex >= _steps.length ||
|
||||
oldIndex == newIndex) {
|
||||
return;
|
||||
}
|
||||
_clearError();
|
||||
final step = _steps.removeAt(oldIndex);
|
||||
_steps.insert(newIndex, step);
|
||||
_reassignStepOrders();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 결재 단계를 수정한다.
|
||||
///
|
||||
/// - 승인자를 변경할 경우 중복 여부를 검사한다.
|
||||
bool updateStep(
|
||||
int index, {
|
||||
ApprovalRequestParticipant? approver,
|
||||
String? note,
|
||||
}) {
|
||||
if (index < 0 || index >= _steps.length) {
|
||||
return false;
|
||||
}
|
||||
final current = _steps[index];
|
||||
final nextApprover = approver ?? current.approver;
|
||||
final duplicated =
|
||||
approver != null &&
|
||||
_steps.asMap().entries.any(
|
||||
(entry) =>
|
||||
entry.key != index && entry.value.approverId == nextApprover.id,
|
||||
);
|
||||
if (_conflictsWithRequester(nextApprover)) {
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
if (duplicated) {
|
||||
_setError('동일한 승인자는 한 번만 추가할 수 있습니다.');
|
||||
return false;
|
||||
}
|
||||
_clearError();
|
||||
_steps[index] = current.copyWith(approver: approver, note: note);
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 최종 승인자를 지정한다.
|
||||
///
|
||||
/// - 단계가 없으면 새로운 마지막 단계를 추가한다.
|
||||
/// - 이미 존재하는 경우 마지막 단계만 해당 승인자로 교체한다.
|
||||
bool setFinalApprover(ApprovalRequestParticipant approver, {String? note}) {
|
||||
if (_conflictsWithRequester(approver)) {
|
||||
_setError('최종 승인자는 상신자와 다른 사람이어야 합니다.');
|
||||
return false;
|
||||
}
|
||||
if (_steps.isEmpty) {
|
||||
return addStep(approver: approver, note: note);
|
||||
}
|
||||
final duplicateOtherIndex = _steps
|
||||
.sublist(0, _steps.length - 1)
|
||||
.any((step) => step.approverId == approver.id);
|
||||
if (duplicateOtherIndex) {
|
||||
_setError('최종 승인자는 다른 단계와 중복될 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
final lastIndex = _steps.length - 1;
|
||||
final last = _steps[lastIndex];
|
||||
_steps[lastIndex] = last.copyWith(
|
||||
approver: approver,
|
||||
note: note ?? last.note,
|
||||
);
|
||||
_clearError();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 템플릿 단계를 그대로 적용한다.
|
||||
void applyTemplateSteps(List<ApprovalRequestStep> steps) {
|
||||
if (_requester != null &&
|
||||
steps.any((step) => step.approverId == _requester!.id)) {
|
||||
_markDirty();
|
||||
_setError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
_steps
|
||||
..clear()
|
||||
..addAll(steps);
|
||||
_reassignStepOrders();
|
||||
_clearError();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 템플릿 스냅샷을 기록한다.
|
||||
void setTemplateSnapshot(ApprovalTemplateSnapshot? snapshot) {
|
||||
_templateSnapshot = snapshot;
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 템플릿 버전이 최신인지 간단히 확인한다.
|
||||
bool isTemplateUpToDate(DateTime? serverUpdatedAt) {
|
||||
final snapshot = _templateSnapshot;
|
||||
if (snapshot == null) {
|
||||
return true;
|
||||
}
|
||||
if (snapshot.updatedAt == null || serverUpdatedAt == null) {
|
||||
return true;
|
||||
}
|
||||
return !snapshot.updatedAt!.isBefore(serverUpdatedAt);
|
||||
}
|
||||
|
||||
/// 현재 상태로부터 결재 상신 입력 모델을 생성한다.
|
||||
ApprovalSubmissionInput buildSubmissionInput({
|
||||
int? transactionId,
|
||||
int? templateId,
|
||||
required int statusId,
|
||||
DateTime? requestedAt,
|
||||
DateTime? decidedAt,
|
||||
DateTime? cancelledAt,
|
||||
DateTime? lastActionAt,
|
||||
String? title,
|
||||
String? summary,
|
||||
String? note,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
final requester = _ensureRequester();
|
||||
final steps = _ensureSteps();
|
||||
return ApprovalSubmissionInput(
|
||||
transactionId: transactionId,
|
||||
templateId: templateId ?? _templateSnapshot?.templateId,
|
||||
statusId: statusId,
|
||||
requesterId: requester.id,
|
||||
finalApproverId: steps.isEmpty ? null : steps.last.approverId,
|
||||
requestedAt: requestedAt,
|
||||
decidedAt: decidedAt,
|
||||
cancelledAt: cancelledAt,
|
||||
lastActionAt: lastActionAt,
|
||||
title: title,
|
||||
summary: summary,
|
||||
note: note,
|
||||
metadata: metadata,
|
||||
steps: steps.map((step) => step.toAssignmentItem()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 재고 전표 결재 입력 모델로 변환한다.
|
||||
StockTransactionApprovalInput buildTransactionApprovalInput({
|
||||
int? approvalStatusId,
|
||||
int? templateId,
|
||||
DateTime? requestedAt,
|
||||
DateTime? decidedAt,
|
||||
DateTime? cancelledAt,
|
||||
DateTime? lastActionAt,
|
||||
String? title,
|
||||
String? summary,
|
||||
String? note,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
final requester = _ensureRequester();
|
||||
final steps = _ensureSteps();
|
||||
return StockTransactionApprovalInput(
|
||||
requestedById: requester.id,
|
||||
approvalStatusId: approvalStatusId,
|
||||
templateId: templateId ?? _templateSnapshot?.templateId,
|
||||
finalApproverId: steps.isEmpty ? null : steps.last.approverId,
|
||||
requestedAt: requestedAt,
|
||||
decidedAt: decidedAt,
|
||||
cancelledAt: cancelledAt,
|
||||
lastActionAt: lastActionAt,
|
||||
title: title,
|
||||
summary: summary,
|
||||
note: note,
|
||||
metadata: metadata,
|
||||
steps: steps.map((step) => step.toAssignmentItem()).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 현재 상태를 초기화한다.
|
||||
void clear() {
|
||||
_requester = null;
|
||||
_steps.clear();
|
||||
_templateSnapshot = null;
|
||||
_errorMessage = null;
|
||||
_isDirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 템플릿을 저장 후 상태를 갱신한다.
|
||||
///
|
||||
/// 외부에서 저장 유즈케이스를 주입한 경우에만 동작한다.
|
||||
Future<ApprovalTemplate?> saveTemplate({
|
||||
int? templateId,
|
||||
required ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput>? steps,
|
||||
}) async {
|
||||
final useCase = _saveTemplateUseCase;
|
||||
if (useCase == null) {
|
||||
throw StateError('SaveApprovalTemplateUseCase가 주입되지 않았습니다.');
|
||||
}
|
||||
final template = await useCase.call(
|
||||
templateId: templateId,
|
||||
input: input,
|
||||
steps: steps,
|
||||
);
|
||||
_templateSnapshot = ApprovalTemplateSnapshot(
|
||||
templateId: template.id,
|
||||
updatedAt: template.updatedAt,
|
||||
);
|
||||
_clearError();
|
||||
_markDirty();
|
||||
notifyListeners();
|
||||
return template;
|
||||
}
|
||||
|
||||
/// 템플릿을 적용해 결재 단계를 갱신한다.
|
||||
///
|
||||
/// - 템플릿 저장소와 Apply 유즈케이스가 모두 주입된 경우에만 지원한다.
|
||||
Future<ApprovalFlow?> applyTemplate({
|
||||
required int approvalId,
|
||||
required int templateId,
|
||||
}) async {
|
||||
final repository = _templateRepository;
|
||||
final useCase = _applyTemplateUseCase;
|
||||
if (repository == null || useCase == null) {
|
||||
throw StateError('템플릿 적용을 위한 의존성이 주입되지 않았습니다.');
|
||||
}
|
||||
_isApplyingTemplate = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final template = await repository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (template.steps.isEmpty) {
|
||||
throw StateError('단계가 없는 템플릿은 적용할 수 없습니다.');
|
||||
}
|
||||
final flow = await useCase.call(
|
||||
approvalId: approvalId,
|
||||
templateId: templateId,
|
||||
);
|
||||
_templateSnapshot = ApprovalTemplateSnapshot(
|
||||
templateId: template.id,
|
||||
updatedAt: template.updatedAt,
|
||||
);
|
||||
final steps = template.steps
|
||||
.map(
|
||||
(step) => ApprovalRequestStep(
|
||||
stepOrder: step.stepOrder,
|
||||
approver: ApprovalRequestParticipant(
|
||||
id: step.approver.id,
|
||||
name: step.approver.name,
|
||||
employeeNo: step.approver.employeeNo,
|
||||
),
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
applyTemplateSteps(steps);
|
||||
return flow;
|
||||
} finally {
|
||||
_isApplyingTemplate = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _setError(String message) {
|
||||
_errorMessage = message;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _clearError() {
|
||||
_errorMessage = null;
|
||||
}
|
||||
|
||||
void _reassignStepOrders() {
|
||||
for (var index = 0; index < _steps.length; index++) {
|
||||
final current = _steps[index];
|
||||
_steps[index] = current.copyWith(stepOrder: index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
List<ApprovalRequestStep> _ensureSteps() {
|
||||
if (_steps.isEmpty) {
|
||||
throw StateError('최소 한 개 이상의 결재 단계를 추가해야 합니다.');
|
||||
}
|
||||
if (hasDuplicateApprover) {
|
||||
throw StateError('동일한 승인자가 중복되어 있습니다.');
|
||||
}
|
||||
final requester = _requester;
|
||||
if (requester != null) {
|
||||
for (var index = 0; index < _steps.length; index++) {
|
||||
final step = _steps[index];
|
||||
if (step.approverId != requester.id) {
|
||||
continue;
|
||||
}
|
||||
if (index == _steps.length - 1) {
|
||||
throw StateError('최종 승인자는 상신자와 다른 사람이어야 합니다.');
|
||||
}
|
||||
throw StateError('상신자는 승인자로 지정할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
return List<ApprovalRequestStep>.unmodifiable(_steps);
|
||||
}
|
||||
|
||||
ApprovalRequestParticipant _ensureRequester() {
|
||||
final requester = _requester;
|
||||
if (requester == null) {
|
||||
throw StateError('상신자를 선택해야 합니다.');
|
||||
}
|
||||
return requester;
|
||||
}
|
||||
|
||||
void _markDirty() {
|
||||
_isDirty = true;
|
||||
}
|
||||
|
||||
bool _conflictsWithRequester(ApprovalRequestParticipant participant) {
|
||||
final requester = _requester;
|
||||
if (requester == null) {
|
||||
return false;
|
||||
}
|
||||
return requester.id == participant.id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import '../../../domain/entities/approval.dart';
|
||||
import '../../../shared/approver_catalog.dart';
|
||||
import '../controllers/approval_request_controller.dart';
|
||||
import '../../../../inventory/transactions/domain/entities/stock_transaction_input.dart';
|
||||
|
||||
/// 재고 전표 결재 섹션에서 공통으로 사용하는 초기화 유틸리티.
|
||||
///
|
||||
/// - 기존 결재 정보 또는 저장된 초안을 기반으로 단계/상신자를 세팅한다.
|
||||
class ApprovalFormInitializer {
|
||||
ApprovalFormInitializer._();
|
||||
|
||||
/// 결재 구성 컨트롤러에 기본값을 주입한다.
|
||||
static void populate({
|
||||
required ApprovalRequestController controller,
|
||||
Approval? existingApproval,
|
||||
StockTransactionApprovalInput? draft,
|
||||
ApprovalRequestParticipant? defaultRequester,
|
||||
}) {
|
||||
if (existingApproval != null) {
|
||||
_applyExistingApproval(controller, existingApproval);
|
||||
return;
|
||||
}
|
||||
if (defaultRequester != null) {
|
||||
controller.setRequester(defaultRequester);
|
||||
}
|
||||
if (draft != null) {
|
||||
_applyDraft(controller, draft);
|
||||
}
|
||||
}
|
||||
|
||||
static void _applyExistingApproval(
|
||||
ApprovalRequestController controller,
|
||||
Approval approval,
|
||||
) {
|
||||
controller.setRequester(
|
||||
ApprovalRequestParticipant(
|
||||
id: approval.requester.id,
|
||||
name: approval.requester.name,
|
||||
employeeNo: approval.requester.employeeNo,
|
||||
),
|
||||
);
|
||||
final steps = approval.steps
|
||||
.map(
|
||||
(step) => ApprovalRequestStep(
|
||||
stepOrder: step.stepOrder,
|
||||
approver: ApprovalRequestParticipant(
|
||||
id: step.approver.id,
|
||||
name: step.approver.name,
|
||||
employeeNo: step.approver.employeeNo,
|
||||
),
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
if (steps.isNotEmpty) {
|
||||
controller.applyTemplateSteps(steps);
|
||||
}
|
||||
}
|
||||
|
||||
static void _applyDraft(
|
||||
ApprovalRequestController controller,
|
||||
StockTransactionApprovalInput draft,
|
||||
) {
|
||||
final requesterCatalog = ApprovalApproverCatalog.byId(draft.requestedById);
|
||||
if (requesterCatalog != null) {
|
||||
controller.setRequester(
|
||||
ApprovalRequestParticipant(
|
||||
id: requesterCatalog.id,
|
||||
name: requesterCatalog.name,
|
||||
employeeNo: requesterCatalog.employeeNo,
|
||||
),
|
||||
);
|
||||
}
|
||||
final steps = draft.steps
|
||||
.map((step) {
|
||||
final catalog = ApprovalApproverCatalog.byId(step.approverId);
|
||||
if (catalog == null) {
|
||||
return null;
|
||||
}
|
||||
return ApprovalRequestStep(
|
||||
stepOrder: step.stepOrder,
|
||||
approver: ApprovalRequestParticipant(
|
||||
id: catalog.id,
|
||||
name: catalog.name,
|
||||
employeeNo: catalog.employeeNo,
|
||||
),
|
||||
note: step.note,
|
||||
);
|
||||
})
|
||||
.whereType<ApprovalRequestStep>()
|
||||
.toList(growable: false);
|
||||
if (steps.isNotEmpty) {
|
||||
controller.applyTemplateSteps(steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../widgets/components/feedback.dart';
|
||||
import '../../../../../widgets/components/superport_dialog.dart';
|
||||
import '../../../shared/approver_catalog.dart';
|
||||
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||
import '../controllers/approval_request_controller.dart';
|
||||
import 'approval_step_row.dart';
|
||||
import 'approval_template_picker.dart';
|
||||
|
||||
/// 결재 단계 구성을 요약하고 모달을 통해 편집할 수 있는 UI 섹션.
|
||||
class ApprovalStepConfigurator extends StatefulWidget {
|
||||
const ApprovalStepConfigurator({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
/// 결재 단계 상태를 제어하는 컨트롤러.
|
||||
final ApprovalRequestController controller;
|
||||
|
||||
/// 읽기 전용 모드 여부.
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
State<ApprovalStepConfigurator> createState() =>
|
||||
_ApprovalStepConfiguratorState();
|
||||
}
|
||||
|
||||
class _ApprovalStepConfiguratorState extends State<ApprovalStepConfigurator> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final steps = widget.controller.steps;
|
||||
final requester = widget.controller.requester;
|
||||
final finalApprover = widget.controller.finalApprover;
|
||||
final templateSnapshot = widget.controller.templateSnapshot;
|
||||
|
||||
return ShadCard(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'결재 단계 구성',
|
||||
style: theme.textTheme.h4.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'상신자, 중간 승인자, 최종 승인자를 정의하고 템플릿으로 저장할 수 있습니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: widget.readOnly
|
||||
? null
|
||||
: () => _openConfiguratorDialog(context),
|
||||
leading: const Icon(lucide.LucideIcons.settings2, size: 16),
|
||||
child: const Text('단계 구성 편집'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_InfoBadge(
|
||||
icon: lucide.LucideIcons.user,
|
||||
label: '상신자',
|
||||
value: requester?.name ?? '미지정',
|
||||
),
|
||||
_InfoBadge(
|
||||
icon: lucide.LucideIcons.badgeCheck,
|
||||
label: '최종 승인자',
|
||||
value: finalApprover?.name ?? '미지정',
|
||||
),
|
||||
_InfoBadge(
|
||||
icon: lucide.LucideIcons.listOrdered,
|
||||
label: '총 단계',
|
||||
value: '${steps.length}개',
|
||||
),
|
||||
if (templateSnapshot != null)
|
||||
_InfoBadge(
|
||||
icon: lucide.LucideIcons.bookmarkCheck,
|
||||
label: '적용 템플릿',
|
||||
value: '#${templateSnapshot.templateId}',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (steps.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 20,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Text(
|
||||
'등록된 결재 단계가 없습니다. 단계 구성 편집을 눌러 승인자를 추가하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
..._buildStepSummary(theme, steps),
|
||||
if (steps.length > 4)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'+ ${steps.length - 4}개 단계 더 있음',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.controller.errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.controller.errorMessage!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildStepSummary(
|
||||
ShadThemeData theme,
|
||||
List<ApprovalRequestStep> steps,
|
||||
) {
|
||||
final limit = steps.length > 4 ? 4 : steps.length;
|
||||
return [
|
||||
for (var index = 0; index < limit; index++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: index == 0 ? 0 : 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: theme.colorScheme.secondary.withValues(alpha: 0.12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${steps[index].stepOrder}',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${steps[index].approver.name} · ${steps[index].approver.employeeNo}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _openConfiguratorDialog(BuildContext context) {
|
||||
return SuperportDialog.show<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
dialog: SuperportDialog(
|
||||
title: '결재 단계 구성',
|
||||
description: '승인자 목록을 편집하고 템플릿을 적용하거나 저장합니다.',
|
||||
child: _ConfiguratorDialogBody(
|
||||
controller: widget.controller,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfiguratorDialogBody extends StatefulWidget {
|
||||
const _ConfiguratorDialogBody({
|
||||
required this.controller,
|
||||
required this.readOnly,
|
||||
});
|
||||
|
||||
final ApprovalRequestController controller;
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
State<_ConfiguratorDialogBody> createState() =>
|
||||
_ConfiguratorDialogBodyState();
|
||||
}
|
||||
|
||||
class _ConfiguratorDialogBodyState extends State<_ConfiguratorDialogBody> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final steps = widget.controller.steps;
|
||||
final duplicates = _collectDuplicateApproverIds(steps);
|
||||
final isApplyingTemplate = widget.controller.isApplyingTemplate;
|
||||
final hasReachedLimit = widget.controller.hasReachedStepLimit;
|
||||
final requester = widget.controller.requester;
|
||||
final hasRequesterConflict = widget.controller.hasRequesterConflict;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'상신자',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Text(
|
||||
requester == null
|
||||
? '상신자가 아직 지정되지 않았습니다.'
|
||||
: '${requester.name} · ${requester.employeeNo}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
IgnorePointer(
|
||||
ignoring: widget.readOnly,
|
||||
child: Opacity(
|
||||
opacity: widget.readOnly ? 0.6 : 1,
|
||||
child: ApprovalTemplatePicker(
|
||||
controller: widget.controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'결재 단계 목록',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (isApplyingTemplate) ...[
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'템플릿을 적용하는 중입니다...',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
ShadButton.outline(
|
||||
onPressed: widget.readOnly || hasReachedLimit
|
||||
? null
|
||||
: _openAddStepDialog,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(lucide.LucideIcons.plus, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('단계 추가'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (steps.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Text(
|
||||
'결재 단계를 추가해 주세요. 마지막 단계가 자동으로 최종 승인자로 사용됩니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: steps.length,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final step = steps[index];
|
||||
final isFinal = index == steps.length - 1;
|
||||
return ApprovalStepRow(
|
||||
key: ValueKey('approval_step_row_$index'),
|
||||
controller: widget.controller,
|
||||
step: step,
|
||||
index: index,
|
||||
isFinal: isFinal,
|
||||
readOnly: widget.readOnly,
|
||||
hasDuplicateApprover: duplicates.contains(
|
||||
step.approverId,
|
||||
),
|
||||
isRequesterConflict: requester?.id == step.approverId,
|
||||
onRemove: widget.readOnly
|
||||
? null
|
||||
: () => widget.controller.removeStepAt(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (steps.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'각 단계 오른쪽 화살표 버튼으로 순서를 조정할 수 있습니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
if (duplicates.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'동일한 승인자가 중복된 단계가 있습니다. 승인자를 조정해 주세요.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasRequesterConflict)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'상신자와 동일한 승인자는 구성에 포함될 수 없습니다. 다른 승인자를 선택하세요.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasReachedLimit)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'결재 단계는 최대 ${widget.controller.maxSteps}개까지 추가할 수 있습니다.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.controller.errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.controller.errorMessage!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Set<int> _collectDuplicateApproverIds(List<ApprovalRequestStep> steps) {
|
||||
final seen = <int>{};
|
||||
final duplicates = <int>{};
|
||||
for (final step in steps) {
|
||||
final id = step.approverId;
|
||||
if (!seen.add(id)) {
|
||||
duplicates.add(id);
|
||||
}
|
||||
}
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
Future<void> _openAddStepDialog() async {
|
||||
ApprovalApproverCatalogItem? selected;
|
||||
final idController = TextEditingController();
|
||||
|
||||
final result = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
dialog: SuperportDialog(
|
||||
title: '결재 단계 추가',
|
||||
description: '승인자를 검색해 새로운 결재 단계를 추가합니다.',
|
||||
primaryAction: ShadButton(
|
||||
onPressed: () => Navigator.of(context, rootNavigator: true).pop(true),
|
||||
child: const Text('추가'),
|
||||
),
|
||||
secondaryAction: ShadButton.ghost(
|
||||
onPressed: () =>
|
||||
Navigator.of(context, rootNavigator: true).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('승인자'),
|
||||
const SizedBox(height: 8),
|
||||
ApprovalApproverAutocompleteField(
|
||||
idController: idController,
|
||||
onSelected: (item) => selected = item,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'상신자와 중복되지 않도록 다른 승인자를 선택해야 합니다.',
|
||||
style: ShadTheme.of(context).textTheme.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
idController.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result != true) {
|
||||
idController.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final participant = _resolveParticipant(selected, idController.text.trim());
|
||||
if (participant == null) {
|
||||
SuperportToast.warning(context, '유효한 승인자를 선택해주세요.');
|
||||
idController.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final added = widget.controller.addStep(approver: participant);
|
||||
if (!added) {
|
||||
final message = widget.controller.errorMessage ?? '결재 단계를 추가하지 못했습니다.';
|
||||
SuperportToast.error(context, message);
|
||||
} else {
|
||||
SuperportToast.success(
|
||||
context,
|
||||
'"${participant.name}" 님을 단계 ${widget.controller.totalSteps}에 추가했습니다.',
|
||||
);
|
||||
}
|
||||
idController.dispose();
|
||||
}
|
||||
|
||||
ApprovalRequestParticipant? _resolveParticipant(
|
||||
ApprovalApproverCatalogItem? selected,
|
||||
String manualInput,
|
||||
) {
|
||||
if (selected != null) {
|
||||
return ApprovalRequestParticipant(
|
||||
id: selected.id,
|
||||
name: selected.name,
|
||||
employeeNo: selected.employeeNo,
|
||||
);
|
||||
}
|
||||
final manualId = int.tryParse(manualInput);
|
||||
if (manualId == null) {
|
||||
return null;
|
||||
}
|
||||
final match = ApprovalApproverCatalog.byId(manualId);
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
return ApprovalRequestParticipant(
|
||||
id: match.id,
|
||||
name: match.name,
|
||||
employeeNo: match.employeeNo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoBadge extends StatelessWidget {
|
||||
const _InfoBadge({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
color: theme.colorScheme.muted.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: theme.colorScheme.mutedForeground),
|
||||
const SizedBox(width: 6),
|
||||
Text('$label: $value', style: theme.textTheme.small),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../widgets/components/feedback.dart';
|
||||
import '../../../shared/approver_catalog.dart';
|
||||
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||
import '../controllers/approval_request_controller.dart';
|
||||
|
||||
/// 결재 단계 테이블에서 단일 행을 편집하기 위한 위젯.
|
||||
///
|
||||
/// - 순번, 승인자 자동완성, 역할/메모 입력, 삭제 버튼을 한 번에 제공한다.
|
||||
class ApprovalStepRow extends StatefulWidget {
|
||||
const ApprovalStepRow({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.step,
|
||||
required this.index,
|
||||
this.onRemove,
|
||||
this.isFinal = false,
|
||||
this.hasDuplicateApprover = false,
|
||||
this.isRequesterConflict = false,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
/// 결재 단계 상태를 관리하는 컨트롤러.
|
||||
final ApprovalRequestController controller;
|
||||
|
||||
/// 현재 행에 해당하는 단계 데이터.
|
||||
final ApprovalRequestStep step;
|
||||
|
||||
/// 행 인덱스(0-base).
|
||||
final int index;
|
||||
|
||||
/// 행 삭제 시 실행할 콜백.
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
/// 마지막 단계(최종 승인자)인지 여부.
|
||||
final bool isFinal;
|
||||
|
||||
/// 승인자 중복 오류가 있는지 여부.
|
||||
final bool hasDuplicateApprover;
|
||||
|
||||
/// 상신자와 중복되는 승인자인지 여부.
|
||||
final bool isRequesterConflict;
|
||||
|
||||
/// 읽기 전용 모드 여부.
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
State<ApprovalStepRow> createState() => _ApprovalStepRowState();
|
||||
}
|
||||
|
||||
class _ApprovalStepRowState extends State<ApprovalStepRow> {
|
||||
late final TextEditingController _approverIdController;
|
||||
late final TextEditingController _noteController;
|
||||
late ApprovalRequestParticipant _currentApprover;
|
||||
int _fieldVersion = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentApprover = widget.step.approver;
|
||||
_approverIdController = TextEditingController(
|
||||
text: widget.step.approverId.toString(),
|
||||
);
|
||||
_noteController = TextEditingController(text: widget.step.note ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ApprovalStepRow oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.step.approverId != widget.step.approverId) {
|
||||
_currentApprover = widget.step.approver;
|
||||
_approverIdController.text = widget.step.approverId.toString();
|
||||
_refreshAutocompleteField();
|
||||
}
|
||||
if (oldWidget.step.note != widget.step.note &&
|
||||
widget.step.note != _noteController.text) {
|
||||
_noteController.text = widget.step.note ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_approverIdController.dispose();
|
||||
_noteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _refreshAutocompleteField() {
|
||||
setState(() {
|
||||
_fieldVersion += 1;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleApproverSelected(
|
||||
BuildContext context,
|
||||
ApprovalApproverCatalogItem? item,
|
||||
) async {
|
||||
if (widget.readOnly) {
|
||||
return;
|
||||
}
|
||||
ApprovalRequestParticipant? nextParticipant;
|
||||
|
||||
if (item != null) {
|
||||
nextParticipant = ApprovalRequestParticipant(
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
employeeNo: item.employeeNo,
|
||||
);
|
||||
} else {
|
||||
final manualId = int.tryParse(_approverIdController.text.trim());
|
||||
if (manualId == null) {
|
||||
SuperportToast.warning(context, '승인자를 다시 선택해주세요.');
|
||||
_restorePreviousApprover();
|
||||
return;
|
||||
}
|
||||
final catalogMatch = ApprovalApproverCatalog.byId(manualId);
|
||||
if (catalogMatch == null) {
|
||||
SuperportToast.warning(context, '등록되지 않은 승인자입니다.');
|
||||
_restorePreviousApprover();
|
||||
return;
|
||||
}
|
||||
nextParticipant = ApprovalRequestParticipant(
|
||||
id: catalogMatch.id,
|
||||
name: catalogMatch.name,
|
||||
employeeNo: catalogMatch.employeeNo,
|
||||
);
|
||||
}
|
||||
|
||||
final updated = widget.controller.updateStep(
|
||||
widget.index,
|
||||
approver: nextParticipant,
|
||||
);
|
||||
if (!updated) {
|
||||
SuperportToast.error(
|
||||
context,
|
||||
widget.controller.errorMessage ?? '승인자 변경에 실패했습니다.',
|
||||
);
|
||||
_restorePreviousApprover();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_currentApprover = nextParticipant!;
|
||||
_approverIdController.text = nextParticipant.id.toString();
|
||||
});
|
||||
if (widget.hasDuplicateApprover) {
|
||||
SuperportToast.warning(context, '동일한 승인자가 존재하지 않도록 구성해주세요.');
|
||||
} else {
|
||||
SuperportToast.info(
|
||||
context,
|
||||
'단계 ${widget.step.stepOrder} 승인자를 ${nextParticipant.name} 님으로 변경했습니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _restorePreviousApprover() {
|
||||
_approverIdController.text = _currentApprover.id.toString();
|
||||
_refreshAutocompleteField();
|
||||
}
|
||||
|
||||
void _handleNoteChanged(String value) {
|
||||
if (widget.readOnly) {
|
||||
return;
|
||||
}
|
||||
final trimmed = value.trim();
|
||||
widget.controller.updateStep(
|
||||
widget.index,
|
||||
note: trimmed.isEmpty ? null : trimmed,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMove(int offset) {
|
||||
if (widget.readOnly) {
|
||||
return;
|
||||
}
|
||||
final targetIndex = widget.index + offset;
|
||||
final total = widget.controller.totalSteps;
|
||||
if (targetIndex < 0 || targetIndex >= total) {
|
||||
return;
|
||||
}
|
||||
widget.controller.moveStep(widget.index, targetIndex);
|
||||
final direction = offset < 0 ? '위로' : '아래로';
|
||||
SuperportToast.info(context, '결재 단계 순서를 $direction 조정했습니다.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final hasError = widget.hasDuplicateApprover || widget.isRequesterConflict;
|
||||
final borderColor = hasError
|
||||
? theme.colorScheme.destructive
|
||||
: theme.colorScheme.border;
|
||||
final badgeColor = hasError
|
||||
? theme.colorScheme.destructive
|
||||
: theme.colorScheme.secondary;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor, width: 1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_StepBadge(order: widget.step.stepOrder, badgeColor: badgeColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.isFinal ? '최종 승인자' : '승인자',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
IgnorePointer(
|
||||
ignoring: widget.readOnly,
|
||||
child: Opacity(
|
||||
opacity: widget.readOnly ? 0.6 : 1,
|
||||
child: ApprovalApproverAutocompleteField(
|
||||
key: ValueKey(
|
||||
'approver_field_${widget.index}_$_fieldVersion',
|
||||
),
|
||||
idController: _approverIdController,
|
||||
onSelected: (item) =>
|
||||
_handleApproverSelected(context, item),
|
||||
hintText: '승인자 이름 또는 사번 검색',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'역할/메모',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ShadInput(
|
||||
controller: _noteController,
|
||||
onChanged: _handleNoteChanged,
|
||||
enabled: !widget.readOnly,
|
||||
placeholder: const Text('예: 팀장 승인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: widget.readOnly || widget.index == 0
|
||||
? null
|
||||
: () => _handleMove(-1),
|
||||
tooltip: '위로 이동',
|
||||
icon: const Icon(lucide.LucideIcons.chevronUp, size: 18),
|
||||
),
|
||||
IconButton(
|
||||
onPressed:
|
||||
widget.readOnly ||
|
||||
widget.index >= widget.controller.totalSteps - 1
|
||||
? null
|
||||
: () => _handleMove(1),
|
||||
tooltip: '아래로 이동',
|
||||
icon: const Icon(
|
||||
lucide.LucideIcons.chevronDown,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: widget.readOnly || widget.onRemove == null
|
||||
? null
|
||||
: widget.onRemove,
|
||||
tooltip: '단계 삭제',
|
||||
icon: Icon(
|
||||
lucide.LucideIcons.trash2,
|
||||
color: widget.readOnly || widget.onRemove == null
|
||||
? theme.colorScheme.mutedForeground
|
||||
: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
lucide.LucideIcons.triangleAlert,
|
||||
size: 16,
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.hasDuplicateApprover
|
||||
? '동일한 승인자가 중복되어 있습니다. 다른 승인자를 선택해주세요.'
|
||||
: '상신자는 승인자로 지정할 수 없습니다. 다른 승인자를 선택해주세요.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.isFinal && !hasError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
lucide.LucideIcons.circleCheck,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'마지막 단계가 최종 승인자로 처리됩니다.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepBadge extends StatelessWidget {
|
||||
const _StepBadge({required this.order, required this.badgeColor});
|
||||
|
||||
final int order;
|
||||
final Color badgeColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: badgeColor.withValues(alpha: 0.4)),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
order.toString(),
|
||||
style: theme.textTheme.large.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: badgeColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../core/network/failure.dart';
|
||||
import '../../../../../widgets/components/feedback.dart';
|
||||
import '../../../domain/entities/approval_template.dart';
|
||||
import '../../../domain/repositories/approval_template_repository.dart';
|
||||
import '../../../domain/usecases/save_approval_template_use_case.dart';
|
||||
import '../controllers/approval_request_controller.dart';
|
||||
|
||||
/// 템플릿을 불러오고 적용/저장할 수 있는 결재 템플릿 선택 위젯.
|
||||
class ApprovalTemplatePicker extends StatefulWidget {
|
||||
const ApprovalTemplatePicker({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.repository,
|
||||
this.saveUseCase,
|
||||
this.onTemplateApplied,
|
||||
this.onTemplatesChanged,
|
||||
});
|
||||
|
||||
/// 결재 단계 상태를 제어하는 컨트롤러.
|
||||
final ApprovalRequestController controller;
|
||||
|
||||
/// 템플릿 저장소. 지정하지 않으면 [GetIt]에서 조회한다.
|
||||
final ApprovalTemplateRepository? repository;
|
||||
|
||||
/// 템플릿 저장 유즈케이스. 지정하지 않으면 [GetIt]에서 조회한다.
|
||||
final SaveApprovalTemplateUseCase? saveUseCase;
|
||||
|
||||
/// 템플릿 적용이 완료됐을 때 호출되는 콜백.
|
||||
final void Function(ApprovalTemplate template)? onTemplateApplied;
|
||||
|
||||
/// 템플릿 목록이 갱신됐을 때 호출되는 콜백.
|
||||
final void Function(List<ApprovalTemplate> templates)? onTemplatesChanged;
|
||||
|
||||
@override
|
||||
State<ApprovalTemplatePicker> createState() => _ApprovalTemplatePickerState();
|
||||
}
|
||||
|
||||
class _ApprovalTemplatePickerState extends State<ApprovalTemplatePicker> {
|
||||
List<ApprovalTemplate> _templates = const <ApprovalTemplate>[];
|
||||
int? _selectedTemplateId;
|
||||
bool _isLoading = false;
|
||||
bool _isSaving = false;
|
||||
String? _error;
|
||||
|
||||
ApprovalTemplateRepository? get _repository =>
|
||||
widget.repository ??
|
||||
(GetIt.I.isRegistered<ApprovalTemplateRepository>()
|
||||
? GetIt.I<ApprovalTemplateRepository>()
|
||||
: null);
|
||||
|
||||
SaveApprovalTemplateUseCase? get _saveUseCase =>
|
||||
widget.saveUseCase ??
|
||||
(GetIt.I.isRegistered<SaveApprovalTemplateUseCase>()
|
||||
? GetIt.I<SaveApprovalTemplateUseCase>()
|
||||
: null);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedTemplateId = widget.controller.templateSnapshot?.templateId;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadTemplates();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ApprovalTemplatePicker oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.controller.templateSnapshot?.templateId !=
|
||||
oldWidget.controller.templateSnapshot?.templateId) {
|
||||
setState(() {
|
||||
_selectedTemplateId = widget.controller.templateSnapshot?.templateId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTemplates() async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
setState(() {
|
||||
_error = '결재 템플릿 저장소가 등록되지 않아 목록을 불러올 수 없습니다.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final result = await repository.list(
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
isActive: true,
|
||||
);
|
||||
setState(() {
|
||||
_templates = result.items;
|
||||
if (_selectedTemplateId != null &&
|
||||
!_templates.any((template) => template.id == _selectedTemplateId)) {
|
||||
_selectedTemplateId = null;
|
||||
}
|
||||
});
|
||||
widget.onTemplatesChanged?.call(result.items);
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
setState(() {
|
||||
_error = failure.describe();
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyTemplate(BuildContext context) async {
|
||||
final repository = _repository;
|
||||
final templateId = _selectedTemplateId;
|
||||
if (repository == null || templateId == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final detail = await repository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (detail.steps.isEmpty) {
|
||||
throw StateError('단계가 없는 템플릿은 적용할 수 없습니다.');
|
||||
}
|
||||
final steps = detail.steps
|
||||
.map(
|
||||
(step) => ApprovalRequestStep(
|
||||
stepOrder: step.stepOrder,
|
||||
approver: ApprovalRequestParticipant(
|
||||
id: step.approver.id,
|
||||
name: step.approver.name,
|
||||
employeeNo: step.approver.employeeNo,
|
||||
),
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
widget.controller.applyTemplateSteps(steps);
|
||||
widget.controller.setTemplateSnapshot(
|
||||
ApprovalTemplateSnapshot(
|
||||
templateId: detail.id,
|
||||
updatedAt: detail.updatedAt,
|
||||
),
|
||||
);
|
||||
widget.onTemplateApplied?.call(detail);
|
||||
SuperportToast.success(context, '템플릿 "${detail.name}"을(를) 적용했습니다.');
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = failure.describe();
|
||||
});
|
||||
if (context.mounted) {
|
||||
SuperportToast.error(context, failure.describe());
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openTemplatePreview(BuildContext context) async {
|
||||
final repository = _repository;
|
||||
final templateId = _selectedTemplateId;
|
||||
if (repository == null || templateId == null) {
|
||||
SuperportToast.info(context, '미리볼 템플릿을 먼저 선택하세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final detail = await repository.fetchDetail(
|
||||
templateId,
|
||||
includeSteps: true,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'템플릿 미리보기',
|
||||
style: theme.textTheme.h4.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(detail.name, style: theme.textTheme.small),
|
||||
if (detail.description?.isNotEmpty ?? false) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(detail.description!, style: theme.textTheme.muted),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (detail.steps.isEmpty)
|
||||
Text('등록된 단계가 없습니다.', style: theme.textTheme.muted)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
for (final step in detail.steps) ...[
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: theme.colorScheme.secondary
|
||||
.withValues(alpha: 0.12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
step.stepOrder.toString(),
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
step.approver.name,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'사번 ${step.approver.employeeNo}',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
if (step.note?.isNotEmpty ?? false)
|
||||
Text(
|
||||
step.note!,
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
SuperportToast.error(context, failure.describe());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openSaveTemplateDialog(BuildContext context) async {
|
||||
final steps = widget.controller.steps;
|
||||
if (steps.isEmpty) {
|
||||
SuperportToast.warning(context, '저장할 결재 단계가 없습니다.');
|
||||
return;
|
||||
}
|
||||
final saveUseCase = _saveUseCase;
|
||||
if (saveUseCase == null) {
|
||||
SuperportToast.error(context, '템플릿 저장 유즈케이스가 등록되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final nameController = TextEditingController();
|
||||
final codeController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
final noteController = TextEditingController();
|
||||
String? errorText;
|
||||
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: !_isSaving,
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
return StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
Future<void> handleSubmit() async {
|
||||
if (_isSaving) return;
|
||||
final nameText = nameController.text.trim();
|
||||
if (nameText.isEmpty) {
|
||||
setModalState(() {
|
||||
errorText = '템플릿명을 입력해주세요.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
final stepInputs = steps
|
||||
.map(
|
||||
(step) => ApprovalTemplateStepInput(
|
||||
stepOrder: step.stepOrder,
|
||||
approverId: step.approverId,
|
||||
note: step.note,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final input = ApprovalTemplateInput(
|
||||
code: codeController.text.trim().isEmpty
|
||||
? null
|
||||
: codeController.text.trim(),
|
||||
name: nameText,
|
||||
description: descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: descriptionController.text.trim(),
|
||||
note: noteController.text.trim().isEmpty
|
||||
? null
|
||||
: noteController.text.trim(),
|
||||
isActive: true,
|
||||
);
|
||||
setModalState(() {
|
||||
_isSaving = true;
|
||||
errorText = null;
|
||||
});
|
||||
try {
|
||||
final template = await saveUseCase.call(
|
||||
templateId: null,
|
||||
input: input,
|
||||
steps: stepInputs,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(dialogContext).pop(true);
|
||||
if (!context.mounted) return;
|
||||
SuperportToast.success(
|
||||
context,
|
||||
'템플릿 "${template.name}"을(를) 저장했습니다.',
|
||||
);
|
||||
widget.controller.setTemplateSnapshot(
|
||||
ApprovalTemplateSnapshot(
|
||||
templateId: template.id,
|
||||
updatedAt: template.updatedAt,
|
||||
),
|
||||
);
|
||||
await _loadTemplates();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedTemplateId = template.id;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
setModalState(() {
|
||||
_isSaving = false;
|
||||
errorText = failure.describe();
|
||||
});
|
||||
if (context.mounted) {
|
||||
SuperportToast.error(context, failure.describe());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'템플릿으로 저장',
|
||||
style: theme.textTheme.h4.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadInput(
|
||||
controller: nameController,
|
||||
placeholder: const Text('템플릿명 (필수)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadInput(
|
||||
controller: codeController,
|
||||
placeholder: const Text('템플릿 코드 (선택)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadTextarea(
|
||||
controller: descriptionController,
|
||||
minHeight: 80,
|
||||
maxHeight: 160,
|
||||
placeholder: const Text('설명 (선택)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ShadTextarea(
|
||||
controller: noteController,
|
||||
minHeight: 80,
|
||||
maxHeight: 160,
|
||||
placeholder: const Text('비고/안내 문구 (선택)'),
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isSaving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
onPressed: _isSaving ? null : handleSubmit,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('저장'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
nameController.dispose();
|
||||
codeController.dispose();
|
||||
descriptionController.dispose();
|
||||
noteController.dispose();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final snapshot = widget.controller.templateSnapshot;
|
||||
ApprovalTemplate? selectedTemplate;
|
||||
for (final template in _templates) {
|
||||
if (template.id == _selectedTemplateId) {
|
||||
selectedTemplate = template;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final isUpToDate = selectedTemplate == null
|
||||
? true
|
||||
: widget.controller.isTemplateUpToDate(selectedTemplate.updatedAt);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadSelect<int?>(
|
||||
placeholder: const Text('템플릿 선택'),
|
||||
initialValue: selectedTemplate?.id,
|
||||
enabled: !_isLoading,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedTemplateId = value;
|
||||
});
|
||||
},
|
||||
selectedOptionBuilder: (context, value) {
|
||||
if (value == null) {
|
||||
return const Text('템플릿 선택');
|
||||
}
|
||||
ApprovalTemplate? match;
|
||||
for (final template in _templates) {
|
||||
if (template.id == value) {
|
||||
match = template;
|
||||
break;
|
||||
}
|
||||
}
|
||||
match ??= selectedTemplate;
|
||||
return Text(match?.name ?? '템플릿 선택');
|
||||
},
|
||||
options: _templates
|
||||
.map(
|
||||
(template) => ShadOption<int?>(
|
||||
value: template.id,
|
||||
child: Text(template.name),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton.outline(
|
||||
onPressed: _isLoading ? null : () => _loadTemplates(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(lucide.LucideIcons.refreshCw, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text('새로고침'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton.ghost(
|
||||
onPressed: () => _openTemplatePreview(context),
|
||||
child: const Text('미리보기'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
onPressed: (_isLoading || _selectedTemplateId == null)
|
||||
? null
|
||||
: () => _applyTemplate(context),
|
||||
child: _isLoading && _selectedTemplateId != null
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('템플릿 적용'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
ShadButton.outline(
|
||||
onPressed: _isSaving
|
||||
? null
|
||||
: () => _openSaveTemplateDialog(context),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(lucide.LucideIcons.save, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text('현재 단계를 템플릿으로 저장'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (snapshot != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'선택됨: #${snapshot.templateId}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_error!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
] else if (selectedTemplate != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
isUpToDate
|
||||
? '템플릿 "${selectedTemplate.name}"이(가) 적용 대기 중입니다.'
|
||||
: '템플릿 "${selectedTemplate.name}"이(가) 서버 버전과 달라져 재적용이 필요합니다.',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: isUpToDate
|
||||
? theme.colorScheme.mutedForeground
|
||||
: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'approval_step_configurator.dart';
|
||||
export 'approval_step_row.dart';
|
||||
export 'approval_template_picker.dart';
|
||||
Reference in New Issue
Block a user