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

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