결재 권한 테스트 및 인벤토리 위젯 안정화
This commit is contained in:
@@ -6,8 +6,13 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../core/config/environment.dart';
|
||||
import '../../../../core/constants/app_sections.dart';
|
||||
import '../../../../core/permissions/permission_manager.dart';
|
||||
import '../../../../widgets/app_layout.dart';
|
||||
import '../../../../widgets/components/feedback.dart';
|
||||
import '../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../widgets/components/superport_date_picker.dart';
|
||||
import '../../../../widgets/components/superport_dialog.dart';
|
||||
import '../../../../widgets/components/superport_table.dart';
|
||||
import '../../../../widgets/spec_page.dart';
|
||||
import '../../domain/entities/approval.dart';
|
||||
import '../../domain/entities/approval_template.dart';
|
||||
@@ -15,6 +20,8 @@ import '../../domain/repositories/approval_repository.dart';
|
||||
import '../../domain/repositories/approval_template_repository.dart';
|
||||
import '../controllers/approval_controller.dart';
|
||||
|
||||
const _approvalsResourcePath = '/approvals/requests';
|
||||
|
||||
class ApprovalPage extends StatelessWidget {
|
||||
const ApprovalPage({super.key});
|
||||
|
||||
@@ -129,9 +136,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
final error = _controller.errorMessage;
|
||||
if (error != null && error != _lastError && mounted) {
|
||||
_lastError = error;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(error)));
|
||||
SuperportToast.error(context, error);
|
||||
_controller.clearError();
|
||||
}
|
||||
}
|
||||
@@ -148,6 +153,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final permissionManager = PermissionScope.of(context);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
@@ -171,6 +177,10 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
final isLoadingTemplates = _controller.isLoadingTemplates;
|
||||
final isApplyingTemplate = _controller.isApplyingTemplate;
|
||||
final applyingTemplateId = _controller.applyingTemplateId;
|
||||
final canPerformStepActions = permissionManager.can(
|
||||
_approvalsResourcePath,
|
||||
PermissionAction.approve,
|
||||
);
|
||||
|
||||
if (templates.isNotEmpty && _selectedTemplateId == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -201,6 +211,17 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actionConfig: FilterBarActionConfig(
|
||||
onApply: _applyFilters,
|
||||
onReset: _resetFilters,
|
||||
hasPendingChanges: false,
|
||||
hasActiveFilters: _hasFilters(),
|
||||
applyEnabled: !_controller.isLoadingList,
|
||||
resetLabel: '필터 초기화',
|
||||
resetKey: const ValueKey('approval_filter_reset'),
|
||||
resetEnabled: !_controller.isLoadingList && _hasFilters(),
|
||||
showReset: true,
|
||||
),
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
@@ -237,21 +258,24 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: ShadButton.outline(
|
||||
onPressed: _pickDateRange,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(lucide.LucideIcons.calendar, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_dateRange == null
|
||||
? '기간 선택'
|
||||
: '${_dateFormat.format(_dateRange!.start)} ~ ${_dateFormat.format(_dateRange!.end)}',
|
||||
child: SuperportDateRangePickerButton(
|
||||
value: _dateRange,
|
||||
dateFormat: _dateFormat,
|
||||
enabled: !_controller.isLoadingList,
|
||||
firstDate: DateTime(DateTime.now().year - 5),
|
||||
lastDate: DateTime(DateTime.now().year + 1),
|
||||
initialDateRange:
|
||||
_dateRange ??
|
||||
DateTimeRange(
|
||||
start: DateTime.now().subtract(const Duration(days: 7)),
|
||||
end: DateTime.now(),
|
||||
),
|
||||
],
|
||||
),
|
||||
onChanged: (range) {
|
||||
if (range == null) return;
|
||||
setState(() => _dateRange = range);
|
||||
_controller.updateDateRange(range.start, range.end);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_dateRange != null)
|
||||
@@ -263,24 +287,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
},
|
||||
child: const Text('기간 초기화'),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoadingList ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
key: const ValueKey('approval_filter_reset'),
|
||||
onPressed: _controller.isLoadingList
|
||||
? null
|
||||
: () {
|
||||
if (!_hasFilters()) return;
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_dateRange = null;
|
||||
_controller.clearFilters();
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('필터 초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
@@ -360,6 +366,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
isApplyingTemplate: isApplyingTemplate,
|
||||
applyingTemplateId: applyingTemplateId,
|
||||
selectedTemplateId: _selectedTemplateId,
|
||||
canPerformStepActions: canPerformStepActions,
|
||||
dateFormat: _dateTimeFormat,
|
||||
onRefresh: () {
|
||||
final id = selectedApproval?.id;
|
||||
@@ -390,33 +397,20 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
_controller.fetch(page: 1);
|
||||
}
|
||||
|
||||
void _resetFilters() {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_dateRange = null;
|
||||
_controller.clearFilters();
|
||||
_controller.fetch(page: 1);
|
||||
}
|
||||
|
||||
bool _hasFilters() {
|
||||
return _searchController.text.isNotEmpty ||
|
||||
_controller.statusFilter != ApprovalStatusFilter.all ||
|
||||
_dateRange != null;
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final now = DateTime.now();
|
||||
final initial =
|
||||
_dateRange ??
|
||||
DateTimeRange(
|
||||
start: DateTime(now.year, now.month, now.day - 7),
|
||||
end: now,
|
||||
);
|
||||
final range = await showDateRangePicker(
|
||||
context: context,
|
||||
initialDateRange: initial,
|
||||
firstDate: DateTime(now.year - 5),
|
||||
lastDate: DateTime(now.year + 1),
|
||||
);
|
||||
if (range != null) {
|
||||
setState(() => _dateRange = range);
|
||||
_controller.updateDateRange(range.start, range.end);
|
||||
_controller.fetch(page: 1);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleStepAction(
|
||||
ApprovalStep step,
|
||||
ApprovalStepActionType type,
|
||||
@@ -433,9 +427,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
if (!mounted || !success) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(_successMessage(type))));
|
||||
SuperportToast.success(context, _successMessage(type));
|
||||
}
|
||||
|
||||
void _handleSelectTemplate(int? templateId) {
|
||||
@@ -451,9 +443,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
}
|
||||
}
|
||||
if (template == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('선택한 템플릿 정보를 찾을 수 없습니다.')));
|
||||
SuperportToast.error(context, '선택한 템플릿 정보를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -467,9 +457,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('템플릿 "${template.name}"을(를) 적용했습니다.')),
|
||||
);
|
||||
SuperportToast.success(context, '템플릿 "${template.name}"을(를) 적용했습니다.');
|
||||
}
|
||||
|
||||
Future<_StepActionDialogResult?> _showStepActionDialog(
|
||||
@@ -486,63 +474,15 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
builder: (context, setState) {
|
||||
final materialTheme = Theme.of(context);
|
||||
final shadTheme = ShadTheme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(_dialogTitle(type)),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'선택 단계: Step ${step.stepOrder}',
|
||||
style: shadTheme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'승인자: ${step.approver.name}',
|
||||
style: shadTheme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'현재 상태: ${step.status.name}',
|
||||
style: shadTheme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('비고', style: shadTheme.textTheme.small),
|
||||
const SizedBox(height: 8),
|
||||
ShadTextarea(
|
||||
controller: noteController,
|
||||
minHeight: 120,
|
||||
maxHeight: 220,
|
||||
),
|
||||
if (requireNote)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
'코멘트에는 비고 입력이 필요합니다.',
|
||||
style: shadTheme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
errorText!,
|
||||
style: shadTheme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
return SuperportDialog(
|
||||
title: _dialogTitle(type),
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
actions: [
|
||||
TextButton(
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
FilledButton(
|
||||
ShadButton(
|
||||
onPressed: () {
|
||||
final note = noteController.text.trim();
|
||||
if (requireNote && note.isEmpty) {
|
||||
@@ -556,6 +496,52 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
child: Text(_dialogConfirmLabel(type)),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'선택 단계: Step ${step.stepOrder}',
|
||||
style: shadTheme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'승인자: ${step.approver.name}',
|
||||
style: shadTheme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'현재 상태: ${step.status.name}',
|
||||
style: shadTheme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('비고', style: shadTheme.textTheme.small),
|
||||
const SizedBox(height: 8),
|
||||
ShadTextarea(
|
||||
controller: noteController,
|
||||
minHeight: 120,
|
||||
maxHeight: 220,
|
||||
),
|
||||
if (requireNote)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
'코멘트에는 비고 입력이 필요합니다.',
|
||||
style: shadTheme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
errorText!,
|
||||
style: shadTheme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -576,24 +562,22 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
buffer.write('\n설명: $description');
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('템플릿 적용 확인'),
|
||||
content: Text(buffer.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('적용'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
dialog: SuperportDialog(
|
||||
title: '템플릿 적용 확인',
|
||||
actions: [
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('적용'),
|
||||
),
|
||||
],
|
||||
child: Text(buffer.toString()),
|
||||
),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
@@ -722,9 +706,11 @@ class _ApprovalTable extends StatelessWidget {
|
||||
rows.add(cells);
|
||||
}
|
||||
|
||||
return ShadTable.list(
|
||||
return SuperportTable.fromCells(
|
||||
header: header,
|
||||
children: rows,
|
||||
rows: rows,
|
||||
rowHeight: 56,
|
||||
maxHeight: 520,
|
||||
columnSpanExtent: (index) {
|
||||
switch (index) {
|
||||
case 1:
|
||||
@@ -758,6 +744,7 @@ class _DetailSection extends StatelessWidget {
|
||||
required this.isApplyingTemplate,
|
||||
required this.applyingTemplateId,
|
||||
required this.selectedTemplateId,
|
||||
required this.canPerformStepActions,
|
||||
required this.dateFormat,
|
||||
required this.onRefresh,
|
||||
required this.onClose,
|
||||
@@ -778,6 +765,7 @@ class _DetailSection extends StatelessWidget {
|
||||
final bool isApplyingTemplate;
|
||||
final int? applyingTemplateId;
|
||||
final int? selectedTemplateId;
|
||||
final bool canPerformStepActions;
|
||||
final intl.DateFormat dateFormat;
|
||||
final VoidCallback onRefresh;
|
||||
final VoidCallback? onClose;
|
||||
@@ -856,6 +844,7 @@ class _DetailSection extends StatelessWidget {
|
||||
isApplyingTemplate: isApplyingTemplate,
|
||||
applyingTemplateId: applyingTemplateId,
|
||||
selectedTemplateId: selectedTemplateId,
|
||||
canPerformStepActions: canPerformStepActions,
|
||||
onSelectTemplate: onSelectTemplate,
|
||||
onApplyTemplate: onApplyTemplate,
|
||||
onReloadTemplates: onReloadTemplates,
|
||||
@@ -952,6 +941,7 @@ class _StepTab extends StatelessWidget {
|
||||
required this.isApplyingTemplate,
|
||||
required this.applyingTemplateId,
|
||||
required this.selectedTemplateId,
|
||||
required this.canPerformStepActions,
|
||||
required this.onSelectTemplate,
|
||||
required this.onApplyTemplate,
|
||||
required this.onReloadTemplates,
|
||||
@@ -970,6 +960,7 @@ class _StepTab extends StatelessWidget {
|
||||
final bool isApplyingTemplate;
|
||||
final int? applyingTemplateId;
|
||||
final int? selectedTemplateId;
|
||||
final bool canPerformStepActions;
|
||||
final void Function(int?) onSelectTemplate;
|
||||
final void Function(int templateId) onApplyTemplate;
|
||||
final VoidCallback onReloadTemplates;
|
||||
@@ -989,6 +980,7 @@ class _StepTab extends StatelessWidget {
|
||||
selectedTemplateId: selectedTemplateId,
|
||||
isApplyingTemplate: isApplyingTemplate,
|
||||
applyingTemplateId: applyingTemplateId,
|
||||
canApplyTemplate: canPerformStepActions,
|
||||
onSelectTemplate: onSelectTemplate,
|
||||
onApplyTemplate: onApplyTemplate,
|
||||
onReload: onReloadTemplates,
|
||||
@@ -1002,6 +994,14 @@ class _StepTab extends StatelessWidget {
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
if (!canPerformStepActions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
|
||||
child: Text(
|
||||
'결재 권한이 없어 단계 행위를 실행할 수 없습니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
),
|
||||
if (steps.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
@@ -1014,7 +1014,10 @@ class _StepTab extends StatelessWidget {
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
itemBuilder: (context, index) {
|
||||
final step = steps[index];
|
||||
final disabledReason = _disabledReason(step);
|
||||
final disabledReason = _disabledReason(
|
||||
step,
|
||||
canPerformStepActions,
|
||||
);
|
||||
final isProcessingStep =
|
||||
isPerformingAction && processingStepId == step.id;
|
||||
final isEnabled = disabledReason == null && !isProcessingStep;
|
||||
@@ -1186,7 +1189,10 @@ class _StepTab extends StatelessWidget {
|
||||
return button;
|
||||
}
|
||||
|
||||
String? _disabledReason(ApprovalStep step) {
|
||||
String? _disabledReason(ApprovalStep step, bool canPerformStepActions) {
|
||||
if (!canPerformStepActions) {
|
||||
return '결재 행위를 수행할 권한이 없습니다.';
|
||||
}
|
||||
if (isLoadingActions) {
|
||||
return '행위 목록을 불러오는 중입니다.';
|
||||
}
|
||||
@@ -1251,6 +1257,7 @@ class _TemplateToolbar extends StatelessWidget {
|
||||
required this.selectedTemplateId,
|
||||
required this.isApplyingTemplate,
|
||||
required this.applyingTemplateId,
|
||||
required this.canApplyTemplate,
|
||||
required this.onSelectTemplate,
|
||||
required this.onApplyTemplate,
|
||||
required this.onReload,
|
||||
@@ -1261,6 +1268,7 @@ class _TemplateToolbar extends StatelessWidget {
|
||||
final int? selectedTemplateId;
|
||||
final bool isApplyingTemplate;
|
||||
final int? applyingTemplateId;
|
||||
final bool canApplyTemplate;
|
||||
final void Function(int?) onSelectTemplate;
|
||||
final void Function(int templateId) onApplyTemplate;
|
||||
final VoidCallback onReload;
|
||||
@@ -1272,11 +1280,45 @@ class _TemplateToolbar extends StatelessWidget {
|
||||
final isApplyingCurrent =
|
||||
isApplyingTemplate && applyingTemplateId == selectedTemplateId;
|
||||
final canApply =
|
||||
canApplyTemplate &&
|
||||
templates.isNotEmpty &&
|
||||
!isLoading &&
|
||||
selectedTemplateId != null &&
|
||||
!isApplyingTemplate;
|
||||
|
||||
Widget applyButton = ShadButton(
|
||||
onPressed: canApply
|
||||
? () {
|
||||
final templateId = selectedTemplateId;
|
||||
if (templateId != null) {
|
||||
onApplyTemplate(templateId);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isApplyingCurrent) ...[
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('적용 중...'),
|
||||
] else ...[
|
||||
const Icon(lucide.LucideIcons.layoutList, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
const Text('템플릿 적용'),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (!canApplyTemplate) {
|
||||
applyButton = Tooltip(message: '템플릿을 적용할 권한이 없습니다.', child: applyButton);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1287,7 +1329,7 @@ class _TemplateToolbar extends StatelessWidget {
|
||||
key: ValueKey(templates.length),
|
||||
placeholder: const Text('템플릿 선택'),
|
||||
initialValue: selectedTemplateId,
|
||||
onChanged: onSelectTemplate,
|
||||
onChanged: canApplyTemplate ? onSelectTemplate : null,
|
||||
selectedOptionBuilder: (context, value) {
|
||||
final match = _findTemplate(value);
|
||||
return Text(match?.name ?? '템플릿 선택');
|
||||
@@ -1315,36 +1357,14 @@ class _TemplateToolbar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
onPressed: canApply
|
||||
? () {
|
||||
final templateId = selectedTemplateId;
|
||||
if (templateId != null) {
|
||||
onApplyTemplate(templateId);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isApplyingCurrent) ...[
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('적용 중...'),
|
||||
] else ...[
|
||||
const Icon(lucide.LucideIcons.layoutList, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
const Text('템플릿 적용'),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
applyButton,
|
||||
],
|
||||
),
|
||||
if (!canApplyTemplate)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text('결재 템플릿 적용 권한이 없습니다.', style: theme.textTheme.muted),
|
||||
),
|
||||
if (isLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:superport_v2/core/constants/app_sections.dart';
|
||||
import 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/filter_bar.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
|
||||
import '../../../../../core/config/environment.dart';
|
||||
import '../../../../../widgets/spec_page.dart';
|
||||
@@ -147,7 +148,8 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
? false
|
||||
: (result.page * result.pageSize) < result.total;
|
||||
|
||||
final showReset = _searchController.text.isNotEmpty ||
|
||||
final showReset =
|
||||
_searchController.text.isNotEmpty ||
|
||||
_controller.groupFilter != null ||
|
||||
_controller.statusFilter != UserStatusFilter.all;
|
||||
|
||||
@@ -162,12 +164,33 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
actions: [
|
||||
ShadButton(
|
||||
leading: const Icon(LucideIcons.plus, size: 16),
|
||||
onPressed:
|
||||
_controller.isSubmitting ? null : () => _openUserForm(context),
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _openUserForm(context),
|
||||
child: const Text('신규 등록'),
|
||||
),
|
||||
],
|
||||
toolbar: FilterBar(
|
||||
actions: [
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateGroupFilter(null);
|
||||
_controller.updateStatusFilter(UserStatusFilter.all);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 260,
|
||||
@@ -184,14 +207,10 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
child: ShadSelect<int?>(
|
||||
key: ValueKey(_controller.groupFilter),
|
||||
initialValue: _controller.groupFilter,
|
||||
placeholder: Text(
|
||||
_groupsLoaded ? '그룹 전체' : '그룹 로딩중...',
|
||||
),
|
||||
placeholder: Text(_groupsLoaded ? '그룹 전체' : '그룹 로딩중...'),
|
||||
selectedOptionBuilder: (context, value) {
|
||||
if (value == null) {
|
||||
return Text(
|
||||
_groupsLoaded ? '그룹 전체' : '그룹 로딩중...',
|
||||
);
|
||||
return Text(_groupsLoaded ? '그룹 전체' : '그룹 로딩중...');
|
||||
}
|
||||
final group = _controller.groups.firstWhere(
|
||||
(g) => g.id == value,
|
||||
@@ -205,10 +224,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
_controller.updateGroupFilter(value);
|
||||
},
|
||||
options: [
|
||||
const ShadOption<int?>(
|
||||
value: null,
|
||||
child: Text('그룹 전체'),
|
||||
),
|
||||
const ShadOption<int?>(value: null, child: Text('그룹 전체')),
|
||||
..._controller.groups.map(
|
||||
(group) => ShadOption<int?>(
|
||||
value: group.id,
|
||||
@@ -239,26 +255,6 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
ShadButton.outline(
|
||||
onPressed: _controller.isLoading ? null : _applyFilters,
|
||||
child: const Text('검색 적용'),
|
||||
),
|
||||
if (showReset)
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading
|
||||
? null
|
||||
: () {
|
||||
_searchController.clear();
|
||||
_searchFocus.requestFocus();
|
||||
_controller.updateQuery('');
|
||||
_controller.updateGroupFilter(null);
|
||||
_controller.updateStatusFilter(
|
||||
UserStatusFilter.all,
|
||||
);
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
child: const Text('초기화'),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ShadCard(
|
||||
@@ -303,25 +299,21 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
: users.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'조건에 맞는 사용자가 없습니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
)
|
||||
: _UserTable(
|
||||
users: users,
|
||||
onEdit: _controller.isSubmitting
|
||||
? null
|
||||
: (user) => _openUserForm(context, user: user),
|
||||
onDelete: _controller.isSubmitting
|
||||
? null
|
||||
: _confirmDelete,
|
||||
onRestore: _controller.isSubmitting
|
||||
? null
|
||||
: _restoreUser,
|
||||
),
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'조건에 맞는 사용자가 없습니다.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
)
|
||||
: _UserTable(
|
||||
users: users,
|
||||
onEdit: _controller.isSubmitting
|
||||
? null
|
||||
: (user) => _openUserForm(context, user: user),
|
||||
onDelete: _controller.isSubmitting ? null : _confirmDelete,
|
||||
onRestore: _controller.isSubmitting ? null : _restoreUser,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -378,277 +370,260 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
final nameError = ValueNotifier<String?>(null);
|
||||
final groupError = ValueNotifier<String?>(null);
|
||||
|
||||
await showDialog<bool>(
|
||||
if (groupNotifier.value == null && _controller.groups.length == 1) {
|
||||
groupNotifier.value = _controller.groups.first.id;
|
||||
}
|
||||
|
||||
await SuperportDialog.show<bool>(
|
||||
context: parentContext,
|
||||
builder: (dialogContext) {
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
final materialTheme = Theme.of(dialogContext);
|
||||
final navigator = Navigator.of(dialogContext);
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: ShadCard(
|
||||
title: Text(
|
||||
isEdit ? '사용자 수정' : '사용자 등록',
|
||||
style: theme.textTheme.h3,
|
||||
),
|
||||
description: Text(
|
||||
'사용자 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
footer: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (_, isSaving, __) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ShadButton(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
final code = codeController.text.trim();
|
||||
final name = nameController.text.trim();
|
||||
final email = emailController.text.trim();
|
||||
final mobile = mobileController.text.trim();
|
||||
final note = noteController.text.trim();
|
||||
final groupId = groupNotifier.value;
|
||||
dialog: SuperportDialog(
|
||||
title: isEdit ? '사용자 수정' : '사용자 등록',
|
||||
description: '사용자 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.',
|
||||
primaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (context, isSaving, _) {
|
||||
return ShadButton(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
final code = codeController.text.trim();
|
||||
final name = nameController.text.trim();
|
||||
final email = emailController.text.trim();
|
||||
final mobile = mobileController.text.trim();
|
||||
final note = noteController.text.trim();
|
||||
final groupId = groupNotifier.value;
|
||||
|
||||
codeError.value = code.isEmpty
|
||||
? '사번을 입력하세요.'
|
||||
: null;
|
||||
nameError.value = name.isEmpty
|
||||
? '성명을 입력하세요.'
|
||||
: null;
|
||||
groupError.value = groupId == null
|
||||
? '그룹을 선택하세요.'
|
||||
: null;
|
||||
codeError.value = code.isEmpty ? '사번을 입력하세요.' : null;
|
||||
nameError.value = name.isEmpty ? '성명을 입력하세요.' : null;
|
||||
groupError.value = groupId == null ? '그룹을 선택하세요.' : null;
|
||||
|
||||
if (codeError.value != null ||
|
||||
nameError.value != null ||
|
||||
groupError.value != null) {
|
||||
return;
|
||||
}
|
||||
if (codeError.value != null ||
|
||||
nameError.value != null ||
|
||||
groupError.value != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
final input = UserInput(
|
||||
employeeNo: code,
|
||||
employeeName: name,
|
||||
groupId: groupId!,
|
||||
email: email.isEmpty ? null : email,
|
||||
mobileNo: mobile.isEmpty ? null : mobile,
|
||||
isActive: isActiveNotifier.value,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
final response = isEdit
|
||||
? await _controller.update(userId!, input)
|
||||
: await _controller.create(input);
|
||||
saving.value = false;
|
||||
if (response != null) {
|
||||
if (!navigator.mounted) {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
_showSnack(
|
||||
isEdit ? '사용자를 수정했습니다.' : '사용자를 등록했습니다.',
|
||||
);
|
||||
}
|
||||
navigator.pop(true);
|
||||
saving.value = true;
|
||||
final navigator = Navigator.of(context);
|
||||
final input = UserInput(
|
||||
employeeNo: code,
|
||||
employeeName: name,
|
||||
groupId: groupId!,
|
||||
email: email.isEmpty ? null : email,
|
||||
mobileNo: mobile.isEmpty ? null : mobile,
|
||||
isActive: isActiveNotifier.value,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
final response = isEdit
|
||||
? await _controller.update(userId!, input)
|
||||
: await _controller.create(input);
|
||||
saving.value = false;
|
||||
if (response != null) {
|
||||
if (!navigator.mounted) {
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
_showSnack(isEdit ? '사용자를 수정했습니다.' : '사용자를 등록했습니다.');
|
||||
}
|
||||
navigator.pop(true);
|
||||
}
|
||||
},
|
||||
child: Text(isEdit ? '저장' : '등록'),
|
||||
);
|
||||
},
|
||||
),
|
||||
secondaryAction: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (context, isSaving, _) {
|
||||
final navigator = Navigator.of(context);
|
||||
return ShadButton.ghost(
|
||||
onPressed: isSaving ? null : () => navigator.pop(false),
|
||||
child: const Text('취소'),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: saving,
|
||||
builder: (context, isSaving, _) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final materialTheme = Theme.of(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: codeError,
|
||||
builder: (_, errorText, __) {
|
||||
return _FormField(
|
||||
label: '사번',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
controller: codeController,
|
||||
readOnly: isEdit,
|
||||
onChanged: (_) {
|
||||
if (codeController.text.trim().isNotEmpty) {
|
||||
codeError.value = null;
|
||||
}
|
||||
},
|
||||
child: Text(isEdit ? '저장' : '등록'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: codeError,
|
||||
builder: (_, errorText, __) {
|
||||
return _FormField(
|
||||
label: '사번',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
controller: codeController,
|
||||
readOnly: isEdit,
|
||||
onChanged: (_) {
|
||||
if (codeController.text.trim().isNotEmpty) {
|
||||
codeError.value = null;
|
||||
}
|
||||
},
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: nameError,
|
||||
builder: (_, errorText, __) {
|
||||
return _FormField(
|
||||
label: '성명',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
controller: nameController,
|
||||
onChanged: (_) {
|
||||
if (nameController.text.trim().isNotEmpty) {
|
||||
nameError.value = null;
|
||||
}
|
||||
},
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<String?>(
|
||||
valueListenable: nameError,
|
||||
builder: (_, errorText, __) {
|
||||
return _FormField(
|
||||
label: '성명',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadInput(
|
||||
controller: nameController,
|
||||
onChanged: (_) {
|
||||
if (nameController.text.trim().isNotEmpty) {
|
||||
nameError.value = null;
|
||||
}
|
||||
},
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '이메일',
|
||||
child: ShadInput(
|
||||
controller: emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '이메일',
|
||||
child: ShadInput(
|
||||
controller: emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '연락처',
|
||||
child: ShadInput(
|
||||
controller: mobileController,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '연락처',
|
||||
child: ShadInput(
|
||||
controller: mobileController,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<int?>(
|
||||
valueListenable: groupNotifier,
|
||||
builder: (_, value, __) {
|
||||
return ValueListenableBuilder<String?>(
|
||||
valueListenable: groupError,
|
||||
builder: (_, errorText, __) {
|
||||
return _FormField(
|
||||
label: '그룹',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadSelect<int?>(
|
||||
initialValue: value,
|
||||
onChanged: saving.value
|
||||
? null
|
||||
: (next) {
|
||||
groupNotifier.value = next;
|
||||
groupError.value = null;
|
||||
},
|
||||
options: _controller.groups
|
||||
.map(
|
||||
(group) => ShadOption<int?>(
|
||||
value: group.id,
|
||||
child: Text(group.groupName),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
placeholder: const Text('그룹을 선택하세요'),
|
||||
selectedOptionBuilder: (context, selected) {
|
||||
if (selected == null) {
|
||||
return const Text('그룹을 선택하세요');
|
||||
}
|
||||
final group = _controller.groups
|
||||
.firstWhere(
|
||||
(g) => g.id == selected,
|
||||
orElse: () => Group(
|
||||
id: selected,
|
||||
groupName: '',
|
||||
),
|
||||
);
|
||||
return Text(group.groupName);
|
||||
},
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color:
|
||||
materialTheme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<int?>(
|
||||
valueListenable: groupNotifier,
|
||||
builder: (_, value, __) {
|
||||
return ValueListenableBuilder<String?>(
|
||||
valueListenable: groupError,
|
||||
builder: (_, errorText, __) {
|
||||
return _FormField(
|
||||
label: '그룹',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShadSelect<int?>(
|
||||
initialValue: value,
|
||||
onChanged: isSaving
|
||||
? null
|
||||
: (next) {
|
||||
groupNotifier.value = next;
|
||||
groupError.value = null;
|
||||
},
|
||||
options: _controller.groups
|
||||
.map(
|
||||
(group) => ShadOption<int?>(
|
||||
value: group.id,
|
||||
child: Text(group.groupName),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
placeholder: const Text('그룹을 선택하세요'),
|
||||
selectedOptionBuilder: (context, selected) {
|
||||
if (selected == null) {
|
||||
return const Text('그룹을 선택하세요');
|
||||
}
|
||||
final group = _controller.groups.firstWhere(
|
||||
(g) => g.id == selected,
|
||||
orElse: () =>
|
||||
Group(id: selected, groupName: ''),
|
||||
);
|
||||
return Text(group.groupName);
|
||||
},
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
errorText,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: materialTheme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: isActiveNotifier,
|
||||
builder: (_, value, __) {
|
||||
return _FormField(
|
||||
label: '사용여부',
|
||||
child: Row(
|
||||
children: [
|
||||
ShadSwitch(
|
||||
value: value,
|
||||
onChanged: saving.value
|
||||
? null
|
||||
: (next) => isActiveNotifier.value = next,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(value ? '사용' : '미사용'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '비고',
|
||||
child: ShadTextarea(controller: noteController),
|
||||
),
|
||||
if (existing != null) ..._buildAuditInfo(existing, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: isActiveNotifier,
|
||||
builder: (_, value, __) {
|
||||
return _FormField(
|
||||
label: '사용여부',
|
||||
child: Row(
|
||||
children: [
|
||||
ShadSwitch(
|
||||
value: value,
|
||||
onChanged: isSaving
|
||||
? null
|
||||
: (next) => isActiveNotifier.value = next,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(value ? '사용' : '미사용'),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '비고',
|
||||
child: ShadTextarea(controller: noteController),
|
||||
),
|
||||
if (existing != null) ..._buildAuditInfo(existing, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
codeController.dispose();
|
||||
@@ -665,24 +640,22 @@ class _UserEnabledPageState extends State<_UserEnabledPage> {
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(UserAccount user) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('사용자 삭제'),
|
||||
content: Text('"${user.employeeName}" 사용자를 삭제하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
dialog: SuperportDialog(
|
||||
title: '사용자 삭제',
|
||||
description: '"${user.employeeName}" 사용자를 삭제하시겠습니까?',
|
||||
actions: [
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && user.id != null) {
|
||||
|
||||
143
lib/widgets/components/keyboard_shortcuts.dart
Normal file
143
lib/widgets/components/keyboard_shortcuts.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class DialogKeyboardShortcuts extends StatefulWidget {
|
||||
const DialogKeyboardShortcuts({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onEscape,
|
||||
this.onSubmit,
|
||||
this.enableFocusTrap = true,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onEscape;
|
||||
final FutureOr<void> Function()? onSubmit;
|
||||
final bool enableFocusTrap;
|
||||
|
||||
@override
|
||||
State<DialogKeyboardShortcuts> createState() =>
|
||||
_DialogKeyboardShortcutsState();
|
||||
}
|
||||
|
||||
class _DialogKeyboardShortcutsState extends State<DialogKeyboardShortcuts> {
|
||||
late final FocusScopeNode _focusScopeNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusScopeNode = FocusScopeNode(debugLabel: 'DialogKeyboardShortcuts');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusScopeNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _hasSubmitHandler => widget.onSubmit != null;
|
||||
|
||||
bool _shouldHandleSubmitKey() {
|
||||
if (!_hasSubmitHandler) {
|
||||
return false;
|
||||
}
|
||||
final primaryFocus = FocusManager.instance.primaryFocus;
|
||||
if (primaryFocus == null) {
|
||||
return true;
|
||||
}
|
||||
final context = primaryFocus.context;
|
||||
if (context == null) {
|
||||
return true;
|
||||
}
|
||||
EditableText? editable;
|
||||
final widget = context.widget;
|
||||
if (widget is EditableText) {
|
||||
editable = widget;
|
||||
} else {
|
||||
editable = context.findAncestorWidgetOfExactType<EditableText>();
|
||||
}
|
||||
if (editable != null) {
|
||||
// Multi-line 입력에서는 엔터 키를 입력값으로 전달한다.
|
||||
final bool isMultiline =
|
||||
editable.maxLines == null || editable.maxLines! > 1;
|
||||
if (isMultiline) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, Intent> get _shortcuts {
|
||||
final shortcuts = <ShortcutActivator, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const _DismissIntent(),
|
||||
};
|
||||
if (_hasSubmitHandler) {
|
||||
shortcuts[LogicalKeySet(LogicalKeyboardKey.enter)] =
|
||||
const _SubmitIntent();
|
||||
shortcuts[LogicalKeySet(LogicalKeyboardKey.numpadEnter)] =
|
||||
const _SubmitIntent();
|
||||
}
|
||||
return shortcuts;
|
||||
}
|
||||
|
||||
Map<Type, Action<Intent>> get _actions {
|
||||
return <Type, Action<Intent>>{
|
||||
_DismissIntent: CallbackAction<_DismissIntent>(
|
||||
onInvoke: (intent) {
|
||||
widget.onEscape?.call();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_SubmitIntent: CallbackAction<_SubmitIntent>(
|
||||
onInvoke: (intent) {
|
||||
if (_shouldHandleSubmitKey()) {
|
||||
final callback = widget.onSubmit;
|
||||
if (callback != null) {
|
||||
final result = callback();
|
||||
if (result is Future<void>) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget content = widget.child;
|
||||
|
||||
if (widget.enableFocusTrap) {
|
||||
content = FocusTraversalGroup(
|
||||
policy: WidgetOrderTraversalPolicy(),
|
||||
child: FocusScope(
|
||||
node: _focusScopeNode,
|
||||
autofocus: true,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
content = FocusTraversalGroup(
|
||||
policy: WidgetOrderTraversalPolicy(),
|
||||
child: FocusScope(autofocus: true, child: content),
|
||||
);
|
||||
}
|
||||
|
||||
return Shortcuts(
|
||||
shortcuts: _shortcuts,
|
||||
child: Actions(actions: _actions, child: content),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DismissIntent extends Intent {
|
||||
const _DismissIntent();
|
||||
}
|
||||
|
||||
class _SubmitIntent extends Intent {
|
||||
const _SubmitIntent();
|
||||
}
|
||||
Reference in New Issue
Block a user