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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개요 섹션에서 사용하는 단순 레코드 모델이다.
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
import 'dart:async';
|
||||
|
||||
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';
|
||||
|
||||
import '../../../../../widgets/components/superport_detail_dialog.dart';
|
||||
import '../../../domain/entities/approval_template.dart';
|
||||
import '../../../../auth/application/auth_service.dart';
|
||||
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||
|
||||
/// 결재 템플릿 상세 다이얼로그에서 발생 가능한 액션 종류이다.
|
||||
enum ApprovalTemplateDetailAction { created, updated, deleted, restored }
|
||||
|
||||
/// 결재 템플릿 상세 다이얼로그 결과 모델이다.
|
||||
class ApprovalTemplateDetailResult {
|
||||
const ApprovalTemplateDetailResult({
|
||||
required this.action,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final ApprovalTemplateDetailAction action;
|
||||
final String message;
|
||||
}
|
||||
|
||||
typedef ApprovalTemplateCreateCallback =
|
||||
Future<ApprovalTemplate?> Function(
|
||||
ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput> steps,
|
||||
);
|
||||
|
||||
typedef ApprovalTemplateUpdateCallback =
|
||||
Future<ApprovalTemplate?> Function(
|
||||
int id,
|
||||
ApprovalTemplateInput input,
|
||||
List<ApprovalTemplateStepInput> steps,
|
||||
);
|
||||
|
||||
typedef ApprovalTemplateDeleteCallback = Future<bool> Function(int id);
|
||||
typedef ApprovalTemplateRestoreCallback =
|
||||
Future<ApprovalTemplate?> Function(int id);
|
||||
|
||||
/// 결재 템플릿 상세 다이얼로그를 표시한다.
|
||||
Future<ApprovalTemplateDetailResult?> showApprovalTemplateDetailDialog({
|
||||
required BuildContext context,
|
||||
required intl.DateFormat dateFormat,
|
||||
ApprovalTemplate? template,
|
||||
required ApprovalTemplateCreateCallback onCreate,
|
||||
required ApprovalTemplateUpdateCallback onUpdate,
|
||||
required ApprovalTemplateDeleteCallback onDelete,
|
||||
required ApprovalTemplateRestoreCallback onRestore,
|
||||
}) {
|
||||
final isCreate = template == null;
|
||||
final summaryBadges = <Widget>[
|
||||
if (template?.isActive == true)
|
||||
const ShadBadge(child: Text('사용'))
|
||||
else if (template != null)
|
||||
const ShadBadge.outline(child: Text('미사용')),
|
||||
];
|
||||
final metadata = template == null
|
||||
? const <SuperportDetailMetadata>[]
|
||||
: [
|
||||
SuperportDetailMetadata.text(label: 'ID', value: '${template.id}'),
|
||||
SuperportDetailMetadata.text(label: '코드', value: template.code),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '상태',
|
||||
value: template.isActive ? '사용' : '미사용',
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '생성일시',
|
||||
value: template.createdAt == null
|
||||
? '-'
|
||||
: dateFormat.format(template.createdAt!.toLocal()),
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '변경일시',
|
||||
value: template.updatedAt == null
|
||||
? '-'
|
||||
: dateFormat.format(template.updatedAt!.toLocal()),
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '비고',
|
||||
value: template.note?.isNotEmpty == true ? template.note! : '-',
|
||||
),
|
||||
];
|
||||
|
||||
final sections = <SuperportDetailDialogSection>[
|
||||
if (!isCreate)
|
||||
SuperportDetailDialogSection(
|
||||
id: _TemplateSections.steps,
|
||||
label: '단계',
|
||||
icon: lucide.LucideIcons.listTree,
|
||||
builder: (_) => _TemplateStepsSection(template: template),
|
||||
),
|
||||
SuperportDetailDialogSection(
|
||||
id: isCreate ? _TemplateSections.create : _TemplateSections.edit,
|
||||
label: isCreate ? '생성' : '수정',
|
||||
icon: lucide.LucideIcons.pencil,
|
||||
builder: (_) => _TemplateFormSection(
|
||||
template: template,
|
||||
onCreate: onCreate,
|
||||
onUpdate: onUpdate,
|
||||
),
|
||||
),
|
||||
if (!isCreate)
|
||||
SuperportDetailDialogSection(
|
||||
id: template.isActive
|
||||
? _TemplateSections.delete
|
||||
: _TemplateSections.restore,
|
||||
label: template.isActive ? '삭제' : '복구',
|
||||
icon: template.isActive
|
||||
? lucide.LucideIcons.trash2
|
||||
: lucide.LucideIcons.history,
|
||||
scrollable: false,
|
||||
builder: (_) => _TemplateDangerSection(
|
||||
template: template,
|
||||
onDelete: onDelete,
|
||||
onRestore: onRestore,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return showSuperportDetailDialog<ApprovalTemplateDetailResult>(
|
||||
context: context,
|
||||
title: isCreate ? '결재 템플릿 생성' : '결재 템플릿 상세',
|
||||
description: isCreate
|
||||
? '반복되는 결재 단계를 템플릿으로 등록합니다.'
|
||||
: '템플릿 정보를 확인하고 수정하거나 삭제/복구할 수 있습니다.',
|
||||
summary: template == null
|
||||
? null
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(template.name, style: ShadTheme.of(context).textTheme.h4),
|
||||
if (template.description?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
template.description!,
|
||||
style: ShadTheme.of(context).textTheme.muted,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
summaryBadges: summaryBadges,
|
||||
metadata: metadata,
|
||||
sections: sections,
|
||||
initialSectionId: isCreate
|
||||
? _TemplateSections.create
|
||||
: _TemplateSections.steps,
|
||||
);
|
||||
}
|
||||
|
||||
/// 다이얼로그 섹션 식별자 상수 모음이다.
|
||||
class _TemplateSections {
|
||||
static const steps = 'steps';
|
||||
static const edit = 'edit';
|
||||
static const create = 'create';
|
||||
static const delete = 'delete';
|
||||
static const restore = 'restore';
|
||||
}
|
||||
|
||||
/// 템플릿 단계 목록을 표시하는 섹션이다.
|
||||
class _TemplateStepsSection extends StatelessWidget {
|
||||
const _TemplateStepsSection({required this.template});
|
||||
|
||||
final ApprovalTemplate template;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
if (template.steps.isEmpty) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondary.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Center(
|
||||
child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final step in template.steps) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: theme.colorScheme.secondary.withValues(alpha: 0.12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${step.stepOrder}',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
step.approver.name,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'사번 ${step.approver.employeeNo}',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
if (step.note?.isNotEmpty ?? false)
|
||||
Text(step.note!, style: theme.textTheme.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 템플릿 등록/수정 폼 섹션이다.
|
||||
class _TemplateFormSection extends StatefulWidget {
|
||||
const _TemplateFormSection({
|
||||
required this.template,
|
||||
required this.onCreate,
|
||||
required this.onUpdate,
|
||||
});
|
||||
|
||||
final ApprovalTemplate? template;
|
||||
final ApprovalTemplateCreateCallback onCreate;
|
||||
final ApprovalTemplateUpdateCallback onUpdate;
|
||||
|
||||
@override
|
||||
State<_TemplateFormSection> createState() => _TemplateFormSectionState();
|
||||
}
|
||||
|
||||
class _TemplateFormSectionState extends State<_TemplateFormSection> {
|
||||
late final TextEditingController _codeController;
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _descriptionController;
|
||||
late final TextEditingController _noteController;
|
||||
late final ValueNotifier<bool> _isActiveNotifier;
|
||||
late final List<_TemplateStepField> _steps;
|
||||
bool _isSubmitting = false;
|
||||
String? _errorText;
|
||||
|
||||
bool get _isEdit => widget.template != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final template = widget.template;
|
||||
_codeController = TextEditingController(
|
||||
text: template?.code ?? _generateTemplateCode(),
|
||||
);
|
||||
_nameController = TextEditingController(text: template?.name ?? '');
|
||||
_descriptionController = TextEditingController(
|
||||
text: template?.description ?? '',
|
||||
);
|
||||
_noteController = TextEditingController(text: template?.note ?? '');
|
||||
_isActiveNotifier = ValueNotifier<bool>(template?.isActive ?? true);
|
||||
_steps = _buildInitialStepFields(template);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_codeController.dispose();
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_noteController.dispose();
|
||||
_isActiveNotifier.dispose();
|
||||
for (final step in _steps) {
|
||||
step.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!_isEdit)
|
||||
_FormField(
|
||||
label: '템플릿 코드',
|
||||
child: ShadInput(
|
||||
controller: _codeController,
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '템플릿명',
|
||||
required: true,
|
||||
child: ShadInput(
|
||||
key: const ValueKey('template_form_name'),
|
||||
controller: _nameController,
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '설명',
|
||||
child: ShadTextarea(
|
||||
key: const ValueKey('template_form_description'),
|
||||
controller: _descriptionController,
|
||||
minHeight: 80,
|
||||
maxHeight: 200,
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '사용 여부',
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _isActiveNotifier,
|
||||
builder: (_, value, __) {
|
||||
return Row(
|
||||
children: [
|
||||
ShadSwitch(
|
||||
value: value,
|
||||
onChanged: _isSubmitting
|
||||
? null
|
||||
: (next) => _isActiveNotifier.value = next,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(value ? '사용' : '미사용'),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '비고',
|
||||
child: ShadTextarea(
|
||||
key: const ValueKey('template_form_note'),
|
||||
controller: _noteController,
|
||||
minHeight: 80,
|
||||
maxHeight: 200,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'결재 단계',
|
||||
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Column(
|
||||
children: [
|
||||
for (var index = 0; index < _steps.length; index++)
|
||||
_StepEditorRow(
|
||||
key: ValueKey('template_step_$index'),
|
||||
field: _steps[index],
|
||||
index: index,
|
||||
isEdit: _isEdit,
|
||||
isDisabled: _isSubmitting,
|
||||
onRemove: _steps.length <= 1 || _isSubmitting
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
final removed = _steps.removeAt(index);
|
||||
removed.dispose();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ShadButton.outline(
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_steps.add(
|
||||
_TemplateStepField.create(order: _steps.length + 1),
|
||||
);
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(lucide.LucideIcons.plus, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('단계 추가'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_errorText != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorText!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ShadButton(
|
||||
onPressed: _isSubmitting ? null : _handleSubmit,
|
||||
child: Text(_isSubmitting ? '저장 중...' : (_isEdit ? '저장' : '등록')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<_TemplateStepField> _buildInitialStepFields(ApprovalTemplate? template) {
|
||||
if (template == null || template.steps.isEmpty) {
|
||||
return [_TemplateStepField.create(order: 1)];
|
||||
}
|
||||
return template.steps
|
||||
.map(
|
||||
(step) => _TemplateStepField(
|
||||
id: step.id,
|
||||
orderController: TextEditingController(
|
||||
text: step.stepOrder.toString(),
|
||||
),
|
||||
approverController: TextEditingController(
|
||||
text: step.approver.id.toString(),
|
||||
),
|
||||
noteController: TextEditingController(text: step.note ?? ''),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String _generateTemplateCode() {
|
||||
final authService = GetIt.I<AuthService>();
|
||||
final session = authService.session;
|
||||
String normalizedEmployee = '';
|
||||
|
||||
final candidateValues = <String?>[
|
||||
session?.user.employeeNo,
|
||||
session?.user.email,
|
||||
session?.user.name,
|
||||
];
|
||||
for (final candidate in candidateValues) {
|
||||
if (candidate == null) {
|
||||
continue;
|
||||
}
|
||||
var source = candidate.trim();
|
||||
final atIndex = source.indexOf('@');
|
||||
if (atIndex > 0) {
|
||||
source = source.substring(0, atIndex);
|
||||
}
|
||||
final normalized = source.toUpperCase().replaceAll(
|
||||
RegExp(r'[^A-Z0-9]'),
|
||||
'',
|
||||
);
|
||||
if (normalized.isNotEmpty) {
|
||||
normalizedEmployee = normalized;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final userId = session?.user.id;
|
||||
if (normalizedEmployee.isEmpty && userId != null) {
|
||||
normalizedEmployee = userId.toString();
|
||||
}
|
||||
|
||||
final suffixSource = normalizedEmployee.isEmpty
|
||||
? '0000'
|
||||
: normalizedEmployee;
|
||||
final suffix = suffixSource.length >= 4
|
||||
? suffixSource.substring(suffixSource.length - 4)
|
||||
: suffixSource.padLeft(4, '0');
|
||||
final timestamp = intl.DateFormat(
|
||||
'yyMMddHHmmssSSS',
|
||||
).format(DateTime.now().toUtc());
|
||||
return 'AP_TEMP_${suffix}_$timestamp';
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
final isEdit = _isEdit;
|
||||
final nameValue = _nameController.text.trim();
|
||||
if (nameValue.isEmpty) {
|
||||
setState(() => _errorText = '템플릿명을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
final validation = _validateSteps(_steps);
|
||||
if (validation != null) {
|
||||
setState(() => _errorText = validation);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_errorText = null;
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
final steps = _steps
|
||||
.map(
|
||||
(field) => ApprovalTemplateStepInput(
|
||||
id: field.id,
|
||||
stepOrder: int.parse(field.orderController.text.trim()),
|
||||
approverId: int.parse(field.approverController.text.trim()),
|
||||
note: field.noteController.text.trim().isEmpty
|
||||
? null
|
||||
: field.noteController.text.trim(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final existingTemplate = widget.template;
|
||||
final input = ApprovalTemplateInput(
|
||||
code: isEdit && existingTemplate != null
|
||||
? existingTemplate.code
|
||||
: _codeController.text.trim(),
|
||||
name: nameValue,
|
||||
description: _descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: _descriptionController.text.trim(),
|
||||
note: _noteController.text.trim().isEmpty
|
||||
? null
|
||||
: _noteController.text.trim(),
|
||||
isActive: _isActiveNotifier.value,
|
||||
);
|
||||
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
ApprovalTemplate? result;
|
||||
|
||||
try {
|
||||
if (isEdit && existingTemplate != null) {
|
||||
result = await widget.onUpdate(existingTemplate.id, input, steps);
|
||||
if (result != null && navigator.mounted) {
|
||||
navigator.pop(
|
||||
ApprovalTemplateDetailResult(
|
||||
action: ApprovalTemplateDetailAction.updated,
|
||||
message: '템플릿 "${result.name}"을(를) 수정했습니다.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
result = await widget.onCreate(input, steps);
|
||||
if (result != null && navigator.mounted) {
|
||||
navigator.pop(
|
||||
ApprovalTemplateDetailResult(
|
||||
action: ApprovalTemplateDetailAction.created,
|
||||
message: '템플릿 "${result.name}"을(를) 생성했습니다.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_errorText = '요청 처리에 실패했습니다. 입력값을 확인한 뒤 다시 시도하세요.';
|
||||
_isSubmitting = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorText = '요청 처리 중 오류가 발생했습니다. 다시 시도하세요.';
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _validateSteps(List<_TemplateStepField> fields) {
|
||||
if (fields.isEmpty) {
|
||||
return '최소 1개 이상의 결재 단계를 입력하세요.';
|
||||
}
|
||||
final orders = <int>{};
|
||||
for (final field in fields) {
|
||||
final order = int.tryParse(field.orderController.text.trim());
|
||||
final approver = int.tryParse(field.approverController.text.trim());
|
||||
if (order == null || order <= 0) {
|
||||
return '모든 단계의 순서를 1 이상의 숫자로 입력하세요.';
|
||||
}
|
||||
if (approver == null || approver <= 0) {
|
||||
return '모든 단계의 승인자 ID를 1 이상의 숫자로 입력하세요.';
|
||||
}
|
||||
if (!orders.add(order)) {
|
||||
return '단계 순서는 중복될 수 없습니다.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 템플릿 삭제/복구 섹션이다.
|
||||
class _TemplateDangerSection extends StatelessWidget {
|
||||
const _TemplateDangerSection({
|
||||
required this.template,
|
||||
required this.onDelete,
|
||||
required this.onRestore,
|
||||
});
|
||||
|
||||
final ApprovalTemplate template;
|
||||
final ApprovalTemplateDeleteCallback onDelete;
|
||||
final ApprovalTemplateRestoreCallback onRestore;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final isActive = template.isActive;
|
||||
|
||||
Future<void> handleAction(
|
||||
Future<ApprovalTemplate?> Function() callback,
|
||||
ApprovalTemplateDetailAction action,
|
||||
String message,
|
||||
) async {
|
||||
final result = await callback();
|
||||
if (result != null && navigator.mounted) {
|
||||
navigator.pop(
|
||||
ApprovalTemplateDetailResult(action: action, message: message),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isActive
|
||||
? '삭제하면 템플릿은 미사용 상태로 전환됩니다. 필요 시 복구할 수 있습니다.'
|
||||
: '복구하면 템플릿이 다시 사용 상태로 전환됩니다.',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (isActive)
|
||||
ShadButton.destructive(
|
||||
onPressed: () => handleAction(
|
||||
() async {
|
||||
final success = await onDelete(template.id);
|
||||
return success ? template.copyWith(isActive: false) : null;
|
||||
},
|
||||
ApprovalTemplateDetailAction.deleted,
|
||||
'템플릿 "${template.name}"을(를) 삭제했습니다.',
|
||||
),
|
||||
child: const Text('삭제'),
|
||||
)
|
||||
else
|
||||
ShadButton(
|
||||
onPressed: () => handleAction(
|
||||
() async => onRestore(template.id),
|
||||
ApprovalTemplateDetailAction.restored,
|
||||
'템플릿 "${template.name}"을(를) 복구했습니다.',
|
||||
),
|
||||
child: const Text('복구'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 폼 필드 레이아웃 위젯이다.
|
||||
class _FormField extends StatelessWidget {
|
||||
const _FormField({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.required = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool required;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (required) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text('*', style: theme.textTheme.small),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 템플릿 단계 편집 행을 렌더링한다.
|
||||
class _StepEditorRow extends StatelessWidget {
|
||||
const _StepEditorRow({
|
||||
super.key,
|
||||
required this.field,
|
||||
required this.index,
|
||||
required this.isEdit,
|
||||
required this.isDisabled,
|
||||
this.onRemove,
|
||||
});
|
||||
|
||||
final _TemplateStepField field;
|
||||
final int index;
|
||||
final bool isEdit;
|
||||
final bool isDisabled;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.border.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
key: ValueKey('template_step_${index}_order'),
|
||||
controller: field.orderController,
|
||||
keyboardType: TextInputType.number,
|
||||
placeholder: const Text('단계 순서'),
|
||||
enabled: !isDisabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
ignoring: isDisabled,
|
||||
child: ApprovalApproverAutocompleteField(
|
||||
key: ValueKey('template_step_${index}_approver'),
|
||||
idController: field.approverController,
|
||||
hintText: '승인자 검색',
|
||||
onSelected: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (onRemove != null)
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onRemove,
|
||||
child: const Icon(lucide.LucideIcons.trash2, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
IgnorePointer(
|
||||
ignoring: isDisabled,
|
||||
child: ShadTextarea(
|
||||
controller: field.noteController,
|
||||
minHeight: 60,
|
||||
maxHeight: 160,
|
||||
placeholder: const Text('비고 (선택)'),
|
||||
),
|
||||
),
|
||||
if (isEdit && field.id != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text('단계 ID: ${field.id}', style: theme.textTheme.small),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 템플릿 단계 필드 상태를 보관한다.
|
||||
class _TemplateStepField {
|
||||
_TemplateStepField({
|
||||
this.id,
|
||||
required this.orderController,
|
||||
required this.approverController,
|
||||
required this.noteController,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final TextEditingController orderController;
|
||||
final TextEditingController approverController;
|
||||
final TextEditingController noteController;
|
||||
|
||||
factory _TemplateStepField.create({required int order}) {
|
||||
return _TemplateStepField(
|
||||
orderController: TextEditingController(text: '$order'),
|
||||
approverController: TextEditingController(),
|
||||
noteController: TextEditingController(),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
orderController.dispose();
|
||||
approverController.dispose();
|
||||
noteController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 단순 레이블/값 모델이다.
|
||||
@@ -9,15 +9,13 @@ import '../../../../../core/constants/app_sections.dart';
|
||||
import '../../../../../widgets/app_layout.dart';
|
||||
import '../../../../../widgets/components/filter_bar.dart';
|
||||
import '../../../../../widgets/components/superport_table.dart';
|
||||
import '../../../../../widgets/components/superport_dialog.dart';
|
||||
import '../../../../../widgets/components/feature_disabled_placeholder.dart';
|
||||
import '../../../shared/widgets/approver_autocomplete_field.dart';
|
||||
import '../../../domain/entities/approval_template.dart';
|
||||
import '../../../domain/repositories/approval_template_repository.dart';
|
||||
import '../../../domain/usecases/apply_approval_template_use_case.dart';
|
||||
import '../../../domain/usecases/save_approval_template_use_case.dart';
|
||||
import '../../../../auth/application/auth_service.dart';
|
||||
import '../controllers/approval_template_controller.dart';
|
||||
import '../dialogs/approval_template_detail_dialog.dart';
|
||||
|
||||
/// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다.
|
||||
class ApprovalTemplatePage extends StatelessWidget {
|
||||
@@ -138,9 +136,7 @@ class _ApprovalTemplateEnabledPageState
|
||||
actions: [
|
||||
ShadButton(
|
||||
leading: const Icon(lucide.LucideIcons.plus, size: 16),
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _openTemplateForm(),
|
||||
onPressed: _controller.isSubmitting ? null : _openCreateTemplate,
|
||||
child: const Text('템플릿 생성'),
|
||||
),
|
||||
],
|
||||
@@ -218,7 +214,6 @@ class _ApprovalTemplateEnabledPageState
|
||||
ShadTableCell.header(child: Text('설명')),
|
||||
ShadTableCell.header(child: Text('사용')),
|
||||
ShadTableCell.header(child: Text('변경일시')),
|
||||
ShadTableCell.header(child: Text('동작')),
|
||||
],
|
||||
rows: templates.map((template) {
|
||||
return [
|
||||
@@ -253,49 +248,6 @@ class _ApprovalTemplateEnabledPageState
|
||||
),
|
||||
),
|
||||
),
|
||||
ShadTableCell(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
ShadButton.ghost(
|
||||
key: ValueKey(
|
||||
'template_preview_${template.id}',
|
||||
),
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: () =>
|
||||
_openTemplatePreview(template.id),
|
||||
child: const Text('보기'),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
key: ValueKey(
|
||||
'template_edit_${template.id}',
|
||||
),
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _openEditTemplate(template),
|
||||
child: const Text('수정'),
|
||||
),
|
||||
template.isActive
|
||||
? ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _confirmDelete(template),
|
||||
child: const Text('삭제'),
|
||||
)
|
||||
: ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: _controller.isSubmitting
|
||||
? null
|
||||
: () => _confirmRestore(template),
|
||||
child: const Text('복구'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}).toList(),
|
||||
rowHeight: 58,
|
||||
@@ -316,8 +268,6 @@ class _ApprovalTemplateEnabledPageState
|
||||
return const FixedTableSpanExtent(100);
|
||||
case 6:
|
||||
return const FixedTableSpanExtent(180);
|
||||
case 7:
|
||||
return const FixedTableSpanExtent(220);
|
||||
default:
|
||||
return const FixedTableSpanExtent(140);
|
||||
}
|
||||
@@ -335,6 +285,22 @@ class _ApprovalTemplateEnabledPageState
|
||||
_controller.fetch(page: 1);
|
||||
},
|
||||
isLoading: _controller.isLoading,
|
||||
onRowTap: _controller.isSubmitting
|
||||
? null
|
||||
: (index) {
|
||||
if (templates.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final int safeIndex;
|
||||
if (index < 0) {
|
||||
safeIndex = 0;
|
||||
} else if (index >= templates.length) {
|
||||
safeIndex = templates.length - 1;
|
||||
} else {
|
||||
safeIndex = index;
|
||||
}
|
||||
_openTemplateDetail(templates[safeIndex]);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -356,144 +322,18 @@ class _ApprovalTemplateEnabledPageState
|
||||
_searchFocus.requestFocus();
|
||||
}
|
||||
|
||||
String _generateTemplateCode() {
|
||||
final authService = GetIt.I<AuthService>();
|
||||
final session = authService.session;
|
||||
String normalizedEmployee = '';
|
||||
|
||||
final candidateValues = <String?>[
|
||||
session?.user.employeeNo,
|
||||
session?.user.email,
|
||||
session?.user.name,
|
||||
];
|
||||
for (final candidate in candidateValues) {
|
||||
if (candidate == null) {
|
||||
continue;
|
||||
}
|
||||
var source = candidate.trim();
|
||||
final atIndex = source.indexOf('@');
|
||||
if (atIndex > 0) {
|
||||
source = source.substring(0, atIndex);
|
||||
}
|
||||
final normalized = source.toUpperCase().replaceAll(
|
||||
RegExp(r'[^A-Z0-9]'),
|
||||
'',
|
||||
);
|
||||
if (normalized.isNotEmpty) {
|
||||
normalizedEmployee = normalized;
|
||||
break;
|
||||
}
|
||||
String _statusLabel(ApprovalTemplateStatusFilter filter) {
|
||||
switch (filter) {
|
||||
case ApprovalTemplateStatusFilter.all:
|
||||
return '전체(사용/미사용)';
|
||||
case ApprovalTemplateStatusFilter.activeOnly:
|
||||
return '사용중';
|
||||
case ApprovalTemplateStatusFilter.inactiveOnly:
|
||||
return '미사용';
|
||||
}
|
||||
if (normalizedEmployee.isEmpty && session?.user.id != null) {
|
||||
normalizedEmployee = session!.user.id.toString();
|
||||
}
|
||||
|
||||
final suffixSource = normalizedEmployee.isEmpty
|
||||
? '0000'
|
||||
: normalizedEmployee;
|
||||
final suffix = suffixSource.length >= 4
|
||||
? suffixSource.substring(suffixSource.length - 4)
|
||||
: suffixSource.padLeft(4, '0');
|
||||
final timestamp = intl.DateFormat(
|
||||
'yyMMddHHmmssSSS',
|
||||
).format(DateTime.now().toUtc());
|
||||
return 'AP_TEMP_${suffix}_$timestamp';
|
||||
}
|
||||
|
||||
Future<void> _openTemplatePreview(int templateId) async {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
final detail = await _controller.fetchDetail(templateId);
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (detail == null) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
const SnackBar(content: Text('템플릿 정보를 불러오지 못했습니다. 다시 시도하세요.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final theme = ShadTheme.of(context);
|
||||
await SuperportDialog.show<void>(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: detail.name,
|
||||
description: detail.description,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 540),
|
||||
child: detail.steps.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final step in detail.steps) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: theme.colorScheme.secondary.withValues(
|
||||
alpha: 0.12,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${step.stepOrder}',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
step.approver.name,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'사번 ${step.approver.employeeNo}',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
if (step.note?.isNotEmpty ?? false)
|
||||
Text(
|
||||
step.note!,
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openEditTemplate(ApprovalTemplate template) async {
|
||||
Future<void> _openTemplateDetail(ApprovalTemplate template) async {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -503,366 +343,44 @@ class _ApprovalTemplateEnabledPageState
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
if (!mounted || detail == null) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final success = await _openTemplateForm(template: detail);
|
||||
if (!mounted || success != true) return;
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('템플릿 "${detail.name}"을(를) 수정했습니다.')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(ApprovalTemplate template) async {
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
if (detail == null) {
|
||||
_showSnack('템플릿 정보를 불러오지 못했습니다. 다시 시도하세요.');
|
||||
return;
|
||||
}
|
||||
final result = await showApprovalTemplateDetailDialog(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: '템플릿 삭제',
|
||||
description:
|
||||
'"${template.name}" 템플릿을 삭제하시겠습니까?\n삭제 시 템플릿은 미사용 상태로 전환됩니다.',
|
||||
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('삭제'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final ok = await _controller.delete(template.id);
|
||||
if (!mounted || !ok) return;
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('템플릿 "${template.name}"을(를) 삭제했습니다.')),
|
||||
dateFormat: _dateFormat,
|
||||
template: detail,
|
||||
onCreate: _controller.create,
|
||||
onUpdate: _controller.update,
|
||||
onDelete: _controller.delete,
|
||||
onRestore: _controller.restore,
|
||||
);
|
||||
if (result != null && mounted) {
|
||||
_showSnack(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmRestore(ApprovalTemplate template) async {
|
||||
final confirmed = await SuperportDialog.show<bool>(
|
||||
Future<void> _openCreateTemplate() async {
|
||||
final result = await showApprovalTemplateDetailDialog(
|
||||
context: context,
|
||||
dialog: SuperportDialog(
|
||||
title: '템플릿 복구',
|
||||
description: '"${template.name}" 템플릿을 복구하시겠습니까?',
|
||||
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('복구'),
|
||||
),
|
||||
],
|
||||
),
|
||||
dateFormat: _dateFormat,
|
||||
onCreate: _controller.create,
|
||||
onUpdate: _controller.update,
|
||||
onDelete: _controller.delete,
|
||||
onRestore: _controller.restore,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final restored = await _controller.restore(template.id);
|
||||
if (!mounted || restored == null) return;
|
||||
if (result != null && mounted) {
|
||||
_showSnack(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(String message) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('템플릿 "${restored.name}"을(를) 복구했습니다.')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> _openTemplateForm({ApprovalTemplate? template}) async {
|
||||
final isEdit = template != null;
|
||||
final existingTemplate = template;
|
||||
final codeController = TextEditingController(
|
||||
text: isEdit ? existingTemplate!.code : _generateTemplateCode(),
|
||||
);
|
||||
final nameController = TextEditingController(text: template?.name ?? '');
|
||||
final descriptionController = TextEditingController(
|
||||
text: template?.description ?? '',
|
||||
);
|
||||
final noteController = TextEditingController(text: template?.note ?? '');
|
||||
final steps = _buildStepFields(template);
|
||||
final statusNotifier = ValueNotifier<bool>(template?.isActive ?? true);
|
||||
bool isSaving = false;
|
||||
String? errorText;
|
||||
StateSetter? modalSetState;
|
||||
|
||||
Future<void> handleSubmit() async {
|
||||
if (isSaving) return;
|
||||
final codeValue = codeController.text.trim();
|
||||
final nameValue = nameController.text.trim();
|
||||
if (!isEdit && codeValue.isEmpty) {
|
||||
modalSetState?.call(() => errorText = '템플릿 코드를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
if (nameValue.isEmpty) {
|
||||
modalSetState?.call(() => errorText = '템플릿명을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
final validation = _validateSteps(steps);
|
||||
if (validation != null) {
|
||||
modalSetState?.call(() => errorText = validation);
|
||||
return;
|
||||
}
|
||||
modalSetState?.call(() => errorText = null);
|
||||
final stepInputs = steps
|
||||
.map(
|
||||
(field) => ApprovalTemplateStepInput(
|
||||
id: field.id,
|
||||
stepOrder: int.parse(field.orderController.text.trim()),
|
||||
approverId: int.parse(field.approverController.text.trim()),
|
||||
note: field.noteController.text.trim().isEmpty
|
||||
? null
|
||||
: field.noteController.text.trim(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final input = ApprovalTemplateInput(
|
||||
code: isEdit ? existingTemplate!.code : codeValue,
|
||||
name: nameValue,
|
||||
description: descriptionController.text.trim().isEmpty
|
||||
? null
|
||||
: descriptionController.text.trim(),
|
||||
note: noteController.text.trim().isEmpty
|
||||
? null
|
||||
: noteController.text.trim(),
|
||||
isActive: statusNotifier.value,
|
||||
);
|
||||
|
||||
modalSetState?.call(() => isSaving = true);
|
||||
|
||||
final success = isEdit
|
||||
? await _controller.update(existingTemplate!.id, input, stepInputs)
|
||||
: await _controller.create(input, stepInputs);
|
||||
if (success != null && mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop(true);
|
||||
} else {
|
||||
modalSetState?.call(() => isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
final result = await showSuperportDialog<bool>(
|
||||
context: context,
|
||||
title: isEdit ? '템플릿 수정' : '템플릿 생성',
|
||||
barrierDismissible: !isSaving,
|
||||
onSubmit: handleSubmit,
|
||||
body: StatefulBuilder(
|
||||
builder: (dialogContext, setModalState) {
|
||||
modalSetState = setModalState;
|
||||
final theme = ShadTheme.of(dialogContext);
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isEdit)
|
||||
_FormField(
|
||||
label: '템플릿 코드',
|
||||
child: ShadInput(
|
||||
controller: codeController,
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
placeholder: const Text('예: AP_INBOUND'),
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '템플릿명',
|
||||
child: ShadInput(controller: nameController),
|
||||
),
|
||||
_FormField(
|
||||
label: '설명',
|
||||
child: ShadTextarea(
|
||||
controller: descriptionController,
|
||||
minHeight: 80,
|
||||
maxHeight: 200,
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '사용 여부',
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: statusNotifier,
|
||||
builder: (_, value, __) {
|
||||
return Row(
|
||||
children: [
|
||||
ShadSwitch(
|
||||
value: value,
|
||||
onChanged: isSaving
|
||||
? null
|
||||
: (next) => statusNotifier.value = next,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(value ? '사용' : '미사용'),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_FormField(
|
||||
label: '비고',
|
||||
child: ShadTextarea(
|
||||
controller: noteController,
|
||||
minHeight: 80,
|
||||
maxHeight: 200,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'결재 단계',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Column(
|
||||
children: [
|
||||
for (var index = 0; index < steps.length; index++)
|
||||
_StepEditorRow(
|
||||
key: ValueKey('step_field_$index'),
|
||||
field: steps[index],
|
||||
index: index,
|
||||
isEdit: isEdit,
|
||||
isDisabled: isSaving,
|
||||
onRemove: steps.length <= 1 || isSaving
|
||||
? null
|
||||
: () {
|
||||
setModalState(() {
|
||||
final removed = steps.removeAt(index);
|
||||
removed.dispose();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ShadButton.outline(
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () {
|
||||
setModalState(() {
|
||||
steps.add(
|
||||
_TemplateStepField.create(
|
||||
order: steps.length + 1,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(lucide.LucideIcons.plus, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('단계 추가'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (errorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
ShadButton.ghost(
|
||||
onPressed: () {
|
||||
if (isSaving) return;
|
||||
Navigator.of(context, rootNavigator: true).pop(false);
|
||||
},
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ShadButton(
|
||||
onPressed: handleSubmit,
|
||||
child: Text(isEdit ? '수정 완료' : '생성 완료'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final createdName = nameController.text.trim();
|
||||
|
||||
for (final field in steps) {
|
||||
field.dispose();
|
||||
}
|
||||
codeController.dispose();
|
||||
nameController.dispose();
|
||||
descriptionController.dispose();
|
||||
noteController.dispose();
|
||||
statusNotifier.dispose();
|
||||
|
||||
if (result == true && mounted && template == null) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('템플릿 "$createdName"을 생성했습니다.')),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String? _validateSteps(List<_TemplateStepField> fields) {
|
||||
if (fields.isEmpty) {
|
||||
return '최소 1개의 결재 단계를 등록하세요.';
|
||||
}
|
||||
for (var index = 0; index < fields.length; index++) {
|
||||
final field = fields[index];
|
||||
final orderText = field.orderController.text.trim();
|
||||
final approverText = field.approverController.text.trim();
|
||||
final order = int.tryParse(orderText);
|
||||
final approver = int.tryParse(approverText);
|
||||
if (order == null || order <= 0) {
|
||||
return '${index + 1}번째 단계의 순서를 올바르게 입력하세요.';
|
||||
}
|
||||
if (approver == null || approver <= 0) {
|
||||
return '${index + 1}번째 단계의 승인자ID를 올바르게 입력하세요.';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<_TemplateStepField> _buildStepFields(ApprovalTemplate? template) {
|
||||
if (template == null || template.steps.isEmpty) {
|
||||
return [_TemplateStepField.create(order: 1)];
|
||||
}
|
||||
return template.steps
|
||||
.map(
|
||||
(step) => _TemplateStepField(
|
||||
id: step.id,
|
||||
orderController: TextEditingController(
|
||||
text: step.stepOrder.toString(),
|
||||
),
|
||||
approverController: TextEditingController(
|
||||
text: step.approver.id.toString(),
|
||||
),
|
||||
noteController: TextEditingController(text: step.note ?? ''),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String _statusLabel(ApprovalTemplateStatusFilter filter) {
|
||||
switch (filter) {
|
||||
case ApprovalTemplateStatusFilter.all:
|
||||
return '전체';
|
||||
case ApprovalTemplateStatusFilter.activeOnly:
|
||||
return '사용만';
|
||||
case ApprovalTemplateStatusFilter.inactiveOnly:
|
||||
return '미사용만';
|
||||
}
|
||||
messenger?.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,139 +473,3 @@ class _TemplateStepSummaryCellState extends State<_TemplateStepSummaryCell> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormField extends StatelessWidget {
|
||||
const _FormField({required this.label, required this.child});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepEditorRow extends StatelessWidget {
|
||||
const _StepEditorRow({
|
||||
super.key,
|
||||
required this.field,
|
||||
required this.index,
|
||||
required this.isEdit,
|
||||
required this.isDisabled,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final _TemplateStepField field;
|
||||
final int index;
|
||||
final bool isEdit;
|
||||
final bool isDisabled;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.border.withValues(alpha: 0.6),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ShadInput(
|
||||
controller: field.orderController,
|
||||
keyboardType: TextInputType.number,
|
||||
placeholder: const Text('단계 순서'),
|
||||
enabled: !isDisabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
ignoring: isDisabled,
|
||||
child: ApprovalApproverAutocompleteField(
|
||||
idController: field.approverController,
|
||||
hintText: '승인자 검색',
|
||||
onSelected: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (onRemove != null)
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed: onRemove,
|
||||
child: const Icon(lucide.LucideIcons.trash2, size: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
IgnorePointer(
|
||||
ignoring: isDisabled,
|
||||
child: ShadTextarea(
|
||||
controller: field.noteController,
|
||||
minHeight: 60,
|
||||
maxHeight: 160,
|
||||
placeholder: const Text('비고 (선택)'),
|
||||
),
|
||||
),
|
||||
if (isEdit && field.id != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text('단계 ID: ${field.id}', style: theme.textTheme.small),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TemplateStepField {
|
||||
_TemplateStepField({
|
||||
this.id,
|
||||
required this.orderController,
|
||||
required this.approverController,
|
||||
required this.noteController,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final TextEditingController orderController;
|
||||
final TextEditingController approverController;
|
||||
final TextEditingController noteController;
|
||||
|
||||
void dispose() {
|
||||
orderController.dispose();
|
||||
approverController.dispose();
|
||||
noteController.dispose();
|
||||
}
|
||||
|
||||
factory _TemplateStepField.create({required int order}) {
|
||||
return _TemplateStepField(
|
||||
orderController: TextEditingController(text: order.toString()),
|
||||
approverController: TextEditingController(),
|
||||
noteController: TextEditingController(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user