결재 API 계약 보완 및 테스트 정리
This commit is contained in:
204
lib/features/dashboard/data/dtos/dashboard_summary_dto.dart
Normal file
204
lib/features/dashboard/data/dtos/dashboard_summary_dto.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import '../../../../core/common/utils/json_utils.dart';
|
||||
import '../../domain/entities/dashboard_kpi.dart';
|
||||
import '../../domain/entities/dashboard_pending_approval.dart';
|
||||
import '../../domain/entities/dashboard_summary.dart';
|
||||
import '../../domain/entities/dashboard_transaction_summary.dart';
|
||||
|
||||
/// 대시보드 요약 응답 DTO.
|
||||
class DashboardSummaryDto {
|
||||
const DashboardSummaryDto({
|
||||
this.generatedAt,
|
||||
this.kpis = const [],
|
||||
this.recentTransactions = const [],
|
||||
this.pendingApprovals = const [],
|
||||
});
|
||||
|
||||
final DateTime? generatedAt;
|
||||
final List<DashboardKpiDto> kpis;
|
||||
final List<DashboardTransactionDto> recentTransactions;
|
||||
final List<DashboardApprovalDto> pendingApprovals;
|
||||
|
||||
factory DashboardSummaryDto.fromJson(Map<String, dynamic> 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);
|
||||
|
||||
return DashboardSummaryDto(
|
||||
generatedAt: generatedAt,
|
||||
kpis: kpiList,
|
||||
recentTransactions: transactionList,
|
||||
pendingApprovals: approvalList,
|
||||
);
|
||||
}
|
||||
|
||||
DashboardSummary toEntity() {
|
||||
return DashboardSummary(
|
||||
generatedAt: generatedAt,
|
||||
kpis: kpis.map((dto) => dto.toEntity()).toList(growable: false),
|
||||
recentTransactions: recentTransactions
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList(growable: false),
|
||||
pendingApprovals: pendingApprovals
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardKpiDto {
|
||||
const DashboardKpiDto({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.trendLabel,
|
||||
this.delta,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String label;
|
||||
final num value;
|
||||
final String? trendLabel;
|
||||
final double? delta;
|
||||
|
||||
factory DashboardKpiDto.fromJson(Map<String, dynamic> json) {
|
||||
final key = _readString(json, 'key') ?? '';
|
||||
final label = _readString(json, 'label') ?? key;
|
||||
final value = _readNum(json, 'value');
|
||||
final trendLabel = _readString(json, 'trend_label');
|
||||
final delta = _readDouble(json, 'delta');
|
||||
return DashboardKpiDto(
|
||||
key: key,
|
||||
label: label,
|
||||
value: value ?? 0,
|
||||
trendLabel: trendLabel,
|
||||
delta: delta,
|
||||
);
|
||||
}
|
||||
|
||||
DashboardKpi toEntity() {
|
||||
return DashboardKpi(
|
||||
key: key,
|
||||
label: label,
|
||||
value: value,
|
||||
trendLabel: trendLabel,
|
||||
delta: delta,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardTransactionDto {
|
||||
const DashboardTransactionDto({
|
||||
required this.transactionNo,
|
||||
required this.transactionDate,
|
||||
required this.transactionType,
|
||||
required this.statusName,
|
||||
required this.createdBy,
|
||||
});
|
||||
|
||||
final String transactionNo;
|
||||
final String transactionDate;
|
||||
final String transactionType;
|
||||
final String statusName;
|
||||
final String createdBy;
|
||||
|
||||
factory DashboardTransactionDto.fromJson(Map<String, dynamic> json) {
|
||||
return DashboardTransactionDto(
|
||||
transactionNo: _readString(json, 'transaction_no') ?? '',
|
||||
transactionDate: _readString(json, 'transaction_date') ?? '',
|
||||
transactionType: _readString(json, 'transaction_type') ?? '',
|
||||
statusName: _readString(json, 'status_name') ?? '',
|
||||
createdBy: _readString(json, 'created_by') ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
DashboardTransactionSummary toEntity() {
|
||||
return DashboardTransactionSummary(
|
||||
transactionNo: transactionNo,
|
||||
transactionDate: transactionDate,
|
||||
transactionType: transactionType,
|
||||
statusName: statusName,
|
||||
createdBy: createdBy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardApprovalDto {
|
||||
const DashboardApprovalDto({
|
||||
required this.approvalNo,
|
||||
required this.title,
|
||||
required this.stepSummary,
|
||||
this.requestedAt,
|
||||
});
|
||||
|
||||
final String approvalNo;
|
||||
final String title;
|
||||
final String stepSummary;
|
||||
final String? requestedAt;
|
||||
|
||||
factory DashboardApprovalDto.fromJson(Map<String, dynamic> json) {
|
||||
return DashboardApprovalDto(
|
||||
approvalNo: _readString(json, 'approval_no') ?? '',
|
||||
title: _readString(json, 'title') ?? '',
|
||||
stepSummary: _readString(json, 'step_summary') ?? '',
|
||||
requestedAt: _readString(json, 'requested_at'),
|
||||
);
|
||||
}
|
||||
|
||||
DashboardPendingApproval toEntity() {
|
||||
return DashboardPendingApproval(
|
||||
approvalNo: approvalNo,
|
||||
title: title,
|
||||
stepSummary: stepSummary,
|
||||
requestedAt: requestedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? _parseDate(Object? value) {
|
||||
if (value is DateTime) {
|
||||
return value;
|
||||
}
|
||||
if (value is String) {
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _readString(Map<String, dynamic>? source, String key) {
|
||||
if (source == null) return null;
|
||||
final value = source[key];
|
||||
if (value is String) {
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
num? _readNum(Map<String, dynamic>? source, String key) {
|
||||
if (source == null) return null;
|
||||
final value = source[key];
|
||||
if (value is num) {
|
||||
return value;
|
||||
}
|
||||
if (value is String) {
|
||||
return num.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
double? _readDouble(Map<String, dynamic>? source, String key) {
|
||||
final value = _readNum(source, key);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.toDouble();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/network/api_routes.dart';
|
||||
import '../../domain/entities/dashboard_summary.dart';
|
||||
import '../../domain/repositories/dashboard_repository.dart';
|
||||
import '../dtos/dashboard_summary_dto.dart';
|
||||
|
||||
/// 대시보드 요약 데이터를 불러오는 원격 저장소.
|
||||
class DashboardRepositoryRemote implements DashboardRepository {
|
||||
DashboardRepositoryRemote({required ApiClient apiClient}) : _api = apiClient;
|
||||
|
||||
final ApiClient _api;
|
||||
|
||||
static const _summaryPath = '${ApiRoutes.apiV1}/dashboard/summary';
|
||||
|
||||
@override
|
||||
Future<DashboardSummary> fetchSummary() async {
|
||||
final response = await _api.get<Map<String, dynamic>>(
|
||||
_summaryPath,
|
||||
options: Options(responseType: ResponseType.json),
|
||||
);
|
||||
final json = (response.data?['data'] as Map<String, dynamic>?) ??
|
||||
response.data ??
|
||||
const <String, dynamic>{};
|
||||
return DashboardSummaryDto.fromJson(json).toEntity();
|
||||
}
|
||||
}
|
||||
33
lib/features/dashboard/domain/entities/dashboard_kpi.dart
Normal file
33
lib/features/dashboard/domain/entities/dashboard_kpi.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
/// 대시보드 KPI 카드에 사용할 수치 정보.
|
||||
class DashboardKpi {
|
||||
const DashboardKpi({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.trendLabel,
|
||||
this.delta,
|
||||
});
|
||||
|
||||
/// API에서 식별 목적으로 사용하는 키 (예: inbound, outbound)
|
||||
final String key;
|
||||
|
||||
/// 사용자에게 노출할 라벨.
|
||||
final String label;
|
||||
|
||||
/// KPI 수치(건수 등)
|
||||
final num value;
|
||||
|
||||
/// 전일 대비 등 비교 텍스트.
|
||||
final String? trendLabel;
|
||||
|
||||
/// 증감 퍼센트(선택)
|
||||
final double? delta;
|
||||
|
||||
/// 카드에 표시할 값 문자열을 생성한다.
|
||||
String get displayValue {
|
||||
if (value is int || value == value.roundToDouble()) {
|
||||
return '${value.round()}건';
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/// 결재 대기 요약 정보.
|
||||
class DashboardPendingApproval {
|
||||
const DashboardPendingApproval({
|
||||
required this.approvalNo,
|
||||
required this.title,
|
||||
required this.stepSummary,
|
||||
this.requestedAt,
|
||||
});
|
||||
|
||||
/// 결재 문서 번호
|
||||
final String approvalNo;
|
||||
|
||||
/// 결재 제목
|
||||
final String title;
|
||||
|
||||
/// 현재 단계/승인자 요약
|
||||
final String stepSummary;
|
||||
|
||||
/// 상신 일시(문자열)
|
||||
final String? requestedAt;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'dashboard_kpi.dart';
|
||||
import 'dashboard_pending_approval.dart';
|
||||
import 'dashboard_transaction_summary.dart';
|
||||
|
||||
/// 대시보드 전체 요약 응답.
|
||||
class DashboardSummary {
|
||||
const DashboardSummary({
|
||||
required this.generatedAt,
|
||||
required this.kpis,
|
||||
required this.recentTransactions,
|
||||
required this.pendingApprovals,
|
||||
});
|
||||
|
||||
/// 요약 데이터 생성 시각.
|
||||
final DateTime? generatedAt;
|
||||
|
||||
/// KPI 카드 목록.
|
||||
final List<DashboardKpi> kpis;
|
||||
|
||||
/// 최근 트랜잭션 목록.
|
||||
final List<DashboardTransactionSummary> recentTransactions;
|
||||
|
||||
/// 결재 대기 목록.
|
||||
final List<DashboardPendingApproval> pendingApprovals;
|
||||
|
||||
/// KPI를 키로 찾는다.
|
||||
DashboardKpi? findKpi(String key) {
|
||||
for (final kpi in kpis) {
|
||||
if (kpi.key == key) {
|
||||
return kpi;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// 최근 트랜잭션 요약 정보.
|
||||
class DashboardTransactionSummary {
|
||||
const DashboardTransactionSummary({
|
||||
required this.transactionNo,
|
||||
required this.transactionDate,
|
||||
required this.transactionType,
|
||||
required this.statusName,
|
||||
required this.createdBy,
|
||||
});
|
||||
|
||||
/// 트랜잭션 번호
|
||||
final String transactionNo;
|
||||
|
||||
/// 발생 일자 (형식: yyyy-MM-dd)
|
||||
final String transactionDate;
|
||||
|
||||
/// 입고/출고/대여 등 유형
|
||||
final String transactionType;
|
||||
|
||||
/// 현재 상태 명칭
|
||||
final String statusName;
|
||||
|
||||
/// 작성자 이름
|
||||
final String createdBy;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import '../entities/dashboard_summary.dart';
|
||||
|
||||
/// 대시보드 데이터를 제공하는 저장소 인터페이스.
|
||||
abstract class DashboardRepository {
|
||||
/// 대시보드 요약 정보를 조회한다.
|
||||
Future<DashboardSummary> fetchSummary();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../../core/network/failure.dart';
|
||||
import '../../domain/entities/dashboard_summary.dart';
|
||||
import '../../domain/repositories/dashboard_repository.dart';
|
||||
|
||||
/// 대시보드 화면 상태를 관리하는 컨트롤러.
|
||||
class DashboardController extends ChangeNotifier {
|
||||
DashboardController({required DashboardRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final DashboardRepository _repository;
|
||||
|
||||
DashboardSummary? _summary;
|
||||
bool _isLoading = false;
|
||||
bool _isRefreshing = false;
|
||||
String? _errorMessage;
|
||||
|
||||
DashboardSummary? get summary => _summary;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isRefreshing => _isRefreshing;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
/// 초기 로딩(캐시가 없을 때만 실행)
|
||||
Future<void> ensureLoaded() async {
|
||||
if (_summary != null || _isLoading) {
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/// 서버에서 최신 요약을 다시 불러온다.
|
||||
Future<void> refresh() async {
|
||||
_isLoading = _summary == null;
|
||||
_isRefreshing = _summary != null;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await _repository.fetchSummary();
|
||||
_summary = result;
|
||||
} catch (error) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
_isRefreshing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,238 @@
|
||||
import 'dart:async';
|
||||
|
||||
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 'package:superport_v2/widgets/app_layout.dart';
|
||||
import 'package:superport_v2/widgets/components/empty_state.dart';
|
||||
|
||||
import '../../domain/entities/dashboard_kpi.dart';
|
||||
import '../../domain/entities/dashboard_pending_approval.dart';
|
||||
import '../../domain/entities/dashboard_transaction_summary.dart';
|
||||
import '../../domain/repositories/dashboard_repository.dart';
|
||||
import '../controllers/dashboard_controller.dart';
|
||||
|
||||
/// Superport 메인 대시보드 화면.
|
||||
class DashboardPage extends StatelessWidget {
|
||||
class DashboardPage extends StatefulWidget {
|
||||
const DashboardPage({super.key});
|
||||
|
||||
static const _recentTransactions = [
|
||||
('IN-20240312-003', '2024-03-12', '입고', '승인완료', '김담당'),
|
||||
('OUT-20240311-005', '2024-03-11', '출고', '출고대기', '이물류'),
|
||||
('RENT-20240310-001', '2024-03-10', '대여', '대여중', '박대여'),
|
||||
('APP-20240309-004', '2024-03-09', '결재', '진행중', '최결재'),
|
||||
];
|
||||
@override
|
||||
State<DashboardPage> createState() => _DashboardPageState();
|
||||
}
|
||||
|
||||
static const _pendingApprovals = [
|
||||
('APP-20240312-010', '설비 구매', '2/4 단계 진행 중'),
|
||||
('APP-20240311-004', '창고 정기 점검', '승인 대기'),
|
||||
('APP-20240309-002', '계약 연장', '반려 후 재상신'),
|
||||
class _DashboardPageState extends State<DashboardPage> {
|
||||
late final DashboardController _controller;
|
||||
Timer? _autoRefreshTimer;
|
||||
final DateFormat _timestampFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
|
||||
static const _kpiPresets = [
|
||||
_KpiPreset(
|
||||
key: 'inbound',
|
||||
label: '오늘 입고',
|
||||
icon: lucide.LucideIcons.packagePlus,
|
||||
),
|
||||
_KpiPreset(
|
||||
key: 'outbound',
|
||||
label: '오늘 출고',
|
||||
icon: lucide.LucideIcons.packageMinus,
|
||||
),
|
||||
_KpiPreset(
|
||||
key: 'pending_approvals',
|
||||
label: '결재 대기',
|
||||
icon: lucide.LucideIcons.messageSquareWarning,
|
||||
),
|
||||
_KpiPreset(
|
||||
key: 'customer_inquiries',
|
||||
label: '고객사 문의',
|
||||
icon: lucide.LucideIcons.users,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = DashboardController(
|
||||
repository: GetIt.I<DashboardRepository>(),
|
||||
);
|
||||
_controller.ensureLoaded();
|
||||
_autoRefreshTimer = Timer.periodic(
|
||||
const Duration(minutes: 5),
|
||||
(_) => _controller.refresh(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoRefreshTimer?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppLayout(
|
||||
title: '대시보드',
|
||||
subtitle: '입·출·대여 현황과 결재 대기를 한 눈에 확인합니다.',
|
||||
breadcrumbs: const [AppBreadcrumbItem(label: '대시보드')],
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12, bottom: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: const [
|
||||
_KpiCard(
|
||||
icon: lucide.LucideIcons.packagePlus,
|
||||
label: '오늘 입고',
|
||||
value: '12건',
|
||||
trend: '+3 vs 어제',
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
return AppLayout(
|
||||
title: '대시보드',
|
||||
subtitle: '입·출·대여 현황과 결재 대기를 한 눈에 확인합니다.',
|
||||
breadcrumbs: const [AppBreadcrumbItem(label: '대시보드')],
|
||||
actions: [
|
||||
ShadButton.ghost(
|
||||
onPressed: _controller.isLoading ? null : _controller.refresh,
|
||||
leading: _controller.isRefreshing
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(lucide.LucideIcons.refreshCw, size: 16),
|
||||
child: const Text('새로고침'),
|
||||
),
|
||||
],
|
||||
child: _buildBody(context),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
final summary = _controller.summary;
|
||||
final theme = ShadTheme.of(context);
|
||||
|
||||
if (_controller.isLoading && summary == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (summary == null) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: ShadCard(
|
||||
title: Text('대시보드 데이터를 불러오지 못했습니다.', style: theme.textTheme.h3),
|
||||
description: Text(
|
||||
_controller.errorMessage ?? '네트워크 연결을 확인한 뒤 다시 시도해 주세요.',
|
||||
style: theme.textTheme.muted,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SuperportEmptyState(
|
||||
title: '데이터가 없습니다',
|
||||
description: '권한 또는 네트워크 상태를 확인한 뒤 다시 시도해 주세요.',
|
||||
),
|
||||
_KpiCard(
|
||||
icon: lucide.LucideIcons.packageMinus,
|
||||
label: '오늘 출고',
|
||||
value: '9건',
|
||||
trend: '-2 vs 어제',
|
||||
),
|
||||
_KpiCard(
|
||||
icon: lucide.LucideIcons.messageSquareWarning,
|
||||
label: '결재 대기',
|
||||
value: '5건',
|
||||
trend: '평균 12시간 지연',
|
||||
),
|
||||
_KpiCard(
|
||||
icon: lucide.LucideIcons.users,
|
||||
label: '고객사 문의',
|
||||
value: '7건',
|
||||
trend: '지원팀 확인 중',
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ShadButton(
|
||||
onPressed: _controller.refresh,
|
||||
child: const Text('다시 시도'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final showSidePanel = constraints.maxWidth > 920;
|
||||
return Flex(
|
||||
direction: showSidePanel ? Axis.horizontal : Axis.vertical,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _RecentTransactionsCard(
|
||||
transactions: _recentTransactions,
|
||||
),
|
||||
),
|
||||
if (showSidePanel)
|
||||
const SizedBox(width: 16)
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: _PendingApprovalCard(approvals: _pendingApprovals),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const _ReminderPanel(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final kpiMap = {for (final item in summary.kpis) item.key: item};
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(right: 12, bottom: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_controller.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.colorScheme.destructive.withValues(alpha: 0.12),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
lucide.LucideIcons.info,
|
||||
color: theme.colorScheme.destructive,
|
||||
),
|
||||
title: Text(
|
||||
'대시보드 데이터를 최신 상태로 동기화하지 못했습니다.',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
subtitle: Text(
|
||||
_controller.errorMessage!,
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
trailing: TextButton(
|
||||
onPressed: _controller.refresh,
|
||||
child: const Text('다시 시도'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
for (final preset in _kpiPresets)
|
||||
_KpiCard(
|
||||
icon: preset.icon,
|
||||
label: preset.label,
|
||||
kpi: kpiMap[preset.key],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (summary.generatedAt != null)
|
||||
Text(
|
||||
'최근 갱신: ${_timestampFormat.format(summary.generatedAt!.toLocal())}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final showSidePanel = constraints.maxWidth > 920;
|
||||
return Flex(
|
||||
direction: showSidePanel ? Axis.horizontal : Axis.vertical,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _RecentTransactionsCard(
|
||||
transactions: summary.recentTransactions,
|
||||
),
|
||||
),
|
||||
if (showSidePanel)
|
||||
const SizedBox(width: 16)
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: _PendingApprovalCard(
|
||||
approvals: summary.pendingApprovals,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const _ReminderPanel(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KpiCard extends StatelessWidget {
|
||||
const _KpiCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.trend,
|
||||
});
|
||||
const _KpiCard({required this.icon, required this.label, this.kpi});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String trend;
|
||||
final DashboardKpi? kpi;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -124,9 +247,9 @@ class _KpiCard extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
Text(label, style: theme.textTheme.small),
|
||||
const SizedBox(height: 6),
|
||||
Text(value, style: theme.textTheme.h3),
|
||||
Text(kpi?.displayValue ?? '--', style: theme.textTheme.h3),
|
||||
const SizedBox(height: 8),
|
||||
Text(trend, style: theme.textTheme.muted),
|
||||
Text(kpi?.trendLabel ?? '데이터 동기화 중', style: theme.textTheme.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -137,7 +260,7 @@ class _KpiCard extends StatelessWidget {
|
||||
class _RecentTransactionsCard extends StatelessWidget {
|
||||
const _RecentTransactionsCard({required this.transactions});
|
||||
|
||||
final List<(String, String, String, String, String)> transactions;
|
||||
final List<DashboardTransactionSummary> transactions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -150,27 +273,34 @@ class _RecentTransactionsCard extends StatelessWidget {
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 320,
|
||||
child: ShadTable.list(
|
||||
header: const [
|
||||
ShadTableCell.header(child: Text('번호')),
|
||||
ShadTableCell.header(child: Text('일자')),
|
||||
ShadTableCell.header(child: Text('유형')),
|
||||
ShadTableCell.header(child: Text('상태')),
|
||||
ShadTableCell.header(child: Text('작성자')),
|
||||
],
|
||||
children: [
|
||||
for (final row in transactions)
|
||||
[
|
||||
ShadTableCell(child: Text(row.$1)),
|
||||
ShadTableCell(child: Text(row.$2)),
|
||||
ShadTableCell(child: Text(row.$3)),
|
||||
ShadTableCell(child: Text(row.$4)),
|
||||
ShadTableCell(child: Text(row.$5)),
|
||||
],
|
||||
],
|
||||
columnSpanExtent: (index) => const FixedTableSpanExtent(140),
|
||||
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
|
||||
),
|
||||
child: transactions.isEmpty
|
||||
? const Center(
|
||||
child: SuperportEmptyState(
|
||||
title: '최근 트랜잭션이 없습니다',
|
||||
description: '최근 7일간 생성된 입·출·대여 트랜잭션이 없습니다.',
|
||||
),
|
||||
)
|
||||
: ShadTable.list(
|
||||
header: const [
|
||||
ShadTableCell.header(child: Text('번호')),
|
||||
ShadTableCell.header(child: Text('일자')),
|
||||
ShadTableCell.header(child: Text('유형')),
|
||||
ShadTableCell.header(child: Text('상태')),
|
||||
ShadTableCell.header(child: Text('작성자')),
|
||||
],
|
||||
children: [
|
||||
for (final row in transactions)
|
||||
[
|
||||
ShadTableCell(child: Text(row.transactionNo)),
|
||||
ShadTableCell(child: Text(row.transactionDate)),
|
||||
ShadTableCell(child: Text(row.transactionType)),
|
||||
ShadTableCell(child: Text(row.statusName)),
|
||||
ShadTableCell(child: Text(row.createdBy)),
|
||||
],
|
||||
],
|
||||
columnSpanExtent: (index) => const FixedTableSpanExtent(140),
|
||||
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -179,7 +309,7 @@ class _RecentTransactionsCard extends StatelessWidget {
|
||||
class _PendingApprovalCard extends StatelessWidget {
|
||||
const _PendingApprovalCard({required this.approvals});
|
||||
|
||||
final List<(String, String, String)> approvals;
|
||||
final List<DashboardPendingApproval> approvals;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -204,44 +334,53 @@ class _PendingApprovalCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final approval in approvals)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
for (final approval in approvals) ...[
|
||||
ListTile(
|
||||
leading: const Icon(lucide.LucideIcons.fileCheck, size: 20),
|
||||
title: Text(approval.approvalNo, style: theme.textTheme.small),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
lucide.LucideIcons.bell,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(approval.$1, style: theme.textTheme.small),
|
||||
const SizedBox(height: 4),
|
||||
Text(approval.$2, style: theme.textTheme.h4),
|
||||
const SizedBox(height: 4),
|
||||
Text(approval.$3, style: theme.textTheme.muted),
|
||||
],
|
||||
Text(approval.title, style: theme.textTheme.p),
|
||||
const SizedBox(height: 4),
|
||||
Text(approval.stepSummary, style: theme.textTheme.muted),
|
||||
if (approval.requestedAt != null &&
|
||||
approval.requestedAt!.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'상신: ${approval.requestedAt}',
|
||||
style: theme.textTheme.small,
|
||||
),
|
||||
),
|
||||
),
|
||||
ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
child: const Text('상세'),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: ShadButton.ghost(
|
||||
size: ShadButtonSize.sm,
|
||||
child: const Text('상세'),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KpiPreset {
|
||||
const _KpiPreset({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
class _ReminderPanel extends StatelessWidget {
|
||||
const _ReminderPanel();
|
||||
|
||||
@@ -297,7 +436,7 @@ class _ReminderItem extends StatelessWidget {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.secondary),
|
||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
|
||||
Reference in New Issue
Block a user