import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:intl/intl.dart' as intl; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/network/failure.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/empty_state.dart'; import 'package:superport_v2/widgets/components/feedback.dart'; import 'package:superport_v2/widgets/components/superport_dialog.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 intl.DateFormat _timestampFormat = intl.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, onPressed: () => _handleViewDetail(context, approval), child: const Text('상세'), ), ), const Divider(), ], ], ), ); } void _handleViewDetail( BuildContext context, DashboardPendingApproval approval, ) async { final approvalId = approval.approvalId; debugPrint( '[DashboardPage] 상세 버튼 클릭: approvalId=$approvalId, approvalNo=${approval.approvalNo}', ); // 클릭 시 결재 식별자를 로그로 남겨 추적한다. if (approvalId == null) { debugPrint( '[DashboardPage] 결재 상세 조회 불가 - pending_approvals 응답에 approval_id가 없습니다.', ); SuperportToast.error(context, '결재 상세를 조회할 수 없습니다. (식별자 없음)'); return; } final repository = GetIt.I(); final detailFuture = repository .fetchDetail(approvalId, includeSteps: true, includeHistories: true) .catchError((error) { final failure = Failure.from(error); debugPrint( '[DashboardPage] 대시보드 결재 상세 조회 실패: ${failure.describe()}', ); // 콘솔에 에러를 표시해 즉시 추적한다. if (context.mounted) { SuperportToast.error(context, failure.describe()); } throw error; }) .then((detail) { debugPrint( '[DashboardPage] 결재 상세 조회 성공: id=${detail.id}, approvalNo=${detail.approvalNo}', ); return detail; }); if (!context.mounted) { return; } await SuperportDialog.show( context: context, dialog: SuperportDialog( title: '결재 상세', description: '결재번호 ${approval.approvalNo}', constraints: const BoxConstraints(maxWidth: 760), actions: [ ShadButton( onPressed: () => Navigator.of(context, rootNavigator: true).maybePop(), child: const Text('닫기'), ), ], child: SizedBox( width: double.infinity, height: 480, child: FutureBuilder( future: detailFuture, builder: (dialogContext, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { final failure = Failure.from(snapshot.error!); return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( failure.describe(), style: ShadTheme.of(dialogContext).textTheme.muted, textAlign: TextAlign.center, ), ), ); } final detail = snapshot.data!; return _DashboardApprovalDetailContent(approval: detail); }, ), ), ), ); } } class _DashboardApprovalDetailContent extends StatelessWidget { const _DashboardApprovalDetailContent({required this.approval}); final Approval approval; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); final overviewRows = [ ('결재번호', approval.approvalNo), ('트랜잭션번호', approval.transactionNo), ('현재 상태', approval.status.name), ( '현재 단계', approval.currentStep == null ? '-' : 'Step ${approval.currentStep!.stepOrder} · ' '${approval.currentStep!.approver.name}', ), ('상신자', approval.requester.name), ('상신일시', dateFormat.format(approval.requestedAt.toLocal())), ( '최종결정일시', approval.decidedAt == null ? '-' : dateFormat.format(approval.decidedAt!.toLocal()), ), ]; final note = approval.note?.trim(); return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('개요', style: theme.textTheme.h4), const SizedBox(height: 12), Wrap( spacing: 16, runSpacing: 12, children: [ for (final row in overviewRows) _DetailField(label: row.$1, value: row.$2), ], ), if (note != null && note.isNotEmpty) ...[ const SizedBox(height: 16), _DetailField(label: '비고', value: note, fullWidth: true), ], const SizedBox(height: 24), Text('단계', style: theme.textTheme.h4), const SizedBox(height: 12), _ApprovalStepList(steps: approval.steps, dateFormat: dateFormat), const SizedBox(height: 24), Text('이력', style: theme.textTheme.h4), const SizedBox(height: 12), _ApprovalHistoryList( histories: approval.histories, dateFormat: dateFormat, ), ], ), ); } } class _DetailField extends StatelessWidget { const _DetailField({ required this.label, required this.value, this.fullWidth = false, }); final String label; final String value; final bool fullWidth; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); return SizedBox( width: fullWidth ? double.infinity : 240, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: theme.textTheme.muted), const SizedBox(height: 4), Text(value, style: theme.textTheme.p), ], ), ); } } class _ApprovalStepList extends StatelessWidget { const _ApprovalStepList({required this.steps, required this.dateFormat}); final List steps; final intl.DateFormat dateFormat; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); if (steps.isEmpty) { return Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted); } return ShadCard( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var index = 0; index < steps.length; index++) ...[ _ApprovalStepTile(step: steps[index], dateFormat: dateFormat), if (index < steps.length - 1) const Divider(), ], ], ), ); } } class _ApprovalStepTile extends StatelessWidget { const _ApprovalStepTile({required this.step, required this.dateFormat}); final ApprovalStep step; final intl.DateFormat dateFormat; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final decidedAt = step.decidedAt; final note = step.note?.trim(); return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Step ${step.stepOrder} · ${step.approver.name}', style: theme.textTheme.small, ), const SizedBox(height: 4), Text('상태: ${step.status.name}', style: theme.textTheme.p), const SizedBox(height: 2), Text( '배정: ${dateFormat.format(step.assignedAt.toLocal())}', style: theme.textTheme.small, ), Text( '결정: ${decidedAt == null ? '-' : dateFormat.format(decidedAt.toLocal())}', style: theme.textTheme.small, ), if (note != null && note.isNotEmpty) ...[ const SizedBox(height: 4), Text('비고: $note', style: theme.textTheme.small), ], ], ), ); } } class _ApprovalHistoryList extends StatelessWidget { const _ApprovalHistoryList({ required this.histories, required this.dateFormat, }); final List histories; final intl.DateFormat dateFormat; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); if (histories.isEmpty) { return Text('등록된 결재 이력이 없습니다.', style: theme.textTheme.muted); } return ShadCard( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var index = 0; index < histories.length; index++) ...[ _ApprovalHistoryTile( history: histories[index], dateFormat: dateFormat, ), if (index < histories.length - 1) const Divider(), ], ], ), ); } } class _ApprovalHistoryTile extends StatelessWidget { const _ApprovalHistoryTile({required this.history, required this.dateFormat}); final ApprovalHistory history; final intl.DateFormat dateFormat; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final note = history.note?.trim(); final fromStatus = history.fromStatus?.name ?? '-'; final toStatus = history.toStatus.name; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${history.approver.name} · ${history.action.name}', style: theme.textTheme.small, ), const SizedBox(height: 4), Text('상태: $fromStatus → $toStatus', style: theme.textTheme.p), const SizedBox(height: 2), Text( '처리일시: ${dateFormat.format(history.actionAt.toLocal())}', style: theme.textTheme.small, ), if (note != null && note.isNotEmpty) ...[ const SizedBox(height: 4), Text('비고: $note', style: theme.textTheme.small), ], ], ), ); } } 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), ], ), ), ], ); } }