결재 비활성 안내 개선 및 테이블 기능 보강

This commit is contained in:
JiWoong Sul
2025-09-29 15:49:06 +09:00
parent fef7108479
commit 98724762ec
18 changed files with 1134 additions and 297 deletions

View 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),
),
);
}

View File

@@ -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();
}
}