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

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