결재 API 계약 보완 및 테스트 정리

This commit is contained in:
JiWoong Sul
2025-10-16 18:53:22 +09:00
parent 9e2244f260
commit efed3c1a6f
44 changed files with 1969 additions and 293 deletions

View File

@@ -57,6 +57,7 @@ class ApprovalController extends ChangeNotifier {
bool _isLoadingList = false;
bool _isLoadingDetail = false;
bool _isLoadingActions = false;
bool _isSubmitting = false;
bool _isPerformingAction = false;
int? _processingStepId;
bool _isLoadingTemplates = false;
@@ -72,6 +73,7 @@ class ApprovalController extends ChangeNotifier {
List<ApprovalAction> _actions = const [];
List<ApprovalTemplate> _templates = const [];
final Map<String, LookupItem> _statusLookup = {};
List<LookupItem> _statusOptions = const [];
final Map<String, String> _statusCodeAliases = Map.fromEntries(
_defaultStatusCodes.entries.map(
(entry) => MapEntry(entry.value, entry.value),
@@ -83,6 +85,7 @@ class ApprovalController extends ChangeNotifier {
bool get isLoadingList => _isLoadingList;
bool get isLoadingDetail => _isLoadingDetail;
bool get isLoadingActions => _isLoadingActions;
bool get isSubmitting => _isSubmitting;
bool get isPerformingAction => _isPerformingAction;
int? get processingStepId => _processingStepId;
String? get errorMessage => _errorMessage;
@@ -107,6 +110,35 @@ class ApprovalController extends ChangeNotifier {
return reason;
}
List<LookupItem> get approvalStatusOptions => _statusOptions;
int? get defaultApprovalStatusId {
if (_statusOptions.isEmpty) {
return null;
}
final defaultItem = _statusOptions.firstWhere(
(item) => item.isDefault,
orElse: () => _statusOptions.first,
);
return defaultItem.id;
}
LookupItem? approvalStatusById(int? id) {
if (id == null) {
return null;
}
final lookup = _statusLookup[id.toString()];
if (lookup != null) {
return lookup;
}
for (final item in _statusOptions) {
if (item.id == id) {
return item;
}
}
return null;
}
Map<String, LookupItem> get statusLookup => _statusLookup;
/// 필터 조건과 페이지 정보를 기반으로 결재 목록을 조회한다.
@@ -174,6 +206,7 @@ class ApprovalController extends ChangeNotifier {
}
try {
final items = await repository.fetchApprovalStatuses();
_statusOptions = List.unmodifiable(items);
_statusLookup
..clear()
..addEntries(
@@ -311,6 +344,53 @@ class ApprovalController extends ChangeNotifier {
notifyListeners();
}
/// 결재를 생성하고 목록/상세 상태를 최신화한다.
Future<Approval?> createApproval(ApprovalCreateInput input) async {
_setSubmitting(true);
_errorMessage = null;
try {
final created = await _repository.create(input);
await fetch(page: 1);
_selected = created;
if (created.id != null) {
await _loadProceedStatus(created.id!);
}
notifyListeners();
return created;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_setSubmitting(false);
}
}
/// 결재 기본 정보를 수정하고 현재 페이지를 유지한다.
Future<Approval?> updateApproval(ApprovalUpdateInput input) async {
_setSubmitting(true);
_errorMessage = null;
try {
final updated = await _repository.update(input);
final currentPage = _result?.page ?? 1;
await fetch(page: currentPage);
_selected = updated;
if (updated.id != null) {
await _loadProceedStatus(updated.id!);
}
notifyListeners();
return updated;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
notifyListeners();
return null;
} finally {
_setSubmitting(false);
}
}
/// 결재 단계에 대해 승인/반려/코멘트 등 지정된 행위를 수행한다.
///
/// - 유효한 단계 ID와 액션이 존재해야 하며, 실행 중에는 중복 호출을 방지한다.
@@ -477,6 +557,14 @@ class ApprovalController extends ChangeNotifier {
notifyListeners();
}
void _setSubmitting(bool value) {
if (_isSubmitting == value) {
return;
}
_isSubmitting = value;
notifyListeners();
}
/// 액션 타입과 동일한 코드(또는 별칭)를 가진 결재 행위를 찾는다.
ApprovalAction? _findActionByType(ApprovalStepActionType type) {
final aliases = _actionAliases[type] ?? [type.code];

View File

@@ -360,111 +360,318 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
/// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다.
Future<void> _openCreateApprovalDialog() async {
final approvalNoController = TextEditingController();
final transactionController = TextEditingController();
final requesterController = TextEditingController();
final noteController = TextEditingController();
var submitted = false;
InventoryEmployeeSuggestion? requesterSelection;
int? statusId = _controller.defaultApprovalStatusId;
String? transactionError;
String? approvalNoError;
String? statusError;
String? requesterError;
final shouldShowToast = await showDialog<bool>(
final created = await showDialog<bool>(
context: context,
builder: (dialogContext) {
builder: (_) {
return StatefulBuilder(
builder: (context, setState) {
final shadTheme = ShadTheme.of(context);
final errorVisible =
submitted && transactionController.text.trim().isEmpty;
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final shadTheme = ShadTheme.of(context);
final materialTheme = Theme.of(context);
final statusOptions = _controller.approvalStatusOptions;
final isSubmitting = _controller.isSubmitting;
statusId ??= _controller.defaultApprovalStatusId;
return SuperportDialog(
title: '신규 결재 등록',
description: '트랜잭션 정보를 입력하면 API 연동 시 자동 제출이 지원됩니다.',
constraints: const BoxConstraints(maxWidth: 480),
actions: [
ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('닫기'),
),
ShadButton(
key: const ValueKey('approval_create_submit'),
onPressed: () {
final trimmed = transactionController.text.trim();
setState(() => submitted = true);
if (trimmed.isEmpty) {
return;
}
Navigator.of(dialogContext).pop(true);
},
child: const Text('임시 저장'),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text('트랜잭션 ID', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadInput(
key: const ValueKey('approval_create_transaction'),
controller: transactionController,
placeholder: const Text('예: 2404-TRX-001'),
onChanged: (_) => setState(() {}),
),
if (errorVisible)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'트랜잭션 ID를 입력해야 결재 생성이 가능합니다.',
style: shadTheme.textTheme.small.copyWith(
color: Theme.of(context).colorScheme.error,
return SuperportDialog(
title: '신규 결재 등록',
description: '트랜잭션과 결재 정보를 입력하면 즉시 생성됩니다.',
constraints: const BoxConstraints(maxWidth: 540),
actions: [
ShadButton.ghost(
onPressed: isSubmitting
? null
: () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
ShadButton(
key: const ValueKey('approval_create_submit'),
onPressed: isSubmitting
? null
: () async {
final approvalNo = approvalNoController.text
.trim();
final transactionText = transactionController.text
.trim();
final transactionId = int.tryParse(
transactionText,
);
final note = noteController.text.trim();
final hasStatuses = statusOptions.isNotEmpty;
setState(() {
approvalNoError = approvalNo.isEmpty
? '결재번호를 입력하세요.'
: null;
transactionError = transactionText.isEmpty
? '트랜잭션 ID를 입력하세요.'
: (transactionId == null
? '트랜잭션 ID는 숫자만 입력하세요.'
: null);
statusError = (!hasStatuses || statusId == null)
? '결재 상태를 선택하세요.'
: null;
requesterError = requesterSelection == null
? '상신자를 선택하세요.'
: null;
});
if (approvalNoError != null ||
transactionError != null ||
statusError != null ||
requesterError != null) {
return;
}
final input = ApprovalCreateInput(
transactionId: transactionId!,
approvalNo: approvalNo,
approvalStatusId: statusId!,
requestedById: requesterSelection!.id,
note: note.isEmpty ? null : note,
);
final result = await _controller.createApproval(
input,
);
if (!mounted || !context.mounted) {
return;
}
if (result != null) {
Navigator.of(context).pop(true);
}
},
child: isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('등록'),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text('결재번호', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadInput(
controller: approvalNoController,
enabled: !isSubmitting,
placeholder: const Text('예: APP-2025-0001'),
onChanged: (_) {
if (approvalNoError != null) {
setState(() => approvalNoError = null);
}
},
),
if (approvalNoError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
approvalNoError!,
style: shadTheme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
const SizedBox(height: 16),
Text('트랜잭션 ID', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadInput(
key: const ValueKey('approval_create_transaction'),
controller: transactionController,
enabled: !isSubmitting,
placeholder: const Text('예: 9001'),
onChanged: (_) {
if (transactionError != null) {
setState(() => transactionError = null);
}
},
),
if (transactionError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
transactionError!,
style: shadTheme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
const SizedBox(height: 16),
Text('결재 상태', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
if (statusOptions.isEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: shadTheme.colorScheme.border,
),
),
child: Text(
'결재 상태 정보를 불러오지 못했습니다. 다시 시도해주세요.',
style: shadTheme.textTheme.muted,
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShadSelect<int>(
key: ValueKey(statusOptions.length),
initialValue: statusId,
enabled: !isSubmitting,
placeholder: const Text('결재 상태 선택'),
onChanged: (value) {
setState(() {
statusId = value;
statusError = null;
});
},
selectedOptionBuilder: (context, value) {
final selected = _controller.approvalStatusById(
value,
);
return Text(selected?.name ?? '결재 상태 선택');
},
options: statusOptions
.map(
(item) => ShadOption<int>(
value: item.id,
child: Text(item.name),
),
)
.toList(),
),
if (statusError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
statusError!,
style: shadTheme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
],
),
const SizedBox(height: 16),
Text('상신자', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: InventoryEmployeeAutocompleteField(
controller: requesterController,
initialSuggestion: requesterSelection,
onSuggestionSelected: (suggestion) {
setState(() {
requesterSelection = suggestion;
requesterError = null;
});
},
onChanged: () {
if (requesterController.text.trim().isEmpty) {
setState(() {
requesterSelection = null;
});
}
},
enabled: !isSubmitting,
placeholder: '상신자 이름 또는 사번 검색',
),
),
const SizedBox(width: 8),
ShadButton.ghost(
onPressed:
requesterSelection == null &&
requesterController.text.isEmpty
? null
: () {
setState(() {
requesterSelection = null;
requesterController.clear();
requesterError = null;
});
},
child: const Text('초기화'),
),
],
),
if (requesterError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
requesterError!,
style: shadTheme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
const SizedBox(height: 16),
Text('비고 (선택)', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadTextarea(
key: const ValueKey('approval_create_note'),
controller: noteController,
enabled: !isSubmitting,
minHeight: 120,
maxHeight: 220,
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: shadTheme.colorScheme.mutedForeground
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'저장 안내',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'저장 시 결재가 생성되고 첫 단계와 현재 상태가 API 규격에 맞춰 초기화됩니다. 등록 후 목록이 자동으로 갱신됩니다.',
),
],
),
),
),
const SizedBox(height: 16),
Text('비고 (선택)', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadTextarea(
key: const ValueKey('approval_create_note'),
controller: noteController,
minHeight: 120,
maxHeight: 220,
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: shadTheme.colorScheme.mutedForeground.withValues(
alpha: 0.08,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'API 연동 준비 중',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'현재는 결재 생성 UI만 제공됩니다. 실제 저장은 백엔드 연동 이후 지원될 예정입니다.',
),
],
),
),
],
),
);
},
);
},
);
},
);
approvalNoController.dispose();
transactionController.dispose();
requesterController.dispose();
noteController.dispose();
if (shouldShowToast == true && mounted) {
SuperportToast.info(
context,
'결재 생성은 API 연동 이후 지원될 예정입니다. 입력한 값은 실제로 저장되지 않았습니다.',
);
if (created == true && mounted) {
SuperportToast.success(context, '결재를 생성했습니다.');
}
}