From 80f3df770d413fe03f3231263603ad5e146d08e4 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 13 Nov 2025 17:30:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=EA=B2=B0=EC=9E=AC=20?= =?UTF-8?q?=EC=97=AD=ED=95=A0=20=EB=B1=83=EC=A7=80=EC=99=80=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doc/frontend_backend_alignment_report.md에 roles 필터 적용 배경과 UI 반영 사항을 기록 - lib/features/dashboard/data/dashboard_summary_dto.dart에서 roles 파싱과 enum 매핑 로직, 문자열 리스트 util 추가 - lib/features/dashboard/domain/entities/dashboard_pending_approval.dart에 역할 enum과 도메인 필드 추가 - lib/features/dashboard/presentation/pages/dashboard_page.dart에서 결재 카드 헤더/설명 수정 및 역할 뱃지 렌더링 지원 - test/features/dashboard/data/dashboard_summary_dto_test.dart 신규 작성해 DTO→도메인 매핑과 무시 케이스를 검증 - test/features/masters/user/presentation/pages/user_page_test.dart에서 사용되지 않는 PermissionManager import 제거 --- doc/frontend_backend_alignment_report.md | 1 + .../data/dtos/dashboard_summary_dto.dart | 30 ++++++++++ .../entities/dashboard_pending_approval.dart | 43 ++++++++++++++ .../presentation/pages/dashboard_page.dart | 25 ++++++-- .../data/dashboard_summary_dto_test.dart | 59 +++++++++++++++++++ .../presentation/pages/user_page_test.dart | 1 - 6 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 test/features/dashboard/data/dashboard_summary_dto_test.dart diff --git a/doc/frontend_backend_alignment_report.md b/doc/frontend_backend_alignment_report.md index 0244b35..e4e952f 100644 --- a/doc/frontend_backend_alignment_report.md +++ b/doc/frontend_backend_alignment_report.md @@ -26,6 +26,7 @@ - `GET /api/v1/dashboard/summary`가 `kpis[]`, `recent_transactions[]`, `pending_approvals[]`를 제공하고 `delta`·`trend_label`이 문서와 코드에 맞춰 채워진다(`backend/src/api/v1/dashboard.rs`). - KPI 카드 구성이 입고/출고/대여/결재 대기로 확정되면서 백엔드는 `kpi.key=rental` 값을 추가했고 프런트는 이를 상단 카드 프리셋에 반영했다. - 프런트 KPI 카드에서 `delta`가 소수(0.125) → 백분율(12.5%)로 변환되는 로직과 `step_summary` 포맷(`"2단계 · 승인자"`)이 정상 노출되는지 UI 스냅샷 테스트를 업데이트한다. +- 백엔드가 `pending_approvals[].roles[]`(assigned/requested/completed)와 `approval_statuses.is_terminal=false` 필터를 사용하도록 리팩터링함에 따라 프런트는 카드 설명·뱃지를 업데이트해 `배정됨/상신자/기결재자` 역할을 노출하고 최종 상태 건 제외 정책을 명시했다. ## 재고·대여 트랜잭션 - 응답 본문이 거래/창고/라인/고객/결재 요약을 중첩 객체로 반환하고 `quantity`·`unit_price`의 null도 유지한다(`backend/src/api/v1/stock_transactions.rs`). diff --git a/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart b/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart index 7d90268..f9f3883 100644 --- a/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart +++ b/lib/features/dashboard/data/dtos/dashboard_summary_dto.dart @@ -141,6 +141,7 @@ class DashboardApprovalDto { required this.title, required this.stepSummary, this.requestedAt, + this.roles = const [], }); final int? approvalId; @@ -148,6 +149,7 @@ class DashboardApprovalDto { final String title; final String stepSummary; final DateTime? requestedAt; + final List roles; factory DashboardApprovalDto.fromJson(Map json) { num? rawId = _readNum(json, 'approval_id'); @@ -176,16 +178,22 @@ class DashboardApprovalDto { title: _readString(json, 'title') ?? '', stepSummary: _readString(json, 'step_summary') ?? '', requestedAt: _parseDate(json['requested_at']), + roles: _readStringList(json, 'roles'), ); } DashboardPendingApproval toEntity() { + final parsedRoles = roles + .map(DashboardApprovalRole.fromName) + .whereType() + .toList(growable: false); return DashboardPendingApproval( approvalId: approvalId, approvalNo: approvalNo, title: title, stepSummary: stepSummary, requestedAt: requestedAt, + roles: parsedRoles, ); } } @@ -233,3 +241,25 @@ double? _readDouble(Map? source, String key) { } return value.toDouble(); } + +List _readStringList(Map? source, String key) { + if (source == null) { + return const []; + } + final value = source[key]; + if (value is List) { + return value + .whereType() + .map((text) => text.trim()) + .where((text) => text.isNotEmpty) + .toList(growable: false); + } + if (value is String) { + final normalized = value.trim(); + if (normalized.isEmpty) { + return const []; + } + return [normalized]; + } + return const []; +} diff --git a/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart b/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart index 6ed6704..36c63f7 100644 --- a/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart +++ b/lib/features/dashboard/domain/entities/dashboard_pending_approval.dart @@ -1,3 +1,42 @@ +/// 대시보드 결재 항목에서 사용자의 역할을 구분한다. +enum DashboardApprovalRole { + assigned(key: 'assigned', label: '배정됨'), + requester(key: 'requester', label: '상신자', aliases: ['requested']), + completed(key: 'completed', label: '기결재자'); + + const DashboardApprovalRole({ + required this.key, + required this.label, + this.aliases = const [], + }); + + /// 백엔드가 내려주는 원시 값. + final String key; + + /// UI에 노출할 한국어 라벨. + final String label; + + /// 과거/대체 표기를 허용하기 위한 값. + final List aliases; + + /// 문자열에서 역할 정보를 복원한다. + static DashboardApprovalRole? fromName(String? raw) { + if (raw == null) { + return null; + } + final normalized = raw.trim().toLowerCase(); + if (normalized.isEmpty) { + return null; + } + for (final role in values) { + if (role.key == normalized || role.aliases.contains(normalized)) { + return role; + } + } + return null; + } +} + /// 결재 대기 요약 정보. class DashboardPendingApproval { const DashboardPendingApproval({ @@ -6,6 +45,7 @@ class DashboardPendingApproval { required this.title, required this.stepSummary, this.requestedAt, + this.roles = const [], }); /// 결재 고유 식별자(ID). 상세 조회가 필요할 때 사용된다. @@ -22,4 +62,7 @@ class DashboardPendingApproval { /// 상신 일시 final DateTime? requestedAt; + + /// 로그인 사용자가 해당 결재에서 가진 역할 집합. + final List roles; } diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index 984f89d..42e0f08 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -392,9 +392,9 @@ class _PendingApprovalCard extends StatelessWidget { final theme = ShadTheme.of(context); if (approvals.isEmpty) { return ShadCard( - title: Text('내 결재 대기', style: theme.textTheme.h3), + title: Text('결재 현황', style: theme.textTheme.h3), description: Text( - '최종 승인 대기 전표는 기본 목록에 노출되지 않습니다.', + '배정됨/상신자/기결재자 역할만 표시하며 최종 상태 건은 자동 제외됩니다.', style: theme.textTheme.muted, ), child: const SuperportEmptyState( @@ -408,9 +408,9 @@ class _PendingApprovalCard extends StatelessWidget { final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); return ShadCard( - title: Text('내 결재 대기', style: theme.textTheme.h3), + title: Text('결재 현황', style: theme.textTheme.h3), description: Text( - '최종 승인 대기 전표를 한곳에서 확인하고 처리할 수 있습니다.', + '내 역할(배정됨/상신자/기결재자)에 해당하는 전표만 모아 보여주고 최종 상태는 제외됩니다.', style: theme.textTheme.muted, ), child: Column( @@ -537,6 +537,12 @@ class _PendingApprovalListTile extends StatelessWidget { ? null : _formatElapsedKorean(now.difference(requestedAt)); final chips = [ShadBadge(child: Text(approval.approvalNo))]; + final roleBadges = approval.roles + .map(_buildRoleBadge) + .toList(growable: false); + if (roleBadges.isNotEmpty) { + chips.addAll(roleBadges); + } if (summary.stage != null && summary.stage!.isNotEmpty) { chips.add(ShadBadge.outline(child: Text(summary.stage!))); } @@ -603,6 +609,17 @@ class _PendingApprovalListTile extends StatelessWidget { ), ); } + + Widget _buildRoleBadge(DashboardApprovalRole role) { + switch (role) { + case DashboardApprovalRole.assigned: + return ShadBadge(child: Text(role.label)); + case DashboardApprovalRole.requester: + return ShadBadge.outline(child: Text(role.label)); + case DashboardApprovalRole.completed: + return ShadBadge.outline(child: Text(role.label)); + } + } } class _PendingApprovalSummary { diff --git a/test/features/dashboard/data/dashboard_summary_dto_test.dart b/test/features/dashboard/data/dashboard_summary_dto_test.dart new file mode 100644 index 0000000..6538928 --- /dev/null +++ b/test/features/dashboard/data/dashboard_summary_dto_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:superport_v2/features/dashboard/data/dtos/dashboard_summary_dto.dart'; +import 'package:superport_v2/features/dashboard/domain/entities/dashboard_pending_approval.dart'; + +void main() { + group('DashboardSummaryDto', () { + test('maps pending approval roles into domain entities', () { + final dto = DashboardSummaryDto.fromJson({ + 'generated_at': '2025-01-01T00:00:00Z', + 'kpis': [], + 'recent_transactions': [], + 'pending_approvals': [ + { + 'approval_id': 1, + 'approval_no': 'APP-001', + 'title': '입고 결재', + 'step_summary': '1단계 → 2단계', + 'requested_at': '2025-01-01T03:00:00Z', + 'roles': ['assigned', 'requested', 'completed'], + }, + ], + }); + + final entity = dto.toEntity(); + final approval = entity.pendingApprovals.single; + + expect( + approval.roles, + containsAll([ + DashboardApprovalRole.assigned, + DashboardApprovalRole.requester, + DashboardApprovalRole.completed, + ]), + ); + }); + + test('ignores unknown role tokens gracefully', () { + final dto = DashboardSummaryDto.fromJson({ + 'generated_at': '2025-01-01T00:00:00Z', + 'kpis': [], + 'recent_transactions': [], + 'pending_approvals': [ + { + 'approval_id': 2, + 'approval_no': 'APP-002', + 'title': '출고 결재', + 'step_summary': '3단계', + 'requested_at': '2025-01-02T03:00:00Z', + 'roles': ['REQUESTER', 'unknown', ''], + }, + ], + }); + + final approval = dto.toEntity().pendingApprovals.single; + + expect(approval.roles, [DashboardApprovalRole.requester]); + }); + }); +} diff --git a/test/features/masters/user/presentation/pages/user_page_test.dart b/test/features/masters/user/presentation/pages/user_page_test.dart index 0cdcdd6..2fac93e 100644 --- a/test/features/masters/user/presentation/pages/user_page_test.dart +++ b/test/features/masters/user/presentation/pages/user_page_test.dart @@ -10,7 +10,6 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../helpers/test_app.dart'; import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; -import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart';