feat(dialog): 상세 팝업 SuperportDetailDialog 통합
- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화 - 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환 - SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거 - 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지 - detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../../../widgets/components/superport_detail_dialog.dart';
|
||||
import '../../domain/entities/approval_step_input.dart';
|
||||
import '../../domain/entities/approval_step_record.dart';
|
||||
|
||||
/// 결재 단계 상세 다이얼로그 내 액션 구분값이다.
|
||||
enum ApprovalStepDetailAction { updated, deleted, restored }
|
||||
|
||||
/// 결재 단계 상세 다이얼로그 종료 시 전달되는 결과 모델이다.
|
||||
class ApprovalStepDetailResult {
|
||||
const ApprovalStepDetailResult({required this.action, required this.message});
|
||||
|
||||
/// 수행된 액션 종류.
|
||||
final ApprovalStepDetailAction action;
|
||||
|
||||
/// 사용자에게 노출할 완료 메시지.
|
||||
final String message;
|
||||
}
|
||||
|
||||
typedef ApprovalStepUpdateCallback =
|
||||
Future<ApprovalStepRecord?> Function(int id, ApprovalStepInput input);
|
||||
typedef ApprovalStepDeleteCallback = Future<bool> Function(int id);
|
||||
typedef ApprovalStepRestoreCallback =
|
||||
Future<ApprovalStepRecord?> Function(int id);
|
||||
|
||||
/// 결재 단계 상세 다이얼로그를 노출한다.
|
||||
Future<ApprovalStepDetailResult?> showApprovalStepDetailDialog({
|
||||
required BuildContext context,
|
||||
required ApprovalStepRecord record,
|
||||
required intl.DateFormat dateFormat,
|
||||
required ApprovalStepUpdateCallback onUpdate,
|
||||
required ApprovalStepDeleteCallback onDelete,
|
||||
required ApprovalStepRestoreCallback onRestore,
|
||||
bool canEdit = true,
|
||||
bool canDelete = true,
|
||||
bool canRestore = true,
|
||||
}) {
|
||||
final step = record.step;
|
||||
final metadata = <SuperportDetailMetadata>[
|
||||
SuperportDetailMetadata.text(label: '결재 ID', value: '${record.approvalId}'),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '트랜잭션번호',
|
||||
value: record.transactionNo ?? '-',
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '템플릿',
|
||||
value: record.templateName ?? '-',
|
||||
),
|
||||
SuperportDetailMetadata.text(label: '단계 순서', value: '${step.stepOrder}'),
|
||||
SuperportDetailMetadata.text(label: '상태', value: step.status.name),
|
||||
SuperportDetailMetadata.text(label: '승인자', value: step.approver.name),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '승인자 사번',
|
||||
value: step.approver.employeeNo,
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '배정일시',
|
||||
value: dateFormat.format(step.assignedAt.toLocal()),
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '결정일시',
|
||||
value: step.decidedAt == null
|
||||
? '-'
|
||||
: dateFormat.format(step.decidedAt!.toLocal()),
|
||||
),
|
||||
SuperportDetailMetadata.text(
|
||||
label: '비고',
|
||||
value: step.note?.isNotEmpty == true ? step.note! : '-',
|
||||
),
|
||||
];
|
||||
|
||||
final sections = <SuperportDetailDialogSection>[
|
||||
if (canEdit)
|
||||
SuperportDetailDialogSection(
|
||||
key: const ValueKey('approval_step_section_edit'),
|
||||
id: _ApprovalStepSections.edit,
|
||||
label: '수정',
|
||||
icon: lucide.LucideIcons.pencil,
|
||||
builder: (_) => _ApprovalStepEditSection(
|
||||
record: record,
|
||||
onSubmit: (input) async {
|
||||
final stepId = step.id;
|
||||
if (stepId == null) {
|
||||
return null;
|
||||
}
|
||||
final updated = await onUpdate(stepId, input);
|
||||
if (updated == null) {
|
||||
return null;
|
||||
}
|
||||
return ApprovalStepDetailResult(
|
||||
action: ApprovalStepDetailAction.updated,
|
||||
message: '결재 단계 정보를 수정했습니다.',
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (step.isDeleted ? canRestore : canDelete)
|
||||
SuperportDetailDialogSection(
|
||||
key: ValueKey(
|
||||
step.isDeleted
|
||||
? 'approval_step_section_restore'
|
||||
: 'approval_step_section_delete',
|
||||
),
|
||||
id: step.isDeleted
|
||||
? _ApprovalStepSections.restore
|
||||
: _ApprovalStepSections.delete,
|
||||
label: step.isDeleted ? '복구' : '삭제',
|
||||
icon: step.isDeleted
|
||||
? lucide.LucideIcons.history
|
||||
: lucide.LucideIcons.trash2,
|
||||
scrollable: false,
|
||||
builder: (_) => _ApprovalStepDangerSection(
|
||||
record: record,
|
||||
canDelete: canDelete,
|
||||
canRestore: canRestore,
|
||||
onDelete: () async {
|
||||
final stepId = step.id;
|
||||
if (stepId == null) {
|
||||
return null;
|
||||
}
|
||||
final success = await onDelete(stepId);
|
||||
if (!success) {
|
||||
return null;
|
||||
}
|
||||
return ApprovalStepDetailResult(
|
||||
action: ApprovalStepDetailAction.deleted,
|
||||
message: '결재 단계를 삭제했습니다.',
|
||||
);
|
||||
},
|
||||
onRestore: () async {
|
||||
final stepId = step.id;
|
||||
if (stepId == null) {
|
||||
return null;
|
||||
}
|
||||
final restored = await onRestore(stepId);
|
||||
if (restored == null) {
|
||||
return null;
|
||||
}
|
||||
return ApprovalStepDetailResult(
|
||||
action: ApprovalStepDetailAction.restored,
|
||||
message: '결재 단계를 복구했습니다.',
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
final initialSectionId = sections.isEmpty ? null : sections.first.id;
|
||||
|
||||
final badges = <Widget>[
|
||||
ShadBadge.outline(child: Text('단계 ${step.stepOrder}')),
|
||||
ShadBadge(child: Text(step.status.name)),
|
||||
if (step.isDeleted) const ShadBadge.destructive(child: Text('삭제됨')),
|
||||
];
|
||||
|
||||
return showSuperportDetailDialog<ApprovalStepDetailResult>(
|
||||
context: context,
|
||||
title: '결재 단계 상세',
|
||||
description: '결재번호 ${record.approvalNo}',
|
||||
sections: sections,
|
||||
summary: _ApprovalStepSummary(record: record),
|
||||
summaryBadges: badges,
|
||||
metadata: metadata,
|
||||
emptyPlaceholder: const Text('표시할 상세 정보가 없습니다.'),
|
||||
initialSectionId: initialSectionId,
|
||||
);
|
||||
}
|
||||
|
||||
/// 다이얼로그 섹션 ID 상수 모음이다.
|
||||
class _ApprovalStepSections {
|
||||
static const edit = 'edit';
|
||||
static const delete = 'delete';
|
||||
static const restore = 'restore';
|
||||
}
|
||||
|
||||
/// 결재 단계 요약 정보를 구성하는 위젯이다.
|
||||
class _ApprovalStepSummary extends StatelessWidget {
|
||||
const _ApprovalStepSummary({required this.record});
|
||||
|
||||
final ApprovalStepRecord record;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final step = record.step;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('결재 단계 ${step.stepOrder}', style: theme.textTheme.h4),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${step.approver.name} · ${step.approver.employeeNo}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
if (step.note?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(step.note!, style: theme.textTheme.muted),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 단계 수정 폼 섹션이다.
|
||||
class _ApprovalStepEditSection extends StatefulWidget {
|
||||
const _ApprovalStepEditSection({
|
||||
required this.record,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
final ApprovalStepRecord record;
|
||||
final Future<ApprovalStepDetailResult?> Function(ApprovalStepInput input)
|
||||
onSubmit;
|
||||
|
||||
@override
|
||||
State<_ApprovalStepEditSection> createState() =>
|
||||
_ApprovalStepEditSectionState();
|
||||
}
|
||||
|
||||
class _ApprovalStepEditSectionState extends State<_ApprovalStepEditSection> {
|
||||
late final TextEditingController _stepOrderController;
|
||||
late final TextEditingController _approverIdController;
|
||||
late final TextEditingController _noteController;
|
||||
bool _isSubmitting = false;
|
||||
String? _stepOrderError;
|
||||
String? _approverIdError;
|
||||
String? _submitError;
|
||||
|
||||
ApprovalStepRecord get _record => widget.record;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stepOrderController = TextEditingController(
|
||||
text: _record.step.stepOrder.toString(),
|
||||
);
|
||||
_approverIdController = TextEditingController(
|
||||
text: _record.step.approver.id.toString(),
|
||||
);
|
||||
_noteController = TextEditingController(text: _record.step.note ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stepOrderController.dispose();
|
||||
_approverIdController.dispose();
|
||||
_noteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ReadOnlyField(label: '결재 ID', value: '${_record.approvalId}'),
|
||||
const SizedBox(height: 12),
|
||||
_ReadOnlyField(label: '결재번호', value: _record.approvalNo),
|
||||
const SizedBox(height: 16),
|
||||
_EditableField(
|
||||
label: '단계 순서',
|
||||
controller: _stepOrderController,
|
||||
errorText: _stepOrderError,
|
||||
keyboardType: TextInputType.number,
|
||||
fieldKey: const ValueKey('approval_step_detail_step_order'),
|
||||
onChanged: (_) {
|
||||
if (_stepOrderError != null) {
|
||||
setState(() => _stepOrderError = null);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_EditableField(
|
||||
label: '승인자 ID',
|
||||
controller: _approverIdController,
|
||||
errorText: _approverIdError,
|
||||
keyboardType: TextInputType.number,
|
||||
fieldKey: const ValueKey('approval_step_detail_approver_id'),
|
||||
onChanged: (_) {
|
||||
if (_approverIdError != null) {
|
||||
setState(() => _approverIdError = null);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'비고',
|
||||
style: theme.textTheme.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ShadTextarea(
|
||||
key: const ValueKey('approval_step_detail_note'),
|
||||
controller: _noteController,
|
||||
minHeight: 96,
|
||||
maxHeight: 200,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_submitError != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_submitError!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ShadButton(
|
||||
key: const ValueKey('approval_step_detail_submit'),
|
||||
onPressed: _isSubmitting ? null : _handleSubmit,
|
||||
child: Text(_isSubmitting ? '저장 중...' : '저장'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
final stepOrder = int.tryParse(_stepOrderController.text.trim());
|
||||
final approverId = int.tryParse(_approverIdController.text.trim());
|
||||
|
||||
setState(() {
|
||||
_stepOrderError = stepOrder == null || stepOrder <= 0
|
||||
? '1 이상의 숫자를 입력하세요.'
|
||||
: null;
|
||||
_approverIdError = approverId == null || approverId <= 0
|
||||
? '1 이상의 숫자를 입력하세요.'
|
||||
: null;
|
||||
_submitError = null;
|
||||
});
|
||||
|
||||
if (_stepOrderError != null || _approverIdError != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final input = ApprovalStepInput(
|
||||
approvalId: _record.approvalId,
|
||||
stepOrder: stepOrder!,
|
||||
approverId: approverId!,
|
||||
note: _noteController.text.trim().isEmpty
|
||||
? null
|
||||
: _noteController.text.trim(),
|
||||
statusId: _record.step.status.id,
|
||||
);
|
||||
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
ApprovalStepDetailResult? result;
|
||||
|
||||
try {
|
||||
result = await widget.onSubmit(input);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result == null) {
|
||||
setState(() {
|
||||
_submitError = '요청 처리에 실패했습니다. 다시 시도해 주세요.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.mounted) {
|
||||
navigator.pop(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 삭제/복구 섹션을 담당하는 위젯이다.
|
||||
class _ApprovalStepDangerSection extends StatelessWidget {
|
||||
const _ApprovalStepDangerSection({
|
||||
required this.record,
|
||||
required this.canDelete,
|
||||
required this.canRestore,
|
||||
required this.onDelete,
|
||||
required this.onRestore,
|
||||
});
|
||||
|
||||
final ApprovalStepRecord record;
|
||||
final bool canDelete;
|
||||
final bool canRestore;
|
||||
final Future<ApprovalStepDetailResult?> Function() onDelete;
|
||||
final Future<ApprovalStepDetailResult?> Function() onRestore;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final step = record.step;
|
||||
final theme = ShadTheme.of(context);
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final description = step.isDeleted
|
||||
? '복구하면 결재 단계가 다시 활성화됩니다.'
|
||||
: '삭제 시 단계는 목록에서 숨겨지지만, 필요 시 복구할 수 있습니다.';
|
||||
|
||||
Future<void> handleAction(
|
||||
Future<ApprovalStepDetailResult?> Function() callback,
|
||||
) async {
|
||||
final result = await callback();
|
||||
if (result != null && navigator.mounted) {
|
||||
navigator.pop(result);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(description, style: theme.textTheme.small),
|
||||
const SizedBox(height: 16),
|
||||
if (step.isDeleted)
|
||||
ShadButton(
|
||||
key: const ValueKey('approval_step_detail_restore'),
|
||||
onPressed: canRestore ? () => handleAction(onRestore) : null,
|
||||
child: const Text('복구'),
|
||||
)
|
||||
else
|
||||
ShadButton.destructive(
|
||||
key: const ValueKey('approval_step_detail_delete'),
|
||||
onPressed: canDelete ? () => handleAction(onDelete) : null,
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 읽기 전용 필드 레이아웃을 제공한다.
|
||||
class _ReadOnlyField extends StatelessWidget {
|
||||
const _ReadOnlyField({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final background = theme.colorScheme.secondary.withValues(alpha: 0.05);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 편집 필드 공통 레이아웃 위젯이다.
|
||||
class _EditableField extends StatelessWidget {
|
||||
const _EditableField({
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.errorText,
|
||||
this.keyboardType,
|
||||
this.onChanged,
|
||||
this.fieldKey,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String? errorText;
|
||||
final TextInputType? keyboardType;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final Key? fieldKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ShadInput(
|
||||
key: fieldKey,
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
errorText!,
|
||||
style: theme.textTheme.small.copyWith(
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개요 섹션에서 사용하는 단순 레코드 모델이다.
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user