Files
superport_v2/lib/features/dashboard/presentation/pages/dashboard_page.dart
JiWoong Sul 6d09e72142 feat(approvals): 결재 상세 전표 연동과 스코프 권한 매핑 확장
- 결재 상세 다이얼로그에 전표 요약·라인·고객 섹션을 추가하고 현재 사용자 단계 강조 및 비고 입력 검증을 개선함

- 대시보드·결재 목록에서 전표 리포지토리와 AuthService를 주입해 상세 진입과 결재 관리 이동 버튼을 제공함

- StockTransactionApprovalInput이 template/steps를 config 노드로 직렬화하도록 변경하고 통합 테스트를 갱신함

- scope 권한 문자열을 리소스권으로 변환하는 PermissionScopeMapper와 단위 테스트를 추가하고 AuthPermission을 연동함

- 재고 메뉴 정렬, 상세 컨트롤러 오류 리셋, 요청자 자동완성 상태 동기화 등 주변 UI 버그를 수정하고 테스트를 보강함
2025-11-14 01:57:02 +09:00

1147 lines
37 KiB
Dart

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<DashboardPage> createState() => _DashboardPageState();
}
class _DashboardPageState extends State<DashboardPage> {
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<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),
// 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<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: '최종 승인 대기 전표가 생성되면 이곳에 표시됩니다.',
),
);
}
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<ApprovalRepository>();
final parentContext = context;
final detailNotifier = ValueNotifier<Approval?>(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<void>(
context: context,
dialog: SuperportDialog(
title: '결재 상세',
description: '결재번호 ${approval.approvalNo}',
constraints: const BoxConstraints(maxWidth: 760),
actions: [
ValueListenableBuilder<Approval?>(
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<Approval>(
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<void> _openApprovalManagement(
BuildContext context,
int approvalId,
) async {
final controller = ApprovalController(
approvalRepository: GetIt.I<ApprovalRepository>(),
templateRepository: GetIt.I<ApprovalTemplateRepository>(),
transactionRepository: GetIt.I<StockTransactionRepository>(),
lookupRepository: GetIt.I.isRegistered<InventoryLookupRepository>()
? GetIt.I<InventoryLookupRepository>()
: null,
saveDraftUseCase: GetIt.I.isRegistered<SaveApprovalDraftUseCase>()
? GetIt.I<SaveApprovalDraftUseCase>()
: null,
getDraftUseCase: GetIt.I.isRegistered<GetApprovalDraftUseCase>()
? GetIt.I<GetApprovalDraftUseCase>()
: null,
listDraftsUseCase: GetIt.I.isRegistered<ListApprovalDraftsUseCase>()
? GetIt.I<ListApprovalDraftsUseCase>()
: null,
);
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
final currentUserId = GetIt.I.isRegistered<AuthService>()
? GetIt.I<AuthService>().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 = <Widget>[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 = <String>[];
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<ApprovalStep> 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<ApprovalHistory> 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),
// ],
// ),
// ),
// ],
// );
// }
// }