결재 API 계약 보완 및 테스트 정리

This commit is contained in:
JiWoong Sul
2025-10-16 18:53:22 +09:00
parent 9e2244f260
commit efed3c1a6f
44 changed files with 1969 additions and 293 deletions

View File

@@ -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(