승인 단계 삭제 복구 흐름 구현하고 API 정렬 문서 추가

This commit is contained in:
JiWoong Sul
2025-10-01 15:51:01 +09:00
parent 5578bf443f
commit 67fc319c3c
16 changed files with 671 additions and 38 deletions

View File

@@ -82,4 +82,24 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
(raw is Map<String, dynamic> ? raw : const <String, dynamic>{});
return ApprovalStepRecordDto.fromJson(data).toEntity();
}
/// 결재 단계를 비활성화한다.
@override
Future<void> delete(int id) async {
await _api.delete<void>('$_basePath/$id');
}
/// 비활성화된 결재 단계를 복구한다.
@override
Future<ApprovalStepRecord> restore(int id) async {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/$id/restore',
options: Options(responseType: ResponseType.json),
);
final raw = response.data;
final data =
(raw?['data'] as Map<String, dynamic>?) ??
(raw is Map<String, dynamic> ? raw : const <String, dynamic>{});
return ApprovalStepRecordDto.fromJson(data).toEntity();
}
}

View File

@@ -23,4 +23,10 @@ abstract class ApprovalStepRepository {
/// 결재 단계를 수정한다.
Future<ApprovalStepRecord> update(int id, ApprovalStepInput input);
/// 결재 단계를 삭제(비활성화)한다.
Future<void> delete(int id);
/// 삭제된 결재 단계를 복구한다.
Future<ApprovalStepRecord> restore(int id);
}

View File

@@ -158,4 +158,64 @@ class ApprovalStepController extends ChangeNotifier {
notifyListeners();
}
}
/// 결재 단계를 삭제(비활성화)하고 목록 상태를 반영한다.
Future<bool> deleteStep(int id) async {
_isSaving = true;
_errorMessage = null;
notifyListeners();
try {
await _repository.delete(id);
if (_result != null) {
final items = _result!.items
.map((record) {
final stepId = record.step.id;
if (stepId != null && stepId == id) {
return record.copyWith(
step: record.step.copyWith(isDeleted: true),
);
}
return record;
})
.toList(growable: false);
_result = _result!.copyWith(items: items);
}
return true;
} catch (e) {
_errorMessage = e.toString();
return false;
} finally {
_isSaving = false;
notifyListeners();
}
}
/// 삭제된 결재 단계를 복구하고 최신 데이터를 반환한다.
Future<ApprovalStepRecord?> restoreStep(int id) async {
_isSaving = true;
_errorMessage = null;
notifyListeners();
try {
final record = await _repository.restore(id);
if (_result != null) {
final items = _result!.items
.map((item) {
final stepId = item.step.id;
if (stepId != null && stepId == id) {
return record;
}
return item;
})
.toList(growable: false);
_result = _result!.copyWith(items: items);
}
return record;
} catch (e) {
_errorMessage = e.toString();
return null;
} finally {
_isSaving = false;
notifyListeners();
}
}
}

View File

@@ -5,6 +5,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../core/constants/app_sections.dart';
import '../../../../../core/permissions/permission_manager.dart';
import '../../../../../widgets/app_layout.dart';
import '../../../../../widgets/components/filter_bar.dart';
import '../../../../../widgets/components/superport_dialog.dart';
@@ -14,6 +15,8 @@ import '../../domain/entities/approval_step_input.dart';
import '../../domain/entities/approval_step_record.dart';
import '../../domain/repositories/approval_step_repository.dart';
const String _stepResourcePath = '/approvals/steps';
/// 결재 단계 관리 진입 페이지. 기능 플래그에 따라 실제 화면 또는 준비중 화면을 노출한다.
class ApprovalStepPage extends StatelessWidget {
const ApprovalStepPage({super.key});
@@ -132,19 +135,23 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
AppBreadcrumbItem(label: '결재 단계'),
],
actions: [
ShadButton(
key: const ValueKey('approval_step_create'),
onPressed: (_controller.isLoading || isSaving)
? null
: _openCreateStepForm,
leading: isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(lucide.LucideIcons.plus, size: 16),
child: Text(isSaving ? '저장 중...' : '단계 추가'),
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.create,
child: ShadButton(
key: const ValueKey('approval_step_create'),
onPressed: (_controller.isLoading || isSaving)
? null
: _openCreateStepForm,
leading: isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(lucide.LucideIcons.plus, size: 16),
child: Text(isSaving ? '저장 중...' : '단계 추가'),
),
),
],
toolbar: FilterBar(
@@ -287,6 +294,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
},
children: records.map((record) {
final step = record.step;
final isDeleted = step.isDeleted;
return [
ShadTableCell(
child: Text(step.id?.toString() ?? '-'),
@@ -313,30 +321,78 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
child: Wrap(
spacing: 8,
children: [
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)
ShadButton(
PermissionGate(
resource: _stepResourcePath,
action: PermissionAction.view,
child: ShadButton.outline(
key: ValueKey(
'step_edit_${step.id}_${step.stepOrder}',
'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading || isSaving
step.id == null ||
_controller.isLoading ||
isSaving
? null
: () => _openEditStepForm(record),
child: const Text('수정'),
: () => _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('복구'),
),
),
],
),
@@ -554,6 +610,92 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
);
}
Future<void> _confirmDeleteStep(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
ScaffoldMessenger.of(
context,
).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).pop(false),
child: const Text('취소'),
),
ShadButton.destructive(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('삭제'),
),
],
),
);
if (confirmed != true) {
return;
}
final success = await _controller.deleteStep(stepId);
if (!mounted) {
return;
}
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결재번호 ${record.approvalNo} 단계가 삭제되었습니다.')),
);
}
}
Future<void> _confirmRestoreStep(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
ScaffoldMessenger.of(
context,
).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).pop(false),
child: const Text('취소'),
),
ShadButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('복구'),
),
],
),
);
if (confirmed != true) {
return;
}
final restored = await _controller.restoreStep(stepId);
if (!mounted) {
return;
}
if (restored != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결재번호 ${restored.approvalNo} 단계가 복구되었습니다.')),
);
}
}
String _formatDate(DateTime date) {
return _dateFormat.format(date.toLocal());
}