결재 권한 테스트 및 인벤토리 위젯 안정화

This commit is contained in:
JiWoong Sul
2025-09-29 01:49:51 +09:00
parent 900990c46b
commit c00c0c9ab2
12 changed files with 5337 additions and 1765 deletions

View File

@@ -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),

View File

@@ -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) {

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