diff --git a/.env.development.example b/.env.development.example index c63bb20..da608c5 100644 --- a/.env.development.example +++ b/.env.development.example @@ -1,4 +1,4 @@ -API_BASE_URL=http://43.201.34.104:8080 +API_BASE_URL=http://3.35.41.39:8080 # 기능 플래그 (true/false) # 백엔드 엔드포인트 준비 상태에 따라 개별 화면 제어에 활용 diff --git a/.env.production.example b/.env.production.example index baeda3d..c9a814f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,4 +1,4 @@ -API_BASE_URL=http://43.201.34.104:8080 +API_BASE_URL=http://3.35.41.39:8080 # 기능 플래그 (true/false) FEATURE_VENDORS_ENABLED=true diff --git a/assets/.env.development b/assets/.env.development index 067030c..345f11d 100644 --- a/assets/.env.development +++ b/assets/.env.development @@ -1,4 +1,4 @@ -API_BASE_URL=http://43.201.34.104:8080 +API_BASE_URL=http://3.35.41.39:8080 TIMEOUT_MS=15000 LOG_LEVEL=debug diff --git a/doc/backup/backend_change_requests.md b/doc/backup/backend_change_requests.md index 6bb39cc..4fb95cc 100644 --- a/doc/backup/backend_change_requests.md +++ b/doc/backup/backend_change_requests.md @@ -18,11 +18,11 @@ - 엔드포인트 - `POST /api/v1/auth/login`: `identifier`, `password`, `remember_me`(bool) 입력을 받아 `{ "data": { "access_token", "refresh_token", "expires_at", "user", "permissions" } }` 구조를 반환해야 한다. `user` 객체는 `{ id, name, employee_no, email, primary_group { id, name } }` 필드를 포함하고, `permissions`는 `resource`와 `actions[]`(소문자 문자열)로 구성된다. - `POST /api/v1/auth/refresh`: `refresh_token`으로 세션을 갱신하며 응답 스키마는 로그인과 동일하다. - - `GET /api/v1/dashboard/summary`: `{ "data": { "generated_at", "kpis": [], "recent_transactions": [], "pending_approvals": [] } }` 형태로 내려 KPI 카드, 최근 전표, 결재 대기 목록을 채울 수 있어야 한다. + - `GET /api/v1/dashboard/summary`: `{ "data": { "generated_at", "kpis": [], "recent_transactions": [], "pending_approvals": [] } }` 형태로 내려 KPI 카드, 최근 전표, 결재 대기 목록을 채울 수 있어야 하며 각 대기 결재는 상세 조회용 `approval_id`를 함께 반환한다. - 요구 사항 - `kpis[]` 항목은 `{ key, label, value, trend_label, delta }` 필드를 제공해 프런트 차트 증감률을 계산할 수 있도록 한다. - `recent_transactions[]`는 `{ transaction_no, transaction_date, transaction_type, status_name, created_by }` 문자열 필드로 구성한다. - - `pending_approvals[]`는 `{ approval_no, title, step_summary, requested_at }`을 포함하며 `requested_at`은 ISO8601 UTC 문자열로 반환한다. + - `pending_approvals[]`는 `{ approval_id, approval_no, title, step_summary, requested_at }`을 포함하며 `requested_at`은 ISO8601 UTC 문자열로 반환한다. - 로그인 실패 시 `invalid credentials`, 비활성 계정 접근 시 `account is inactive`, 갱신 토큰 만료는 `token expired`, 재사용·서명 오류는 `invalid token` 메시지를 반환해 프런트 알림 문구와 동일하게 맞춘다. - 인증 실패(401), 세션 만료·권한 거부(403) 시 `{ "error": { "code": , "message": "...", "details": [...] } }` 규격을 사용하고, 만료/재사용 토큰별 메시지를 문서화한다. diff --git a/doc/stock_approval_system_api_v4.md b/doc/stock_approval_system_api_v4.md index 64954b3..8e7b6ec 100644 --- a/doc/stock_approval_system_api_v4.md +++ b/doc/stock_approval_system_api_v4.md @@ -1798,6 +1798,7 @@ ], "pending_approvals": [ { + "approval_id": 5005, "approval_no": "APP-202511100005", "title": "출고 결재", "step_summary": "2단계/3단계 진행중", @@ -1808,6 +1809,8 @@ } ``` +- `pending_approvals[].approval_id`는 결재 상세 조회(`GET /approvals/{id}`)에 사용되는 `approvals.id` 값을 그대로 노출한다. + --- ## 9. 구현 참고 diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 1821491..fdec361 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -122,7 +122,7 @@ final appRouter = GoRouter( GoRoute( path: '/approvals/requests', name: 'approvals-requests', - builder: (context, state) => const ApprovalRequestPage(), + builder: (context, state) => ApprovalRequestPage(routeUri: state.uri), ), GoRoute( path: '/approvals/steps', diff --git a/lib/features/approvals/data/dtos/approval_dto.dart b/lib/features/approvals/data/dtos/approval_dto.dart index dd5b9d2..c906ecd 100644 --- a/lib/features/approvals/data/dtos/approval_dto.dart +++ b/lib/features/approvals/data/dtos/approval_dto.dart @@ -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 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 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; +} diff --git a/lib/features/approvals/presentation/controllers/approval_controller.dart b/lib/features/approvals/presentation/controllers/approval_controller.dart index 8e26e3a..21e023b 100644 --- a/lib/features/approvals/presentation/controllers/approval_controller.dart +++ b/lib/features/approvals/presentation/controllers/approval_controller.dart @@ -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; diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index 6dfd3c1..512519e 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -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(), templateRepository: GetIt.I(), @@ -98,9 +105,24 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { _controller.loadStatusLookups(), ]); await _controller.fetch(); + _applyRouteSelectionIfNeeded( + _controller.result?.items ?? const [], + ); }); } + @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 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 _selectApproval(int id, {bool reveal = true}) async { + await _controller.selectApproval(id); + if (!mounted) { + return; + } + if (!reveal) { + return; + } + await _revealDetailSection(); + } + + Future _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 []; + 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, diff --git a/lib/features/approvals/request/presentation/pages/approval_request_page.dart b/lib/features/approvals/request/presentation/pages/approval_request_page.dart index 44b7533..d3956a8 100644 --- a/lib/features/approvals/request/presentation/pages/approval_request_page.dart +++ b/lib/features/approvals/request/presentation/pages/approval_request_page.dart @@ -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); } } diff --git a/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart b/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart index f7bab96..051ed58 100644 --- a/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart +++ b/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + import '../../../../core/common/utils/json_utils.dart'; import '../../domain/entities/dashboard_kpi.dart'; import '../../domain/entities/dashboard_pending_approval.dart'; @@ -20,17 +22,18 @@ class DashboardSummaryDto { factory DashboardSummaryDto.fromJson(Map json) { final generatedAt = _parseDate(json['generated_at']); - final kpiList = JsonUtils.extractList(json, keys: const ['kpis']) - .map(DashboardKpiDto.fromJson) - .toList(growable: false); - final transactionList = - JsonUtils.extractList(json, keys: const ['recent_transactions']) - .map(DashboardTransactionDto.fromJson) - .toList(growable: false); - final approvalList = - JsonUtils.extractList(json, keys: const ['pending_approvals']) - .map(DashboardApprovalDto.fromJson) - .toList(growable: false); + final kpiList = JsonUtils.extractList( + json, + keys: const ['kpis'], + ).map(DashboardKpiDto.fromJson).toList(growable: false); + final transactionList = JsonUtils.extractList( + json, + keys: const ['recent_transactions'], + ).map(DashboardTransactionDto.fromJson).toList(growable: false); + final approvalList = JsonUtils.extractList( + json, + keys: const ['pending_approvals'], + ).map(DashboardApprovalDto.fromJson).toList(growable: false); return DashboardSummaryDto( generatedAt: generatedAt, @@ -133,19 +136,42 @@ class DashboardTransactionDto { class DashboardApprovalDto { const DashboardApprovalDto({ + this.approvalId, required this.approvalNo, required this.title, required this.stepSummary, this.requestedAt, }); + final int? approvalId; final String approvalNo; final String title; final String stepSummary; final String? requestedAt; factory DashboardApprovalDto.fromJson(Map json) { + num? rawId = _readNum(json, 'approval_id'); + if (rawId == null) { + final approvalMap = json['approval']; + if (approvalMap is Map) { + rawId = _readNum(approvalMap, 'id'); + if (rawId == null && approvalMap.containsKey('id')) { + final fallbackValue = approvalMap['id']; + debugPrint( + '[DashboardSummaryDto] approval.id 파싱 실패: runtimeType=${fallbackValue.runtimeType}, value=$fallbackValue', + ); + } + } + } + final approvalIdValue = rawId?.toInt(); + if (approvalIdValue == null && json.containsKey('approval_id')) { + final rawValue = json['approval_id']; + debugPrint( + '[DashboardSummaryDto] approval_id 파싱 실패: runtimeType=${rawValue.runtimeType}, value=$rawValue', + ); + } return DashboardApprovalDto( + approvalId: approvalIdValue, approvalNo: _readString(json, 'approval_no') ?? '', title: _readString(json, 'title') ?? '', stepSummary: _readString(json, 'step_summary') ?? '', @@ -155,6 +181,7 @@ class DashboardApprovalDto { DashboardPendingApproval toEntity() { return DashboardPendingApproval( + approvalId: approvalId, approvalNo: approvalNo, title: title, stepSummary: stepSummary, @@ -190,7 +217,11 @@ num? _readNum(Map? source, String key) { return value; } if (value is String) { - return num.tryParse(value); + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + return num.tryParse(trimmed); } return null; } diff --git a/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart b/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart index e209ead..7a56483 100644 --- a/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart +++ b/lib/features/dashboard/data/repositories/dashboard_repository_remote.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import '../../../../core/network/api_client.dart'; import '../../../../core/network/api_routes.dart'; @@ -20,6 +23,17 @@ class DashboardRepositoryRemote implements DashboardRepository { _summaryPath, options: Options(responseType: ResponseType.json), ); + if (kDebugMode) { + try { + debugPrint( + '[DashboardRepositoryRemote] dashboard/summary 응답: ${jsonEncode(response.data)}', + ); + } catch (error) { + debugPrint( + '[DashboardRepositoryRemote] dashboard/summary 응답 직렬화 실패: $error', + ); + } + } return DashboardSummaryDto.fromJson(_api.unwrapAsMap(response)).toEntity(); } } diff --git a/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart b/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart index 56dadde..e5f68e1 100644 --- a/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart +++ b/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart @@ -1,12 +1,16 @@ /// 결재 대기 요약 정보. class DashboardPendingApproval { const DashboardPendingApproval({ + this.approvalId, required this.approvalNo, required this.title, required this.stepSummary, this.requestedAt, }); + /// 결재 고유 식별자(ID). 상세 조회가 필요할 때 사용된다. + final int? approvalId; + /// 결재 문서 번호 final String approvalNo; diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index d90d24f..d5ebc6f 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -2,11 +2,17 @@ import 'dart:async'; 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'; +import 'package:superport_v2/core/network/failure.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/empty_state.dart'; +import 'package:superport_v2/widgets/components/feedback.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; import '../../domain/entities/dashboard_kpi.dart'; import '../../domain/entities/dashboard_pending_approval.dart'; @@ -25,7 +31,7 @@ class DashboardPage extends StatefulWidget { class _DashboardPageState extends State { late final DashboardController _controller; Timer? _autoRefreshTimer; - final DateFormat _timestampFormat = DateFormat('yyyy-MM-dd HH:mm'); + final intl.DateFormat _timestampFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); static const _kpiPresets = [ _KpiPreset( @@ -203,7 +209,9 @@ class _DashboardPageState extends State { return Flex( direction: showSidePanel ? Axis.horizontal : Axis.vertical, crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: showSidePanel ? MainAxisSize.max : MainAxisSize.min, + mainAxisSize: showSidePanel + ? MainAxisSize.max + : MainAxisSize.min, children: [ Flexible( flex: 3, @@ -287,14 +295,14 @@ class _DeltaTrendRow extends StatelessWidget { final percentText = delta > 0 ? '+$trimmedPercent%' : delta < 0 - ? '-$trimmedPercent%' - : '0%'; + ? '-$trimmedPercent%' + : '0%'; final icon = delta > 0 ? lucide.LucideIcons.arrowUpRight : delta < 0 - ? lucide.LucideIcons.arrowDownRight - : lucide.LucideIcons.minus; + ? lucide.LucideIcons.arrowDownRight + : lucide.LucideIcons.minus; final Color color; if (delta > 0) { @@ -310,10 +318,7 @@ class _DeltaTrendRow extends StatelessWidget { children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 4), - Text( - percentText, - style: theme.textTheme.small.copyWith(color: color), - ), + Text(percentText, style: theme.textTheme.small.copyWith(color: color)), const SizedBox(width: 8), Flexible( child: Text( @@ -427,8 +432,8 @@ class _PendingApprovalCard extends StatelessWidget { ), trailing: ShadButton.ghost( size: ShadButtonSize.sm, + onPressed: () => _handleViewDetail(context, approval), child: const Text('상세'), - onPressed: () {}, ), ), const Divider(), @@ -437,6 +442,320 @@ class _PendingApprovalCard extends StatelessWidget { ), ); } + + void _handleViewDetail( + BuildContext context, + DashboardPendingApproval approval, + ) async { + final approvalId = approval.approvalId; + debugPrint( + '[DashboardPage] 상세 버튼 클릭: approvalId=$approvalId, approvalNo=${approval.approvalNo}', + ); // 클릭 시 결재 식별자를 로그로 남겨 추적한다. + if (approvalId == null) { + debugPrint( + '[DashboardPage] 결재 상세 조회 불가 - pending_approvals 응답에 approval_id가 없습니다.', + ); + SuperportToast.error(context, '결재 상세를 조회할 수 없습니다. (식별자 없음)'); + return; + } + final repository = GetIt.I(); + final detailFuture = repository + .fetchDetail(approvalId, includeSteps: true, includeHistories: true) + .catchError((error) { + final failure = Failure.from(error); + debugPrint( + '[DashboardPage] 대시보드 결재 상세 조회 실패: ${failure.describe()}', + ); // 콘솔에 에러를 표시해 즉시 추적한다. + if (context.mounted) { + SuperportToast.error(context, failure.describe()); + } + throw error; + }) + .then((detail) { + debugPrint( + '[DashboardPage] 결재 상세 조회 성공: id=${detail.id}, approvalNo=${detail.approvalNo}', + ); + return detail; + }); + if (!context.mounted) { + return; + } + await SuperportDialog.show( + context: context, + dialog: SuperportDialog( + title: '결재 상세', + description: '결재번호 ${approval.approvalNo}', + constraints: const BoxConstraints(maxWidth: 760), + actions: [ + ShadButton( + onPressed: () => + Navigator.of(context, rootNavigator: true).maybePop(), + child: const Text('닫기'), + ), + ], + child: SizedBox( + width: double.infinity, + height: 480, + child: FutureBuilder( + future: detailFuture, + builder: (dialogContext, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + final failure = Failure.from(snapshot.error!); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + failure.describe(), + style: ShadTheme.of(dialogContext).textTheme.muted, + textAlign: TextAlign.center, + ), + ), + ); + } + final detail = snapshot.data!; + return _DashboardApprovalDetailContent(approval: detail); + }, + ), + ), + ), + ); + } +} + +class _DashboardApprovalDetailContent extends StatelessWidget { + const _DashboardApprovalDetailContent({required this.approval}); + + final Approval approval; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + final overviewRows = [ + ('결재번호', approval.approvalNo), + ('트랜잭션번호', approval.transactionNo), + ('현재 상태', approval.status.name), + ( + '현재 단계', + approval.currentStep == null + ? '-' + : 'Step ${approval.currentStep!.stepOrder} · ' + '${approval.currentStep!.approver.name}', + ), + ('상신자', approval.requester.name), + ('상신일시', dateFormat.format(approval.requestedAt.toLocal())), + ( + '최종결정일시', + approval.decidedAt == null + ? '-' + : dateFormat.format(approval.decidedAt!.toLocal()), + ), + ]; + final note = approval.note?.trim(); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('개요', style: theme.textTheme.h4), + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 12, + children: [ + for (final row in overviewRows) + _DetailField(label: row.$1, value: row.$2), + ], + ), + if (note != null && note.isNotEmpty) ...[ + const SizedBox(height: 16), + _DetailField(label: '비고', value: note, fullWidth: true), + ], + const SizedBox(height: 24), + Text('단계', style: theme.textTheme.h4), + const SizedBox(height: 12), + _ApprovalStepList(steps: approval.steps, dateFormat: dateFormat), + const SizedBox(height: 24), + Text('이력', style: theme.textTheme.h4), + const SizedBox(height: 12), + _ApprovalHistoryList( + histories: approval.histories, + dateFormat: dateFormat, + ), + ], + ), + ); + } +} + +class _DetailField extends StatelessWidget { + const _DetailField({ + required this.label, + required this.value, + this.fullWidth = false, + }); + + final String label; + final String value; + final bool fullWidth; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return SizedBox( + width: fullWidth ? double.infinity : 240, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.muted), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.p), + ], + ), + ); + } +} + +class _ApprovalStepList extends StatelessWidget { + const _ApprovalStepList({required this.steps, required this.dateFormat}); + + final List steps; + final intl.DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (steps.isEmpty) { + return Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted); + } + return ShadCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var index = 0; index < steps.length; index++) ...[ + _ApprovalStepTile(step: steps[index], dateFormat: dateFormat), + if (index < steps.length - 1) const Divider(), + ], + ], + ), + ); + } +} + +class _ApprovalStepTile extends StatelessWidget { + const _ApprovalStepTile({required this.step, required this.dateFormat}); + + final ApprovalStep step; + final intl.DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final decidedAt = step.decidedAt; + final note = step.note?.trim(); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Step ${step.stepOrder} · ${step.approver.name}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text('상태: ${step.status.name}', style: theme.textTheme.p), + const SizedBox(height: 2), + Text( + '배정: ${dateFormat.format(step.assignedAt.toLocal())}', + style: theme.textTheme.small, + ), + Text( + '결정: ${decidedAt == null ? '-' : dateFormat.format(decidedAt.toLocal())}', + style: theme.textTheme.small, + ), + if (note != null && note.isNotEmpty) ...[ + const SizedBox(height: 4), + Text('비고: $note', style: theme.textTheme.small), + ], + ], + ), + ); + } +} + +class _ApprovalHistoryList extends StatelessWidget { + const _ApprovalHistoryList({ + required this.histories, + required this.dateFormat, + }); + + final List histories; + final intl.DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (histories.isEmpty) { + return Text('등록된 결재 이력이 없습니다.', style: theme.textTheme.muted); + } + return ShadCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var index = 0; index < histories.length; index++) ...[ + _ApprovalHistoryTile( + history: histories[index], + dateFormat: dateFormat, + ), + if (index < histories.length - 1) const Divider(), + ], + ], + ), + ); + } +} + +class _ApprovalHistoryTile extends StatelessWidget { + const _ApprovalHistoryTile({required this.history, required this.dateFormat}); + + final ApprovalHistory history; + final intl.DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final note = history.note?.trim(); + final fromStatus = history.fromStatus?.name ?? '-'; + final toStatus = history.toStatus.name; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${history.approver.name} · ${history.action.name}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text('상태: $fromStatus → $toStatus', style: theme.textTheme.p), + const SizedBox(height: 2), + Text( + '처리일시: ${dateFormat.format(history.actionAt.toLocal())}', + style: theme.textTheme.small, + ), + if (note != null && note.isNotEmpty) ...[ + const SizedBox(height: 4), + Text('비고: $note', style: theme.textTheme.small), + ], + ], + ), + ); + } } class _KpiPreset {