결재 단계 편집 다이얼로그 구현

This commit is contained in:
JiWoong Sul
2025-09-25 17:57:29 +09:00
parent 6d6781f552
commit 8a6ad1e81b
17 changed files with 1689 additions and 42 deletions

View File

@@ -9,6 +9,7 @@ import '../../../../../widgets/app_layout.dart';
import '../../../../../widgets/components/filter_bar.dart';
import '../../../../../widgets/spec_page.dart';
import '../controllers/approval_step_controller.dart';
import '../../domain/entities/approval_step_input.dart';
import '../../domain/entities/approval_step_record.dart';
import '../../domain/repositories/approval_step_repository.dart';
@@ -141,6 +142,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
final selectedStatus = _controller.statusId ?? -1;
final approverOptions = _buildApproverOptions(records);
final selectedApprover = _controller.approverId ?? -1;
final isSaving = _controller.isSaving;
return AppLayout(
title: '결재 단계 관리',
@@ -151,13 +153,19 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
AppBreadcrumbItem(label: '결재 단계'),
],
actions: [
Tooltip(
message: '결재 단계 생성은 정책 정리 후 제공됩니다.',
child: ShadButton(
onPressed: null,
leading: const Icon(lucide.LucideIcons.plus, size: 16),
child: const Text('단계 추가'),
),
ShadButton(
key: const ValueKey('approval_step_create'),
onPressed: (_controller.isLoading || isSaving)
? null
: _openCreateStepForm,
leading: isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(lucide.LucideIcons.plus, size: 16),
child: Text(isSaving ? '저장 중...' : '단계 추가'),
),
],
toolbar: FilterBar(
@@ -225,12 +233,16 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
),
),
ShadButton.outline(
onPressed: _controller.isLoading ? null : _applyFilters,
onPressed: (_controller.isLoading || isSaving)
? null
: _applyFilters,
child: const Text('검색 적용'),
),
ShadButton.ghost(
onPressed:
!_controller.isLoading && _controller.hasActiveFilters
!_controller.isLoading &&
!isSaving &&
_controller.hasActiveFilters
? _resetFilters
: null,
child: const Text('필터 초기화'),
@@ -319,15 +331,35 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
ShadTableCell(
child: Align(
alignment: Alignment.centerRight,
child: ShadButton.outline(
key: ValueKey(
'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed: step.id == null
? null
: () => _openDetail(record),
child: const Text('상세'),
child: Wrap(
spacing: 8,
children: [
ShadButton.outline(
key: ValueKey(
'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
step.id == null ||
_controller.isLoading ||
isSaving
? null
: () => _openDetail(record),
child: const Text('상세'),
),
if (step.id != null)
ShadButton(
key: ValueKey(
'step_edit_${step.id}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading || isSaving
? null
: () => _openEditStepForm(record),
child: const Text('수정'),
),
],
),
),
),
@@ -345,7 +377,9 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading || currentPage <= 1
_controller.isLoading ||
isSaving ||
currentPage <= 1
? null
: () => _controller.fetch(
page: currentPage - 1,
@@ -355,7 +389,10 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext
onPressed:
_controller.isLoading ||
isSaving ||
!hasNext
? null
: () => _controller.fetch(
page: currentPage + 1,
@@ -413,6 +450,67 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
_searchFocus.requestFocus();
}
Future<void> _openCreateStepForm() async {
final input = await showDialog<ApprovalStepInput>(
context: context,
builder: (dialogContext) {
return _StepFormDialog(
title: '결재 단계 추가',
submitLabel: '저장',
isEditing: false,
);
},
);
if (!mounted || input == null) {
return;
}
final created = await _controller.createStep(input);
if (!mounted || created == null) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결재번호 ${created.approvalNo} 단계가 추가되었습니다.')),
);
}
Future<void> _openEditStepForm(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('저장되지 않은 단계는 수정할 수 없습니다.')));
return;
}
final input = await showDialog<ApprovalStepInput>(
context: context,
builder: (dialogContext) {
return _StepFormDialog(
title: '결재 단계 수정',
submitLabel: '저장',
isEditing: true,
initialRecord: record,
);
},
);
if (!mounted || input == null) {
return;
}
final updated = await _controller.updateStep(stepId, input);
if (!mounted || updated == null) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결재번호 ${updated.approvalNo} 단계 정보를 수정했습니다.')),
);
}
Future<void> _openDetail(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
@@ -564,3 +662,263 @@ class _DetailRow extends StatelessWidget {
);
}
}
class _StepFormDialog extends StatefulWidget {
const _StepFormDialog({
required this.title,
required this.submitLabel,
required this.isEditing,
this.initialRecord,
});
final String title;
final String submitLabel;
final bool isEditing;
final ApprovalStepRecord? initialRecord;
@override
State<_StepFormDialog> createState() => _StepFormDialogState();
}
class _StepFormDialogState extends State<_StepFormDialog> {
late final TextEditingController _approvalIdController;
late final TextEditingController _approvalNoController;
late final TextEditingController _stepOrderController;
late final TextEditingController _approverIdController;
late final TextEditingController _noteController;
Map<String, String?> _errors = const {};
@override
void initState() {
super.initState();
final record = widget.initialRecord;
_approvalIdController = TextEditingController(
text: widget.isEditing && record != null
? record.approvalId.toString()
: '',
);
_approvalNoController = TextEditingController(
text: record?.approvalNo ?? '',
);
_stepOrderController = TextEditingController(
text: record?.step.stepOrder.toString() ?? '',
);
_approverIdController = TextEditingController(
text: record?.step.approver.id.toString() ?? '',
);
_noteController = TextEditingController(text: record?.step.note ?? '');
}
@override
void dispose() {
_approvalIdController.dispose();
_approvalNoController.dispose();
_stepOrderController.dispose();
_approverIdController.dispose();
_noteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final materialTheme = Theme.of(context);
return Dialog(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ShadCard(
title: Text(widget.title, style: theme.textTheme.h3),
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
key: const ValueKey('step_form_submit'),
onPressed: _handleSubmit,
child: Text(widget.submitLabel),
),
],
),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.isEditing)
_FormFieldBlock(
label: '결재 ID',
errorText: _errors['approvalId'],
child: ShadInput(
key: const ValueKey('step_form_approval_id'),
controller: _approvalIdController,
onChanged: (_) => _clearError('approvalId'),
),
)
else ...[
_FormFieldBlock(
label: '결재 ID',
child: ShadInput(
controller: _approvalIdController,
readOnly: true,
),
),
const SizedBox(height: 16),
_FormFieldBlock(
label: '결재번호',
child: ShadInput(
controller: _approvalNoController,
readOnly: true,
),
),
],
if (!widget.isEditing) const SizedBox(height: 16),
_FormFieldBlock(
label: '단계 순서',
errorText: _errors['stepOrder'],
child: ShadInput(
key: const ValueKey('step_form_step_order'),
controller: _stepOrderController,
onChanged: (_) => _clearError('stepOrder'),
),
),
const SizedBox(height: 16),
_FormFieldBlock(
label: '승인자 ID',
errorText: _errors['approverId'],
child: ShadInput(
key: const ValueKey('step_form_approver_id'),
controller: _approverIdController,
onChanged: (_) => _clearError('approverId'),
),
),
const SizedBox(height: 16),
_FormFieldBlock(
label: '비고',
helperText: '필요 시 단계에 대한 참고 내용을 남길 수 있습니다.',
child: ShadTextarea(
key: const ValueKey('step_form_note'),
controller: _noteController,
minHeight: 100,
maxHeight: 200,
),
),
if (_errors['form'] != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
_errors['form']!,
style: theme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
],
),
),
),
);
}
void _handleSubmit() {
final Map<String, String?> nextErrors = {};
int? approvalId;
if (widget.isEditing) {
approvalId = widget.initialRecord?.approvalId;
} else {
approvalId = int.tryParse(_approvalIdController.text.trim());
if (approvalId == null || approvalId <= 0) {
nextErrors['approvalId'] = '결재 ID를 1 이상의 숫자로 입력하세요.';
}
}
final stepOrder = int.tryParse(_stepOrderController.text.trim());
if (stepOrder == null || stepOrder <= 0) {
nextErrors['stepOrder'] = '단계 순서를 1 이상의 숫자로 입력하세요.';
}
final approverId = int.tryParse(_approverIdController.text.trim());
if (approverId == null || approverId <= 0) {
nextErrors['approverId'] = '승인자 ID를 1 이상의 숫자로 입력하세요.';
}
setState(() => _errors = nextErrors);
if (nextErrors.isNotEmpty) {
return;
}
final note = _noteController.text.trim();
final input = ApprovalStepInput(
approvalId: approvalId,
stepOrder: stepOrder!,
approverId: approverId!,
note: note.isEmpty ? null : note,
statusId: widget.initialRecord?.step.status.id,
);
Navigator.of(context).pop(input);
}
void _clearError(String field) {
if (_errors[field] == null) {
return;
}
setState(() {
final updated = Map<String, String?>.from(_errors);
updated.remove(field);
_errors = updated;
});
}
}
class _FormFieldBlock extends StatelessWidget {
const _FormFieldBlock({
required this.label,
this.errorText,
this.helperText,
required this.child,
});
final String label;
final Widget child;
final String? errorText;
final String? helperText;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final materialTheme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
child,
if (errorText != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
errorText!,
style: theme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
if (helperText != null && helperText!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(helperText!, style: theme.textTheme.muted),
),
],
);
}
}