결재 비활성 안내 개선 및 테이블 기능 보강
This commit is contained in:
@@ -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