결재 단계 편집 다이얼로그 구현

This commit is contained in:
JiWoong Sul
2025-09-25 17:57:29 +09:00
parent 6d6781f552
commit 8a6ad1e81b
17 changed files with 1689 additions and 42 deletions

View File

@@ -0,0 +1,114 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/approvals/data/dtos/approval_dto.dart';
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
import '../../domain/entities/approval_history_record.dart';
class ApprovalHistoryRecordDto {
ApprovalHistoryRecordDto({
required this.id,
required this.approvalId,
required this.approvalNo,
this.stepOrder,
required this.action,
this.fromStatus,
required this.toStatus,
required this.approver,
required this.actionAt,
this.note,
});
final int id;
final int approvalId;
final String approvalNo;
final int? stepOrder;
final ApprovalAction action;
final ApprovalStatus? fromStatus;
final ApprovalStatus toStatus;
final ApprovalApprover approver;
final DateTime actionAt;
final String? note;
factory ApprovalHistoryRecordDto.fromJson(Map<String, dynamic> json) {
final approvalData = json['approval'] as Map<String, dynamic>?;
final id = json['id'] as int? ?? 0;
final approvalId =
json['approval_id'] as int? ?? approvalData?['id'] as int? ?? 0;
final approvalNo =
json['approval_no'] as String? ??
approvalData?['approval_no'] as String? ??
approvalData?['approvalNo'] as String? ??
'-';
final stepOrder =
json['step_order'] as int? ??
(json['step'] as Map<String, dynamic>?)?['step_order'] as int?;
final action = ApprovalActionDto.fromJson(
json['action'] as Map<String, dynamic>? ?? const {},
).toEntity();
final fromStatus = json['from_status'] is Map<String, dynamic>
? ApprovalStatusDto.fromJson(
json['from_status'] as Map<String, dynamic>,
).toEntity()
: null;
final toStatus = ApprovalStatusDto.fromJson(
json['to_status'] as Map<String, dynamic>? ?? const {},
).toEntity();
final approver = ApprovalApproverDto.fromJson(
json['approver'] as Map<String, dynamic>? ?? const {},
).toEntity();
final actionAt = _parseDate(json['action_at']) ?? DateTime.now();
final note = json['note'] as String?;
return ApprovalHistoryRecordDto(
id: id,
approvalId: approvalId,
approvalNo: approvalNo,
stepOrder: stepOrder,
action: action,
fromStatus: fromStatus,
toStatus: toStatus,
approver: approver,
actionAt: actionAt,
note: note,
);
}
ApprovalHistoryRecord toEntity() {
return ApprovalHistoryRecord(
id: id,
approvalId: approvalId,
approvalNo: approvalNo,
stepOrder: stepOrder,
action: action,
fromStatus: fromStatus,
toStatus: toStatus,
approver: approver,
actionAt: actionAt,
note: note,
);
}
static PaginatedResult<ApprovalHistoryRecord> parsePaginated(
Map<String, dynamic>? json,
) {
final items = (json?['items'] as List<dynamic>? ?? [])
.whereType<Map<String, dynamic>>()
.map(ApprovalHistoryRecordDto.fromJson)
.map((dto) => dto.toEntity())
.toList();
return PaginatedResult<ApprovalHistoryRecord>(
items: items,
page: json?['page'] as int? ?? 1,
pageSize: json?['page_size'] as int? ?? items.length,
total: json?['total'] as int? ?? items.length,
);
}
}
DateTime? _parseDate(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
if (value is String) return DateTime.tryParse(value);
return null;
}

View File

