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