결재 및 마스터 모듈을 v4 API 계약에 맞게 조정

This commit is contained in:
JiWoong Sul
2025-10-16 17:27:20 +09:00
parent d5c99627db
commit 9e2244f260
34 changed files with 1394 additions and 330 deletions

View File

@@ -25,24 +25,28 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
Future<PaginatedResult<Approval>> list({
int page = 1,
int pageSize = 20,
String? query,
String? status,
DateTime? from,
DateTime? to,
int? transactionId,
int? approvalStatusId,
int? requestedById,
bool includeHistories = false,
bool includeSteps = false,
}) async {
final includeParts = <String>[];
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,
if (query != null && query.isNotEmpty) 'q': query,
if (status != null && status.isNotEmpty) 'status': status,
if (from != null) 'from': from.toIso8601String(),
if (to != null) 'to': to.toIso8601String(),
if (includeHistories) 'include_histories': true,
if (includeSteps) 'include_steps': true,
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(','),
},
options: Options(responseType: ResponseType.json),
);
@@ -56,12 +60,16 @@ class ApprovalRepositoryRemote implements ApprovalRepository {
bool includeSteps = true,
bool includeHistories = true,
}) async {
final includeParts = <String>[];
if (includeSteps) {
includeParts.add('steps');
}
if (includeHistories) {
includeParts.add('histories');
}
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/$id',
query: {
if (includeSteps) 'include_steps': true,
if (includeHistories) 'include_histories': true,
},
query: {if (includeParts.isNotEmpty) 'include': includeParts.join(',')},
options: Options(responseType: ResponseType.json),
);
final data = (response.data?['data'] as Map<String, dynamic>?) ?? {};

View File

