feat(approvals): 결재 상세 전표 카드 개편
- 상단 요약부를 전용 Highlight 카드로 교체하고 출발지/도착지 및 라인 품목 정보를 한 화면에서 제공 - 결재 상세 다이얼로그에 통화 포매터를 연결하고 새 카드 위젯을 포함해 금액·수량을 명시적으로 표현 - 입고/출고/대여 별 라우팅/품목 정보를 검증하는 위젯 테스트를 추가해 회귀 안정성을 확보
This commit is contained in:
@@ -11,6 +11,7 @@ import '../../../../widgets/components/superport_detail_dialog.dart';
|
|||||||
import '../../domain/entities/approval.dart';
|
import '../../domain/entities/approval.dart';
|
||||||
import '../../domain/entities/approval_template.dart';
|
import '../../domain/entities/approval_template.dart';
|
||||||
import '../../presentation/controllers/approval_controller.dart';
|
import '../../presentation/controllers/approval_controller.dart';
|
||||||
|
import '../widgets/approval_transaction_highlight_card.dart';
|
||||||
import '../../../inventory/transactions/domain/entities/stock_transaction.dart';
|
import '../../../inventory/transactions/domain/entities/stock_transaction.dart';
|
||||||
|
|
||||||
/// 결재 상세 다이얼로그를 표시한다.
|
/// 결재 상세 다이얼로그를 표시한다.
|
||||||
@@ -89,6 +90,11 @@ class ApprovalDetailDialogView extends StatefulWidget {
|
|||||||
|
|
||||||
class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
|
class _ApprovalDetailDialogViewState extends State<ApprovalDetailDialogView> {
|
||||||
int? _selectedTemplateId;
|
int? _selectedTemplateId;
|
||||||
|
late final intl.NumberFormat _currencyFormatter = intl.NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '₩',
|
||||||
|
decimalDigits: 0,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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(
|
final summary = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: summaryChildren,
|
||||||
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 summaryBadges = _buildSummaryBadges(approval, transaction);
|
final summaryBadges = _buildSummaryBadges(approval, transaction);
|
||||||
final metadata = _buildMetadata(
|
final metadata = _buildMetadata(
|
||||||
|
|||||||
@@ -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(', ');
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ void main() {
|
|||||||
uom: StockTransactionUomSummary(id: 3, name: 'EA'),
|
uom: StockTransactionUomSummary(id: 3, name: 'EA'),
|
||||||
),
|
),
|
||||||
quantity: 12,
|
quantity: 12,
|
||||||
unitPrice: 0,
|
unitPrice: 450000,
|
||||||
note: '라인 비고',
|
note: '라인 비고',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -151,7 +151,6 @@ void main() {
|
|||||||
await controller.loadTemplates(force: true);
|
await controller.loadTemplates(force: true);
|
||||||
await controller.loadActionOptions(force: true);
|
await controller.loadActionOptions(force: true);
|
||||||
await controller.selectApproval(sampleApproval.id!);
|
await controller.selectApproval(sampleApproval.id!);
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
||||||
expect(controller.templates, isNotEmpty);
|
expect(controller.templates, isNotEmpty);
|
||||||
expect(controller.selected, isNotNull);
|
expect(controller.selected, isNotNull);
|
||||||
expect(controller.canProceedSelected, isTrue);
|
expect(controller.canProceedSelected, isTrue);
|
||||||
@@ -197,6 +196,128 @@ void main() {
|
|||||||
expect(find.text('템플릿 적용'), findsOneWidget);
|
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 {
|
testWidgets('템플릿 적용 버튼을 누르면 assignSteps가 호출되고 성공 토스트를 노출한다', (tester) async {
|
||||||
await openDialog(tester);
|
await openDialog(tester);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user