feat(dialog): 상세 팝업 SuperportDetailDialog 통합

- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화

- 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환

- SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거

- 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지

- detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
This commit is contained in:
JiWoong Sul
2025-11-07 19:02:43 +09:00
parent 1f78171294
commit 2f8b529506
64 changed files with 13721 additions and 7545 deletions

View File

@@ -0,0 +1,534 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../widgets/components/superport_detail_dialog.dart';
import '../../domain/entities/approval_step_input.dart';
import '../../domain/entities/approval_step_record.dart';
/// 결재 단계 상세 다이얼로그 내 액션 구분값이다.
enum ApprovalStepDetailAction { updated, deleted, restored }
/// 결재 단계 상세 다이얼로그 종료 시 전달되는 결과 모델이다.
class ApprovalStepDetailResult {
const ApprovalStepDetailResult({required this.action, required this.message});
/// 수행된 액션 종류.
final ApprovalStepDetailAction action;
/// 사용자에게 노출할 완료 메시지.
final String message;
}
typedef ApprovalStepUpdateCallback =
Future<ApprovalStepRecord?> Function(int id, ApprovalStepInput input);
typedef ApprovalStepDeleteCallback = Future<bool> Function(int id);
typedef ApprovalStepRestoreCallback =
Future<ApprovalStepRecord?> Function(int id);
/// 결재 단계 상세 다이얼로그를 노출한다.
Future<ApprovalStepDetailResult?> showApprovalStepDetailDialog({
required BuildContext context,
required ApprovalStepRecord record,
required intl.DateFormat dateFormat,
required ApprovalStepUpdateCallback onUpdate,
required ApprovalStepDeleteCallback onDelete,
required ApprovalStepRestoreCallback onRestore,
bool canEdit = true,
bool canDelete = true,
bool canRestore = true,
}) {
final step = record.step;
final metadata = <SuperportDetailMetadata>[
SuperportDetailMetadata.text(label: '결재 ID', value: '${record.approvalId}'),
SuperportDetailMetadata.text(
label: '트랜잭션번호',
value: record.transactionNo ?? '-',
),
SuperportDetailMetadata.text(
label: '템플릿',
value: record.templateName ?? '-',
),
SuperportDetailMetadata.text(label: '단계 순서', value: '${step.stepOrder}'),
SuperportDetailMetadata.text(label: '상태', value: step.status.name),
SuperportDetailMetadata.text(label: '승인자', value: step.approver.name),
SuperportDetailMetadata.text(
label: '승인자 사번',
value: step.approver.employeeNo,
),
SuperportDetailMetadata.text(
label: '배정일시',
value: dateFormat.format(step.assignedAt.toLocal()),
),
SuperportDetailMetadata.text(
label: '결정일시',
value: step.decidedAt == null
? '-'
: dateFormat.format(step.decidedAt!.toLocal()),
),
SuperportDetailMetadata.text(
label: '비고',
value: step.note?.isNotEmpty == true ? step.note! : '-',
),
];
final sections = <SuperportDetailDialogSection>[
if (canEdit)
SuperportDetailDialogSection(
key: const ValueKey('approval_step_section_edit'),
id: _ApprovalStepSections.edit,
label: '수정',
icon: lucide.LucideIcons.pencil,
builder: (_) => _ApprovalStepEditSection(
record: record,
onSubmit: (input) async {
final stepId = step.id;
if (stepId == null) {
return null;
}
final updated = await onUpdate(stepId, input);
if (updated == null) {
return null;
}
return ApprovalStepDetailResult(
action: ApprovalStepDetailAction.updated,
message: '결재 단계 정보를 수정했습니다.',
);
},
),
),
if (step.isDeleted ? canRestore : canDelete)
SuperportDetailDialogSection(
key: ValueKey(
step.isDeleted
? 'approval_step_section_restore'
: 'approval_step_section_delete',
),
id: step.isDeleted
? _ApprovalStepSections.restore
: _ApprovalStepSections.delete,
label: step.isDeleted ? '복구' : '삭제',
icon: step.isDeleted
? lucide.LucideIcons.history
: lucide.LucideIcons.trash2,
scrollable: false,
builder: (_) => _ApprovalStepDangerSection(
record: record,
canDelete: canDelete,
canRestore: canRestore,
onDelete: () async {
final stepId = step.id;
if (stepId == null) {
return null;
}
final success = await onDelete(stepId);
if (!success) {
return null;
}
return ApprovalStepDetailResult(
action: ApprovalStepDetailAction.deleted,
message: '결재 단계를 삭제했습니다.',
);
},
onRestore: () async {
final stepId = step.id;
if (stepId == null) {
return null;
}
final restored = await onRestore(stepId);
if (restored == null) {
return null;
}
return ApprovalStepDetailResult(
action: ApprovalStepDetailAction.restored,
message: '결재 단계를 복구했습니다.',
);
},
),
),
];
final initialSectionId = sections.isEmpty ? null : sections.first.id;
final badges = <Widget>[
ShadBadge.outline(child: Text('단계 ${step.stepOrder}')),
ShadBadge(child: Text(step.status.name)),
if (step.isDeleted) const ShadBadge.destructive(child: Text('삭제됨')),
];
return showSuperportDetailDialog<ApprovalStepDetailResult>(
context: context,
title: '결재 단계 상세',
description: '결재번호 ${record.approvalNo}',
sections: sections,
summary: _ApprovalStepSummary(record: record),
summaryBadges: badges,
metadata: metadata,
emptyPlaceholder: const Text('표시할 상세 정보가 없습니다.'),
initialSectionId: initialSectionId,
);
}
/// 다이얼로그 섹션 ID 상수 모음이다.
class _ApprovalStepSections {
static const edit = 'edit';
static const delete = 'delete';
static const restore = 'restore';
}
/// 결재 단계 요약 정보를 구성하는 위젯이다.
class _ApprovalStepSummary extends StatelessWidget {
const _ApprovalStepSummary({required this.record});
final ApprovalStepRecord record;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final step = record.step;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('결재 단계 ${step.stepOrder}', style: theme.textTheme.h4),
const SizedBox(height: 4),
Text(
'${step.approver.name} · ${step.approver.employeeNo}',
style: theme.textTheme.small,
),
if (step.note?.isNotEmpty == true) ...[
const SizedBox(height: 8),
Text(step.note!, style: theme.textTheme.muted),
],
],
);
}
}
/// 결재 단계 수정 폼 섹션이다.
class _ApprovalStepEditSection extends StatefulWidget {
const _ApprovalStepEditSection({
required this.record,
required this.onSubmit,
});
final ApprovalStepRecord record;
final Future<ApprovalStepDetailResult?> Function(ApprovalStepInput input)
onSubmit;
@override
State<_ApprovalStepEditSection> createState() =>
_ApprovalStepEditSectionState();
}
class _ApprovalStepEditSectionState extends State<_ApprovalStepEditSection> {
late final TextEditingController _stepOrderController;
late final TextEditingController _approverIdController;
late final TextEditingController _noteController;
bool _isSubmitting = false;
String? _stepOrderError;
String? _approverIdError;
String? _submitError;
ApprovalStepRecord get _record => widget.record;
@override
void initState() {
super.initState();
_stepOrderController = TextEditingController(
text: _record.step.stepOrder.toString(),
);
_approverIdController = TextEditingController(
text: _record.step.approver.id.toString(),
);
_noteController = TextEditingController(text: _record.step.note ?? '');
}
@override
void dispose() {
_stepOrderController.dispose();
_approverIdController.dispose();
_noteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ReadOnlyField(label: '결재 ID', value: '${_record.approvalId}'),
const SizedBox(height: 12),
_ReadOnlyField(label: '결재번호', value: _record.approvalNo),
const SizedBox(height: 16),
_EditableField(
label: '단계 순서',
controller: _stepOrderController,
errorText: _stepOrderError,
keyboardType: TextInputType.number,
fieldKey: const ValueKey('approval_step_detail_step_order'),
onChanged: (_) {
if (_stepOrderError != null) {
setState(() => _stepOrderError = null);
}
},
),
const SizedBox(height: 16),
_EditableField(
label: '승인자 ID',
controller: _approverIdController,
errorText: _approverIdError,
keyboardType: TextInputType.number,
fieldKey: const ValueKey('approval_step_detail_approver_id'),
onChanged: (_) {
if (_approverIdError != null) {
setState(() => _approverIdError = null);
}
},
),
const SizedBox(height: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'비고',
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
ShadTextarea(
key: const ValueKey('approval_step_detail_note'),
controller: _noteController,
minHeight: 96,
maxHeight: 200,
),
],
),
if (_submitError != null) ...[
const SizedBox(height: 12),
Text(
_submitError!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
],
const SizedBox(height: 20),
Align(
alignment: Alignment.centerRight,
child: ShadButton(
key: const ValueKey('approval_step_detail_submit'),
onPressed: _isSubmitting ? null : _handleSubmit,
child: Text(_isSubmitting ? '저장 중...' : '저장'),
),
),
],
);
}
Future<void> _handleSubmit() async {
final stepOrder = int.tryParse(_stepOrderController.text.trim());
final approverId = int.tryParse(_approverIdController.text.trim());
setState(() {
_stepOrderError = stepOrder == null || stepOrder <= 0
? '1 이상의 숫자를 입력하세요.'
: null;
_approverIdError = approverId == null || approverId <= 0
? '1 이상의 숫자를 입력하세요.'
: null;
_submitError = null;
});
if (_stepOrderError != null || _approverIdError != null) {
return;
}
setState(() => _isSubmitting = true);
final input = ApprovalStepInput(
approvalId: _record.approvalId,
stepOrder: stepOrder!,
approverId: approverId!,
note: _noteController.text.trim().isEmpty
? null
: _noteController.text.trim(),
statusId: _record.step.status.id,
);
final navigator = Navigator.of(context, rootNavigator: true);
ApprovalStepDetailResult? result;
try {
result = await widget.onSubmit(input);
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
if (!mounted) {
return;
}
if (result == null) {
setState(() {
_submitError = '요청 처리에 실패했습니다. 다시 시도해 주세요.';
});
return;
}
if (navigator.mounted) {
navigator.pop(result);
}
}
}
/// 삭제/복구 섹션을 담당하는 위젯이다.
class _ApprovalStepDangerSection extends StatelessWidget {
const _ApprovalStepDangerSection({
required this.record,
required this.canDelete,
required this.canRestore,
required this.onDelete,
required this.onRestore,
});
final ApprovalStepRecord record;
final bool canDelete;
final bool canRestore;
final Future<ApprovalStepDetailResult?> Function() onDelete;
final Future<ApprovalStepDetailResult?> Function() onRestore;
@override
Widget build(BuildContext context) {
final step = record.step;
final theme = ShadTheme.of(context);
final navigator = Navigator.of(context, rootNavigator: true);
final description = step.isDeleted
? '복구하면 결재 단계가 다시 활성화됩니다.'
: '삭제 시 단계는 목록에서 숨겨지지만, 필요 시 복구할 수 있습니다.';
Future<void> handleAction(
Future<ApprovalStepDetailResult?> Function() callback,
) async {
final result = await callback();
if (result != null && navigator.mounted) {
navigator.pop(result);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(description, style: theme.textTheme.small),
const SizedBox(height: 16),
if (step.isDeleted)
ShadButton(
key: const ValueKey('approval_step_detail_restore'),
onPressed: canRestore ? () => handleAction(onRestore) : null,
child: const Text('복구'),
)
else
ShadButton.destructive(
key: const ValueKey('approval_step_detail_delete'),
onPressed: canDelete ? () => handleAction(onDelete) : null,
child: const Text('삭제'),
),
],
);
}
}
/// 읽기 전용 필드 레이아웃을 제공한다.
class _ReadOnlyField extends StatelessWidget {
const _ReadOnlyField({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final background = theme.colorScheme.secondary.withValues(alpha: 0.05);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.colorScheme.border),
),
child: Text(
value.isEmpty ? '-' : value,
style: theme.textTheme.small,
),
),
],
);
}
}
/// 편집 필드 공통 레이아웃 위젯이다.
class _EditableField extends StatelessWidget {
const _EditableField({
required this.label,
required this.controller,
this.errorText,
this.keyboardType,
this.onChanged,
this.fieldKey,
});
final String label;
final TextEditingController controller;
final String? errorText;
final TextInputType? keyboardType;
final ValueChanged<String>? onChanged;
final Key? fieldKey;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
ShadInput(
key: fieldKey,
controller: controller,
keyboardType: keyboardType,
onChanged: onChanged,
),
if (errorText != null) ...[
const SizedBox(height: 6),
Text(
errorText!,
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
),
),
],
],
);
}
}
/// 개요 섹션에서 사용하는 단순 레코드 모델이다.

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:intl/intl.dart' as intl;
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
@@ -11,8 +12,10 @@ import '../../../../../widgets/app_layout.dart';
import '../../../../../widgets/components/filter_bar.dart';
import '../../../../../widgets/components/superport_dialog.dart';
import '../../../../../widgets/components/superport_pagination_controls.dart';
import '../../../../../widgets/components/superport_table.dart';
import '../../../../../widgets/components/feature_disabled_placeholder.dart';
import '../controllers/approval_step_controller.dart';
import '../dialogs/approval_step_detail_dialog.dart';
import '../../domain/entities/approval_step_input.dart';
import '../../domain/entities/approval_step_record.dart';
import '../../domain/repositories/approval_step_repository.dart';
@@ -70,7 +73,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
late final ApprovalStepController _controller;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocus = FocusNode();
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
String? _lastError;
@override
@@ -253,154 +256,77 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 480,
child: ShadTable.list(
header:
[
'ID',
'결재번호',
'단계순서',
'승인자',
'상태',
'배정일시',
'결정일시',
'동작',
]
.map(
(label) => ShadTableCell.header(
child: Text(label),
),
)
.toList(),
columnSpanExtent: (index) {
switch (index) {
case 1:
return const FixedTableSpanExtent(160);
case 2:
return const FixedTableSpanExtent(100);
case 3:
return const FixedTableSpanExtent(150);
case 4:
return const FixedTableSpanExtent(120);
case 5:
case 6:
return const FixedTableSpanExtent(160);
case 7:
return const FixedTableSpanExtent(110);
default:
return const FixedTableSpanExtent(90);
}
},
children: records.map((record) {
final step = record.step;
final isDeleted = step.isDeleted;
return [
ShadTableCell(
child: Text(step.id?.toString() ?? '-'),
),
ShadTableCell(child: Text(record.approvalNo)),
ShadTableCell(child: Text('${step.stepOrder}')),
ShadTableCell(child: Text(step.approver.name)),
ShadTableCell(
child: ShadBadge(child: Text(step.status.name)),
),
ShadTableCell(
child: Text(_formatDate(step.assignedAt)),
),
ShadTableCell(
child: Text(
step.decidedAt == null
? '-'
: _formatDate(step.decidedAt!),
),
),
ShadTableCell(
child: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
children: [
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.view,
child: 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 && !isDeleted)
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.edit,
child: ShadButton(
key: ValueKey(
'step_edit_${step.id}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading ||
isSaving
? null
: () =>
_openEditStepForm(record),
child: const Text('수정'),
),
),
if (step.id != null && !isDeleted)
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.delete,
child: ShadButton.destructive(
key: ValueKey(
'step_delete_${step.id}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading ||
isSaving
? null
: () => _confirmDeleteStep(
record,
),
child: const Text('삭제'),
),
),
if (step.id != null && isDeleted)
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.restore,
child: ShadButton.outline(
key: ValueKey(
'step_restore_${step.id}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading ||
isSaving
? null
: () => _confirmRestoreStep(
record,
),
child: const Text('복구'),
),
),
],
SuperportTable(
columns: const [
Text('ID'),
Text('결재번호'),
Text('단계순서'),
Text('승인자'),
Text('상태'),
Text('배정일시'),
Text('결정일시'),
],
rows: records.map((record) {
final step = record.step;
final isDeleted = step.isDeleted;
final decidedAt = step.decidedAt;
return [
Text(step.id?.toString() ?? '-'),
Text(record.approvalNo),
Text('${step.stepOrder}'),
Text(step.approver.name),
Row(
children: [
ShadBadge(child: Text(step.status.name)),
if (isDeleted) ...[
const SizedBox(width: 8),
const ShadBadge.destructive(
child: Text('삭제됨'),
),
),
),
];
}).toList(),
),
],
],
),
Text(_formatDate(step.assignedAt)),
Text(
decidedAt == null ? '-' : _formatDate(decidedAt),
),
];
}).toList(),
rowHeight: 56,
maxHeight: 520,
columnSpanExtent: (index) {
switch (index) {
case 1:
return const FixedTableSpanExtent(160);
case 2:
return const FixedTableSpanExtent(100);
case 3:
return const FixedTableSpanExtent(150);
case 4:
return const FixedTableSpanExtent(200);
case 5:
case 6:
return const FixedTableSpanExtent(160);
default:
return const FixedTableSpanExtent(90);
}
},
onRowTap: (_controller.isLoading || isSaving)
? null
: (index) {
if (records.isEmpty) {
return;
}
final int safeIndex;
if (index < 0) {
safeIndex = 0;
} else if (index >= records.length) {
safeIndex = records.length - 1;
} else {
safeIndex = index;
}
_openDetail(records[safeIndex]);
},
),
const SizedBox(height: 16),
Row(
@@ -471,11 +397,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
final input = await showDialog<ApprovalStepInput>(
context: context,
builder: (dialogContext) {
return _StepFormDialog(
title: '결재 단계 추가',
submitLabel: '저장',
isEditing: false,
);
return _StepFormDialog(title: '결재 단계 추가', submitLabel: '저장');
},
);
@@ -494,43 +416,6 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
);
}
Future<void> _openEditStepForm(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.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;
}
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.showSnackBar(
SnackBar(content: Text('결재번호 ${updated.approvalNo} 단계 정보를 수정했습니다.')),
);
}
Future<void> _openDetail(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
@@ -548,146 +433,44 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
final detail = await _controller.fetchDetail(stepId);
if (!mounted) return;
Navigator.of(context, rootNavigator: true).pop();
if (detail == null) return;
final step = detail.step;
await SuperportDialog.show<void>(
context: context,
dialog: SuperportDialog(
title: '결재 단계 상세',
description: '결재번호 ${detail.approvalNo}',
constraints: const BoxConstraints(maxWidth: 560),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 18,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_DetailRow(label: '단계 순서', value: '${step.stepOrder}'),
_DetailRow(label: '승인자', value: step.approver.name),
_DetailRow(label: '상태', value: step.status.name),
_DetailRow(label: '배정일시', value: _formatDate(step.assignedAt)),
_DetailRow(
label: '결정일시',
value: step.decidedAt == null
? '-'
: _formatDate(step.decidedAt!),
),
_DetailRow(label: '템플릿', value: detail.templateName ?? '-'),
_DetailRow(label: '트랜잭션번호', value: detail.transactionNo ?? '-'),
const SizedBox(height: 12),
Text(
'비고',
style: ShadTheme.of(
context,
).textTheme.small.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
ShadTextarea(
initialValue: step.note ?? '',
readOnly: true,
minHeight: 80,
maxHeight: 200,
),
],
),
),
if (detail == null) {
final error = _controller.errorMessage;
if (error != null) {
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.showSnackBar(SnackBar(content: Text(error)));
_controller.clearError();
}
return;
}
final permissionManager = PermissionScope.of(context);
final canEdit = permissionManager.can(
_stepResourcePath,
PermissionAction.edit,
);
}
Future<void> _confirmDeleteStep(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.showSnackBar(
const SnackBar(content: Text('저장되지 않은 단계는 삭제할 수 없습니다.')),
);
return;
}
final confirmed = await SuperportDialog.show<bool>(
context: context,
dialog: SuperportDialog(
title: '결재 단계 삭제',
description:
'결재번호 ${record.approvalNo}${record.step.stepOrder}단계를 삭제하시겠습니까? 삭제 후 복구할 수 있습니다.',
actions: [
ShadButton.ghost(
onPressed: () =>
Navigator.of(context, rootNavigator: true).pop(false),
child: const Text('취소'),
),
ShadButton.destructive(
onPressed: () =>
Navigator.of(context, rootNavigator: true).pop(true),
child: const Text('삭제'),
),
],
),
final canDelete = permissionManager.can(
_stepResourcePath,
PermissionAction.delete,
);
if (confirmed != true) {
return;
}
final success = await _controller.deleteStep(stepId);
if (!mounted) {
return;
}
if (success) {
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.showSnackBar(
SnackBar(content: Text('결재번호 ${record.approvalNo} 단계가 삭제되었습니다.')),
);
}
}
Future<void> _confirmRestoreStep(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.showSnackBar(
const SnackBar(content: Text('단계 식별자가 없어 복구할 수 없습니다.')),
);
return;
}
final confirmed = await SuperportDialog.show<bool>(
context: context,
dialog: SuperportDialog(
title: '결재 단계 복구',
description:
'결재번호 ${record.approvalNo}${record.step.stepOrder}단계를 복구하시겠습니까?',
actions: [
ShadButton.ghost(
onPressed: () =>
Navigator.of(context, rootNavigator: true).pop(false),
child: const Text('취소'),
),
ShadButton(
onPressed: () =>
Navigator.of(context, rootNavigator: true).pop(true),
child: const Text('복구'),
),
],
),
final canRestore = permissionManager.can(
_stepResourcePath,
PermissionAction.restore,
);
if (confirmed != true) {
final result = await showApprovalStepDetailDialog(
context: context,
record: detail,
dateFormat: _dateFormat,
onUpdate: (id, input) => _controller.updateStep(id, input),
onDelete: (id) => _controller.deleteStep(id),
onRestore: (id) => _controller.restoreStep(id),
canEdit: canEdit,
canDelete: canDelete,
canRestore: canRestore,
);
if (!mounted || result == null) {
return;
}
final restored = await _controller.restoreStep(stepId);
if (!mounted) {
return;
}
if (restored != null) {
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.showSnackBar(
SnackBar(content: Text('결재번호 ${restored.approvalNo} 단계가 복구되었습니다.')),
);
}
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.showSnackBar(SnackBar(content: Text(result.message)));
}
String _formatDate(DateTime date) {
@@ -725,48 +508,11 @@ class _ApproverOption {
int get hashCode => id.hashCode;
}
class _DetailRow extends StatelessWidget {
const _DetailRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Expanded(child: Text(value, style: theme.textTheme.small)),
],
),
);
}
}
class _StepFormDialog extends StatefulWidget {
const _StepFormDialog({
required this.title,
required this.submitLabel,
required this.isEditing,
this.initialRecord,
});
const _StepFormDialog({required this.title, required this.submitLabel});
final String title;
final String submitLabel;
final bool isEditing;
final ApprovalStepRecord? initialRecord;
@override
State<_StepFormDialog> createState() => _StepFormDialogState();
@@ -774,7 +520,6 @@ class _StepFormDialog extends StatefulWidget {
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;
@@ -783,28 +528,15 @@ class _StepFormDialogState extends State<_StepFormDialog> {
@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 ?? '');
_approvalIdController = TextEditingController();
_stepOrderController = TextEditingController();
_approverIdController = TextEditingController();
_noteController = TextEditingController();
}
@override
void dispose() {
_approvalIdController.dispose();
_approvalNoController.dispose();
_stepOrderController.dispose();
_approverIdController.dispose();
_noteController.dispose();
@@ -834,34 +566,16 @@ class _StepFormDialogState extends State<_StepFormDialog> {
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,
),
_FormFieldBlock(
label: '결재 ID',
errorText: _errors['approvalId'],
child: ShadInput(
key: const ValueKey('step_form_approval_id'),
controller: _approvalIdController,
onChanged: (_) => _clearError('approvalId'),
),
const SizedBox(height: 16),
_FormFieldBlock(
label: '결재번호',
child: ShadInput(
controller: _approvalNoController,
readOnly: true,
),
),
],
if (!widget.isEditing) const SizedBox(height: 16),
),
const SizedBox(height: 16),
_FormFieldBlock(
label: '단계 순서',
errorText: _errors['stepOrder'],
@@ -910,14 +624,9 @@ class _StepFormDialogState extends State<_StepFormDialog> {
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 approvalId = int.tryParse(_approvalIdController.text.trim());
if (approvalId == null || approvalId <= 0) {
nextErrors['approvalId'] = '결재 ID를 1 이상의 숫자로 입력하세요.';
}
final stepOrder = int.tryParse(_stepOrderController.text.trim());
@@ -941,7 +650,6 @@ class _StepFormDialogState extends State<_StepFormDialog> {
stepOrder: stepOrder!,
approverId: approverId!,
note: note.isEmpty ? null : note,
statusId: widget.initialRecord?.step.status.id,
);
Navigator.of(context, rootNavigator: true).pop(input);