feat(approvals): 결재 상세 전표 카드 개편

- 상단 요약부를 전용 Highlight 카드로 교체하고 출발지/도착지 및 라인 품목 정보를 한 화면에서 제공
- 결재 상세 다이얼로그에 통화 포매터를 연결하고 새 카드 위젯을 포함해 금액·수량을 명시적으로 표현
- 입고/출고/대여 별 라우팅/품목 정보를 검증하는 위젯 테스트를 추가해 회귀 안정성을 확보
This commit is contained in:
JiWoong Sul
2025-11-14 15:53:21 +09:00
parent 6d09e72142
commit 046b27a51a
3 changed files with 627 additions and 17 deletions

View File

@@ -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<ApprovalDetailDialogView> {
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<ApprovalDetailDialogView> {
);
}
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 = <Widget>[
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(

View File

@@ -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<Widget> _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<StockTransactionLine> 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 = <ShadTableCell>[
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>[
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<StockTransactionLine> 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<StockTransactionCustomer> 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(', ');
}