diff --git a/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart b/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart index bf84781..f7f2056 100644 --- a/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart +++ b/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart @@ -11,6 +11,7 @@ import '../../../../widgets/components/superport_detail_dialog.dart'; import '../../domain/entities/approval.dart'; import '../../domain/entities/approval_template.dart'; import '../../presentation/controllers/approval_controller.dart'; +import '../widgets/approval_transaction_highlight_card.dart'; import '../../../inventory/transactions/domain/entities/stock_transaction.dart'; /// 결재 상세 다이얼로그를 표시한다. @@ -89,6 +90,11 @@ class ApprovalDetailDialogView extends StatefulWidget { class _ApprovalDetailDialogViewState extends State { int? _selectedTemplateId; + late final intl.NumberFormat _currencyFormatter = intl.NumberFormat.currency( + locale: 'ko_KR', + symbol: '₩', + decimalDigits: 0, + ); @override void initState() { @@ -441,23 +447,38 @@ class _ApprovalDetailDialogViewState extends State { ); } + final highlightCard = + (isTransactionLoading || + transaction != null || + transactionError != null) + ? ApprovalTransactionHighlightCard( + key: const ValueKey('approval_transaction_highlight_card'), + transaction: transaction, + isLoading: isTransactionLoading, + errorMessage: transactionError, + currencyFormatter: _currencyFormatter, + dateFormat: widget.dateFormat, + ) + : null; + final summaryChildren = [ + if (highlightCard != null) ...[ + highlightCard, + const SizedBox(height: 16), + ], + Text('결재번호 ${approval.approvalNo}', style: theme.textTheme.h4), + const SizedBox(height: 4), + Text('트랜잭션 ${approval.transactionNo}', style: theme.textTheme.muted), + if (transaction != null) ...[ + const SizedBox(height: 2), + Text( + '${transaction.type.name} · ${transaction.warehouse.name}', + style: theme.textTheme.small, + ), + ], + ]; final summary = Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('결재번호 ${approval.approvalNo}', style: theme.textTheme.h4), - const SizedBox(height: 4), - Text( - '트랜잭션 ${approval.transactionNo}', - style: theme.textTheme.muted, - ), - if (transaction != null) ...[ - const SizedBox(height: 2), - Text( - '${transaction.type.name} · ${transaction.warehouse.name}', - style: theme.textTheme.small, - ), - ], - ], + children: summaryChildren, ); final summaryBadges = _buildSummaryBadges(approval, transaction); final metadata = _buildMetadata( diff --git a/lib/features/approvals/presentation/widgets/approval_transaction_highlight_card.dart b/lib/features/approvals/presentation/widgets/approval_transaction_highlight_card.dart new file mode 100644 index 0000000..270ed2b --- /dev/null +++ b/lib/features/approvals/presentation/widgets/approval_transaction_highlight_card.dart @@ -0,0 +1,468 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../inventory/transactions/domain/entities/stock_transaction.dart'; + +/// 결재 상세 팝업 상단에서 입고/출고/대여 전표의 핵심 정보를 즉시 확인할 수 있는 카드. +class ApprovalTransactionHighlightCard extends StatelessWidget { + const ApprovalTransactionHighlightCard({ + super.key, + required this.transaction, + required this.isLoading, + required this.errorMessage, + required this.currencyFormatter, + required this.dateFormat, + }); + + final StockTransaction? transaction; + final bool isLoading; + final String? errorMessage; + final intl.NumberFormat currencyFormatter; + final intl.DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final content = _buildContent(theme); + if (content == null) { + return const SizedBox.shrink(); + } + return ShadCard(padding: const EdgeInsets.all(16), child: content); + } + + Widget? _buildContent(ShadThemeData theme) { + if (isLoading) { + return Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text('전표 정보를 불러오는 중입니다.', style: theme.textTheme.p), + ], + ); + } + if (errorMessage != null) { + return _buildStatusText( + theme, + errorMessage!, + color: theme.colorScheme.destructiveForeground, + ); + } + final txn = transaction; + if (txn == null) { + return _buildStatusText(theme, '연결된 전표 정보가 없습니다.'); + } + final flow = _resolveTransactionFlow(txn.type.name); + final routeInfo = _RouteInfo.from(flow, txn); + final stats = _buildStats(flow, txn); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap(spacing: 8, runSpacing: 8, children: _buildBadges(flow, txn)), + const SizedBox(height: 12), + _RouteSummary(route: routeInfo), + if (stats.isNotEmpty) ...[ + const SizedBox(height: 12), + _RouteStats(stats: stats), + ], + const SizedBox(height: 16), + _LineItemTable(lines: txn.lines, currencyFormatter: currencyFormatter), + ], + ); + } + + Widget _buildStatusText(ShadThemeData theme, String message, {Color? color}) { + return Row( + children: [ + Icon( + Icons.info_outline, + size: 18, + color: color ?? theme.colorScheme.mutedForeground, + ), + const SizedBox(width: 8), + Expanded( + child: Text(message, style: theme.textTheme.p.copyWith(color: color)), + ), + ], + ); + } + + List<_RouteStat> _buildStats( + _TransactionFlow flow, + StockTransaction transaction, + ) { + final stats = <_RouteStat>[ + _RouteStat(label: '품목 수', value: '${transaction.itemCount}건'), + _RouteStat( + label: '총 수량', + value: _formatQuantity(transaction.totalQuantity), + ), + ]; + if (flow == _TransactionFlow.inbound) { + stats.add( + _RouteStat( + label: '총 금액', + value: currencyFormatter.format(transaction.lines.totalAmount), + ), + ); + } + final expectedReturn = transaction.expectedReturnDate; + if (flow == _TransactionFlow.rental && expectedReturn != null) { + stats.add( + _RouteStat( + label: '예상 반납일', + value: dateFormat.format(expectedReturn.toLocal()), + ), + ); + } + return stats; + } + + List _buildBadges( + _TransactionFlow flow, + StockTransaction transaction, + ) { + final typeLabel = () { + switch (flow) { + case _TransactionFlow.inbound: + return '입고'; + case _TransactionFlow.outbound: + return '출고'; + case _TransactionFlow.rental: + return '대여'; + case _TransactionFlow.unknown: + return transaction.type.name; + } + }(); + return [ + ShadBadge(child: Text(typeLabel)), + ShadBadge.outline(child: Text(transaction.status.name)), + ]; + } +} + +class _RouteSummary extends StatelessWidget { + const _RouteSummary({required this.route}); + + final _RouteInfo route; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final children = [ + _RouteTile(label: '출발지', value: route.sourceValue), + _RouteTile(label: '도착지', value: route.destinationValue), + ]; + return LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 520; + if (isNarrow) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < children.length; index++) ...[ + if (index > 0) const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(8), + ), + child: children[index], + ), + ], + ], + ); + } + return Row( + children: [ + for (var index = 0; index < children.length; index++) ...[ + if (index > 0) const SizedBox(width: 12), + Expanded( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(8), + ), + child: children[index], + ), + ), + ], + ], + ); + }, + ); + } +} + +class _RouteTile extends StatelessWidget { + const _RouteTile({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.mutedForeground, + ), + ), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.p), + ], + ); + } +} + +class _RouteStats extends StatelessWidget { + const _RouteStats({required this.stats}); + + final List<_RouteStat> stats; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : MediaQuery.of(context).size.width; + final columns = maxWidth < 520 ? 1 : 3; + final spacing = columns == 1 ? 0.0 : 12.0; + final tileWidth = columns == 1 + ? maxWidth + : (maxWidth - spacing * (columns - 1)) / columns; + return Wrap( + spacing: 12, + runSpacing: 12, + children: stats + .map( + (stat) => SizedBox( + width: columns == 1 ? maxWidth : tileWidth, + child: _RouteTile(label: stat.label, value: stat.value), + ), + ) + .toList(growable: false), + ); + }, + ); + } +} + +class _LineItemTable extends StatelessWidget { + const _LineItemTable({required this.lines, required this.currencyFormatter}); + + final List lines; + final intl.NumberFormat currencyFormatter; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (lines.isEmpty) { + return Text('등록된 라인 품목이 없습니다.', style: theme.textTheme.muted); + } + final header = [ + const ShadTableCell.header(child: Text('라인')), + const ShadTableCell.header(child: Text('품목')), + const ShadTableCell.header(child: Text('단위')), + const ShadTableCell.header(child: Text('수량')), + const ShadTableCell.header(child: Text('단가')), + const ShadTableCell.header(child: Text('금액')), + const ShadTableCell.header(child: Text('비고')), + ]; + final quantityFormatter = intl.NumberFormat.decimalPattern('ko_KR'); + final rows = lines + .map( + (line) => [ + ShadTableCell(child: Text('${line.lineNo}')), + ShadTableCell( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_productLabel(line.product.code, line.product.name)), + if (line.product.vendor?.name != null) + Text( + line.product.vendor!.name, + style: theme.textTheme.small, + ), + ], + ), + ), + ShadTableCell(child: Text(line.product.uom?.name ?? '-')), + ShadTableCell( + child: Text('${quantityFormatter.format(line.quantity)}개'), + ), + ShadTableCell( + child: Text(currencyFormatter.format(line.unitPrice)), + ), + ShadTableCell( + child: Text( + currencyFormatter.format(line.unitPrice * line.quantity), + ), + ), + ShadTableCell(child: Text(_formatOptional(line.note))), + ], + ) + .toList(growable: false); + final tableHeight = _lineTableHeight(rows.length); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '라인 품목', + style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + SizedBox( + height: tableHeight, + child: ShadTable.list( + header: header, + children: rows, + columnSpanExtent: (index) => + FixedTableSpanExtent(_lineColumnWidth(index)), + ), + ), + ], + ); + } + + String _formatOptional(String? value) { + if (value == null || value.trim().isEmpty) { + return '-'; + } + return value.trim(); + } + + double _lineColumnWidth(int index) { + const widths = [60.0, 260.0, 80.0, 80.0, 120.0, 140.0, 160.0]; + if (index < widths.length) { + return widths[index]; + } + return widths.last; + } + + double _lineTableHeight(int rowCount) { + const rowHeight = 52.0; + return (rowCount + 1) * rowHeight; + } +} + +class _RouteInfo { + _RouteInfo({required this.sourceValue, required this.destinationValue}); + + final String sourceValue; + final String destinationValue; + + factory _RouteInfo.from(_TransactionFlow flow, StockTransaction transaction) { + switch (flow) { + case _TransactionFlow.inbound: + return _RouteInfo( + sourceValue: _sourceVendors(transaction.lines), + destinationValue: _warehouseLabel(transaction.warehouse), + ); + case _TransactionFlow.outbound: + case _TransactionFlow.rental: + return _RouteInfo( + sourceValue: _warehouseLabel(transaction.warehouse), + destinationValue: _customerSummary(transaction.customers), + ); + case _TransactionFlow.unknown: + return _RouteInfo( + sourceValue: _warehouseLabel(transaction.warehouse), + destinationValue: '-', + ); + } + } +} + +class _RouteStat { + const _RouteStat({required this.label, required this.value}); + + final String label; + final String value; +} + +enum _TransactionFlow { inbound, outbound, rental, unknown } + +_TransactionFlow _resolveTransactionFlow(String rawTypeName) { + final normalized = rawTypeName.toLowerCase(); + if (normalized.contains('입고') || normalized.contains('inbound')) { + return _TransactionFlow.inbound; + } + if (normalized.contains('출고') || normalized.contains('outbound')) { + return _TransactionFlow.outbound; + } + if (normalized.contains('대여') || normalized.contains('rental')) { + return _TransactionFlow.rental; + } + return _TransactionFlow.unknown; +} + +String _formatQuantity(int value) { + final formatter = intl.NumberFormat.decimalPattern('ko_KR'); + return '${formatter.format(value)}개'; +} + +String _productLabel(String code, String name) { + final trimmedCode = code.trim(); + final trimmedName = name.trim(); + if (trimmedCode.isEmpty) { + return trimmedName.isEmpty ? '-' : trimmedName; + } + if (trimmedName.isEmpty) { + return trimmedCode; + } + return '$trimmedCode · $trimmedName'; +} + +String _warehouseLabel(StockTransactionWarehouse warehouse) { + final code = warehouse.code.trim(); + final name = warehouse.name.trim(); + if (code.isEmpty) { + return name.isEmpty ? '창고 정보 없음' : name; + } + if (name.isEmpty) { + return code; + } + return '$name ($code)'; +} + +String _sourceVendors(List lines) { + final names = lines + .map((line) => line.product.vendor?.name ?? '') + .map((name) => name.trim()) + .where((name) => name.isNotEmpty) + .toSet() + .toList(growable: false); + if (names.isEmpty) { + return '-'; + } + return names.join(', '); +} + +String _customerSummary(List customers) { + if (customers.isEmpty) { + return '-'; + } + final names = customers + .map((customer) => customer.customer.name.trim()) + .where((name) => name.isNotEmpty) + .toSet() + .toList(growable: false); + if (names.isEmpty) { + return '-'; + } + return names.join(', '); +} diff --git a/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart b/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart index a5302bc..f691af4 100644 --- a/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart +++ b/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart @@ -61,7 +61,7 @@ void main() { uom: StockTransactionUomSummary(id: 3, name: 'EA'), ), quantity: 12, - unitPrice: 0, + unitPrice: 450000, note: '라인 비고', ), ], @@ -151,7 +151,6 @@ void main() { await controller.loadTemplates(force: true); await controller.loadActionOptions(force: true); await controller.selectApproval(sampleApproval.id!); - await Future.delayed(const Duration(milliseconds: 10)); expect(controller.templates, isNotEmpty); expect(controller.selected, isNotNull); expect(controller.canProceedSelected, isTrue); @@ -197,6 +196,128 @@ void main() { expect(find.text('템플릿 적용'), findsOneWidget); }); + testWidgets('입고 결재 상세 상단 카드가 파트너와 금액을 요약한다', (tester) async { + await openDialog(tester); + + final highlightCard = find.byKey( + const ValueKey('approval_transaction_highlight_card'), + ); + expect(highlightCard, findsOneWidget); + expect( + find.descendant(of: highlightCard, matching: find.text('출발지')), + findsOneWidget, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('한빛상사')), + findsWidgets, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('도착지')), + findsOneWidget, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('1센터 (WH-001)')), + findsWidgets, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('라인 품목')), + findsOneWidget, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('P-501 · 샘플 제품')), + findsOneWidget, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('총 금액')), + findsOneWidget, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('₩5,400,000')), + findsWidgets, + ); + }); + + testWidgets('출고 결재 상세 상단 카드가 고객사 정보를 노출한다', (tester) async { + final outboundTransaction = sampleTransaction.copyWith( + type: StockTransactionType(id: 2, name: '출고'), + customers: [ + StockTransactionCustomer( + id: 9002, + customer: StockTransactionCustomerSummary( + id: 4002, + code: 'C-4002', + name: '고객B', + ), + ), + ], + ); + + stockRepository.detail = outboundTransaction; + await controller.selectApproval(sampleApproval.id!); + await openDialog(tester); + + final highlightCard = find.byKey( + const ValueKey('approval_transaction_highlight_card'), + ); + expect(highlightCard, findsOneWidget); + expect( + find.descendant(of: highlightCard, matching: find.text('출발지')), + findsOneWidget, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('1센터 (WH-001)')), + findsWidgets, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('도착지')), + findsOneWidget, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('고객B')), + findsOneWidget, + ); + }); + + testWidgets('대여 결재 상세 상단 카드가 예상 반납일을 표시한다', (tester) async { + final rentalTransaction = sampleTransaction.copyWith( + type: StockTransactionType(id: 3, name: '대여'), + customers: [ + StockTransactionCustomer( + id: 9003, + customer: StockTransactionCustomerSummary( + id: 4003, + code: 'C-4003', + name: '고객C', + ), + ), + ], + ); + + stockRepository.detail = rentalTransaction; + await controller.selectApproval(sampleApproval.id!); + await openDialog(tester); + + final highlightCard = find.byKey( + const ValueKey('approval_transaction_highlight_card'), + ); + expect(highlightCard, findsOneWidget); + expect( + find.descendant(of: highlightCard, matching: find.text('도착지')), + findsOneWidget, + ); + expect( + find.descendant(of: highlightCard, matching: find.text('예상 반납일')), + findsOneWidget, + ); + expect( + find.descendant( + of: highlightCard, + matching: find.text('2024-01-10 00:00'), + ), + findsOneWidget, + ); + }); + testWidgets('템플릿 적용 버튼을 누르면 assignSteps가 호출되고 성공 토스트를 노출한다', (tester) async { await openDialog(tester);