feat(dialog): 상세 팝업 SuperportDetailDialog 통합
- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화 - 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환 - SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거 - 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지 - detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개요 섹션에서 사용하는 단순 레코드 모델이다.
|
||||
Reference in New Issue
Block a user