feat(dashboard): 결재 역할 뱃지와 문서 정합성 반영
- 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 제거
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
- `GET /api/v1/dashboard/summary`가 `kpis[]`, `recent_transactions[]`, `pending_approvals[]`를 제공하고 `delta`·`trend_label`이 문서와 코드에 맞춰 채워진다(`backend/src/api/v1/dashboard.rs`).
|
- `GET /api/v1/dashboard/summary`가 `kpis[]`, `recent_transactions[]`, `pending_approvals[]`를 제공하고 `delta`·`trend_label`이 문서와 코드에 맞춰 채워진다(`backend/src/api/v1/dashboard.rs`).
|
||||||
- KPI 카드 구성이 입고/출고/대여/결재 대기로 확정되면서 백엔드는 `kpi.key=rental` 값을 추가했고 프런트는 이를 상단 카드 프리셋에 반영했다.
|
- KPI 카드 구성이 입고/출고/대여/결재 대기로 확정되면서 백엔드는 `kpi.key=rental` 값을 추가했고 프런트는 이를 상단 카드 프리셋에 반영했다.
|
||||||
- 프런트 KPI 카드에서 `delta`가 소수(0.125) → 백분율(12.5%)로 변환되는 로직과 `step_summary` 포맷(`"2단계 · 승인자"`)이 정상 노출되는지 UI 스냅샷 테스트를 업데이트한다.
|
- 프런트 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`).
|
- 응답 본문이 거래/창고/라인/고객/결재 요약을 중첩 객체로 반환하고 `quantity`·`unit_price`의 null도 유지한다(`backend/src/api/v1/stock_transactions.rs`).
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ class DashboardApprovalDto {
|
|||||||
required this.title,
|
required this.title,
|
||||||
required this.stepSummary,
|
required this.stepSummary,
|
||||||
this.requestedAt,
|
this.requestedAt,
|
||||||
|
this.roles = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final int? approvalId;
|
final int? approvalId;
|
||||||
@@ -148,6 +149,7 @@ class DashboardApprovalDto {
|
|||||||
final String title;
|
final String title;
|
||||||
final String stepSummary;
|
final String stepSummary;
|
||||||
final DateTime? requestedAt;
|
final DateTime? requestedAt;
|
||||||
|
final List<String> roles;
|
||||||
|
|
||||||
factory DashboardApprovalDto.fromJson(Map<String, dynamic> json) {
|
factory DashboardApprovalDto.fromJson(Map<String, dynamic> json) {
|
||||||
num? rawId = _readNum(json, 'approval_id');
|
num? rawId = _readNum(json, 'approval_id');
|
||||||
@@ -176,16 +178,22 @@ class DashboardApprovalDto {
|
|||||||
title: _readString(json, 'title') ?? '',
|
title: _readString(json, 'title') ?? '',
|
||||||
stepSummary: _readString(json, 'step_summary') ?? '',
|
stepSummary: _readString(json, 'step_summary') ?? '',
|
||||||
requestedAt: _parseDate(json['requested_at']),
|
requestedAt: _parseDate(json['requested_at']),
|
||||||
|
roles: _readStringList(json, 'roles'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
DashboardPendingApproval toEntity() {
|
DashboardPendingApproval toEntity() {
|
||||||
|
final parsedRoles = roles
|
||||||
|
.map(DashboardApprovalRole.fromName)
|
||||||
|
.whereType<DashboardApprovalRole>()
|
||||||
|
.toList(growable: false);
|
||||||
return DashboardPendingApproval(
|
return DashboardPendingApproval(
|
||||||
approvalId: approvalId,
|
approvalId: approvalId,
|
||||||
approvalNo: approvalNo,
|
approvalNo: approvalNo,
|
||||||
title: title,
|
title: title,
|
||||||
stepSummary: stepSummary,
|
stepSummary: stepSummary,
|
||||||
requestedAt: requestedAt,
|
requestedAt: requestedAt,
|
||||||
|
roles: parsedRoles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,3 +241,25 @@ double? _readDouble(Map<String, dynamic>? source, String key) {
|
|||||||
}
|
}
|
||||||
return value.toDouble();
|
return value.toDouble();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> _readStringList(Map<String, dynamic>? source, String key) {
|
||||||
|
if (source == null) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
final value = source[key];
|
||||||
|
if (value is List) {
|
||||||
|
return value
|
||||||
|
.whereType<String>()
|
||||||
|
.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 [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<String> 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 {
|
class DashboardPendingApproval {
|
||||||
const DashboardPendingApproval({
|
const DashboardPendingApproval({
|
||||||
@@ -6,6 +45,7 @@ class DashboardPendingApproval {
|
|||||||
required this.title,
|
required this.title,
|
||||||
required this.stepSummary,
|
required this.stepSummary,
|
||||||
this.requestedAt,
|
this.requestedAt,
|
||||||
|
this.roles = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 결재 고유 식별자(ID). 상세 조회가 필요할 때 사용된다.
|
/// 결재 고유 식별자(ID). 상세 조회가 필요할 때 사용된다.
|
||||||
@@ -22,4 +62,7 @@ class DashboardPendingApproval {
|
|||||||
|
|
||||||
/// 상신 일시
|
/// 상신 일시
|
||||||
final DateTime? requestedAt;
|
final DateTime? requestedAt;
|
||||||
|
|
||||||
|
/// 로그인 사용자가 해당 결재에서 가진 역할 집합.
|
||||||
|
final List<DashboardApprovalRole> roles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,9 +392,9 @@ class _PendingApprovalCard extends StatelessWidget {
|
|||||||
final theme = ShadTheme.of(context);
|
final theme = ShadTheme.of(context);
|
||||||
if (approvals.isEmpty) {
|
if (approvals.isEmpty) {
|
||||||
return ShadCard(
|
return ShadCard(
|
||||||
title: Text('내 결재 대기', style: theme.textTheme.h3),
|
title: Text('결재 현황', style: theme.textTheme.h3),
|
||||||
description: Text(
|
description: Text(
|
||||||
'최종 승인 대기 전표는 기본 목록에 노출되지 않습니다.',
|
'배정됨/상신자/기결재자 역할만 표시하며 최종 상태 건은 자동 제외됩니다.',
|
||||||
style: theme.textTheme.muted,
|
style: theme.textTheme.muted,
|
||||||
),
|
),
|
||||||
child: const SuperportEmptyState(
|
child: const SuperportEmptyState(
|
||||||
@@ -408,9 +408,9 @@ class _PendingApprovalCard extends StatelessWidget {
|
|||||||
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
|
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
|
||||||
|
|
||||||
return ShadCard(
|
return ShadCard(
|
||||||
title: Text('내 결재 대기', style: theme.textTheme.h3),
|
title: Text('결재 현황', style: theme.textTheme.h3),
|
||||||
description: Text(
|
description: Text(
|
||||||
'최종 승인 대기 전표를 한곳에서 확인하고 처리할 수 있습니다.',
|
'내 역할(배정됨/상신자/기결재자)에 해당하는 전표만 모아 보여주고 최종 상태는 제외됩니다.',
|
||||||
style: theme.textTheme.muted,
|
style: theme.textTheme.muted,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -537,6 +537,12 @@ class _PendingApprovalListTile extends StatelessWidget {
|
|||||||
? null
|
? null
|
||||||
: _formatElapsedKorean(now.difference(requestedAt));
|
: _formatElapsedKorean(now.difference(requestedAt));
|
||||||
final chips = <Widget>[ShadBadge(child: Text(approval.approvalNo))];
|
final chips = <Widget>[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) {
|
if (summary.stage != null && summary.stage!.isNotEmpty) {
|
||||||
chips.add(ShadBadge.outline(child: Text(summary.stage!)));
|
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 {
|
class _PendingApprovalSummary {
|
||||||
|
|||||||
59
test/features/dashboard/data/dashboard_summary_dto_test.dart
Normal file
59
test/features/dashboard/data/dashboard_summary_dto_test.dart
Normal file
@@ -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>[
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
|||||||
import '../../../../../helpers/test_app.dart';
|
import '../../../../../helpers/test_app.dart';
|
||||||
import '../../../../../helpers/tester_extensions.dart';
|
import '../../../../../helpers/tester_extensions.dart';
|
||||||
import 'package:superport_v2/core/common/models/paginated_result.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/entities/group.dart';
|
||||||
import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.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';
|
import 'package:superport_v2/features/masters/group_permission/domain/entities/group_permission.dart';
|
||||||
|
|||||||
Reference in New Issue
Block a user