@@ -0,0 +1,41 @@
import 'package:dio/dio.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/network/api_client.dart';
import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart';
import '../dtos/approval_history_record_dto.dart';
class ApprovalHistoryRepositoryRemote implements ApprovalHistoryRepository {
ApprovalHistoryRepositoryRemote({required ApiClient apiClient})
: _api = apiClient;
final ApiClient _api;
static const _basePath = '/approval-histories';
@override
Future<PaginatedResult<ApprovalHistoryRecord>> list({
int page = 1,
int pageSize = 20,
String? query,
String? action,
DateTime? from,
DateTime? to,
}) async {
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: {
'page': page,
'page_size': pageSize,
if (query != null && query.isNotEmpty) 'q': query,
if (action != null && action.isNotEmpty) 'action': action,
if (from != null) 'from': from.toIso8601String(),
if (to != null) 'to': to.toIso8601String(),
},
options: Options(responseType: ResponseType.json),
);
return ApprovalHistoryRecordDto.parsePaginated(response.data);
}
}

View File

@@ -0,0 +1,56 @@
import '../../../domain/entities/approval.dart';
/// 결재 이력 레코드
///
/// - 결재별 단계 변경 로그를 목록 화면에서 표시하기 위한 모델이다.
class ApprovalHistoryRecord {
ApprovalHistoryRecord({
required this.id,
required this.approvalId,
required this.approvalNo,
this.stepOrder,
required this.action,
this.fromStatus,
required this.toStatus,
required this.approver,
required this.actionAt,
this.note,
});
final int id;
final int approvalId;
final String approvalNo;
final int? stepOrder;
final ApprovalAction action;
final ApprovalStatus? fromStatus;
final ApprovalStatus toStatus;
final ApprovalApprover approver;
final DateTime actionAt;
final String? note;
ApprovalHistoryRecord copyWith({
int? id,
int? approvalId,
String? approvalNo,
int? stepOrder,
ApprovalAction? action,
ApprovalStatus? fromStatus,
ApprovalStatus? toStatus,
ApprovalApprover? approver,
DateTime? actionAt,
String? note,
}) {
return ApprovalHistoryRecord(
id: id ?? this.id,
approvalId: approvalId ?? this.approvalId,
approvalNo: approvalNo ?? this.approvalNo,
stepOrder: stepOrder ?? this.stepOrder,
action: action ?? this.action,
fromStatus: fromStatus ?? this.fromStatus,
toStatus: toStatus ?? this.toStatus,
approver: approver ?? this.approver,
actionAt: actionAt ?? this.actionAt,
note: note ?? this.note,
);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval_history_record.dart';
abstract class ApprovalHistoryRepository {
Future<PaginatedResult<ApprovalHistoryRecord>> list({
int page = 1,
int pageSize = 20,
String? query,
String? action,
DateTime? from,
DateTime? to,
});
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart';
enum ApprovalHistoryActionFilter { all, approve, reject, comment }
class ApprovalHistoryController extends ChangeNotifier {
ApprovalHistoryController({required ApprovalHistoryRepository repository})
: _repository = repository;
final ApprovalHistoryRepository _repository;
PaginatedResult<ApprovalHistoryRecord>? _result;
bool _isLoading = false;
String _query = '';
ApprovalHistoryActionFilter _actionFilter = ApprovalHistoryActionFilter.all;
DateTime? _from;
DateTime? _to;
String? _errorMessage;
PaginatedResult<ApprovalHistoryRecord>? get result => _result;
bool get isLoading => _isLoading;
String get query => _query;
ApprovalHistoryActionFilter get actionFilter => _actionFilter;
DateTime? get from => _from;
DateTime? get to => _to;
String? get errorMessage => _errorMessage;
Future<void> fetch({int page = 1}) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
final action = switch (_actionFilter) {
ApprovalHistoryActionFilter.all => null,
ApprovalHistoryActionFilter.approve => 'approve',
ApprovalHistoryActionFilter.reject => 'reject',
ApprovalHistoryActionFilter.comment => 'comment',
};
final response = await _repository.list(
page: page,
pageSize: _result?.pageSize ?? 20,
query: _query.trim().isEmpty ? null : _query.trim(),
action: action,
from: _from,
to: _to,
);
_result = response;
} catch (e) {
_errorMessage = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
void updateQuery(String value) {
_query = value;
notifyListeners();
}
void updateActionFilter(ApprovalHistoryActionFilter filter) {
_actionFilter = filter;
notifyListeners();
}
void updateDateRange(DateTime? from, DateTime? to) {
_from = from;
_to = to;
notifyListeners();
}
void clearFilters() {
_query = '';
_actionFilter = ApprovalHistoryActionFilter.all;
_from = null;
_to = null;
notifyListeners();
}
void clearError() {
_errorMessage = null;
notifyListeners();
}
bool get hasActiveFilters =>
_query.trim().isNotEmpty ||
_actionFilter != ApprovalHistoryActionFilter.all ||
_from != null ||
_to != null;
}

View File

@@ -1,11 +1,16 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../core/constants/app_sections.dart';
import '../../../../../widgets/app_layout.dart';
import '../../../../../widgets/components/coming_soon_card.dart';
import '../../../../../widgets/components/filter_bar.dart';
import '../../../../../widgets/spec_page.dart';
import '../../domain/entities/approval_history_record.dart';
import '../../domain/repositories/approval_history_repository.dart';
import '../controllers/approval_history_controller.dart';
class ApprovalHistoryPage extends StatelessWidget {
const ApprovalHistoryPage({super.key});
@@ -25,7 +30,7 @@ class ApprovalHistoryPage extends StatelessWidget {
columns: [
'번호',
'결재ID',
'단계ID',
'단계순서',
'승인자',
'행위',
'변경전상태',
@@ -37,7 +42,7 @@ class ApprovalHistoryPage extends StatelessWidget {
[
'1',
'APP-20240301-001',
'STEP-1',
'1',
'최관리',
'승인',
'승인대기',
@@ -52,22 +57,366 @@ class ApprovalHistoryPage extends StatelessWidget {
);
}
return AppLayout(
title: '결재 이력 조회',
subtitle: '결재 단계별 변경 기록을 확인할 수 있도록 준비 중입니다.',
breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '결재', path: '/approvals/history'),
AppBreadcrumbItem(label: '결재 이력'),
],
toolbar: FilterBar(
children: const [Text('이력 검색 조건은 API 사양 확정 후 제공될 예정입니다.')],
),
child: const ComingSoonCard(
title: '결재 이력 화면 구현 준비 중',
description: '결재 단계 로그 API와 연동해 조건 검색 및 엑셀 내보내기를 제공할 예정입니다.',
items: ['결재번호/승인자/행위 유형별 필터', '기간·상태 조건 조합 검색', '다운로드(Excel/PDF) 기능'],
),
return const _ApprovalHistoryEnabledPage();
}
}
class _ApprovalHistoryEnabledPage extends StatefulWidget {
const _ApprovalHistoryEnabledPage();
@override
State<_ApprovalHistoryEnabledPage> createState() =>
_ApprovalHistoryEnabledPageState();
}
class _ApprovalHistoryEnabledPageState
extends State<_ApprovalHistoryEnabledPage> {
late final ApprovalHistoryController _controller;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocus = FocusNode();
final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
DateTimeRange? _dateRange;
String? _lastError;
@override
void initState() {
super.initState();
_controller = ApprovalHistoryController(
repository: GetIt.I<ApprovalHistoryRepository>(),
)..addListener(_handleUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _controller.fetch();
});
}
void _handleUpdate() {
final error = _controller.errorMessage;
if (error != null && error != _lastError && mounted) {
_lastError = error;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
_controller.clearError();
}
}
@override
void dispose() {
_controller.removeListener(_handleUpdate);
_controller.dispose();
_searchController.dispose();
_searchFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final theme = ShadTheme.of(context);
final result = _controller.result;
final histories = result?.items ?? const <ApprovalHistoryRecord>[];
final totalCount = result?.total ?? 0;
final currentPage = result?.page ?? 1;
final totalPages = result == null || result.pageSize == 0
? 1
: (result.total / result.pageSize).ceil().clamp(1, 9999);
final hasNext = result == null
? false
: (result.page * result.pageSize) < result.total;
return AppLayout(
title: '결재 이력 조회',
subtitle: '결재 단계 변경 기록을 결재번호·행위·기간으로 조회합니다.',
breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '결재', path: '/approvals/history'),
AppBreadcrumbItem(label: '결재 이력'),
],
actions: [
Tooltip(
message: '다운로드 기능은 API 연동 후 제공됩니다.',
child: ShadButton(
onPressed: null,
leading: const Icon(lucide.LucideIcons.download, size: 16),
child: const Text('엑셀 다운로드'),
),
),
],
toolbar: FilterBar(
children: [
SizedBox(
width: 240,
child: ShadInput(
controller: _searchController,
focusNode: _searchFocus,
placeholder: const Text('결재번호, 승인자 검색'),
leading: const Icon(lucide.LucideIcons.search, size: 16),
onSubmitted: (_) => _applyFilters(),
),
),
SizedBox(
width: 200,
child: ShadSelect<ApprovalHistoryActionFilter>(
key: ValueKey(_controller.actionFilter),
initialValue: _controller.actionFilter,
selectedOptionBuilder: (context, value) =>
Text(_actionLabel(value)),
onChanged: (value) {
if (value == null) return;
_controller.updateActionFilter(value);
_controller.fetch(page: 1);
},
options: ApprovalHistoryActionFilter.values
.map(
(filter) => ShadOption(
value: filter,
child: Text(_actionLabel(filter)),
),
)
.toList(),
),
),
SizedBox(
width: 220,
child: ShadButton.outline(
onPressed: _pickDateRange,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(lucide.LucideIcons.calendar, size: 16),
const SizedBox(width: 8),
Text(
_dateRange == null
? '기간 선택'
: '${_formatDate(_dateRange!.start)} ~ ${_formatDate(_dateRange!.end)}',
),
],
),
),
),
if (_dateRange != null)
ShadButton.ghost(
onPressed: _controller.isLoading ? null : _clearDateRange,
child: const Text('기간 초기화'),
),
ShadButton.outline(
onPressed: _controller.isLoading ? null : _applyFilters,
child: const Text('검색 적용'),
),
ShadButton.ghost(
onPressed:
_controller.isLoading || !_controller.hasActiveFilters
? null
: _resetFilters,
child: const Text('필터 초기화'),
),
],
),
child: ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('이력 목록', style: theme.textTheme.h3),
Text('$totalCount건', style: theme.textTheme.muted),
],
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
Row(
children: [
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1
? null
: () => _controller.fetch(page: currentPage - 1),
child: const Text('이전'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext
? null
: () => _controller.fetch(page: currentPage + 1),
child: const Text('다음'),
),
],
),
],
),
child: _controller.isLoading
? const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
)
: histories.isEmpty
? Padding(
padding: const EdgeInsets.all(32),
child: Text(
'조건에 맞는 결재 이력이 없습니다.',
style: theme.textTheme.muted,
),
)
: _ApprovalHistoryTable(
histories: histories,
dateFormat: _dateTimeFormat,
query: _controller.query,
),
),
);
},
);
}
void _applyFilters() {
_controller.updateQuery(_searchController.text.trim());
if (_dateRange != null) {
_controller.updateDateRange(_dateRange!.start, _dateRange!.end);
}
_controller.fetch(page: 1);
}
Future<void> _pickDateRange() async {
final now = DateTime.now();
final initial =
_dateRange ??
DateTimeRange(
start: DateTime(now.year, now.month, now.day - 7),
end: now,
);
final range = await showDateRangePicker(
context: context,
initialDateRange: initial,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 1),
);
if (range != null) {
setState(() => _dateRange = range);
_controller.updateDateRange(range.start, range.end);
_controller.fetch(page: 1);
}
}
void _clearDateRange() {
setState(() => _dateRange = null);
_controller.updateDateRange(null, null);
_controller.fetch(page: 1);
}
void _resetFilters() {
_searchController.clear();
_searchFocus.requestFocus();
_dateRange = null;
_controller.clearFilters();
_controller.fetch(page: 1);
}
String _formatDate(DateTime date) {
return DateFormat('yyyy-MM-dd').format(date.toLocal());
}
String _actionLabel(ApprovalHistoryActionFilter filter) {
switch (filter) {
case ApprovalHistoryActionFilter.all:
return '전체 행위';
case ApprovalHistoryActionFilter.approve:
return '승인';
case ApprovalHistoryActionFilter.reject:
return '반려';
case ApprovalHistoryActionFilter.comment:
return '코멘트';
}
}
}
class _ApprovalHistoryTable extends StatelessWidget {
const _ApprovalHistoryTable({
required this.histories,
required this.dateFormat,
required this.query,
});
final List<ApprovalHistoryRecord> histories;
final DateFormat dateFormat;
final String query;
@override
Widget build(BuildContext context) {
final normalizedQuery = query.trim().toLowerCase();
final header = [
'ID',
'결재번호',
'단계순서',
'승인자',
'행위',
'변경전 상태',
'변경후 상태',
'작업일시',
'비고',
].map((label) => ShadTableCell.header(child: Text(label))).toList();
final rows = histories.map((history) {
final isHighlighted =
normalizedQuery.isNotEmpty &&
history.approvalNo.toLowerCase().contains(normalizedQuery);
return [
ShadTableCell(child: Text(history.id.toString())),
ShadTableCell(
child: Text(
history.approvalNo,
style: isHighlighted
? ShadTheme.of(
context,
).textTheme.small.copyWith(fontWeight: FontWeight.w600)
: null,
),
),
ShadTableCell(
child: Text(
history.stepOrder == null ? '-' : history.stepOrder.toString(),
),
),
ShadTableCell(child: Text(history.approver.name)),
ShadTableCell(child: Text(history.action.name)),
ShadTableCell(child: Text(history.fromStatus?.name ?? '-')),
ShadTableCell(child: Text(history.toStatus.name)),
ShadTableCell(
child: Text(dateFormat.format(history.actionAt.toLocal())),
),
ShadTableCell(
child: Text(
history.note?.trim().isEmpty ?? true ? '-' : history.note!,
),
),
];
}).toList();
return ShadTable.list(
header: header,
children: rows,
columnSpanExtent: (index) {
switch (index) {
case 1:
return const FixedTableSpanExtent(180);
case 2:
case 4:
return const FixedTableSpanExtent(120);
case 5:
case 6:
return const FixedTableSpanExtent(150);
case 7:
return const FixedTableSpanExtent(180);
default:
return const FixedTableSpanExtent(110);
}
},
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:superport_v2/core/network/api_client.dart';
import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart';
import 'package:superport_v2/features/approvals/step/domain/repositories/approval_step_repository.dart';
import '../dtos/approval_step_record_dto.dart';
import '../../domain/entities/approval_step_input.dart';
class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
ApprovalStepRepositoryRemote({required ApiClient apiClient})
@@ -48,4 +49,32 @@ class ApprovalStepRepositoryRemote implements ApprovalStepRepository {
final data = (response.data?['data'] as Map<String, dynamic>?) ?? const {};
return ApprovalStepRecordDto.fromJson(data).toEntity();
}
@override
Future<ApprovalStepRecord> create(ApprovalStepInput input) async {
final response = await _api.post<Map<String, dynamic>>(
_basePath,
data: input.toPayload(),
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();
}
@override
Future<ApprovalStepRecord> update(int id, ApprovalStepInput input) async {
final response = await _api.patch<Map<String, dynamic>>(
'$_basePath/$id',
data: input.toPayload(),
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

@@ -0,0 +1,61 @@
/// 결재 단계 생성/수정 입력 모델
///
/// - 단계 순서, 승인자, 비고 등의 값을 API 페이로드로 직렬화한다.
/// - `approvalId`는 생성 시에만 필요하며 수정 시에는 null로 둘 수 있다.
class ApprovalStepInput {
ApprovalStepInput({
this.approvalId,
required this.stepOrder,
required this.approverId,
this.statusId,
this.assignedAt,
this.decidedAt,
this.note,
}) : assert(stepOrder > 0, '단계 순서는 1 이상의 정수여야 합니다.'),
assert(approverId > 0, '승인자 ID는 양수여야 합니다.');
final int? approvalId;
final int stepOrder;
final int approverId;
final int? statusId;
final DateTime? assignedAt;
final DateTime? decidedAt;
final String? note;
/// API 요청 페이로드를 구성한다.
Map<String, dynamic> toPayload() {
final payload = <String, dynamic>{
'step_order': stepOrder,
'approver_id': approverId,
if (statusId != null) 'status_id': statusId,
if (assignedAt != null)
'assigned_at': assignedAt!.toUtc().toIso8601String(),
if (decidedAt != null) 'decided_at': decidedAt!.toUtc().toIso8601String(),
if (note != null && note!.trim().isNotEmpty) 'note': note,
};
if (approvalId != null) {
payload['approval_id'] = approvalId;
}
return payload;
}
ApprovalStepInput copyWith({
int? approvalId,
int? stepOrder,
int? approverId,
int? statusId,
DateTime? assignedAt,
DateTime? decidedAt,
String? note,
}) {
return ApprovalStepInput(
approvalId: approvalId ?? this.approvalId,
stepOrder: stepOrder ?? this.stepOrder,
approverId: approverId ?? this.approverId,
statusId: statusId ?? this.statusId,
assignedAt: assignedAt ?? this.assignedAt,
decidedAt: decidedAt ?? this.decidedAt,
note: note ?? this.note,
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../entities/approval_step_input.dart';
import '../entities/approval_step_record.dart';
abstract class ApprovalStepRepository {
@@ -13,4 +14,10 @@ abstract class ApprovalStepRepository {
});
Future<ApprovalStepRecord> fetchDetail(int id);
/// 결재 단계를 생성한다.
Future<ApprovalStepRecord> create(ApprovalStepInput input);
/// 결재 단계를 수정한다.
Future<ApprovalStepRecord> update(int id, ApprovalStepInput input);
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import '../../domain/entities/approval_step_input.dart';
import '../../domain/entities/approval_step_record.dart';
import '../../domain/repositories/approval_step_repository.dart';
@@ -12,6 +13,7 @@ class ApprovalStepController extends ChangeNotifier {
PaginatedResult<ApprovalStepRecord>? _result;
bool _isLoading = false;
bool _isSaving = false;
String _query = '';
int? _statusId;
int? _approverId;
@@ -21,6 +23,7 @@ class ApprovalStepController extends ChangeNotifier {
PaginatedResult<ApprovalStepRecord>? get result => _result;
bool get isLoading => _isLoading;
bool get isSaving => _isSaving;
String get query => _query;
int? get statusId => _statusId;
int? get approverId => _approverId;
@@ -101,4 +104,43 @@ class ApprovalStepController extends ChangeNotifier {
_approverId = null;
notifyListeners();
}
Future<ApprovalStepRecord?> createStep(ApprovalStepInput input) async {
_isSaving = true;
_errorMessage = null;
notifyListeners();
try {
final created = await _repository.create(input);
final nextPage = _result?.page ?? 1;
await fetch(page: nextPage);
return created;
} catch (e) {
_errorMessage = e.toString();
return null;
} finally {
_isSaving = false;
notifyListeners();
}
}
Future<ApprovalStepRecord?> updateStep(
int id,
ApprovalStepInput input,
) async {
_isSaving = true;
_errorMessage = null;
notifyListeners();
try {
final updated = await _repository.update(id, input);
final nextPage = _result?.page ?? 1;
await fetch(page: nextPage);
return updated;
} catch (e) {
_errorMessage = e.toString();
return null;
} finally {
_isSaving = false;
notifyListeners();
}
}
}

View File

@@ -9,6 +9,7 @@ import '../../../../../widgets/app_layout.dart';
import '../../../../../widgets/components/filter_bar.dart';
import '../../../../../widgets/spec_page.dart';
import '../controllers/approval_step_controller.dart';
import '../../domain/entities/approval_step_input.dart';
import '../../domain/entities/approval_step_record.dart';
import '../../domain/repositories/approval_step_repository.dart';
@@ -141,6 +142,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
final selectedStatus = _controller.statusId ?? -1;
final approverOptions = _buildApproverOptions(records);
final selectedApprover = _controller.approverId ?? -1;
final isSaving = _controller.isSaving;
return AppLayout(
title: '결재 단계 관리',
@@ -151,13 +153,19 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
AppBreadcrumbItem(label: '결재 단계'),
],
actions: [
Tooltip(
message: '결재 단계 생성은 정책 정리 후 제공됩니다.',
child: ShadButton(
onPressed: null,
leading: const Icon(lucide.LucideIcons.plus, size: 16),
child: const Text('단계 추가'),
),
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(
@@ -225,12 +233,16 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
),
),
ShadButton.outline(
onPressed: _controller.isLoading ? null : _applyFilters,
onPressed: (_controller.isLoading || isSaving)
? null
: _applyFilters,
child: const Text('검색 적용'),
),
ShadButton.ghost(
onPressed:
!_controller.isLoading && _controller.hasActiveFilters
!_controller.isLoading &&
!isSaving &&
_controller.hasActiveFilters
? _resetFilters
: null,
child: const Text('필터 초기화'),
@@ -319,15 +331,35 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
ShadTableCell(
child: Align(
alignment: Alignment.centerRight,
child: ShadButton.outline(
key: ValueKey(
'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed: step.id == null
? null
: () => _openDetail(record),
child: const Text('상세'),
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(
key: ValueKey(
'step_edit_${step.id}_${step.stepOrder}',
),
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading || isSaving
? null
: () => _openEditStepForm(record),
child: const Text('수정'),
),
],
),
),
),
@@ -345,7 +377,9 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading || currentPage <= 1
_controller.isLoading ||
isSaving ||
currentPage <= 1
? null
: () => _controller.fetch(
page: currentPage - 1,
@@ -355,7 +389,10 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext
onPressed:
_controller.isLoading ||
isSaving ||
!hasNext
? null
: () => _controller.fetch(
page: currentPage + 1,
@@ -413,6 +450,67 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> {
_searchFocus.requestFocus();
}
Future<void> _openCreateStepForm() async {
final input = await showDialog<ApprovalStepInput>(
context: context,
builder: (dialogContext) {
return _StepFormDialog(
title: '결재 단계 추가',
submitLabel: '저장',
isEditing: false,
);
},
);
if (!mounted || input == null) {
return;
}
final created = await _controller.createStep(input);
if (!mounted || created == null) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결재번호 ${created.approvalNo} 단계가 추가되었습니다.')),
);
}
Future<void> _openEditStepForm(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
ScaffoldMessenger.of(
context,
).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;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결재번호 ${updated.approvalNo} 단계 정보를 수정했습니다.')),
);
}
Future<void> _openDetail(ApprovalStepRecord record) async {
final stepId = record.step.id;
if (stepId == null) {
@@ -564,3 +662,263 @@ class _DetailRow extends StatelessWidget {
);
}
}
class _StepFormDialog extends StatefulWidget {
const _StepFormDialog({
required this.title,
required this.submitLabel,
required this.isEditing,
this.initialRecord,
});
final String title;
final String submitLabel;
final bool isEditing;
final ApprovalStepRecord? initialRecord;
@override
State<_StepFormDialog> createState() => _StepFormDialogState();
}
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;
Map<String, String?> _errors = const {};
@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 ?? '');
}
@override
void dispose() {
_approvalIdController.dispose();
_approvalNoController.dispose();
_stepOrderController.dispose();
_approverIdController.dispose();
_noteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final materialTheme = Theme.of(context);
return Dialog(
insetPadding: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: ShadCard(
title: Text(widget.title, style: theme.textTheme.h3),
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.ghost(
onPressed: () => Navigator.of(context).pop(),
child: const Text('취소'),
),
const SizedBox(width: 12),
ShadButton(
key: const ValueKey('step_form_submit'),
onPressed: _handleSubmit,
child: Text(widget.submitLabel),
),
],
),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Column(
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,
),
),
const SizedBox(height: 16),
_FormFieldBlock(
label: '결재번호',
child: ShadInput(
controller: _approvalNoController,
readOnly: true,
),
),
],
if (!widget.isEditing) const SizedBox(height: 16),
_FormFieldBlock(
label: '단계 순서',
errorText: _errors['stepOrder'],
child: ShadInput(
key: const ValueKey('step_form_step_order'),
controller: _stepOrderController,
onChanged: (_) => _clearError('stepOrder'),
),
),
const SizedBox(height: 16),
_FormFieldBlock(
label: '승인자 ID',
errorText: _errors['approverId'],
child: ShadInput(
key: const ValueKey('step_form_approver_id'),
controller: _approverIdController,
onChanged: (_) => _clearError('approverId'),
),
),
const SizedBox(height: 16),
_FormFieldBlock(
label: '비고',
helperText: '필요 시 단계에 대한 참고 내용을 남길 수 있습니다.',
child: ShadTextarea(
key: const ValueKey('step_form_note'),
controller: _noteController,
minHeight: 100,
maxHeight: 200,
),
),
if (_errors['form'] != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
_errors['form']!,
style: theme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
],
),
),
),
);
}
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 stepOrder = int.tryParse(_stepOrderController.text.trim());
if (stepOrder == null || stepOrder <= 0) {
nextErrors['stepOrder'] = '단계 순서를 1 이상의 숫자로 입력하세요.';
}
final approverId = int.tryParse(_approverIdController.text.trim());
if (approverId == null || approverId <= 0) {
nextErrors['approverId'] = '승인자 ID를 1 이상의 숫자로 입력하세요.';
}
setState(() => _errors = nextErrors);
if (nextErrors.isNotEmpty) {
return;
}
final note = _noteController.text.trim();
final input = ApprovalStepInput(
approvalId: approvalId,
stepOrder: stepOrder!,
approverId: approverId!,
note: note.isEmpty ? null : note,
statusId: widget.initialRecord?.step.status.id,
);
Navigator.of(context).pop(input);
}
void _clearError(String field) {
if (_errors[field] == null) {
return;
}
setState(() {
final updated = Map<String, String?>.from(_errors);
updated.remove(field);
_errors = updated;
});
}
}
class _FormFieldBlock extends StatelessWidget {
const _FormFieldBlock({
required this.label,
this.errorText,
this.helperText,
required this.child,
});
final String label;
final Widget child;
final String? errorText;
final String? helperText;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final materialTheme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
child,
if (errorText != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
errorText!,
style: theme.textTheme.small.copyWith(
color: materialTheme.colorScheme.error,
),
),
),
if (helperText != null && helperText!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(helperText!, style: theme.textTheme.muted),
),
],
);
}
}