@@ -11,10 +11,9 @@ abstract class ApprovalRepository {
Future<PaginatedResult<Approval>> list({
int page = 1,
int pageSize = 20,
String? query,
String? status,
DateTime? from,
DateTime? to,
int? transactionId,
int? approvalStatusId,
int? requestedById,
bool includeHistories = false,
bool includeSteps = false,
});

View File

@@ -64,10 +64,11 @@ class ApprovalController extends ChangeNotifier {
int? _applyingTemplateId;
ApprovalProceedStatus? _proceedStatus;
String? _errorMessage;
String _query = '';
ApprovalStatusFilter _statusFilter = ApprovalStatusFilter.all;
DateTime? _fromDate;
DateTime? _toDate;
int? _transactionIdFilter;
int? _requestedById;
String? _requestedByName;
String? _requestedByEmployeeNo;
List<ApprovalAction> _actions = const [];
List<ApprovalTemplate> _templates = const [];
final Map<String, LookupItem> _statusLookup = {};
@@ -85,10 +86,11 @@ class ApprovalController extends ChangeNotifier {
bool get isPerformingAction => _isPerformingAction;
int? get processingStepId => _processingStepId;
String? get errorMessage => _errorMessage;
String get query => _query;
ApprovalStatusFilter get statusFilter => _statusFilter;
DateTime? get fromDate => _fromDate;
DateTime? get toDate => _toDate;
int? get transactionIdFilter => _transactionIdFilter;
int? get requestedById => _requestedById;
String? get requestedByName => _requestedByName;
String? get requestedByEmployeeNo => _requestedByEmployeeNo;
List<ApprovalAction> get actionOptions => _actions;
bool get hasActionOptions => _actions.isNotEmpty;
List<ApprovalTemplate> get templates => _templates;
@@ -116,14 +118,13 @@ class ApprovalController extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
try {
final statusParam = _statusCodeFor(_statusFilter);
final statusId = _statusIdFor(_statusFilter);
final response = await _repository.list(
page: page,
pageSize: _result?.pageSize ?? 20,
query: _query.isEmpty ? null : _query,
status: statusParam,
from: _fromDate,
to: _toDate,
transactionId: _transactionIdFilter,
approvalStatusId: statusId,
requestedById: _requestedById,
includeSteps: false,
includeHistories: false,
);
@@ -176,18 +177,29 @@ class ApprovalController extends ChangeNotifier {
_statusLookup
..clear()
..addEntries(
items.map(
(item) => MapEntry(
(item.code ?? item.name).toLowerCase(),
item,
),
),
items.expand((item) {
final keys = <String>{};
final code = item.code?.trim();
if (code != null && code.isNotEmpty) {
keys.add(code.toLowerCase());
}
final name = item.name.trim();
if (name.isNotEmpty) {
keys.add(name.toLowerCase());
}
keys.add(item.id.toString());
return keys.map((key) => MapEntry(key, item));
}),
);
for (final entry in _defaultStatusCodes.entries) {
final code = entry.value.toLowerCase();
final lookup = _statusLookup[code];
final defaultCode = entry.value;
final normalized = defaultCode.toLowerCase();
final lookup = _statusLookup[normalized];
if (lookup != null) {
_statusCodeAliases[entry.value] = lookup.code?.toLowerCase() ?? code;
final alias = lookup.code?.toLowerCase() ?? normalized;
_statusCodeAliases[defaultCode] = alias;
} else {
_statusCodeAliases[defaultCode] = defaultCode;
}
}
notifyListeners();
@@ -229,6 +241,15 @@ class ApprovalController extends ChangeNotifier {
return _statusCodeAliases[defaultCode] ?? defaultCode;
}
int? _statusIdFor(ApprovalStatusFilter filter) {
final code = _statusCodeFor(filter);
if (code == null) {
return null;
}
final lookup = _statusLookup[code.toLowerCase()];
return lookup?.id;
}
/// 활성화된 결재 템플릿 목록을 조회해 캐싱한다.
///
/// 템플릿이 비어 있거나 [force]가 `true`이면 API를 다시 호출한다.
@@ -325,8 +346,7 @@ class ApprovalController extends ChangeNotifier {
final proceedStatus = await _repository.canProceed(approvalId);
_proceedStatus = proceedStatus;
if (!proceedStatus.canProceed) {
_errorMessage = proceedStatus.reason ??
'결재 단계가 현재 상태에서 진행될 수 없습니다.';
_errorMessage = proceedStatus.reason ?? '결재 단계가 현재 상태에서 진행될 수 없습니다.';
return false;
}
@@ -421,31 +441,33 @@ class ApprovalController extends ChangeNotifier {
}
}
/// 검색 키워드를 변경하고 UI 갱신을 유도한다.
void updateQuery(String value) {
_query = value;
notifyListeners();
}
/// 상태 필터 값을 변경한다.
void updateStatusFilter(ApprovalStatusFilter filter) {
_statusFilter = filter;
notifyListeners();
}
/// 조회 기간을 설정한다. 두 값 모두 `null`이면 기간 조건을 제한다.
void updateDateRange(DateTime? from, DateTime? to) {
_fromDate = from;
_toDate = to;
/// 트랜잭션 ID 필터를 갱신한다. null이면 조건을 제한다.
void updateTransactionFilter(int? transactionId) {
_transactionIdFilter = transactionId;
notifyListeners();
}
/// 검색어/상태/기간 등의 필터 조건을 초기화한다.
/// 상신자(요청자) 필터를 갱신한다. null 값을 전달하면 조건을 제거한다.
void updateRequestedByFilter({int? id, String? name, String? employeeNo}) {
_requestedById = id;
_requestedByName = name;
_requestedByEmployeeNo = employeeNo;
notifyListeners();
}
/// 상태/트랜잭션/상신자 필터를 초기값으로 되돌린다.
void clearFilters() {
_query = '';
_statusFilter = ApprovalStatusFilter.all;
_fromDate = null;
_toDate = null;
_transactionIdFilter = null;
_requestedById = null;
_requestedByName = null;
_requestedByEmployeeNo = null;
notifyListeners();
}

View File

@@ -11,7 +11,6 @@ import '../../../../core/permissions/permission_resources.dart';
import '../../../../widgets/app_layout.dart';
import '../../../../widgets/components/feedback.dart';
import '../../../../widgets/components/filter_bar.dart';
import '../../../../widgets/components/superport_date_picker.dart';
import '../../../../widgets/components/superport_dialog.dart';
import '../../../../widgets/components/superport_table.dart';
import '../../../../widgets/components/feature_disabled_placeholder.dart';
@@ -20,6 +19,7 @@ import '../../domain/entities/approval_template.dart';
import '../../domain/repositories/approval_repository.dart';
import '../../domain/repositories/approval_template_repository.dart';
import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
import '../../../inventory/shared/widgets/employee_autocomplete_field.dart';
import '../controllers/approval_controller.dart';
const _approvalsResourcePath = PermissionResources.approvals;
@@ -73,11 +73,11 @@ class _ApprovalEnabledPage extends StatefulWidget {
class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
late final ApprovalController _controller;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocus = FocusNode();
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd');
final TextEditingController _transactionController = TextEditingController();
final TextEditingController _requesterController = TextEditingController();
final FocusNode _transactionFocus = FocusNode();
InventoryEmployeeSuggestion? _selectedRequester;
final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
DateTimeRange? _dateRange;
String? _lastError;
int? _selectedTemplateId;
@@ -114,8 +114,9 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
void dispose() {
_controller.removeListener(_handleControllerUpdate);
_controller.dispose();
_searchController.dispose();
_searchFocus.dispose();
_transactionController.dispose();
_requesterController.dispose();
_transactionFocus.dispose();
super.dispose();
}
@@ -197,16 +198,39 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
),
children: [
SizedBox(
width: 260,
width: 180,
child: ShadInput(
controller: _searchController,
focusNode: _searchFocus,
placeholder: const Text('결재번호, 트랜잭션번호, 상신자 검색'),
leading: const Icon(lucide.LucideIcons.search, size: 16),
controller: _transactionController,
focusNode: _transactionFocus,
enabled: !_controller.isLoadingList,
keyboardType: TextInputType.number,
placeholder: const Text('트랜잭션 ID 입력'),
leading: const Icon(lucide.LucideIcons.hash, size: 16),
onChanged: (_) => setState(() {}),
onSubmitted: (_) => _applyFilters(),
),
),
SizedBox(
width: 280,
child: InventoryEmployeeAutocompleteField(
controller: _requesterController,
initialSuggestion: _selectedRequester,
enabled: !_controller.isLoadingList,
onSuggestionSelected: (suggestion) {
setState(() => _selectedRequester = suggestion);
},
onChanged: () {
setState(() {
final selectedLabel = _selectedRequester == null
? ''
: '${_selectedRequester!.name} (${_selectedRequester!.employeeNo})';
if (_requesterController.text.trim() != selectedLabel) {
_selectedRequester = null;
}
});
},
),
),
SizedBox(
width: 200,
child: ShadSelect<ApprovalStatusFilter>(
@@ -229,37 +253,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
.toList(),
),
),
SizedBox(
width: 220,
child: SuperportDateRangePickerButton(
value: _dateRange,
dateFormat: _dateFormat,
enabled: !_controller.isLoadingList,
firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime(DateTime.now().year + 1),
initialDateRange:
_dateRange ??
DateTimeRange(
start: DateTime.now().subtract(const Duration(days: 7)),
end: DateTime.now(),
),
onChanged: (range) {
if (range == null) return;
setState(() => _dateRange = range);
_controller.updateDateRange(range.start, range.end);
_controller.fetch(page: 1);
},
),
),
if (_dateRange != null)
ShadButton.ghost(
onPressed: () {
setState(() => _dateRange = null);
_controller.updateDateRange(null, null);
_controller.fetch(page: 1);
},
child: const Text('기간 초기화'),
),
],
),
child: Column(
@@ -476,25 +469,48 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
}
void _applyFilters() {
_controller.updateQuery(_searchController.text.trim());
if (_dateRange != null) {
_controller.updateDateRange(_dateRange!.start, _dateRange!.end);
final transactionText = _transactionController.text.trim();
if (transactionText.isNotEmpty) {
final transactionId = int.tryParse(transactionText);
if (transactionId == null) {
SuperportToast.error(context, '트랜잭션 ID는 숫자만 입력해야 합니다.');
return;
}
_controller.updateTransactionFilter(transactionId);
} else {
_controller.updateTransactionFilter(null);
}
final requester = _selectedRequester;
if (requester != null) {
_controller.updateRequestedByFilter(
id: requester.id,
name: requester.name,
employeeNo: requester.employeeNo,
);
} else if (_requesterController.text.trim().isEmpty) {
_controller.updateRequestedByFilter(id: null);
}
_controller.fetch(page: 1);
}
void _resetFilters() {
_searchController.clear();
_searchFocus.requestFocus();
_dateRange = null;
_transactionController.clear();
_requesterController.clear();
setState(() => _selectedRequester = null);
_transactionFocus.requestFocus();
_controller.clearFilters();
_controller.fetch(page: 1);
}
bool _hasFilters() {
return _searchController.text.isNotEmpty ||
_controller.statusFilter != ApprovalStatusFilter.all ||
_dateRange != null;
return _transactionController.text.trim().isNotEmpty ||
_requesterController.text.trim().isNotEmpty ||
_selectedRequester != null ||
_controller.transactionIdFilter != null ||
_controller.requestedById != null ||
_controller.statusFilter != ApprovalStatusFilter.all;
}
Future<void> _handleStepAction(