대시보드 결재 상세 진입 지원
This commit is contained in:
@@ -102,7 +102,7 @@ class ApprovalDto {
|
||||
decidedAt: _parseDate(
|
||||
json['decided_at'] ?? approvalEnvelope['decided_at'],
|
||||
),
|
||||
note: json['note'] as String? ?? approvalEnvelope['note'] as String?,
|
||||
note: _readString(json['note']) ?? _readString(approvalEnvelope['note']),
|
||||
isActive:
|
||||
(json['is_active'] as bool?) ??
|
||||
(approvalEnvelope['is_active'] as bool?) ??
|
||||
@@ -178,12 +178,12 @@ class ApprovalStatusDto {
|
||||
json['approval_status_id'] as int? ??
|
||||
0,
|
||||
name:
|
||||
json['name'] as String? ??
|
||||
json['status_name'] as String? ??
|
||||
json['approval_status_name'] as String? ??
|
||||
(json['status'] as String?) ??
|
||||
_readString(json['name']) ??
|
||||
_readString(json['status_name']) ??
|
||||
_readString(json['approval_status_name']) ??
|
||||
_readString(json['status']) ??
|
||||
'-',
|
||||
color: json['color'] as String?,
|
||||
color: _readString(json['color']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,8 +206,11 @@ class ApprovalRequesterDto {
|
||||
factory ApprovalRequesterDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalRequesterDto(
|
||||
id: json['id'] as int? ?? json['employee_id'] as int? ?? 0,
|
||||
employeeNo: json['employee_no'] as String? ?? '-',
|
||||
name: json['name'] as String? ?? json['employee_name'] as String? ?? '-',
|
||||
employeeNo: _readString(json['employee_no']) ?? '-',
|
||||
name:
|
||||
_readString(json['name']) ??
|
||||
_readString(json['employee_name']) ??
|
||||
'-',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -231,8 +234,11 @@ class ApprovalApproverDto {
|
||||
factory ApprovalApproverDto.fromJson(Map<String, dynamic> json) {
|
||||
return ApprovalApproverDto(
|
||||
id: json['id'] as int? ?? json['approver_id'] as int? ?? 0,
|
||||
employeeNo: json['employee_no'] as String? ?? '-',
|
||||
name: json['name'] as String? ?? json['employee_name'] as String? ?? '-',
|
||||
employeeNo: _readString(json['employee_no']) ?? '-',
|
||||
name:
|
||||
_readString(json['name']) ??
|
||||
_readString(json['employee_name']) ??
|
||||
'-',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -278,7 +284,7 @@ class ApprovalStepDto {
|
||||
),
|
||||
assignedAt: _parseDate(json['assigned_at']) ?? DateTime.now(),
|
||||
decidedAt: _parseDate(json['decided_at']),
|
||||
note: json['note'] as String?,
|
||||
note: _readString(json['note']),
|
||||
isDeleted:
|
||||
json['is_deleted'] as bool? ??
|
||||
(json['deleted_at'] != null ||
|
||||
@@ -337,9 +343,9 @@ class ApprovalHistoryDto {
|
||||
final fallbackAction = {
|
||||
'id': json['approval_action_id'] ?? json['action_id'],
|
||||
'name':
|
||||
json['approval_action_name'] ??
|
||||
json['action_name'] ??
|
||||
(json['action'] as String?) ??
|
||||
_readString(json['approval_action_name']) ??
|
||||
_readString(json['action_name']) ??
|
||||
_readString(json['action']) ??
|
||||
'-',
|
||||
};
|
||||
|
||||
@@ -355,7 +361,7 @@ class ApprovalHistoryDto {
|
||||
approver: ApprovalApproverDto.fromJson(approverMap),
|
||||
actionAt:
|
||||
_parseDate(json['action_at'] ?? json['actionAt']) ?? DateTime.now(),
|
||||
note: json['note'] as String?,
|
||||
note: _readString(json['note']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -389,10 +395,10 @@ class ApprovalActionDto {
|
||||
json['approval_action_id'] as int? ??
|
||||
0,
|
||||
name:
|
||||
json['name'] as String? ??
|
||||
json['action_name'] as String? ??
|
||||
json['approval_action_name'] as String? ??
|
||||
(json['action'] as String?) ??
|
||||
_readString(json['name']) ??
|
||||
_readString(json['action_name']) ??
|
||||
_readString(json['approval_action_name']) ??
|
||||
_readString(json['action']) ??
|
||||
'-',
|
||||
);
|
||||
}
|
||||
@@ -441,3 +447,17 @@ DateTime? _parseDate(Object? value) {
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _readString(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is String) {
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
if (value is num || value is bool) {
|
||||
return value.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -345,6 +345,9 @@ class ApprovalController extends ChangeNotifier {
|
||||
}
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
debugPrint(
|
||||
'[ApprovalController] 결재 상세 조회 실패: ${failure.describe()}',
|
||||
); // 에러 발생 시 콘솔에 남겨 즉시 파악할 수 있도록 한다.
|
||||
_errorMessage = failure.describe();
|
||||
} finally {
|
||||
_isLoadingDetail = false;
|
||||
|
||||
@@ -28,7 +28,9 @@ const _approvalsResourcePath = PermissionResources.approvals;
|
||||
///
|
||||
/// 기능 플래그에 따라 실제 화면 또는 비활성 안내 화면을 보여준다.
|
||||
class ApprovalPage extends StatelessWidget {
|
||||
const ApprovalPage({super.key});
|
||||
const ApprovalPage({super.key, this.routeUri});
|
||||
|
||||
final Uri? routeUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -59,13 +61,15 @@ class ApprovalPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return const _ApprovalEnabledPage();
|
||||
return _ApprovalEnabledPage(routeUri: routeUri);
|
||||
}
|
||||
}
|
||||
|
||||
/// 결재 기능이 활성화되었을 때 사용되는 실제 페이지 위젯.
|
||||
class _ApprovalEnabledPage extends StatefulWidget {
|
||||
const _ApprovalEnabledPage();
|
||||
const _ApprovalEnabledPage({this.routeUri});
|
||||
|
||||
final Uri? routeUri;
|
||||
|
||||
@override
|
||||
State<_ApprovalEnabledPage> createState() => _ApprovalEnabledPageState();
|
||||
@@ -76,14 +80,17 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
final TextEditingController _transactionController = TextEditingController();
|
||||
final TextEditingController _requesterController = TextEditingController();
|
||||
final FocusNode _transactionFocus = FocusNode();
|
||||
final GlobalKey _detailSectionKey = GlobalKey();
|
||||
InventoryEmployeeSuggestion? _selectedRequester;
|
||||
final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
|
||||
String? _lastError;
|
||||
int? _selectedTemplateId;
|
||||
String? _pendingRouteSelection;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pendingRouteSelection = _parseRouteSelection(widget.routeUri);
|
||||
_controller = ApprovalController(
|
||||
approvalRepository: GetIt.I<ApprovalRepository>(),
|
||||
templateRepository: GetIt.I<ApprovalTemplateRepository>(),
|
||||
@@ -98,9 +105,24 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
_controller.loadStatusLookups(),
|
||||
]);
|
||||
await _controller.fetch();
|
||||
_applyRouteSelectionIfNeeded(
|
||||
_controller.result?.items ?? const <Approval>[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _ApprovalEnabledPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.routeUri != widget.routeUri) {
|
||||
_pendingRouteSelection = _parseRouteSelection(widget.routeUri);
|
||||
final currentResult = _controller.result;
|
||||
if (currentResult != null) {
|
||||
_applyRouteSelectionIfNeeded(currentResult.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleControllerUpdate() {
|
||||
final error = _controller.errorMessage;
|
||||
if (error != null && error != _lastError && mounted) {
|
||||
@@ -110,6 +132,57 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 라우트 쿼리에서 선택된 결재번호를 읽어온다.
|
||||
String? _parseRouteSelection(Uri? routeUri) {
|
||||
final value = routeUri?.queryParameters['selected']?.trim();
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// 최초 로딩 시 라우트에서 전달된 결재번호가 있으면 자동으로 상세를 연다.
|
||||
void _applyRouteSelectionIfNeeded(List<Approval> approvals) {
|
||||
final target = _pendingRouteSelection;
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
for (final approval in approvals) {
|
||||
if (approval.approvalNo == target && approval.id != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_selectApproval(approval.id!);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
_pendingRouteSelection = null;
|
||||
}
|
||||
|
||||
Future<void> _selectApproval(int id, {bool reveal = true}) async {
|
||||
await _controller.selectApproval(id);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (!reveal) {
|
||||
return;
|
||||
}
|
||||
await _revealDetailSection();
|
||||
}
|
||||
|
||||
Future<void> _revealDetailSection() async {
|
||||
final detailContext = _detailSectionKey.currentContext;
|
||||
if (detailContext == null) {
|
||||
return;
|
||||
}
|
||||
await Scrollable.ensureVisible(
|
||||
detailContext,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
alignment: 0.05,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_handleControllerUpdate);
|
||||
@@ -130,6 +203,9 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
builder: (context, _) {
|
||||
final result = _controller.result;
|
||||
final approvals = result?.items ?? const <Approval>[];
|
||||
if (result != null) {
|
||||
_applyRouteSelectionIfNeeded(approvals);
|
||||
}
|
||||
final selectedApproval = _controller.selected;
|
||||
final totalCount = result?.total ?? 0;
|
||||
final currentPage = result?.page ?? 1;
|
||||
@@ -279,8 +355,8 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed:
|
||||
_controller.isLoadingList || currentPage <= 1
|
||||
? null
|
||||
: () => _controller.fetch(page: 1),
|
||||
? null
|
||||
: () => _controller.fetch(page: 1),
|
||||
child: const Text('처음'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -304,9 +380,10 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
ShadButton.outline(
|
||||
size: ShadButtonSize.sm,
|
||||
onPressed:
|
||||
_controller.isLoadingList || currentPage >= totalPages
|
||||
? null
|
||||
: () => _controller.fetch(page: totalPages),
|
||||
_controller.isLoadingList ||
|
||||
currentPage >= totalPages
|
||||
? null
|
||||
: () => _controller.fetch(page: totalPages),
|
||||
child: const Text('마지막'),
|
||||
),
|
||||
],
|
||||
@@ -332,13 +409,14 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
onView: (approval) {
|
||||
final id = approval.id;
|
||||
if (id != null) {
|
||||
_controller.selectApproval(id);
|
||||
_selectApproval(id);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_DetailSection(
|
||||
key: _detailSectionKey,
|
||||
approval: selectedApproval,
|
||||
isLoading: _controller.isLoadingDetail,
|
||||
isLoadingActions: isLoadingActions,
|
||||
@@ -358,7 +436,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
onRefresh: () {
|
||||
final id = selectedApproval?.id;
|
||||
if (id != null) {
|
||||
_controller.selectApproval(id);
|
||||
_selectApproval(id, reveal: false);
|
||||
}
|
||||
},
|
||||
onClose: selectedApproval == null
|
||||
@@ -411,9 +489,9 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
onPressed: isSubmitting
|
||||
? null
|
||||
: () => Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pop(false),
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ShadButton(
|
||||
@@ -678,10 +756,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
|
||||
if (created == true && mounted) {
|
||||
final number = createdApprovalNo ?? '-';
|
||||
SuperportToast.success(
|
||||
context,
|
||||
'결재를 생성했습니다. ($number)',
|
||||
);
|
||||
SuperportToast.success(context, '결재를 생성했습니다. ($number)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,10 +873,8 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
actions: [
|
||||
ShadButton.ghost(
|
||||
onPressed: () => Navigator.of(
|
||||
dialogContext,
|
||||
rootNavigator: true,
|
||||
).pop(),
|
||||
onPressed: () =>
|
||||
Navigator.of(dialogContext, rootNavigator: true).pop(),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ShadButton(
|
||||
@@ -811,10 +884,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> {
|
||||
setState(() => errorText = '비고를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
Navigator.of(
|
||||
dialogContext,
|
||||
rootNavigator: true,
|
||||
).pop(
|
||||
Navigator.of(dialogContext, rootNavigator: true).pop(
|
||||
_StepActionDialogResult(note: note.isEmpty ? null : note),
|
||||
);
|
||||
},
|
||||
@@ -1049,6 +1119,7 @@ class _ApprovalTable extends StatelessWidget {
|
||||
/// 선택 상태와 로딩 여부에 따라 안내 문구 또는 상세 정보를 노출한다.
|
||||
class _DetailSection extends StatelessWidget {
|
||||
const _DetailSection({
|
||||
super.key,
|
||||
required this.approval,
|
||||
required this.isLoading,
|
||||
required this.isLoadingActions,
|
||||
|
||||
@@ -2,12 +2,14 @@ import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../presentation/pages/approval_page.dart';
|
||||
|
||||
/// 결재 요청 탭에서 사용하는 래퍼 페이지. 실 구현은 [ApprovalPage]를 재사용한다.
|
||||
/// 결재 요청 탭에서 [ApprovalPage]를 전달하기 위한 래퍼 페이지.
|
||||
class ApprovalRequestPage extends StatelessWidget {
|
||||
const ApprovalRequestPage({super.key});
|
||||
const ApprovalRequestPage({super.key, this.routeUri});
|
||||
|
||||
final Uri? routeUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const ApprovalPage();
|
||||
return ApprovalPage(routeUri: routeUri);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user