463 lines
15 KiB
Dart
463 lines
15 KiB
Dart
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 StatefulWidget {
|
|
const DashboardPage({super.key});
|
|
|
|
@override
|
|
State<DashboardPage> createState() => _DashboardPageState();
|
|
}
|
|
|
|
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 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: '권한 또는 네트워크 상태를 확인한 뒤 다시 시도해 주세요.',
|
|
),
|
|
const SizedBox(height: 16),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: ShadButton(
|
|
onPressed: _controller.isLoading
|
|
? null
|
|
: _controller.refresh,
|
|
child: const Text('다시 시도'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final kpiMap = summary.kpis.fold<Map<String, DashboardKpi>>({}, (map, kpi) {
|
|
map[kpi.key] = kpi;
|
|
return map;
|
|
});
|
|
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.only(bottom: 48),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
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,
|
|
mainAxisSize: showSidePanel ? MainAxisSize.max : MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
flex: 3,
|
|
fit: showSidePanel ? FlexFit.tight : FlexFit.loose,
|
|
child: _RecentTransactionsCard(
|
|
transactions: summary.recentTransactions,
|
|
),
|
|
),
|
|
if (showSidePanel)
|
|
const SizedBox(width: 16)
|
|
else
|
|
const SizedBox(height: 16),
|
|
Flexible(
|
|
flex: 2,
|
|
fit: showSidePanel ? FlexFit.tight : FlexFit.loose,
|
|
child: _PendingApprovalCard(
|
|
approvals: summary.pendingApprovals,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 24),
|
|
const _ReminderPanel(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _KpiCard extends StatelessWidget {
|
|
const _KpiCard({required this.icon, required this.label, this.kpi});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final DashboardKpi? kpi;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = ShadTheme.of(context);
|
|
return ConstrainedBox(
|
|
constraints: const BoxConstraints(minWidth: 220, maxWidth: 260),
|
|
child: ShadCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 20, color: theme.colorScheme.primary),
|
|
const SizedBox(height: 12),
|
|
Text(label, style: theme.textTheme.small),
|
|
const SizedBox(height: 6),
|
|
Text(kpi?.displayValue ?? '--', style: theme.textTheme.h3),
|
|
const SizedBox(height: 8),
|
|
Text(kpi?.trendLabel ?? '데이터 동기화 중', style: theme.textTheme.muted),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RecentTransactionsCard extends StatelessWidget {
|
|
const _RecentTransactionsCard({required this.transactions});
|
|
|
|
final List<DashboardTransactionSummary> transactions;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = ShadTheme.of(context);
|
|
return ShadCard(
|
|
title: Text('최근 트랜잭션', style: theme.textTheme.h3),
|
|
description: Text(
|
|
'최근 7일간의 입·출고 및 대여/결재 흐름입니다.',
|
|
style: theme.textTheme.muted,
|
|
),
|
|
child: SizedBox(
|
|
height: 320,
|
|
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PendingApprovalCard extends StatelessWidget {
|
|
const _PendingApprovalCard({required this.approvals});
|
|
|
|
final List<DashboardPendingApproval> approvals;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = ShadTheme.of(context);
|
|
if (approvals.isEmpty) {
|
|
return ShadCard(
|
|
title: Text('내 결재 대기', style: theme.textTheme.h3),
|
|
description: Text(
|
|
'현재 승인 대기 중인 결재 요청입니다.',
|
|
style: theme.textTheme.muted,
|
|
),
|
|
child: const SuperportEmptyState(
|
|
title: '대기 중인 결재가 없습니다',
|
|
description: '새로운 결재 요청이 등록되면 이곳에서 바로 확인할 수 있습니다.',
|
|
),
|
|
);
|
|
}
|
|
|
|
return ShadCard(
|
|
title: Text('내 결재 대기', style: theme.textTheme.h3),
|
|
description: Text('현재 승인 대기 중인 결재 요청입니다.', style: theme.textTheme.muted),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
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: [
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = ShadTheme.of(context);
|
|
return ShadCard(
|
|
title: Text('주의/알림', style: theme.textTheme.h3),
|
|
description: Text(
|
|
'지연된 결재나 시스템 점검 일정을 확인하세요.',
|
|
style: theme.textTheme.muted,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: const [
|
|
_ReminderItem(
|
|
icon: lucide.LucideIcons.clock,
|
|
label: '결재 지연',
|
|
message: '영업부 장비 구매 결재가 2일째 대기 중입니다.',
|
|
),
|
|
SizedBox(height: 12),
|
|
_ReminderItem(
|
|
icon: lucide.LucideIcons.triangleAlert,
|
|
label: '시스템 점검',
|
|
message: '2024-03-15 22:00 ~ 23:00 서버 점검이 예정되어 있습니다.',
|
|
),
|
|
SizedBox(height: 12),
|
|
_ReminderItem(
|
|
icon: lucide.LucideIcons.mail,
|
|
label: '고객 문의',
|
|
message: '3건의 신규 고객 문의가 접수되었습니다.',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ReminderItem extends StatelessWidget {
|
|
const _ReminderItem({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.message,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = ShadTheme.of(context);
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: theme.textTheme.small),
|
|
const SizedBox(height: 4),
|
|
Text(message, style: theme.textTheme.p),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|