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

@@ -205,7 +205,9 @@ class ApprovalHistoryController extends ChangeNotifier {
_selectedFlow = flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
_errorMessage = failure.statusCode == 403
? failure.describe()
: '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.';
if (failure.statusCode == 403) {
_isSelectionForbidden = true;
_selectedFlow = null;
@@ -238,7 +240,9 @@ class ApprovalHistoryController extends ChangeNotifier {
return flow;
} catch (error) {
final failure = Failure.from(error);
_errorMessage = failure.describe();
_errorMessage = failure.statusCode == 403
? failure.describe()
: '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.';
notifyListeners();
return null;
}

View File

@@ -0,0 +1,770 @@
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/feedback.dart';
import '../../../../../widgets/components/superport_detail_dialog.dart';
import '../../../../../widgets/components/superport_dialog.dart';
import '../../../../../widgets/components/superport_table.dart';
import '../../../../auth/domain/entities/authenticated_user.dart';
import '../../../domain/entities/approval.dart';
import '../../domain/entities/approval_history_record.dart';
import '../controllers/approval_history_controller.dart';
import '../widgets/approval_audit_log_table.dart';
import '../widgets/approval_flow_timeline.dart';
import '../../../shared/widgets/approver_autocomplete_field.dart';
import '../../../shared/widgets/approval_ui_helpers.dart';
import '../../../domain/entities/approval_flow.dart';
/// 결재 이력 상세 다이얼로그를 표시한다.
Future<void> showApprovalHistoryDetailDialog({
required BuildContext context,
required ApprovalHistoryController controller,
required ApprovalHistoryRecord record,
required intl.DateFormat dateFormat,
required AuthenticatedUser? currentUser,
}) {
return showSuperportDialog<void>(
context: context,
title: '결재 이력 상세',
description: '결재번호 ${record.approvalNo}',
body: _ApprovalHistoryDetailDialogBody(
controller: controller,
record: record,
dateFormat: dateFormat,
currentUser: currentUser,
),
constraints: const BoxConstraints(maxWidth: 920),
barrierDismissible: true,
scrollable: true,
);
}
class _ApprovalHistoryDetailDialogBody extends StatefulWidget {
const _ApprovalHistoryDetailDialogBody({
required this.controller,
required this.record,
required this.dateFormat,
required this.currentUser,
});
final ApprovalHistoryController controller;
final ApprovalHistoryRecord record;
final intl.DateFormat dateFormat;
final AuthenticatedUser? currentUser;
@override
State<_ApprovalHistoryDetailDialogBody> createState() =>
_ApprovalHistoryDetailDialogBodyState();
}
class _ApprovalHistoryDetailDialogBodyState
extends State<_ApprovalHistoryDetailDialogBody> {
late ApprovalHistoryRecord _record;
final TextEditingController _auditActorIdController = TextEditingController();
final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
static const _auditActionAll = '__all__';
ApprovalHistoryController get _controller => widget.controller;
@override
void initState() {
super.initState();
_record = widget.record;
WidgetsBinding.instance.addPostFrameCallback((_) {
_initialize();
});
}
@override
void didUpdateWidget(covariant _ApprovalHistoryDetailDialogBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.record.id != widget.record.id) {
_record = widget.record;
_initialize();
}
}
Future<void> _initialize() async {
try {
_controller.updateActiveTab(ApprovalHistoryTab.flow);
await _controller.loadApprovalFlow(_record.approvalId, force: true);
if (!_controller.isSelectionForbidden) {
await _controller.fetchAuditLogs(approvalId: _record.approvalId);
}
} catch (_) {
// 오류 메시지는 컨트롤러 리스너에서 처리된다.
}
}
@override
void dispose() {
_auditActorIdController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final flow = _controller.selectedFlow;
final auditResult = _controller.auditResult;
final auditLogs = auditResult?.items ?? const <ApprovalHistory>[];
final pagination = auditResult == null
? null
: SuperportTablePagination(
currentPage: auditResult.page,
totalPages: auditResult.pageSize == 0
? 1
: (auditResult.total / auditResult.pageSize).ceil().clamp(
1,
9999,
),
totalItems: auditResult.total,
pageSize: auditResult.pageSize,
);
final metadata = _buildMetadata();
final badges = _buildBadges(flow);
final sections = <SuperportDetailDialogSection>[
SuperportDetailDialogSection(
id: 'timeline',
label: '상태 타임라인',
icon: lucide.LucideIcons.listTree,
builder: (_) => _buildTimelineSection(flow),
),
SuperportDetailDialogSection(
id: 'audit',
label: '감사 로그',
icon: lucide.LucideIcons.listChecks,
builder: (_) =>
_buildAuditSection(logs: auditLogs, pagination: pagination),
),
];
return SuperportDetailDialog(
sections: sections,
summary: _buildSummary(flow),
summaryBadges: badges,
metadata: metadata,
initialSectionId: 'timeline',
);
},
);
}
Widget _buildSummary(ApprovalFlow? flow) {
final theme = ShadTheme.of(context);
final requester = flow?.requester;
final currentStep = flow?.statusSummary.currentStepOrder;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ApprovalStatusBadge(
label: _record.toStatus.name,
colorHex: _record.toStatus.color,
),
const SizedBox(height: 12),
Text('결재번호 ${_record.approvalNo}', style: theme.textTheme.small),
const SizedBox(height: 4),
Text(
_record.stepOrder == null
? '단계 정보 없음 · ${_record.approver.name}'
: '${_record.stepOrder}단계 · ${_record.approver.name}',
style: theme.textTheme.small,
),
const SizedBox(height: 4),
Text(_statusLabel(_record), style: theme.textTheme.muted),
if (requester != null) ...[
const SizedBox(height: 8),
Text(
'상신자 ${requester.name} (${requester.employeeNo})',
style: theme.textTheme.small,
),
],
if (flow != null && currentStep != null) ...[
const SizedBox(height: 4),
Text(
'현재 진행 단계: $currentStep / ${flow.statusSummary.totalSteps}',
style: theme.textTheme.small,
),
],
const SizedBox(height: 16),
_buildActionButtons(flow),
],
);
}
List<Widget> _buildBadges(ApprovalFlow? flow) {
final badges = <Widget>[
ShadBadge.outline(child: Text(_record.action.name)),
ShadBadge(child: Text(_record.toStatus.name)),
];
final isForbidden = _controller.isSelectionForbidden;
if (isForbidden) {
badges.add(const ShadBadge.destructive(child: Text('열람 제한')));
} else if (flow?.approval.updatedAt != null) {
badges.add(
ShadBadge.outline(
child: Text(
'변경 ${_dateTimeFormat.format(flow!.approval.updatedAt!.toLocal())}',
),
),
);
}
return badges;
}
List<SuperportDetailMetadata> _buildMetadata() {
return [
SuperportDetailMetadata.text(
label: '결재 ID',
value: '${_record.approvalId}',
),
SuperportDetailMetadata.text(
label: '승인자 사번',
value: _record.approver.employeeNo,
),
SuperportDetailMetadata.text(
label: '행위 시간',
value: _dateTimeFormat.format(_record.actionAt.toLocal()),
),
SuperportDetailMetadata(
label: '메모',
value: ApprovalNoteTooltip(note: _record.note),
),
];
}
Widget _buildTimelineSection(ApprovalFlow? flow) {
final theme = ShadTheme.of(context);
if (_controller.isLoadingFlow) {
return const Center(child: CircularProgressIndicator());
}
if (_controller.isSelectionForbidden) {
return _buildForbiddenNotice(theme);
}
if (flow == null) {
return _buildPlaceholder(theme, '결재 정보를 불러오는 중입니다.');
}
return ApprovalFlowTimeline(flow: flow, dateFormat: _dateTimeFormat);
}
Widget _buildAuditSection({
required List<ApprovalHistory> logs,
required SuperportTablePagination? pagination,
}) {
final theme = ShadTheme.of(context);
if (_controller.isSelectionForbidden) {
return _buildForbiddenNotice(theme);
}
if (_controller.isLoadingAudit) {
return const Center(child: CircularProgressIndicator());
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildAuditFilters(theme),
const SizedBox(height: 16),
ApprovalAuditLogTable(
logs: logs,
dateFormat: _dateTimeFormat,
pagination: pagination,
onPageChange: (page) {
_controller.fetchAuditLogs(
approvalId: _record.approvalId,
page: page,
);
},
onPageSizeChange: (value) {
_controller.updateAuditPageSize(value);
_controller.fetchAuditLogs(approvalId: _record.approvalId, page: 1);
},
isLoading: _controller.isLoadingAudit,
),
],
);
}
Widget _buildAuditFilters(ShadThemeData theme) {
final actorId = _controller.auditActorId;
final actorText = actorId?.toString() ?? '';
if (_auditActorIdController.text.trim() != actorText) {
_auditActorIdController.value = TextEditingValue(text: actorText);
}
final actionOptions = _controller.auditActions;
final currentAction = _controller.auditActionCode ?? _auditActionAll;
final isLoading = _controller.isLoadingAudit;
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: 240,
child: ApprovalApproverAutocompleteField(
key: ValueKey(actorId ?? 'all'),
idController: _auditActorIdController,
hintText: '행위자 검색',
onSelected: (candidate) {
final selected =
candidate?.id ??
int.tryParse(_auditActorIdController.text.trim());
_controller.updateAuditActor(selected);
_controller.fetchAuditLogs(
approvalId: _record.approvalId,
page: 1,
);
},
),
),
SizedBox(
width: 200,
child: ShadSelect<String>(
key: ValueKey(currentAction),
initialValue: currentAction,
selectedOptionBuilder: (context, value) =>
Text(_auditActionLabel(value, actionOptions)),
onChanged: isLoading
? null
: (value) {
if (value == null || value == _auditActionAll) {
_controller.updateAuditAction(null);
} else {
_controller.updateAuditAction(value);
}
_controller.fetchAuditLogs(
approvalId: _record.approvalId,
page: 1,
);
},
options: [
const ShadOption(value: _auditActionAll, child: Text('전체 행위')),
...actionOptions
.where(
(action) => action.code != null && action.code!.isNotEmpty,
)
.map(
(action) => ShadOption(
value: action.code!,
child: Text(action.name),
),
),
],
),
),
if (_controller.hasActiveAuditFilters)
ShadButton.ghost(
onPressed: isLoading ? null : _clearAuditFilters,
child: const Text('필터 초기화'),
),
],
);
}
Widget _buildActionButtons(ApprovalFlow? flow) {
final theme = ShadTheme.of(context);
final canRecall = flow != null && _canRecall(flow);
final canResubmit = flow != null && _canResubmit(flow);
final recallReason = flow == null
? '결재 정보를 불러오는 중입니다.'
: _recallDisabledReason(flow);
final resubmitReason = flow == null
? '결재 정보를 불러오는 중입니다.'
: _resubmitDisabledReason(flow);
final recallNotice = _buildRecallConditionNotice(
theme: theme,
flow: flow,
canRecall: canRecall,
reason: recallReason,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ShadButton(
onPressed:
flow == null || !canRecall || _controller.isPerformingAction
? null
: () => _handleRecall(flow),
child: const Text('회수'),
),
ShadButton.outline(
onPressed:
flow == null || !canResubmit || _controller.isPerformingAction
? null
: () => _handleResubmit(flow),
child: const Text('재상신'),
),
],
),
if (_controller.isPerformingAction) ...[
const SizedBox(height: 8),
Text(
'결재 작업을 처리하는 중입니다...',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
] else ...[
if (recallNotice != null) ...[
const SizedBox(height: 8),
recallNotice,
],
if (!canResubmit && resubmitReason != null) ...[
SizedBox(height: recallNotice == null ? 8 : 4),
Text(
'재상신 불가: $resubmitReason',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
],
],
);
}
Widget? _buildRecallConditionNotice({
required ShadThemeData theme,
required ApprovalFlow? flow,
required bool canRecall,
required String? reason,
}) {
if (flow == null) {
return Row(
children: [
Icon(
lucide.LucideIcons.info,
size: 16,
color: theme.colorScheme.mutedForeground,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'회수 조건을 확인하는 중입니다.',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
),
],
);
}
final icon = canRecall
? lucide.LucideIcons.badgeCheck
: lucide.LucideIcons.shieldAlert;
final color = canRecall
? theme.colorScheme.primary
: theme.colorScheme.destructive;
final message = canRecall
? '첫 승인자가 아직 결정을 내리지 않아 회수할 수 있습니다.'
: (reason ?? '회수 조건을 확인할 수 없습니다.');
return Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: theme.textTheme.small.copyWith(color: color),
),
),
],
);
}
Widget _buildForbiddenNotice(ShadThemeData theme) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'열람 권한이 없습니다',
style: theme.textTheme.p.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.destructive,
),
),
const SizedBox(height: 8),
Text(
'상신자 또는 기결재자만 감사 로그와 상세 내역을 확인할 수 있습니다.',
style: theme.textTheme.small,
),
const SizedBox(height: 8),
Text(
'필요 시 담당자에게 접근 권한을 요청하거나 다른 결재를 선택하세요.',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
),
);
}
Widget _buildPlaceholder(ShadThemeData theme, String message) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
decoration: BoxDecoration(
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.border),
),
child: Text(message, style: theme.textTheme.muted),
);
}
void _clearAuditFilters() {
_auditActorIdController.clear();
_controller.clearAuditFilters();
_controller.fetchAuditLogs(approvalId: _record.approvalId, page: 1);
}
String _statusLabel(ApprovalHistoryRecord record) {
final from = record.fromStatus?.name;
if (from == null || from.isEmpty) {
return record.toStatus.name;
}
return '$from${record.toStatus.name}';
}
String _auditActionLabel(String value, List<ApprovalAction> actions) {
if (value == _auditActionAll) {
return '전체 행위';
}
for (final action in actions) {
if (action.code == value) {
return action.name;
}
}
return '전체 행위';
}
bool _canRecall(ApprovalFlow flow) {
if (flow.status.isTerminal) {
return false;
}
if (flow.steps.isEmpty) {
return false;
}
final first = flow.steps.first;
return first.decidedAt == null;
}
bool _canResubmit(ApprovalFlow flow) {
if (!flow.status.isTerminal) {
return false;
}
final statusName = flow.status.name.toLowerCase();
return statusName.contains('반려') || statusName.contains('reject');
}
String? _recallDisabledReason(ApprovalFlow flow) {
if (flow.status.isTerminal) {
return '결재가 종료되었습니다.';
}
if (flow.steps.isEmpty) {
return '결재 단계 정보가 없습니다.';
}
if (flow.steps.first.decidedAt != null) {
return '첫 승인자가 이미 결정을 내려 회수할 수 없습니다.';
}
return null;
}
String? _resubmitDisabledReason(ApprovalFlow flow) {
if (!flow.status.isTerminal) {
return '결재가 아직 진행 중입니다.';
}
final statusName = flow.status.name.toLowerCase();
if (!(statusName.contains('반려') || statusName.contains('reject'))) {
return '반려 상태에서만 재상신할 수 있습니다.';
}
return null;
}
Future<void> _handleRecall(ApprovalFlow flow) async {
final user = widget.currentUser;
if (user == null) {
SuperportToast.error(context, '현재 사용자 정보를 확인할 수 없습니다.');
return;
}
final approvalId = flow.id;
if (approvalId == null) {
SuperportToast.error(context, '결재 식별자를 확인할 수 없습니다.');
return;
}
final note = await _promptActionNote(
title: '결재 회수',
confirmLabel: '회수',
description: '회수 사유를 입력하세요. 입력하지 않아도 회수를 진행할 수 있습니다.',
);
final refreshed = await _controller.refreshFlow(approvalId);
if (!mounted) {
return;
}
if (refreshed == null) {
SuperportToast.error(context, '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.');
return;
}
final latestFlow = refreshed;
final sanitizedNote = note?.isEmpty == true ? null : note;
final transactionUpdatedAt = latestFlow.transactionUpdatedAt;
if (transactionUpdatedAt == null) {
SuperportToast.error(
context,
'연동 전표 변경 시각을 확인할 수 없습니다. 화면을 새로고침한 뒤 다시 시도하세요.',
);
return;
}
final input = ApprovalRecallInput(
approvalId: approvalId,
actorId: user.id,
note: sanitizedNote,
expectedUpdatedAt: latestFlow.approval.updatedAt,
transactionExpectedUpdatedAt: transactionUpdatedAt,
);
final result = await _controller.recallApproval(input);
if (!mounted) {
return;
}
if (result != null) {
SuperportToast.success(
context,
'결재(${latestFlow.approvalNo}) 회수를 완료했습니다.',
);
await _controller.loadApprovalFlow(approvalId, force: true);
await _controller.fetchAuditLogs(approvalId: approvalId);
}
}
Future<void> _handleResubmit(ApprovalFlow flow) async {
final user = widget.currentUser;
if (user == null) {
SuperportToast.error(context, '현재 사용자 정보를 확인할 수 없습니다.');
return;
}
final approvalId = flow.id;
if (approvalId == null) {
SuperportToast.error(context, '결재 식별자를 확인할 수 없습니다.');
return;
}
final note = await _promptActionNote(
title: '결재 재상신',
confirmLabel: '재상신',
description: '재상신 시 전달할 메시지를 입력하세요. 입력하지 않아도 재상신됩니다.',
);
final refreshed = await _controller.refreshFlow(approvalId);
if (!mounted) {
return;
}
if (refreshed == null) {
SuperportToast.error(context, '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.');
return;
}
final latestFlow = refreshed;
final sanitizedNote = note?.isEmpty == true ? null : note;
final transactionUpdatedAt = latestFlow.transactionUpdatedAt;
if (transactionUpdatedAt == null) {
SuperportToast.error(
context,
'연동 전표 변경 시각을 확인할 수 없습니다. 화면을 새로고침한 뒤 다시 시도하세요.',
);
return;
}
final steps = latestFlow.steps
.map(
(step) => ApprovalStepAssignmentItem(
stepOrder: step.stepOrder,
approverId: step.approver.id,
note: step.note,
),
)
.toList(growable: false);
final submission = ApprovalSubmissionInput(
transactionId: latestFlow.transactionId,
statusId: latestFlow.status.id,
requesterId: latestFlow.requester.id,
finalApproverId: latestFlow.finalApprover?.id,
note: latestFlow.note,
steps: steps,
);
final input = ApprovalResubmissionInput(
approvalId: approvalId,
actorId: user.id,
submission: submission,
note: sanitizedNote,
expectedUpdatedAt: latestFlow.approval.updatedAt,
transactionExpectedUpdatedAt: transactionUpdatedAt,
);
final result = await _controller.resubmitApproval(input);
if (!mounted) {
return;
}
if (result != null) {
SuperportToast.success(
context,
'결재(${latestFlow.approvalNo}) 재상신을 완료했습니다.',
);
await _controller.loadApprovalFlow(approvalId, force: true);
await _controller.fetchAuditLogs(approvalId: approvalId);
}
}
Future<String?> _promptActionNote({
required String title,
required String confirmLabel,
required String description,
}) async {
final theme = ShadTheme.of(context);
final controller = TextEditingController();
String? result;
await showSuperportDialog<void>(
context: context,
title: title,
description: description,
constraints: const BoxConstraints(maxWidth: 420),
actions: [
ShadButton.ghost(
onPressed: () =>
Navigator.of(context, rootNavigator: true).maybePop(),
child: const Text('취소'),
),
ShadButton(
onPressed: () {
result = controller.text.trim();
Navigator.of(context, rootNavigator: true).maybePop();
},
child: Text(confirmLabel),
),
],
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('사유는 선택 입력입니다. 비워두면 전달되지 않습니다.', style: theme.textTheme.muted),
const SizedBox(height: 12),
ShadTextarea(controller: controller, minHeight: 120, maxHeight: 200),
],
),
);
controller.dispose();
return result;
}
}