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 createState() => _DashboardPageState(); } class _DashboardPageState extends State { 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(), ); _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, 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), _DeltaTrendRow(kpi: kpi), ], ), ), ); } } class _DeltaTrendRow extends StatelessWidget { const _DeltaTrendRow({this.kpi}); final DashboardKpi? kpi; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final delta = kpi?.delta; if (delta == null) { return Text(kpi?.trendLabel ?? '데이터 동기화 중', style: theme.textTheme.muted); } final absDelta = delta.abs(); final percent = (absDelta * 100).toStringAsFixed(1); final trimmedPercent = percent.endsWith('.0') ? percent.substring(0, percent.length - 2) : percent; final percentText = delta > 0 ? '+$trimmedPercent%' : delta < 0 ? '-$trimmedPercent%' : '0%'; final icon = delta > 0 ? lucide.LucideIcons.arrowUpRight : delta < 0 ? lucide.LucideIcons.arrowDownRight : lucide.LucideIcons.minus; final Color color; if (delta > 0) { color = theme.colorScheme.primary; } else if (delta < 0) { color = theme.colorScheme.destructive; } else { color = theme.textTheme.muted.color ?? theme.colorScheme.mutedForeground; } return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 4), Text( percentText, style: theme.textTheme.small.copyWith(color: color), ), const SizedBox(width: 8), Flexible( child: Text( kpi?.trendLabel ?? '전일 대비', overflow: TextOverflow.ellipsis, style: theme.textTheme.muted, ), ), ], ); } } class _RecentTransactionsCard extends StatelessWidget { const _RecentTransactionsCard({required this.transactions}); final List 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 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), ], ), ), ], ); } }