결재 비활성 안내 개선 및 테이블 기능 보강
This commit is contained in:
118
lib/features/approvals/shared/approver_catalog.dart
Normal file
118
lib/features/approvals/shared/approver_catalog.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
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: '경영기획팀',
|
||||
),
|
||||
]);
|
||||
|
||||
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,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../approver_catalog.dart';
|
||||
|
||||
/// 승인자 자동완성 필드.
|
||||
///
|
||||
/// - 사용자가 이름/사번으로 검색하면 일치하는 승인자를 제안한다.
|
||||
/// - 항목을 선택하면 `idController`에 승인자 ID가 채워진다.
|
||||
class ApprovalApproverAutocompleteField extends StatefulWidget {
|
||||
const ApprovalApproverAutocompleteField({
|
||||
super.key,
|
||||
required this.idController,
|
||||
this.hintText,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
final TextEditingController idController;
|
||||
final String? hintText;
|
||||
final void Function(ApprovalApproverCatalogItem?)? onSelected;
|
||||
|
||||
@override
|
||||
State<ApprovalApproverAutocompleteField> createState() =>
|
||||
_ApprovalApproverAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _ApprovalApproverAutocompleteFieldState
|
||||
extends State<ApprovalApproverAutocompleteField> {
|
||||
late final TextEditingController _textController;
|
||||
late final FocusNode _focusNode;
|
||||
ApprovalApproverCatalogItem? _selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController = TextEditingController();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_handleFocusChange);
|
||||
_syncFromId();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
widget.onSelected?.call(null);
|
||||
});
|
||||
}
|
||||
|
||||
String _displayLabel(ApprovalApproverCatalogItem item) {
|
||||
return '${item.name} (${item.employeeNo}) · ${item.team}';
|
||||
}
|
||||
|
||||
void _applyManualEntry(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
_handleCleared();
|
||||
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);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selected = null;
|
||||
widget.idController.text = manualId.toString();
|
||||
_textController.text = '직접 입력: $manualId';
|
||||
widget.onSelected?.call(null);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
if (!_focusNode.hasFocus) {
|
||||
_applyManualEntry(_textController.text);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return RawAutocomplete<ApprovalApproverCatalogItem>(
|
||||
textEditingController: _textController,
|
||||
focusNode: _focusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
final text = textEditingValue.text.trim();
|
||||
if (text.isEmpty) {
|
||||
return const Iterable<ApprovalApproverCatalogItem>.empty();
|
||||
}
|
||||
return _options(text);
|
||||
},
|
||||
displayStringForOption: _displayLabel,
|
||||
onSelected: _handleSelected,
|
||||
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();
|
||||
},
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
if (options.isEmpty) {
|
||||
return 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_focusNode
|
||||
..removeListener(_handleFocusChange)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user