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,
),
),
],
],
);
}
}
/// 개요 섹션에서 사용하는 단순 레코드 모델이다.