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

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