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/core/permissions/permission_manager.dart'; import 'package:superport_v2/core/permissions/permission_resources.dart'; import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; import 'package:superport_v2/features/auth/application/auth_service.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; import 'package:superport_v2/features/approvals/domain/usecases/get_approval_draft_use_case.dart'; import 'package:superport_v2/features/approvals/domain/usecases/list_approval_drafts_use_case.dart'; import 'package:superport_v2/features/approvals/domain/usecases/save_approval_draft_use_case.dart'; import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; import 'package:superport_v2/features/approvals/presentation/dialogs/approval_detail_dialog.dart'; import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_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: 'rental', label: '오늘 대여', icon: lucide.LucideIcons.handshake, ), _KpiPreset( key: 'pending_approvals', label: '결재 대기', icon: lucide.LucideIcons.messageSquareWarning, ), ]; @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), // TODO(superport-team): 백엔드 알림 API 연동 후 _ReminderPanel을 복원한다. // 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: '최종 승인 대기 전표가 생성되면 이곳에 표시됩니다.', ), ); } final now = DateTime.now(); final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); return ShadCard( title: Text('결재 현황', style: theme.textTheme.h3), description: Text( '내 역할(배정됨/상신자/기결재자)에 해당하는 전표만 모아 보여주고 최종 상태는 제외됩니다.', style: theme.textTheme.muted, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var index = 0; index < approvals.length; index++) ...[ _PendingApprovalListTile( approval: approvals[index], now: now, dateFormat: dateFormat, onViewDetail: () => _handleViewDetail(context, approvals[index]), ), if (index < approvals.length - 1) 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 parentContext = context; final detailNotifier = ValueNotifier(null); 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}', ); detailNotifier.value = detail; return detail; }); if (!context.mounted) { detailNotifier.dispose(); return; } await SuperportDialog.show( context: context, dialog: SuperportDialog( title: '결재 상세', description: '결재번호 ${approval.approvalNo}', constraints: const BoxConstraints(maxWidth: 760), actions: [ ValueListenableBuilder( valueListenable: detailNotifier, builder: (dialogContext, detail, _) { return ShadButton.outline( onPressed: detail == null ? null : () async { final approvalDetailId = detail.id; if (approvalDetailId == null) { SuperportToast.error( parentContext, '결재 ID가 없어 결재 관리 화면을 열 수 없습니다.', ); return; } await Navigator.of( dialogContext, rootNavigator: true, ).maybePop(); if (!parentContext.mounted) { return; } await _openApprovalManagement( parentContext, approvalDetailId, ); }, child: const Text('결재 관리'), ); }, ), 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); }, ), ), ), ); detailNotifier.dispose(); } Future _openApprovalManagement( BuildContext context, int approvalId, ) async { final controller = ApprovalController( approvalRepository: GetIt.I(), templateRepository: GetIt.I(), transactionRepository: GetIt.I(), lookupRepository: GetIt.I.isRegistered() ? GetIt.I() : null, saveDraftUseCase: GetIt.I.isRegistered() ? GetIt.I() : null, getDraftUseCase: GetIt.I.isRegistered() ? GetIt.I() : null, listDraftsUseCase: GetIt.I.isRegistered() ? GetIt.I() : null, ); final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); final currentUserId = GetIt.I.isRegistered() ? GetIt.I().session?.user.id : null; try { await Future.wait([ controller.loadActionOptions(), controller.loadTemplates(), controller.loadStatusLookups(), ]); await controller.selectApproval(approvalId); if (controller.selected == null) { final error = controller.errorMessage ?? '결재 상세 정보를 불러오지 못했습니다.'; if (context.mounted) { SuperportToast.error(context, error); } return; } if (!context.mounted) { return; } final permissionScope = PermissionScope.of(context); final canPerformStepActions = permissionScope.can( PermissionResources.approvals, PermissionAction.approve, ); final canApplyTemplate = permissionScope.can( PermissionResources.approvals, PermissionAction.edit, ); await showApprovalDetailDialog( context: context, controller: controller, dateFormat: dateFormat, canPerformStepActions: canPerformStepActions, canApplyTemplate: canApplyTemplate, currentUserId: currentUserId, ); } finally { controller.dispose(); } } } class _PendingApprovalListTile extends StatelessWidget { const _PendingApprovalListTile({ required this.approval, required this.now, required this.dateFormat, required this.onViewDetail, }); final DashboardPendingApproval approval; final DateTime now; final intl.DateFormat dateFormat; final VoidCallback onViewDetail; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final summary = _PendingApprovalSummary.parse(approval.stepSummary); final requestedAt = approval.requestedAt; final timestampLabel = requestedAt == null ? '상신일시 확인 불가' : dateFormat.format(requestedAt.toLocal()); final elapsed = requestedAt == null ? null : _formatElapsedKorean(now.difference(requestedAt)); final chips = [ShadBadge(child: Text(approval.approvalNo))]; final roleBadges = approval.roles .map(_buildRoleBadge) .toList(growable: false); if (roleBadges.isNotEmpty) { chips.addAll(roleBadges); } if (summary.stage != null && summary.stage!.isNotEmpty) { chips.add(ShadBadge.outline(child: Text(summary.stage!))); } if (summary.actor != null && summary.actor!.isNotEmpty) { chips.add(ShadBadge.outline(child: Text('승인자 ${summary.actor!}'))); } if (summary.status != null && summary.status!.isNotEmpty) { chips.add(ShadBadge.outline(child: Text('상태 ${summary.status!}'))); } final description = summary.description; return Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 4), child: Icon( lucide.LucideIcons.fileCheck, size: 18, color: theme.colorScheme.mutedForeground, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( approval.title, style: theme.textTheme.p.copyWith( fontWeight: FontWeight.w600, color: theme.colorScheme.foreground, ), ), if (chips.isNotEmpty) ...[ const SizedBox(height: 8), Wrap(spacing: 8, runSpacing: 6, children: chips), ], if (description != null) ...[ const SizedBox(height: 8), Text(description, style: theme.textTheme.small), ], const SizedBox(height: 8), Text( elapsed == null ? '상신: $timestampLabel' : '상신: $timestampLabel · 경과 $elapsed', style: theme.textTheme.small.copyWith( color: theme.colorScheme.mutedForeground, ), ), ], ), ), const SizedBox(width: 12), ShadButton.ghost( size: ShadButtonSize.sm, onPressed: onViewDetail, child: const Text('상세'), ), ], ), ); } Widget _buildRoleBadge(DashboardApprovalRole role) { switch (role) { case DashboardApprovalRole.assigned: return ShadBadge(child: Text(role.label)); case DashboardApprovalRole.requester: return ShadBadge.outline(child: Text(role.label)); case DashboardApprovalRole.completed: return ShadBadge.outline(child: Text(role.label)); } } } class _PendingApprovalSummary { const _PendingApprovalSummary({this.stage, this.actor, this.status}); final String? stage; final String? actor; final String? status; static _PendingApprovalSummary parse(String? raw) { if (raw == null) { return const _PendingApprovalSummary(); } var text = raw.trim(); if (text.isEmpty) { return const _PendingApprovalSummary(); } String? status; final statusMatch = RegExp(r'\(([^)]+)\)$').firstMatch(text); if (statusMatch != null) { status = statusMatch.group(1)?.trim(); text = text.substring(0, statusMatch.start).trim(); } final parts = text.split(RegExp(r'\s*[·/→>]+\s*')); String? stage; String? actor; if (parts.isNotEmpty) { final value = parts.first.trim(); if (value.isNotEmpty) { stage = value; } } if (parts.length >= 2) { final joined = parts.sublist(1).join(' · ').trim(); if (joined.isNotEmpty) { actor = joined; } } return _PendingApprovalSummary(stage: stage, actor: actor, status: status); } String? get description { final segments = []; if (stage != null && stage!.isNotEmpty) { segments.add('현재 단계 $stage'); } if (actor != null && actor!.isNotEmpty) { segments.add('승인자 $actor'); } if (status != null && status!.isNotEmpty) { segments.add('상태 $status'); } if (segments.isEmpty) { return null; } return segments.join(' · '); } } String _formatElapsedKorean(Duration duration) { var value = duration; if (value.isNegative) { value = Duration(seconds: -value.inSeconds); } if (value.inMinutes < 1) { return '1분 미만'; } if (value.inHours < 1) { return '${value.inMinutes}분'; } if (value.inHours < 24) { final hours = value.inHours; final minutes = value.inMinutes % 60; if (minutes == 0) { return '$hours시간'; } return '$hours시간 $minutes분'; } if (value.inDays < 7) { final days = value.inDays; final hours = value.inHours % 24; if (hours == 0) { return '$days일'; } return '$days일 $hours시간'; } final weeks = value.inDays ~/ 7; final days = value.inDays % 7; if (days == 0) { return '$weeks주'; } return '$weeks주 $days일'; } 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; } // TODO(superport-team): 백엔드 알림 API가 준비되면 아래 mock 패널을 실제 데이터 기반으로 재구현한다. // 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), // ], // ), // ), // ], // ); // } // }