승인 단계 삭제 복구 흐름 구현하고 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

@@ -194,6 +194,7 @@ class ApprovalStepDto {
required this.assignedAt,
this.decidedAt,
this.note,
this.isDeleted = false,
});
final int? id;
@@ -203,6 +204,7 @@ class ApprovalStepDto {
final DateTime assignedAt;
final DateTime? decidedAt;
final String? note;
final bool isDeleted;
factory ApprovalStepDto.fromJson(Map<String, dynamic> json) {
return ApprovalStepDto(
@@ -217,6 +219,10 @@ class ApprovalStepDto {
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
decidedAt: _parseDate(json['decided_at']),
note: json['note'] as String?,
isDeleted:
json['is_deleted'] as bool? ??
(json['deleted_at'] != null ||
(json['is_active'] is bool && !(json['is_active'] as bool))),
);
}
@@ -229,6 +235,7 @@ class ApprovalStepDto {
assignedAt: assignedAt,
decidedAt: decidedAt,
note: note,
isDeleted: isDeleted,
);
}

View File

@@ -103,6 +103,7 @@ class ApprovalStep {
required this.assignedAt,
this.decidedAt,
this.note,
this.isDeleted = false,
});
final int? id;
@@ -112,6 +113,29 @@ class ApprovalStep {
final DateTime assignedAt;
final DateTime? decidedAt;
final String? note;
final bool isDeleted;
ApprovalStep copyWith({
int? id,
int? stepOrder,
ApprovalApprover? approver,
ApprovalStatus? status,
DateTime? assignedAt,
DateTime? decidedAt,
String? note,
bool? isDeleted,
}) {
return ApprovalStep(
id: id ?? this.id,
stepOrder: stepOrder ?? this.stepOrder,
approver: approver ?? this.approver,
status: status ?? this.status,
assignedAt: assignedAt ?? this.assignedAt,
decidedAt: decidedAt ?? this.decidedAt,
note: note ?? this.note,
isDeleted: isDeleted ?? this.isDeleted,
);
}
}
class ApprovalApprover {

View File

@@ -173,7 +173,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
actions: [
ShadButton(
leading: const Icon(lucide.LucideIcons.plus, size: 16),
onPressed: () {},
onPressed: _openCreateApprovalDialog,
child: const Text('신규 결재'),
),
],
@@ -357,6 +357,116 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
);
}
/// 신규 결재 등록 다이얼로그를 열어 UI 단계에서 필요한 필드와 안내를 제공한다.
Future<void> _openCreateApprovalDialog() async {
final transactionController = TextEditingController();
final noteController = TextEditingController();
var submitted = false;
final shouldShowToast = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
final shadTheme = ShadTheme.of(context);
final errorVisible =
submitted && transactionController.text.trim().isEmpty;
return SuperportDialog(
title: '신규 결재 등록',
description: '트랜잭션 정보를 입력하면 API 연동 시 자동 제출이 지원됩니다.',
constraints: const BoxConstraints(maxWidth: 480),
actions: [
ShadButton.ghost(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('닫기'),
),
ShadButton(
key: const ValueKey('approval_create_submit'),
onPressed: () {
final trimmed = transactionController.text.trim();
setState(() => submitted = true);
if (trimmed.isEmpty) {
return;
}
Navigator.of(dialogContext).pop(true);
},
child: const Text('임시 저장'),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text('트랜잭션 ID', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadInput(
key: const ValueKey('approval_create_transaction'),
controller: transactionController,
placeholder: const Text('예: 2404-TRX-001'),
onChanged: (_) => setState(() {}),
),
if (errorVisible)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'트랜잭션 ID를 입력해야 결재 생성이 가능합니다.',
style: shadTheme.textTheme.small.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
const SizedBox(height: 16),
Text('비고 (선택)', style: shadTheme.textTheme.small),
const SizedBox(height: 8),
ShadTextarea(
key: const ValueKey('approval_create_note'),
controller: noteController,
minHeight: 120,
maxHeight: 220,
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: shadTheme.colorScheme.mutedForeground.withValues(
alpha: 0.08,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'API 연동 준비 중',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 6),
Text(
'현재는 결재 생성 UI만 제공됩니다. 실제 저장은 백엔드 연동 이후 지원될 예정입니다.',
),
],
),
),
],
),
);
},
);
},
);
transactionController.dispose();
noteController.dispose();
if (shouldShowToast == true && mounted) {
SuperportToast.info(
context,
'결재 생성은 API 연동 이후 지원될 예정입니다. 입력한 값은 실제로 저장되지 않았습니다.',
);
}
}
void _applyFilters() {
_controller.updateQuery(_searchController.text.trim());
if (_dateRange != null) {

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());
}