- 결재 상세 다이얼로그에 전표 요약·라인·고객 섹션을 추가하고 현재 사용자 단계 강조 및 비고 입력 검증을 개선함 - 대시보드·결재 목록에서 전표 리포지토리와 AuthService를 주입해 상세 진입과 결재 관리 이동 버튼을 제공함 - StockTransactionApprovalInput이 template/steps를 config 노드로 직렬화하도록 변경하고 통합 테스트를 갱신함 - scope 권한 문자열을 리소스권으로 변환하는 PermissionScopeMapper와 단위 테스트를 추가하고 AuthPermission을 연동함 - 재고 메뉴 정렬, 상세 컨트롤러 오류 리셋, 요청자 자동완성 상태 동기화 등 주변 UI 버그를 수정하고 테스트를 보강함
1147 lines
37 KiB
Dart
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),
|
|
// ],
|
|
// ),
|
|
// ),
|
|
// ],
|
|
// );
|
|
// }
|
|
// }
|