feat(frontend): 승인 템플릿 API 통합 및 디버그 로그인 확장
- docs 폴더 문서를 최신 API 계약으로 갱신하고 가이드를 다듬었다\n- approvals data/presentation 레이어를 API v4 스펙에 맞춰 리팩터링했다\n- approver 자동완성 위젯을 신규 공유 레포지토리에 맞춰 교체하고 UX를 보강했다\n- inventory/rental 페이지 테이블 초기화 시 승인 기준 연동을 정비했다\n- 로그인 페이지 디버그 버튼을 tera/exa 계정으로 분리해 QA 로그인을 단순화했다\n- get_it 등록과 테스트 케이스를 신규 공유 리포지토리에 맞춰 업데이트했다
This commit is contained in:
@@ -1,148 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 결재 승인자(approver)를 자동완성으로 검색하기 위한 카탈로그 항목.
|
||||
class ApprovalApproverCatalogItem {
|
||||
const ApprovalApproverCatalogItem({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
required this.team,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
final String team;
|
||||
}
|
||||
|
||||
String _normalize(String value) {
|
||||
return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9가-힣]'), '');
|
||||
}
|
||||
|
||||
/// 결재용 승인자 카탈로그.
|
||||
///
|
||||
/// - API 연동 전까지 고정된 데이터를 사용한다.
|
||||
class ApprovalApproverCatalog {
|
||||
static final List<ApprovalApproverCatalogItem> items = List.unmodifiable([
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 101,
|
||||
employeeNo: 'EMP101',
|
||||
name: '이검토',
|
||||
team: '물류운영팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 102,
|
||||
employeeNo: 'EMP102',
|
||||
name: '최검수',
|
||||
team: '품질보증팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 103,
|
||||
employeeNo: 'EMP103',
|
||||
name: '문회수',
|
||||
team: '품질보증팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 104,
|
||||
employeeNo: 'EMP104',
|
||||
name: '박팀장',
|
||||
team: '운영혁신팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 105,
|
||||
employeeNo: 'EMP105',
|
||||
name: '정차장',
|
||||
team: '구매팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 106,
|
||||
employeeNo: 'EMP106',
|
||||
name: '오승훈',
|
||||
team: '영업지원팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 107,
|
||||
employeeNo: 'EMP107',
|
||||
name: '유컨펌',
|
||||
team: '총무팀',
|
||||
),
|
||||
const ApprovalApproverCatalogItem(
|
||||
id: 108,
|
||||
employeeNo: 'EMP108',
|
||||
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 = {
|
||||
for (final item in items) item.id: item,
|
||||
};
|
||||
|
||||
static final Map<String, ApprovalApproverCatalogItem> _byEmployeeNo = {
|
||||
for (final item in items) item.employeeNo.toLowerCase(): item,
|
||||
};
|
||||
|
||||
static ApprovalApproverCatalogItem? byId(int? id) =>
|
||||
id == null ? null : _byId[id];
|
||||
|
||||
static ApprovalApproverCatalogItem? byEmployeeNo(String? employeeNo) {
|
||||
if (employeeNo == null) return null;
|
||||
return _byEmployeeNo[employeeNo.toLowerCase()];
|
||||
}
|
||||
|
||||
static List<ApprovalApproverCatalogItem> filter(String query) {
|
||||
final normalized = _normalize(query);
|
||||
if (normalized.isEmpty) {
|
||||
return items.take(10).toList();
|
||||
}
|
||||
final lower = query.toLowerCase();
|
||||
return [
|
||||
for (final item in items)
|
||||
if (_normalize(item.name).contains(normalized) ||
|
||||
item.employeeNo.toLowerCase().contains(lower) ||
|
||||
item.team.toLowerCase().contains(lower) ||
|
||||
item.id.toString().contains(lower))
|
||||
item,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 자동완성 추천이 없을 때 보여줄 위젯.
|
||||
Widget buildEmptyApproverResult(TextTheme textTheme) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text('일치하는 승인자를 찾지 못했습니다.', style: textTheme.bodySmall),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||
|
||||
import '../../domain/entities/approval_approver_candidate.dart';
|
||||
|
||||
/// 승인자 후보 응답을 파싱하는 DTO.
|
||||
class ApprovalApproverCandidateDto {
|
||||
ApprovalApproverCandidateDto({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
this.team,
|
||||
this.email,
|
||||
this.phone,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
final String? team;
|
||||
final String? email;
|
||||
final String? phone;
|
||||
|
||||
/// JSON 응답에서 DTO를 생성한다.
|
||||
factory ApprovalApproverCandidateDto.fromJson(Map<String, dynamic> json) {
|
||||
final group = json['group'] is Map<String, dynamic>
|
||||
? json['group'] as Map<String, dynamic>
|
||||
: null;
|
||||
return ApprovalApproverCandidateDto(
|
||||
id: json['id'] as int? ?? JsonUtils.readInt(json, 'user_id', fallback: 0),
|
||||
employeeNo: json['employee_id'] as String? ??
|
||||
json['employee_no'] as String? ??
|
||||
'-',
|
||||
name: json['name'] as String? ??
|
||||
json['employee_name'] as String? ??
|
||||
'-',
|
||||
team: group?['group_name'] as String? ?? json['team'] as String?,
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String? ?? json['mobile_no'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// DTO를 도메인 엔티티로 변환한다.
|
||||
ApprovalApproverCandidate toEntity() {
|
||||
return ApprovalApproverCandidate(
|
||||
id: id,
|
||||
employeeNo: employeeNo,
|
||||
name: name,
|
||||
team: team,
|
||||
email: email,
|
||||
phone: phone,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../../core/network/api_client.dart';
|
||||
import '../../../../../core/network/api_routes.dart';
|
||||
import '../../domain/entities/approval_approver_candidate.dart';
|
||||
import '../../domain/repositories/approval_approver_repository.dart';
|
||||
import '../dtos/approval_approver_candidate_dto.dart';
|
||||
|
||||
/// 승인자 자동완성용 원격 저장소 구현체.
|
||||
class ApprovalApproverRepositoryRemote implements ApprovalApproverRepository {
|
||||
ApprovalApproverRepositoryRemote({required ApiClient apiClient})
|
||||
: _api = apiClient;
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const _basePath = '${ApiRoutes.apiV1}/users';
|
||||
|
||||
@override
|
||||
Future<List<ApprovalApproverCandidate>> search({
|
||||
required String keyword,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final trimmed = keyword.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: _buildQuery(limit: limit, keyword: trimmed),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
|
||||
return _mapCandidates(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalApproverCandidate?> fetchById(int id) async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
'$_basePath/$id',
|
||||
query: ApiClient.buildQuery(include: const ['group']),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
|
||||
final payload = _api.unwrapAsMap(response);
|
||||
if (payload.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return ApprovalApproverCandidateDto.fromJson(payload).toEntity();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ApprovalApproverCandidate>> listInitial({int limit = 20}) async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_basePath,
|
||||
query: _buildQuery(limit: limit),
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
|
||||
return _mapCandidates(response.data);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildQuery({required int limit, String? keyword}) {
|
||||
return ApiClient.buildQuery(
|
||||
page: 1,
|
||||
pageSize: limit,
|
||||
q: keyword,
|
||||
sort: 'name',
|
||||
order: 'asc',
|
||||
include: const ['group'],
|
||||
filters: const {'is_active': true},
|
||||
);
|
||||
}
|
||||
|
||||
List<ApprovalApproverCandidate> _mapCandidates(
|
||||
Map<String, dynamic>? payload,
|
||||
) {
|
||||
return (payload?['items'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ApprovalApproverCandidateDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// 결재 승인자 자동완성에 사용되는 후보 정보.
|
||||
class ApprovalApproverCandidate {
|
||||
const ApprovalApproverCandidate({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
this.team,
|
||||
this.email,
|
||||
this.phone,
|
||||
});
|
||||
|
||||
/// 승인자 고유 ID (users.id).
|
||||
final int id;
|
||||
|
||||
/// 사번 혹은 직원 식별자.
|
||||
final String employeeNo;
|
||||
|
||||
/// 직원 이름.
|
||||
final String name;
|
||||
|
||||
/// 소속 팀 또는 그룹명.
|
||||
final String? team;
|
||||
|
||||
/// 이메일 주소.
|
||||
final String? email;
|
||||
|
||||
/// 전화번호.
|
||||
final String? phone;
|
||||
|
||||
/// 리스트 등에서 표시할 기본 라벨을 반환한다.
|
||||
String get displayLabel => '$name ($employeeNo)';
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import '../entities/approval_approver_candidate.dart';
|
||||
|
||||
/// 승인자 검색을 제공하는 저장소 인터페이스.
|
||||
abstract class ApprovalApproverRepository {
|
||||
/// 키워드로 승인자 후보를 검색한다.
|
||||
Future<List<ApprovalApproverCandidate>> search({
|
||||
required String keyword,
|
||||
int limit = 20,
|
||||
});
|
||||
|
||||
/// ID로 승인자 정보를 조회한다.
|
||||
Future<ApprovalApproverCandidate?> fetchById(int id);
|
||||
|
||||
/// 자동완성 드롭다운 초기 노출용 활성 승인자 목록을 조회한다.
|
||||
Future<List<ApprovalApproverCandidate>> listInitial({int limit = 20});
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../approver_catalog.dart';
|
||||
import '../domain/entities/approval_approver_candidate.dart';
|
||||
import '../domain/repositories/approval_approver_repository.dart';
|
||||
|
||||
/// 승인자 자동완성 필드.
|
||||
///
|
||||
/// - 사용자가 이름/사번으로 검색하면 일치하는 승인자를 제안한다.
|
||||
/// - 항목을 선택하면 `idController`에 승인자 ID가 채워진다.
|
||||
/// - 이름/사번을 입력하면 API에서 승인자 후보를 검색한다.
|
||||
/// - 항목 선택 시 `idController`에 승인자 ID를 기록한다.
|
||||
class ApprovalApproverAutocompleteField extends StatefulWidget {
|
||||
const ApprovalApproverAutocompleteField({
|
||||
super.key,
|
||||
@@ -17,104 +21,309 @@ class ApprovalApproverAutocompleteField extends StatefulWidget {
|
||||
|
||||
final TextEditingController idController;
|
||||
final String? hintText;
|
||||
final void Function(ApprovalApproverCatalogItem?)? onSelected;
|
||||
final void Function(ApprovalApproverCandidate?)? onSelected;
|
||||
|
||||
@override
|
||||
State<ApprovalApproverAutocompleteField> createState() =>
|
||||
_ApprovalApproverAutocompleteFieldState();
|
||||
}
|
||||
|
||||
/// 승인자 자동완성 필드의 내부 상태를 관리한다.
|
||||
class _ApprovalApproverAutocompleteFieldState
|
||||
extends State<ApprovalApproverAutocompleteField> {
|
||||
late final TextEditingController _textController;
|
||||
late final FocusNode _focusNode;
|
||||
ApprovalApproverCatalogItem? _selected;
|
||||
static const _debounceDuration = Duration(milliseconds: 250);
|
||||
static const _pageSize = 15;
|
||||
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final List<ApprovalApproverCandidate> _suggestions = [];
|
||||
final List<ApprovalApproverCandidate> _initialSuggestions = [];
|
||||
ApprovalApproverCandidate? _selected;
|
||||
Timer? _debounce;
|
||||
bool _isSearching = false;
|
||||
bool _isLoadingInitial = false;
|
||||
bool _initialLoaded = false;
|
||||
bool _isApplyingText = false;
|
||||
int _requestId = 0;
|
||||
|
||||
ApprovalApproverRepository? get _repository =>
|
||||
GetIt.I.isRegistered<ApprovalApproverRepository>()
|
||||
? GetIt.I<ApprovalApproverRepository>()
|
||||
: null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController = TextEditingController();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_handleFocusChange);
|
||||
_syncFromId();
|
||||
_initializeFromId();
|
||||
unawaited(_prefetchInitialCandidates());
|
||||
}
|
||||
|
||||
/// 외부에서 제공된 ID 값으로부터 표시 문자열을 동기화한다.
|
||||
void _syncFromId() {
|
||||
final idText = widget.idController.text.trim();
|
||||
final id = int.tryParse(idText);
|
||||
final match = ApprovalApproverCatalog.byId(id);
|
||||
if (match != null) {
|
||||
_selected = match;
|
||||
_textController.text = _displayLabel(match);
|
||||
} else if (id != null) {
|
||||
_selected = null;
|
||||
_textController.text = '직접 입력: $id';
|
||||
} else {
|
||||
_selected = null;
|
||||
_textController.clear();
|
||||
@override
|
||||
void didUpdateWidget(covariant ApprovalApproverAutocompleteField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(widget.idController, oldWidget.idController)) {
|
||||
_initializeFromId();
|
||||
}
|
||||
}
|
||||
|
||||
/// 검색어에 매칭되는 승인자 목록을 반환한다.
|
||||
Iterable<ApprovalApproverCatalogItem> _options(String query) {
|
||||
return ApprovalApproverCatalog.filter(query);
|
||||
}
|
||||
|
||||
/// 특정 승인자를 선택했을 때 내부 상태와 콜백을 갱신한다.
|
||||
void _handleSelected(ApprovalApproverCatalogItem item) {
|
||||
setState(() {
|
||||
_selected = item;
|
||||
widget.idController.text = item.id.toString();
|
||||
_textController.text = _displayLabel(item);
|
||||
widget.onSelected?.call(item);
|
||||
});
|
||||
}
|
||||
|
||||
/// 선택된 값을 초기화한다.
|
||||
void _handleCleared() {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
widget.idController.clear();
|
||||
_textController.clear();
|
||||
Future<void> _initializeFromId() async {
|
||||
final idText = widget.idController.text.trim();
|
||||
final id = int.tryParse(idText);
|
||||
if (id == null) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
_setText('');
|
||||
});
|
||||
return;
|
||||
}
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final candidate = await repository.fetchById(id);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (candidate == null) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
_setText('');
|
||||
});
|
||||
widget.onSelected?.call(null);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selected = candidate;
|
||||
});
|
||||
_ensureCandidateCached(candidate);
|
||||
_setText(_displayLabel(candidate));
|
||||
widget.onSelected?.call(candidate);
|
||||
} catch (_) {
|
||||
// 조회 실패 시 기존 상태를 유지하되 텍스트를 비운다.
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selected = null;
|
||||
_setText('');
|
||||
});
|
||||
widget.onSelected?.call(null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prefetchInitialCandidates() async {
|
||||
if (_initialLoaded || _isLoadingInitial) {
|
||||
return;
|
||||
}
|
||||
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoadingInitial = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final results = await repository.listInitial(limit: _pageSize);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_initialSuggestions.clear();
|
||||
_initialSuggestions.addAll(results);
|
||||
if (_selected != null &&
|
||||
!_initialSuggestions.any((item) => item.id == _selected!.id)) {
|
||||
_initialSuggestions.insert(0, _selected!);
|
||||
}
|
||||
if (_textController.text.trim().isEmpty) {
|
||||
_suggestions.clear();
|
||||
_suggestions.addAll(_initialSuggestions);
|
||||
}
|
||||
_isLoadingInitial = false;
|
||||
_initialLoaded = true;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoadingInitial = false;
|
||||
_initialLoaded = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
if (_focusNode.hasFocus) {
|
||||
if (!_initialLoaded && !_isLoadingInitial) {
|
||||
unawaited(_prefetchInitialCandidates());
|
||||
} else if (_textController.text.trim().isEmpty &&
|
||||
_initialSuggestions.isNotEmpty) {
|
||||
setState(() {
|
||||
_suggestions.clear();
|
||||
_suggestions.addAll(_initialSuggestions);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
unawaited(_confirmManualEntry(_textController.text));
|
||||
}
|
||||
|
||||
void _scheduleSearch(String keyword) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(_debounceDuration, () {
|
||||
unawaited(_search(keyword));
|
||||
});
|
||||
}
|
||||
|
||||
String _displayLabel(ApprovalApproverCatalogItem item) {
|
||||
return '${item.name} (${item.employeeNo}) · ${item.team}';
|
||||
Future<void> _search(String keyword) async {
|
||||
final repository = _repository;
|
||||
final trimmed = keyword.trim();
|
||||
if (repository == null || trimmed.isEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_suggestions.clear();
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final request = ++_requestId;
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
});
|
||||
try {
|
||||
final results = await repository.search(
|
||||
keyword: trimmed,
|
||||
limit: _pageSize,
|
||||
);
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions.clear();
|
||||
_suggestions.addAll(results);
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions.clear();
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 사용자가 직접 입력한 사번(ID)을 기반으로 값을 결정한다.
|
||||
void _applyManualEntry(String value) {
|
||||
Future<void> _confirmManualEntry(String value) async {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
_handleCleared();
|
||||
_clearSelection();
|
||||
return;
|
||||
}
|
||||
final manualId = int.tryParse(trimmed.replaceAll(RegExp(r'[^0-9]'), ''));
|
||||
if (manualId == null) {
|
||||
return;
|
||||
}
|
||||
final match = ApprovalApproverCatalog.byId(manualId);
|
||||
if (match != null) {
|
||||
_handleSelected(match);
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final candidate = await repository.fetchById(manualId);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (candidate == null) {
|
||||
_clearSelection();
|
||||
return;
|
||||
}
|
||||
_applySelection(candidate);
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
void _applySelection(ApprovalApproverCandidate candidate) {
|
||||
setState(() {
|
||||
_selected = candidate;
|
||||
widget.idController.text = candidate.id.toString();
|
||||
_isSearching = false;
|
||||
});
|
||||
_ensureCandidateCached(candidate);
|
||||
_setText(_displayLabel(candidate));
|
||||
widget.onSelected?.call(candidate);
|
||||
}
|
||||
|
||||
void _clearSelection({
|
||||
bool useInitialSuggestions = false,
|
||||
bool resetText = true,
|
||||
}) {
|
||||
final hadSelection =
|
||||
_selected != null || widget.idController.text.isNotEmpty;
|
||||
setState(() {
|
||||
_selected = null;
|
||||
_isSearching = false;
|
||||
_suggestions.clear();
|
||||
if (useInitialSuggestions && _initialSuggestions.isNotEmpty) {
|
||||
_suggestions.addAll(_initialSuggestions);
|
||||
}
|
||||
});
|
||||
if (hadSelection) {
|
||||
widget.idController.clear();
|
||||
}
|
||||
widget.onSelected?.call(null);
|
||||
if (resetText || useInitialSuggestions || hadSelection) {
|
||||
_setText('');
|
||||
}
|
||||
}
|
||||
|
||||
String _displayLabel(ApprovalApproverCandidate candidate) {
|
||||
final team = candidate.team?.trim();
|
||||
if (team == null || team.isEmpty) {
|
||||
return '${candidate.name} (${candidate.employeeNo})';
|
||||
}
|
||||
return '${candidate.name} (${candidate.employeeNo}) · $team';
|
||||
}
|
||||
|
||||
void _ensureCandidateCached(ApprovalApproverCandidate candidate) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final exists = _initialSuggestions.any((item) => item.id == candidate.id);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selected = null;
|
||||
widget.idController.text = manualId.toString();
|
||||
_textController.text = '직접 입력: $manualId';
|
||||
widget.onSelected?.call(null);
|
||||
_initialSuggestions.insert(0, candidate);
|
||||
if (_textController.text.trim().isEmpty) {
|
||||
_suggestions.clear();
|
||||
_suggestions.addAll(_initialSuggestions);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 포커스가 해제될 때 수동 입력을 확정한다.
|
||||
void _handleFocusChange() {
|
||||
if (!_focusNode.hasFocus) {
|
||||
_applyManualEntry(_textController.text);
|
||||
}
|
||||
void _setText(String value) {
|
||||
_isApplyingText = true;
|
||||
_textController.text = value;
|
||||
_textController.selection = TextSelection.collapsed(offset: value.length);
|
||||
_isApplyingText = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_textController.dispose();
|
||||
_focusNode.removeListener(_handleFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -122,135 +331,124 @@ class _ApprovalApproverAutocompleteFieldState
|
||||
final theme = ShadTheme.of(context);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return RawAutocomplete<ApprovalApproverCatalogItem>(
|
||||
final maxWidth = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: 360.0;
|
||||
return RawAutocomplete<ApprovalApproverCandidate>(
|
||||
textEditingController: _textController,
|
||||
focusNode: _focusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
final text = textEditingValue.text.trim();
|
||||
if (text.isEmpty) {
|
||||
return const Iterable<ApprovalApproverCatalogItem>.empty();
|
||||
}
|
||||
return _options(text);
|
||||
return _suggestions;
|
||||
},
|
||||
displayStringForOption: _displayLabel,
|
||||
onSelected: _handleSelected,
|
||||
onSelected: _applySelection,
|
||||
fieldViewBuilder:
|
||||
(context, textController, focusNode, onFieldSubmitted) {
|
||||
return ShadInput(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
placeholder: Text(widget.hintText ?? '승인자 이름 또는 사번 검색'),
|
||||
onChanged: (value) {
|
||||
if (value.isEmpty) {
|
||||
_handleCleared();
|
||||
} else if (_selected != null &&
|
||||
value != _displayLabel(_selected!)) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
widget.idController.clear();
|
||||
});
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
_applyManualEntry(textController.text);
|
||||
onFieldSubmitted();
|
||||
},
|
||||
onPressedOutside: (event) {
|
||||
// 드롭다운에서 항목을 고르기 전에 포커스를 잃지 않도록 한다.
|
||||
focusNode.requestFocus();
|
||||
},
|
||||
assert(identical(textController, _textController));
|
||||
return Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
children: [
|
||||
ShadInput(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
placeholder: Text(widget.hintText ?? '승인자 이름 또는 사번 검색'),
|
||||
onChanged: (value) {
|
||||
if (_isApplyingText) {
|
||||
return;
|
||||
}
|
||||
if (value.trim().isEmpty) {
|
||||
_debounce?.cancel();
|
||||
_clearSelection(useInitialSuggestions: true);
|
||||
if (!_initialLoaded && !_isLoadingInitial) {
|
||||
unawaited(_prefetchInitialCandidates());
|
||||
}
|
||||
} else {
|
||||
_scheduleSearch(value);
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
unawaited(_confirmManualEntry(textController.text));
|
||||
onFieldSubmitted();
|
||||
},
|
||||
onPressedOutside: (_) => focusNode.requestFocus(),
|
||||
),
|
||||
if (_isSearching ||
|
||||
(_isLoadingInitial &&
|
||||
textController.text.trim().isEmpty))
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
if (options.isEmpty) {
|
||||
return Listener(
|
||||
onPointerDown: (_) {
|
||||
if (!_focusNode.hasPrimaryFocus) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: 220,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
'일치하는 승인자를 찾지 못했습니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
final isInitialLoadInProgress =
|
||||
_isLoadingInitial && _textController.text.trim().isEmpty;
|
||||
if (_isSearching || isInitialLoadInProgress) {
|
||||
return _buildDropdownWrapper(
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: 220,
|
||||
theme: theme,
|
||||
child: const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Listener(
|
||||
onPointerDown: (_) {
|
||||
if (!_focusNode.hasPrimaryFocus) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: 260,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = options.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () => onSelected(option),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${option.name} · ${option.team}',
|
||||
style: theme.textTheme.p,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'ID ${option.id} · ${option.employeeNo}',
|
||||
style: theme.textTheme.muted.copyWith(
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (options.isEmpty) {
|
||||
final hasKeyword = _textController.text.trim().isNotEmpty;
|
||||
final message = hasKeyword
|
||||
? '일치하는 승인자를 찾지 못했습니다.'
|
||||
: '표시할 승인자가 없습니다.';
|
||||
return _buildDropdownWrapper(
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: 220,
|
||||
theme: theme,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(message, style: theme.textTheme.muted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildDropdownWrapper(
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: 260,
|
||||
theme: theme,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = options.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () => onSelected(option),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(option.name, style: theme.textTheme.p),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_optionSubtitle(option),
|
||||
style: theme.textTheme.muted.copyWith(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -259,12 +457,35 @@ class _ApprovalApproverAutocompleteFieldState
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_focusNode
|
||||
..removeListener(_handleFocusChange)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
Widget _buildDropdownWrapper({
|
||||
required double maxWidth,
|
||||
required double maxHeight,
|
||||
required Widget child,
|
||||
required ShadThemeData theme,
|
||||
}) {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: maxHeight),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _optionSubtitle(ApprovalApproverCandidate candidate) {
|
||||
final team = candidate.team?.trim();
|
||||
final buffer = StringBuffer('ID ${candidate.id} · ${candidate.employeeNo}');
|
||||
if (team != null && team.isNotEmpty) {
|
||||
buffer.write(' · $team');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user