재고 상세 다이얼로그화 및 마스터 레이아웃 개선

This commit is contained in:
JiWoong Sul
2025-10-22 18:52:21 +09:00
parent a14133df52
commit 09c31b2503
20 changed files with 1187 additions and 923 deletions

View File

@@ -203,9 +203,11 @@ class _DashboardPageState extends State<DashboardPage> {
return Flex(
direction: showSidePanel ? Axis.horizontal : Axis.vertical,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: showSidePanel ? MainAxisSize.max : MainAxisSize.min,
children: [
Expanded(
Flexible(
flex: 3,
fit: showSidePanel ? FlexFit.tight : FlexFit.loose,
child: _RecentTransactionsCard(
transactions: summary.recentTransactions,
),
@@ -216,6 +218,7 @@ class _DashboardPageState extends State<DashboardPage> {
const SizedBox(height: 16),
Flexible(
flex: 2,
fit: showSidePanel ? FlexFit.tight : FlexFit.loose,
child: _PendingApprovalCard(
approvals: summary.pendingApprovals,
),

View File

@@ -28,8 +28,10 @@ import 'package:superport_v2/features/inventory/transactions/domain/entities/sto
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart';
import '../../../lookups/domain/entities/lookup_item.dart';
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
import '../widgets/inbound_detail_view.dart';
const String _inboundTransactionTypeId = '입고';
@@ -456,6 +458,7 @@ class _InboundPageState extends State<InboundPage> {
selected: _selectedRecord,
onSelect: (record) {
setState(() => _selectedRecord = record);
_showDetailDialog(record);
},
dateFormatter: _dateFormatter,
currencyFormatter: _currencyFormatter,
@@ -572,51 +575,6 @@ class _InboundPageState extends State<InboundPage> {
],
),
),
if (_selectedRecord != null)
ResponsiveVisibility(
visibleOn: const {
DeviceBreakpoint.tablet,
DeviceBreakpoint.desktop,
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
_DetailCard(
record: _selectedRecord!,
dateFormatter: _dateFormatter,
currencyFormatter: _currencyFormatter,
onEdit: () => _handleEdit(_selectedRecord!),
transitionsEnabled: _transitionsEnabled,
onSubmit: _canSubmit(_selectedRecord!)
? () async => _submitRecord(_selectedRecord!)
: null,
onComplete: _canComplete(_selectedRecord!)
? () async => _completeRecord(_selectedRecord!)
: null,
onApprove: _canApprove(_selectedRecord!)
? () async => _approveRecord(_selectedRecord!)
: null,
onReject: _canReject(_selectedRecord!)
? () async => _rejectRecord(_selectedRecord!)
: null,
onCancel: _canCancel(_selectedRecord!)
? () async => _cancelRecord(_selectedRecord!)
: null,
canSubmit:
!_isProcessing(_selectedRecord!.id) && !_isLoading,
canComplete:
!_isProcessing(_selectedRecord!.id) && !_isLoading,
canApprove:
!_isProcessing(_selectedRecord!.id) && !_isLoading,
canReject:
!_isProcessing(_selectedRecord!.id) && !_isLoading,
canCancel:
!_isProcessing(_selectedRecord!.id) && !_isLoading,
),
],
),
),
],
),
);
@@ -704,9 +662,11 @@ class _InboundPageState extends State<InboundPage> {
rowSpanExtent: (index) =>
const FixedTableSpanExtent(InboundTableSpec.rowSpanHeight),
onRowTap: (rowIndex) {
final record = records[rowIndex];
setState(() {
_selectedRecord = records[rowIndex];
_selectedRecord = record;
});
_showDetailDialog(record);
},
);
}
@@ -728,6 +688,82 @@ class _InboundPageState extends State<InboundPage> {
];
}
Future<void> _showDetailDialog(InboundRecord record) async {
await showInventoryTransactionDetailDialog<void>(
context: context,
title: '입고 상세',
transactionNumber: record.transactionNumber,
body: InboundDetailView(
record: record,
dateFormatter: _dateFormatter,
currencyFormatter: _currencyFormatter,
transitionsEnabled: _transitionsEnabled,
),
actions: _buildDetailActions(record),
constraints: const BoxConstraints(maxWidth: 920),
);
}
List<Widget> _buildDetailActions(InboundRecord record) {
final isProcessing = _isProcessing(record.id) || _isLoading;
final actions = <Widget>[];
if (_canSubmit(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _submitRecord(record),
child: const Text('상신'),
),
);
}
if (_canApprove(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _approveRecord(record),
child: const Text('승인'),
),
);
}
if (_canReject(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _rejectRecord(record),
child: const Text('반려'),
),
);
}
if (_canCancel(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _cancelRecord(record),
child: const Text('취소'),
),
);
}
if (_canComplete(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _completeRecord(record),
child: const Text('완료 처리'),
),
);
}
actions.add(
ShadButton.outline(
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
onPressed: isProcessing
? null
: () {
Navigator.of(context).maybePop();
_handleEdit(record);
},
child: const Text('수정'),
),
);
return actions;
}
Future<void> _handleCreate() async {
final record = await _showInboundFormDialog();
if (record != null) {
@@ -1900,208 +1936,6 @@ class _InboundPageState extends State<InboundPage> {
}
}
class _DetailCard extends StatelessWidget {
const _DetailCard({
required this.record,
required this.dateFormatter,
required this.currencyFormatter,
required this.onEdit,
this.transitionsEnabled = true,
this.onSubmit,
this.onComplete,
this.onApprove,
this.onReject,
this.onCancel,
this.canSubmit = true,
this.canComplete = true,
this.canApprove = true,
this.canReject = true,
this.canCancel = true,
});
final InboundRecord record;
final DateFormat dateFormatter;
final NumberFormat currencyFormatter;
final VoidCallback onEdit;
final bool transitionsEnabled;
final Future<void> Function()? onSubmit;
final Future<void> Function()? onComplete;
final Future<void> Function()? onApprove;
final Future<void> Function()? onReject;
final Future<void> Function()? onCancel;
final bool canSubmit;
final bool canComplete;
final bool canApprove;
final bool canReject;
final bool canCancel;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('선택된 입고 상세', style: theme.textTheme.h3),
Wrap(
spacing: 8,
children: [
if (onSubmit != null)
ShadButton.outline(
onPressed: canSubmit
? () {
onSubmit?.call();
}
: null,
child: const Text('상신'),
),
if (onApprove != null)
ShadButton.outline(
onPressed: canApprove
? () {
onApprove?.call();
}
: null,
child: const Text('승인'),
),
if (onReject != null)
ShadButton.outline(
onPressed: canReject
? () {
onReject?.call();
}
: null,
child: const Text('반려'),
),
if (onCancel != null)
ShadButton.outline(
onPressed: canCancel
? () {
onCancel?.call();
}
: null,
child: const Text('취소'),
),
if (onComplete != null)
ShadButton.outline(
onPressed: canComplete
? () {
onComplete?.call();
}
: null,
child: const Text('완료 처리'),
),
ShadButton.outline(
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
onPressed: onEdit,
child: const Text('수정'),
),
],
),
],
),
description: Text(
'트랜잭션번호 ${record.transactionNumber}',
style: theme.textTheme.muted,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!transitionsEnabled) ...[
ShadBadge.outline(
child: Text(
'재고 상태 전이가 비활성화된 상태입니다.',
style: theme.textTheme.small,
),
),
const SizedBox(height: 16),
],
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_DetailChip(
label: '처리일자',
value: dateFormatter.format(record.processedAt),
),
_DetailChip(label: '창고', value: record.warehouse),
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
_DetailChip(label: '상태', value: record.status),
_DetailChip(label: '작성자', value: record.writer),
_DetailChip(label: '품목 수', value: '${record.itemCount}'),
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
_DetailChip(
label: '총 금액',
value: currencyFormatter.format(record.totalAmount),
),
],
),
const SizedBox(height: 24),
Text('라인 품목', style: theme.textTheme.h4),
const SizedBox(height: 8),
SizedBox(
height: (record.items.length * 52).clamp(160, 260).toDouble(),
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('제품')),
ShadTableCell.header(child: Text('제조사')),
ShadTableCell.header(child: Text('단위')),
ShadTableCell.header(child: Text('수량')),
ShadTableCell.header(child: Text('단가')),
ShadTableCell.header(child: Text('비고')),
],
children: [
for (final item in record.items)
[
ShadTableCell(child: Text(item.product)),
ShadTableCell(child: Text(item.manufacturer)),
ShadTableCell(child: Text(item.unit)),
ShadTableCell(child: Text('${item.quantity}')),
ShadTableCell(
child: Text(currencyFormatter.format(item.price)),
),
ShadTableCell(
child: Text(item.remark.isEmpty ? '-' : item.remark),
),
],
],
columnSpanExtent: (index) => const FixedTableSpanExtent(136),
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
),
),
],
),
);
}
}
class _DetailChip extends StatelessWidget {
const _DetailChip({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return ShadBadge.outline(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: theme.textTheme.small),
const SizedBox(height: 2),
Text(value, style: theme.textTheme.p),
],
),
),
);
}
}
class _SummaryBadge extends StatelessWidget {
const _SummaryBadge({
required this.icon,

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../models/inbound_record.dart';
/// 입고 트랜잭션 상세 정보를 다이얼로그 본문에 표시하는 뷰이다.
class InboundDetailView extends StatelessWidget {
const InboundDetailView({
super.key,
required this.record,
required this.dateFormatter,
required this.currencyFormatter,
this.transitionsEnabled = true,
});
final InboundRecord record;
final intl.DateFormat dateFormatter;
final intl.NumberFormat currencyFormatter;
final bool transitionsEnabled;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!transitionsEnabled) ...[
ShadBadge.outline(
child: Text(
'재고 상태 전이가 비활성화된 상태입니다.',
style: theme.textTheme.small,
),
),
const SizedBox(height: 16),
],
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_DetailChip(
label: '처리일자',
value: dateFormatter.format(record.processedAt),
),
_DetailChip(label: '창고', value: record.warehouse),
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
_DetailChip(label: '상태', value: record.status),
_DetailChip(label: '작성자', value: record.writer),
_DetailChip(label: '품목 수', value: '${record.itemCount}'),
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
_DetailChip(
label: '총 금액',
value: currencyFormatter.format(record.totalAmount),
),
if (record.remark.isNotEmpty)
_DetailChip(label: '비고', value: record.remark),
],
),
const SizedBox(height: 24),
Text('라인 품목', style: theme.textTheme.h4),
const SizedBox(height: 8),
_buildLineTable(),
],
);
}
Widget _buildLineTable() {
final rowHeight = (record.items.length * 52).clamp(160, 260).toDouble();
return SizedBox(
height: rowHeight,
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('제품')),
ShadTableCell.header(child: Text('제조사')),
ShadTableCell.header(child: Text('단위')),
ShadTableCell.header(child: Text('수량')),
ShadTableCell.header(child: Text('단가')),
ShadTableCell.header(child: Text('비고')),
],
children: [
for (final item in record.items)
[
ShadTableCell(child: Text(item.product)),
ShadTableCell(child: Text(item.manufacturer)),
ShadTableCell(child: Text(item.unit)),
ShadTableCell(child: Text('${item.quantity}')),
ShadTableCell(
child: Text(currencyFormatter.format(item.price)),
),
ShadTableCell(
child: Text(item.remark.isEmpty ? '-' : item.remark),
),
],
],
columnSpanExtent: (index) => const FixedTableSpanExtent(136),
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
),
);
}
}
class _DetailChip extends StatelessWidget {
const _DetailChip({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.card,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.colorScheme.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(label, style: theme.textTheme.small, textAlign: TextAlign.center),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.p,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -28,10 +28,12 @@ import 'package:superport_v2/features/inventory/transactions/domain/entities/sto
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart';
import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart';
import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart';
import '../../../lookups/domain/entities/lookup_item.dart';
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
import '../widgets/outbound_detail_view.dart';
const String _outboundTransactionTypeId = '출고';
@@ -577,9 +579,11 @@ class _OutboundPageState extends State<OutboundPage> {
OutboundTableSpec.rowSpanHeight,
),
onRowTap: (rowIndex) {
final record = visibleRecords[rowIndex];
setState(() {
_selectedRecord = visibleRecords[rowIndex];
_selectedRecord = record;
});
_showDetailDialog(record);
},
),
),
@@ -672,36 +676,6 @@ class _OutboundPageState extends State<OutboundPage> {
],
),
),
if (_selectedRecord != null) ...[
const SizedBox(height: 24),
_OutboundDetailCard(
record: _selectedRecord!,
dateFormatter: _dateFormatter,
currencyFormatter: _currencyFormatter,
onEdit: () => _handleEdit(_selectedRecord!),
transitionsEnabled: _transitionsEnabled,
onSubmit: _canSubmit(_selectedRecord!)
? () async => _submitRecord(_selectedRecord!)
: null,
onComplete: _canComplete(_selectedRecord!)
? () async => _completeRecord(_selectedRecord!)
: null,
onApprove: _canApprove(_selectedRecord!)
? () async => _approveRecord(_selectedRecord!)
: null,
onReject: _canReject(_selectedRecord!)
? () async => _rejectRecord(_selectedRecord!)
: null,
onCancel: _canCancel(_selectedRecord!)
? () async => _cancelRecord(_selectedRecord!)
: null,
canSubmit: !_isProcessing(_selectedRecord!.id) && !_isLoading,
canComplete: !_isProcessing(_selectedRecord!.id) && !_isLoading,
canApprove: !_isProcessing(_selectedRecord!.id) && !_isLoading,
canReject: !_isProcessing(_selectedRecord!.id) && !_isLoading,
canCancel: !_isProcessing(_selectedRecord!.id) && !_isLoading,
),
],
],
),
);
@@ -795,6 +769,82 @@ class _OutboundPageState extends State<OutboundPage> {
];
}
Future<void> _showDetailDialog(OutboundRecord record) async {
await showInventoryTransactionDetailDialog<void>(
context: context,
title: '출고 상세',
transactionNumber: record.transactionNumber,
body: OutboundDetailView(
record: record,
dateFormatter: _dateFormatter,
currencyFormatter: _currencyFormatter,
transitionsEnabled: _transitionsEnabled,
),
actions: _buildDetailActions(record),
constraints: const BoxConstraints(maxWidth: 920),
);
}
List<Widget> _buildDetailActions(OutboundRecord record) {
final isProcessing = _isProcessing(record.id) || _isLoading;
final actions = <Widget>[];
if (_canSubmit(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _submitRecord(record),
child: const Text('상신'),
),
);
}
if (_canApprove(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _approveRecord(record),
child: const Text('승인'),
),
);
}
if (_canReject(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _rejectRecord(record),
child: const Text('반려'),
),
);
}
if (_canCancel(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _cancelRecord(record),
child: const Text('취소'),
),
);
}
if (_canComplete(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _completeRecord(record),
child: const Text('출고 완료'),
),
);
}
actions.add(
ShadButton.outline(
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
onPressed: isProcessing
? null
: () {
Navigator.of(context).maybePop();
_handleEdit(record);
},
child: const Text('수정'),
),
);
return actions;
}
Future<void> _handleCreate() async {
final record = await _showOutboundFormDialog();
if (record != null) {
@@ -2122,202 +2172,6 @@ class _OutboundPageState extends State<OutboundPage> {
}
}
class _OutboundDetailCard extends StatelessWidget {
const _OutboundDetailCard({
required this.record,
required this.dateFormatter,
required this.currencyFormatter,
required this.onEdit,
this.transitionsEnabled = true,
this.onSubmit,
this.onComplete,
this.onApprove,
this.onReject,
this.onCancel,
this.canSubmit = true,
this.canComplete = true,
this.canApprove = true,
this.canReject = true,
this.canCancel = true,
});
final OutboundRecord record;
final DateFormat dateFormatter;
final NumberFormat currencyFormatter;
final VoidCallback onEdit;
final bool transitionsEnabled;
final Future<void> Function()? onSubmit;
final Future<void> Function()? onComplete;
final Future<void> Function()? onApprove;
final Future<void> Function()? onReject;
final Future<void> Function()? onCancel;
final bool canSubmit;
final bool canComplete;
final bool canApprove;
final bool canReject;
final bool canCancel;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('선택된 출고 상세', style: theme.textTheme.h3),
Wrap(
spacing: 8,
children: [
if (onSubmit != null)
ShadButton.outline(
onPressed: canSubmit
? () {
onSubmit?.call();
}
: null,
child: const Text('상신'),
),
if (onApprove != null)
ShadButton.outline(
onPressed: canApprove
? () {
onApprove?.call();
}
: null,
child: const Text('승인'),
),
if (onReject != null)
ShadButton.outline(
onPressed: canReject
? () {
onReject?.call();
}
: null,
child: const Text('반려'),
),
if (onCancel != null)
ShadButton.outline(
onPressed: canCancel
? () {
onCancel?.call();
}
: null,
child: const Text('취소'),
),
if (onComplete != null)
ShadButton.outline(
onPressed: canComplete
? () {
onComplete?.call();
}
: null,
child: const Text('출고 완료'),
),
ShadButton.outline(
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
onPressed: onEdit,
child: const Text('수정'),
),
],
),
],
),
description: Text(
'트랜잭션번호 ${record.transactionNumber}',
style: theme.textTheme.muted,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!transitionsEnabled) ...[
ShadBadge.outline(
child: Text(
'재고 상태 전이가 비활성화된 상태입니다.',
style: theme.textTheme.small,
),
),
const SizedBox(height: 16),
],
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_DetailChip(
label: '처리일자',
value: dateFormatter.format(record.processedAt),
),
_DetailChip(label: '창고', value: record.warehouse),
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
_DetailChip(label: '상태', value: record.status),
_DetailChip(label: '작성자', value: record.writer),
_DetailChip(label: '고객 수', value: '${record.customerCount}'),
_DetailChip(label: '품목 수', value: '${record.itemCount}'),
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
_DetailChip(
label: '총 금액',
value: currencyFormatter.format(record.totalAmount),
),
],
),
const SizedBox(height: 16),
Text('출고 고객사', style: theme.textTheme.h4),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final customer in record.customers)
ShadBadge(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text('${customer.name} · ${customer.code}'),
),
),
],
),
const SizedBox(height: 24),
Text('라인 품목', style: theme.textTheme.h4),
const SizedBox(height: 8),
SizedBox(
height: (record.items.length * 52).clamp(160, 260).toDouble(),
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('제품')),
ShadTableCell.header(child: Text('제조사')),
ShadTableCell.header(child: Text('단위')),
ShadTableCell.header(child: Text('수량')),
ShadTableCell.header(child: Text('단가')),
ShadTableCell.header(child: Text('비고')),
],
children: [
for (final item in record.items)
[
ShadTableCell(child: Text(item.product)),
ShadTableCell(child: Text(item.manufacturer)),
ShadTableCell(child: Text(item.unit)),
ShadTableCell(child: Text('${item.quantity}')),
ShadTableCell(
child: Text(currencyFormatter.format(item.price)),
),
ShadTableCell(
child: Text(item.remark.isEmpty ? '-' : item.remark),
),
],
],
columnSpanExtent: (index) => const FixedTableSpanExtent(136),
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
),
),
],
),
);
}
}
class _OutboundLineItemRow extends StatelessWidget {
const _OutboundLineItemRow({
required this.draft,
@@ -2829,29 +2683,3 @@ enum _OutboundSortField {
writer,
customerCount,
}
class _DetailChip extends StatelessWidget {
const _DetailChip({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return ShadBadge.outline(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: theme.textTheme.small),
const SizedBox(height: 2),
Text(value, style: theme.textTheme.p),
],
),
),
);
}
}

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../models/outbound_record.dart';
/// 출고 트랜잭션 상세 정보를 다이얼로그에 표시하는 뷰이다.
class OutboundDetailView extends StatelessWidget {
const OutboundDetailView({
super.key,
required this.record,
required this.dateFormatter,
required this.currencyFormatter,
this.transitionsEnabled = true,
});
final OutboundRecord record;
final intl.DateFormat dateFormatter;
final intl.NumberFormat currencyFormatter;
final bool transitionsEnabled;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!transitionsEnabled) ...[
ShadBadge.outline(
child: Text(
'재고 상태 전이가 비활성화된 상태입니다.',
style: theme.textTheme.small,
),
),
const SizedBox(height: 16),
],
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_DetailChip(
label: '처리일자',
value: dateFormatter.format(record.processedAt),
),
_DetailChip(label: '창고', value: record.warehouse),
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
_DetailChip(label: '상태', value: record.status),
_DetailChip(label: '작성자', value: record.writer),
_DetailChip(label: '고객 수', value: '${record.customerCount}'),
_DetailChip(label: '품목 수', value: '${record.itemCount}'),
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
_DetailChip(
label: '총 금액',
value: currencyFormatter.format(record.totalAmount),
),
if (record.remark.isNotEmpty && record.remark != '-')
_DetailChip(label: '비고', value: record.remark),
],
),
const SizedBox(height: 16),
Text('출고 고객사', style: theme.textTheme.h4),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final customer in record.customers)
ShadBadge(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text('${customer.name} · ${customer.code}'),
),
),
],
),
const SizedBox(height: 24),
Text('라인 품목', style: theme.textTheme.h4),
const SizedBox(height: 8),
_buildLineTable(),
],
);
}
Widget _buildLineTable() {
final height = (record.items.length * 52).clamp(160, 260).toDouble();
return SizedBox(
height: height,
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('제품')),
ShadTableCell.header(child: Text('제조사')),
ShadTableCell.header(child: Text('단위')),
ShadTableCell.header(child: Text('수량')),
ShadTableCell.header(child: Text('단가')),
ShadTableCell.header(child: Text('비고')),
],
children: [
for (final item in record.items)
[
ShadTableCell(child: Text(item.product)),
ShadTableCell(child: Text(item.manufacturer)),
ShadTableCell(child: Text(item.unit)),
ShadTableCell(child: Text('${item.quantity}')),
ShadTableCell(
child: Text(currencyFormatter.format(item.price)),
),
ShadTableCell(
child: Text(item.remark.isEmpty ? '-' : item.remark),
),
],
],
columnSpanExtent: (index) => const FixedTableSpanExtent(136),
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
),
);
}
}
class _DetailChip extends StatelessWidget {
const _DetailChip({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.card,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.colorScheme.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(label, style: theme.textTheme.small, textAlign: TextAlign.center),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.p,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -28,8 +28,10 @@ import 'package:superport_v2/features/inventory/transactions/domain/entities/sto
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart';
import '../../../lookups/domain/entities/lookup_item.dart';
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
import '../widgets/rental_detail_view.dart';
const String _rentalTransactionTypeRent = '대여';
const String _rentalTransactionTypeReturn = '반납';
@@ -522,9 +524,11 @@ class _RentalPageState extends State<RentalPage> {
RentalTableSpec.rowSpanHeight,
),
onRowTap: (rowIndex) {
final record = visibleRecords[rowIndex];
setState(() {
_selectedRecord = visibleRecords[rowIndex];
_selectedRecord = record;
});
_showDetailDialog(record);
},
),
),
@@ -619,36 +623,6 @@ class _RentalPageState extends State<RentalPage> {
],
),
),
if (_selectedRecord != null) ...[
const SizedBox(height: 24),
_RentalDetailCard(
record: _selectedRecord!,
dateFormatter: _dateFormatter,
currencyFormatter: _currencyFormatter,
onEdit: () => _handleEdit(_selectedRecord!),
transitionsEnabled: _transitionsEnabled,
onSubmit: _canSubmit(_selectedRecord!)
? () async => _submitRecord(_selectedRecord!)
: null,
onComplete: _canComplete(_selectedRecord!)
? () async => _completeRecord(_selectedRecord!)
: null,
onApprove: _canApprove(_selectedRecord!)
? () async => _approveRecord(_selectedRecord!)
: null,
onReject: _canReject(_selectedRecord!)
? () async => _rejectRecord(_selectedRecord!)
: null,
onCancel: _canCancel(_selectedRecord!)
? () async => _cancelRecord(_selectedRecord!)
: null,
canSubmit: !_isProcessing(_selectedRecord!.id) && !_isLoading,
canComplete: !_isProcessing(_selectedRecord!.id) && !_isLoading,
canApprove: !_isProcessing(_selectedRecord!.id) && !_isLoading,
canReject: !_isProcessing(_selectedRecord!.id) && !_isLoading,
canCancel: !_isProcessing(_selectedRecord!.id) && !_isLoading,
),
],
],
),
);
@@ -752,6 +726,82 @@ class _RentalPageState extends State<RentalPage> {
];
}
Future<void> _showDetailDialog(RentalRecord record) async {
await showInventoryTransactionDetailDialog<void>(
context: context,
title: '대여 상세',
transactionNumber: record.transactionNumber,
body: RentalDetailView(
record: record,
dateFormatter: _dateFormatter,
currencyFormatter: _currencyFormatter,
transitionsEnabled: _transitionsEnabled,
),
actions: _buildDetailActions(record),
constraints: const BoxConstraints(maxWidth: 920),
);
}
List<Widget> _buildDetailActions(RentalRecord record) {
final isProcessing = _isProcessing(record.id) || _isLoading;
final actions = <Widget>[];
if (_canSubmit(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _submitRecord(record),
child: const Text('반납 요청'),
),
);
}
if (_canApprove(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _approveRecord(record),
child: const Text('승인'),
),
);
}
if (_canReject(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _rejectRecord(record),
child: const Text('반려'),
),
);
}
if (_canCancel(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _cancelRecord(record),
child: const Text('취소'),
),
);
}
if (_canComplete(record)) {
actions.add(
ShadButton.outline(
onPressed: isProcessing ? null : () => _completeRecord(record),
child: const Text('대여 완료'),
),
);
}
actions.add(
ShadButton.outline(
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
onPressed: isProcessing
? null
: () {
Navigator.of(context).maybePop();
_handleEdit(record);
},
child: const Text('수정'),
),
);
return actions;
}
Future<void> _handleCreate() async {
final record = await _showRentalFormDialog();
if (record != null) {
@@ -2218,208 +2268,6 @@ enum _RentalSortField {
totalQuantity,
}
class _RentalDetailCard extends StatelessWidget {
const _RentalDetailCard({
required this.record,
required this.dateFormatter,
required this.currencyFormatter,
required this.onEdit,
this.transitionsEnabled = true,
this.onSubmit,
this.onComplete,
this.onApprove,
this.onReject,
this.onCancel,
this.canSubmit = true,
this.canComplete = true,
this.canApprove = true,
this.canReject = true,
this.canCancel = true,
});
final RentalRecord record;
final DateFormat dateFormatter;
final NumberFormat currencyFormatter;
final VoidCallback onEdit;
final bool transitionsEnabled;
final Future<void> Function()? onSubmit;
final Future<void> Function()? onComplete;
final Future<void> Function()? onApprove;
final Future<void> Function()? onReject;
final Future<void> Function()? onCancel;
final bool canSubmit;
final bool canComplete;
final bool canApprove;
final bool canReject;
final bool canCancel;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('선택된 대여 상세', style: theme.textTheme.h3),
Wrap(
spacing: 8,
children: [
if (onSubmit != null)
ShadButton.outline(
onPressed: canSubmit
? () {
onSubmit?.call();
}
: null,
child: const Text('반납 요청'),
),
if (onApprove != null)
ShadButton.outline(
onPressed: canApprove
? () {
onApprove?.call();
}
: null,
child: const Text('승인'),
),
if (onReject != null)
ShadButton.outline(
onPressed: canReject
? () {
onReject?.call();
}
: null,
child: const Text('반려'),
),
if (onCancel != null)
ShadButton.outline(
onPressed: canCancel
? () {
onCancel?.call();
}
: null,
child: const Text('취소'),
),
if (onComplete != null)
ShadButton.outline(
onPressed: canComplete
? () {
onComplete?.call();
}
: null,
child: const Text('대여 완료'),
),
ShadButton.outline(
leading: const Icon(lucide.LucideIcons.pencil, size: 16),
onPressed: onEdit,
child: const Text('수정'),
),
],
),
],
),
description: Text(
'트랜잭션번호 ${record.transactionNumber}',
style: theme.textTheme.muted,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!transitionsEnabled) ...[
ShadBadge.outline(
child: Text(
'재고 상태 전이가 비활성화된 상태입니다.',
style: theme.textTheme.small,
),
),
const SizedBox(height: 16),
],
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_DetailChip(
label: '처리일자',
value: dateFormatter.format(record.processedAt),
),
_DetailChip(label: '창고', value: record.warehouse),
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
_DetailChip(label: '대여 구분', value: record.rentalType),
_DetailChip(label: '상태', value: record.status),
_DetailChip(label: '작성자', value: record.writer),
_DetailChip(
label: '반납 예정일',
value: record.returnDueDate == null
? '-'
: dateFormatter.format(record.returnDueDate!),
),
_DetailChip(label: '고객 수', value: '${record.customerCount}'),
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
_DetailChip(
label: '총 금액',
value: currencyFormatter.format(record.totalAmount),
),
],
),
const SizedBox(height: 16),
Text('연결 고객사', style: theme.textTheme.h4),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final customer in record.customers)
ShadBadge(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text('${customer.name} · ${customer.code}'),
),
),
],
),
const SizedBox(height: 24),
Text('라인 품목', style: theme.textTheme.h4),
const SizedBox(height: 8),
SizedBox(
height: (record.items.length * 52).clamp(160, 260).toDouble(),
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('제품')),
ShadTableCell.header(child: Text('제조사')),
ShadTableCell.header(child: Text('단위')),
ShadTableCell.header(child: Text('수량')),
ShadTableCell.header(child: Text('단가')),
ShadTableCell.header(child: Text('비고')),
],
children: [
for (final item in record.items)
[
ShadTableCell(child: Text(item.product)),
ShadTableCell(child: Text(item.manufacturer)),
ShadTableCell(child: Text(item.unit)),
ShadTableCell(child: Text('${item.quantity}')),
ShadTableCell(
child: Text(currencyFormatter.format(item.price)),
),
ShadTableCell(
child: Text(item.remark.isEmpty ? '-' : item.remark),
),
],
],
columnSpanExtent: (index) => const FixedTableSpanExtent(136),
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
),
),
],
),
);
}
}
class _FormFieldLabel extends StatelessWidget {
const _FormFieldLabel({required this.label, required this.child});
@@ -2881,29 +2729,3 @@ class _RentalSummaryBadge extends StatelessWidget {
);
}
}
class _DetailChip extends StatelessWidget {
const _DetailChip({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return ShadBadge.outline(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: theme.textTheme.small),
const SizedBox(height: 2),
Text(value, style: theme.textTheme.p),
],
),
),
);
}
}

View File

@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;
import 'package:shadcn_ui/shadcn_ui.dart';
import '../models/rental_record.dart';
/// 대여 트랜잭션 상세 정보를 다이얼로그로 노출하는 뷰이다.
class RentalDetailView extends StatelessWidget {
const RentalDetailView({
super.key,
required this.record,
required this.dateFormatter,
required this.currencyFormatter,
this.transitionsEnabled = true,
});
final RentalRecord record;
final intl.DateFormat dateFormatter;
final intl.NumberFormat currencyFormatter;
final bool transitionsEnabled;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!transitionsEnabled) ...[
ShadBadge.outline(
child: Text(
'재고 상태 전이가 비활성화된 상태입니다.',
style: theme.textTheme.small,
),
),
const SizedBox(height: 16),
],
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_DetailChip(
label: '처리일자',
value: dateFormatter.format(record.processedAt),
),
_DetailChip(label: '창고', value: record.warehouse),
_DetailChip(label: '트랜잭션 유형', value: record.transactionType),
_DetailChip(label: '대여 구분', value: record.rentalType),
_DetailChip(label: '상태', value: record.status),
_DetailChip(label: '작성자', value: record.writer),
_DetailChip(
label: '반납 예정일',
value: record.returnDueDate == null
? '-'
: dateFormatter.format(record.returnDueDate!),
),
_DetailChip(label: '고객 수', value: '${record.customerCount}'),
_DetailChip(label: '총 수량', value: '${record.totalQuantity}'),
_DetailChip(
label: '총 금액',
value: currencyFormatter.format(record.totalAmount),
),
if (record.remark.isNotEmpty && record.remark != '-')
_DetailChip(label: '비고', value: record.remark),
],
),
const SizedBox(height: 16),
Text('연결 고객사', style: theme.textTheme.h4),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final customer in record.customers)
ShadBadge(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text('${customer.name} · ${customer.code}'),
),
),
],
),
const SizedBox(height: 24),
Text('라인 품목', style: theme.textTheme.h4),
const SizedBox(height: 8),
_buildLineTable(),
],
);
}
Widget _buildLineTable() {
final height = (record.items.length * 52).clamp(160, 260).toDouble();
return SizedBox(
height: height,
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('제품')),
ShadTableCell.header(child: Text('제조사')),
ShadTableCell.header(child: Text('단위')),
ShadTableCell.header(child: Text('수량')),
ShadTableCell.header(child: Text('단가')),
ShadTableCell.header(child: Text('비고')),
],
children: [
for (final item in record.items)
[
ShadTableCell(child: Text(item.product)),
ShadTableCell(child: Text(item.manufacturer)),
ShadTableCell(child: Text(item.unit)),
ShadTableCell(child: Text('${item.quantity}')),
ShadTableCell(
child: Text(currencyFormatter.format(item.price)),
),
ShadTableCell(
child: Text(item.remark.isEmpty ? '-' : item.remark),
),
],
],
columnSpanExtent: (index) => const FixedTableSpanExtent(136),
rowSpanExtent: (index) => const FixedTableSpanExtent(52),
),
);
}
}
class _DetailChip extends StatelessWidget {
const _DetailChip({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.card,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.colorScheme.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(label, style: theme.textTheme.small, textAlign: TextAlign.center),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.p,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../widgets/components/superport_dialog.dart';
/// 재고 트랜잭션 상세 정보를 Superport 다이얼로그로 표시하는 헬퍼이다.
Future<T?> showInventoryTransactionDetailDialog<T>({
required BuildContext context,
required String title,
required String transactionNumber,
required Widget body,
List<Widget> actions = const [],
bool includeDefaultClose = true,
BoxConstraints? constraints,
EdgeInsetsGeometry? contentPadding,
bool scrollable = true,
bool barrierDismissible = true,
FutureOr<void> Function()? onSubmit,
}) {
final resolvedActions = <Widget>[
...actions,
if (includeDefaultClose)
ShadButton.ghost(
onPressed: () => Navigator.of(context).maybePop(),
child: const Text('닫기'),
),
];
return showSuperportDialog<T>(
context: context,
title: title,
description: '트랜잭션번호 $transactionNumber',
body: body,
actions: resolvedActions,
constraints: constraints,
contentPadding: contentPadding,
scrollable: scrollable,
barrierDismissible: barrierDismissible,
onSubmit: onSubmit,
);
}

View File

@@ -9,6 +9,7 @@ import 'package:superport_v2/widgets/components/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_dialog.dart';
import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart';
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart';
import 'package:superport_v2/widgets/components/responsive_section.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../widgets/spec_page.dart';
@@ -103,7 +104,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocus = FocusNode();
String? _lastError;
bool _routeApplied = false;
String? _lastAppliedRoute;
@override
void initState() {
@@ -115,9 +116,14 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_routeApplied) {
_routeApplied = true;
_applyRouteParameters();
_applyRouteIfNeeded();
}
@override
void didUpdateWidget(covariant _CustomerEnabledPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.routeUri != oldWidget.routeUri) {
_applyRouteIfNeeded();
}
}
@@ -133,8 +139,17 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
}
}
void _applyRouteParameters() {
final params = widget.routeUri.queryParameters;
void _applyRouteIfNeeded() {
final current = widget.routeUri.toString();
if (_lastAppliedRoute == current) {
return;
}
_lastAppliedRoute = current;
_applyRouteParameters(widget.routeUri);
}
void _applyRouteParameters(Uri route) {
final params = route.queryParameters;
final query = params['q'] ?? '';
final type = _typeFromParam(params['type']);
final status = _statusFromParam(params['status']);
@@ -231,8 +246,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
),
],
children: [
SizedBox(
width: 260,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 260),
child: ShadInput(
controller: _searchController,
focusNode: _searchFocus,
@@ -241,8 +256,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
onSubmitted: (_) => _applyFilters(),
),
),
SizedBox(
width: 200,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: ShadSelect<CustomerTypeFilter>(
key: ValueKey(_controller.typeFilter),
initialValue: _controller.typeFilter,
@@ -262,8 +277,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
.toList(),
),
),
SizedBox(
width: 200,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: ShadSelect<CustomerStatusFilter>(
key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter,
@@ -286,21 +301,27 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
],
),
child: ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('고객사 목록', style: theme.textTheme.h3),
Text('$totalCount건', style: theme.textTheme.muted),
],
title: ResponsiveStackedRow(
leading: Text('고객사 목록', style: theme.textTheme.h3),
trailing: Align(
alignment: Alignment.centerRight,
child: Text('$totalCount건', style: theme.textTheme.muted),
),
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
Row(
footer: ResponsiveStackedRow(
gap: 8,
breakpoint: 420,
leading: Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
trailing: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
runAlignment: WrapAlignment.end,
children: [
ShadButton.outline(
size: ShadButtonSize.sm,
@@ -309,7 +330,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
: () => _goToPage(1),
child: const Text('처음'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1
@@ -317,7 +337,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
: () => _goToPage(currentPage - 1),
child: const Text('이전'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext
@@ -325,18 +344,17 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
: () => _goToPage(currentPage + 1),
child: const Text('다음'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading || currentPage >= totalPages
? null
: () => _goToPage(totalPages),
? null
: () => _goToPage(totalPages),
child: const Text('마지막'),
),
],
),
],
),
),
child: _controller.isLoading
? const Padding(
@@ -703,8 +721,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
return ShadButton.ghost(
onPressed: isSaving
? null
: () =>
Navigator.of(context, rootNavigator: true).pop(false),
: () => Navigator.of(context, rootNavigator: true).pop(false),
child: const Text('취소'),
);
},

View File

@@ -8,6 +8,7 @@ import 'package:superport_v2/widgets/app_layout.dart';
import 'package:superport_v2/widgets/components/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_dialog.dart';
import 'package:superport_v2/widgets/components/superport_table.dart';
import 'package:superport_v2/widgets/components/responsive_section.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../widgets/spec_page.dart';
@@ -208,8 +209,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
),
],
children: [
SizedBox(
width: 260,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 260),
child: ShadInput(
controller: _searchController,
focusNode: _searchFocus,
@@ -218,8 +219,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
onSubmitted: (_) => _applyFilters(),
),
),
SizedBox(
width: 220,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: ShadSelect<int?>(
key: ValueKey(_controller.vendorFilter),
initialValue: _controller.vendorFilter,
@@ -248,8 +249,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
],
),
),
SizedBox(
width: 220,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: ShadSelect<int?>(
key: ValueKey(_controller.uomFilter),
initialValue: _controller.uomFilter,
@@ -277,8 +278,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
],
),
),
SizedBox(
width: 200,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: ShadSelect<ProductStatusFilter>(
key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter,
@@ -301,21 +302,27 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
],
),
child: ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('제품 목록', style: theme.textTheme.h3),
Text('$totalCount건', style: theme.textTheme.muted),
],
title: ResponsiveStackedRow(
leading: Text('제품 목록', style: theme.textTheme.h3),
trailing: Align(
alignment: Alignment.centerRight,
child: Text('$totalCount건', style: theme.textTheme.muted),
),
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
Row(
footer: ResponsiveStackedRow(
gap: 8,
breakpoint: 420,
leading: Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
trailing: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
runAlignment: WrapAlignment.end,
children: [
ShadButton.outline(
size: ShadButtonSize.sm,
@@ -324,7 +331,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
: () => _goToPage(1),
child: const Text('처음'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1
@@ -332,7 +338,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
: () => _goToPage(currentPage - 1),
child: const Text('이전'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext
@@ -340,7 +345,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
: () => _goToPage(currentPage + 1),
child: const Text('다음'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed:
@@ -351,7 +355,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
),
],
),
],
),
),
child: _controller.isLoading
? const Padding(

View File

@@ -8,6 +8,7 @@ import 'package:superport_v2/widgets/app_layout.dart';
import 'package:superport_v2/widgets/components/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_dialog.dart';
import 'package:superport_v2/widgets/components/superport_table.dart';
import 'package:superport_v2/widgets/components/responsive_section.dart';
import '../../../../../core/config/environment.dart';
import '../../../../../widgets/spec_page.dart';
@@ -198,8 +199,8 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
),
],
children: [
SizedBox(
width: 280,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: ShadInput(
controller: _searchController,
focusNode: _searchFocusNode,
@@ -208,8 +209,8 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
onSubmitted: (_) => _applyFilters(),
),
),
SizedBox(
width: 220,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 220),
child: ShadSelect<VendorStatusFilter>(
key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter,
@@ -233,21 +234,27 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
],
),
child: ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('벤더 목록', style: theme.textTheme.h3),
Text('$totalCount건', style: theme.textTheme.muted),
],
title: ResponsiveStackedRow(
leading: Text('벤더 목록', style: theme.textTheme.h3),
trailing: Align(
alignment: Alignment.centerRight,
child: Text('$totalCount건', style: theme.textTheme.muted),
),
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
Row(
footer: ResponsiveStackedRow(
gap: 8,
breakpoint: 420,
leading: Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
trailing: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
runAlignment: WrapAlignment.end,
children: [
ShadButton.outline(
size: ShadButtonSize.sm,
@@ -256,7 +263,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
: () => _goToPage(1),
child: const Text('처음'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1
@@ -264,7 +270,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
: () => _goToPage(currentPage - 1),
child: const Text('이전'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext
@@ -272,17 +277,17 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
: () => _goToPage(currentPage + 1),
child: const Text('다음'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage >= totalPages
onPressed:
_controller.isLoading || currentPage >= totalPages
? null
: () => _goToPage(totalPages),
child: const Text('마지막'),
),
],
),
],
),
),
child: _controller.isLoading
? const Padding(
@@ -510,8 +515,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
return ShadButton.ghost(
onPressed: isSaving
? null
: () =>
Navigator.of(context, rootNavigator: true).pop(false),
: () => Navigator.of(context, rootNavigator: true).pop(false),
child: const Text('취소'),
);
},

View File

@@ -8,6 +8,7 @@ import 'package:superport_v2/widgets/app_layout.dart';
import 'package:superport_v2/widgets/components/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_dialog.dart';
import 'package:superport_v2/widgets/components/superport_table.dart';
import 'package:superport_v2/widgets/components/responsive_section.dart';
import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart';
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart';
@@ -99,7 +100,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
final FocusNode _searchFocus = FocusNode();
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
String? _lastError;
bool _routeApplied = false;
String? _lastAppliedRoute;
@override
void initState() {
@@ -112,9 +113,14 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_routeApplied) {
_routeApplied = true;
_applyRouteParameters();
_applyRouteIfNeeded();
}
@override
void didUpdateWidget(covariant _WarehouseEnabledPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.routeUri != oldWidget.routeUri) {
_applyRouteIfNeeded();
}
}
@@ -205,8 +211,8 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
),
],
children: [
SizedBox(
width: 260,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 260),
child: ShadInput(
controller: _searchController,
focusNode: _searchFocus,
@@ -215,8 +221,8 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
onSubmitted: (_) => _applyFilters(),
),
),
SizedBox(
width: 200,
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: ShadSelect<WarehouseStatusFilter>(
key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter,
@@ -240,21 +246,27 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
],
),
child: ShadCard(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('창고 목록', style: theme.textTheme.h3),
Text('$totalCount건', style: theme.textTheme.muted),
],
title: ResponsiveStackedRow(
leading: Text('창고 목록', style: theme.textTheme.h3),
trailing: Align(
alignment: Alignment.centerRight,
child: Text('$totalCount건', style: theme.textTheme.muted),
),
),
footer: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
Row(
footer: ResponsiveStackedRow(
gap: 8,
breakpoint: 420,
leading: Text(
'페이지 $currentPage / $totalPages',
style: theme.textTheme.small,
),
trailing: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
runAlignment: WrapAlignment.end,
children: [
ShadButton.outline(
size: ShadButtonSize.sm,
@@ -263,7 +275,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
: () => _goToPage(1),
child: const Text('처음'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1
@@ -271,7 +282,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
: () => _goToPage(currentPage - 1),
child: const Text('이전'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext
@@ -279,18 +289,17 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
: () => _goToPage(currentPage + 1),
child: const Text('다음'),
),
const SizedBox(width: 8),
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed:
_controller.isLoading || currentPage >= totalPages
? null
: () => _goToPage(totalPages),
? null
: () => _goToPage(totalPages),
child: const Text('마지막'),
),
],
),
],
),
),
child: _controller.isLoading
? const Padding(
@@ -323,6 +332,15 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
);
}
void _applyRouteIfNeeded() {
final current = widget.routeUri.toString();
if (_lastAppliedRoute == current) {
return;
}
_lastAppliedRoute = current;
_applyRouteParameters();
}
void _applyFilters() {
final keyword = _searchController.text.trim();
_controller.updateQuery(keyword);
@@ -576,8 +594,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
return ShadButton.ghost(
onPressed: isSaving
? null
: () =>
Navigator.of(context, rootNavigator: true).pop(false),
: () => Navigator.of(context, rootNavigator: true).pop(false),
child: const Text('취소'),
);
},

View File

@@ -246,10 +246,14 @@ class _BrandTitle extends StatelessWidget {
),
),
const SizedBox(width: 12),
Text(
'Superport v2',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
Flexible(
child: Text(
'Superport v2',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'responsive_section.dart';
/// 검색/필터 영역을 위한 공통 래퍼.
class FilterBar extends StatelessWidget {
const FilterBar({
@@ -38,31 +40,31 @@ class FilterBar extends StatelessWidget {
if (hasHeading)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 12),
],
if (title != null && title!.isNotEmpty)
Text(title!, style: theme.textTheme.h3),
],
),
),
if (computedActions.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: computedActions,
),
],
child: ResponsiveStackedRow(
breakpoint: 560,
gap: 12,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 12),
],
if (title != null && title!.isNotEmpty)
Flexible(child: Text(title!, style: theme.textTheme.h3)),
],
),
trailing: computedActions.isEmpty
? const SizedBox.shrink()
: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: computedActions,
),
),
),
),
Wrap(spacing: spacing, runSpacing: runSpacing, children: children),

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'responsive_section.dart';
/// 페이지 상단 타이틀/설명/액션을 일관되게 출력하는 헤더.
class PageHeader extends StatelessWidget {
const PageHeader({
@@ -22,27 +24,52 @@ class PageHeader extends StatelessWidget {
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) ...[leading!, const SizedBox(width: 16)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.h2),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(subtitle!, style: theme.textTheme.muted),
final actionWidgets = actions ?? const <Widget>[];
final trailingSection = (actionWidgets.isEmpty && trailing == null)
? const SizedBox.shrink()
: Align(
alignment: Alignment.centerRight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (actionWidgets.isNotEmpty)
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.end,
children: actionWidgets,
),
if (actionWidgets.isNotEmpty && trailing != null)
const SizedBox(height: 12),
if (trailing != null) trailing!,
],
],
),
);
return ResponsiveStackedRow(
breakpoint: 640,
gap: 12,
leading: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) ...[leading!, const SizedBox(width: 16)],
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.h2),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(subtitle!, style: theme.textTheme.muted),
],
],
),
),
),
if (actions != null && actions!.isNotEmpty) ...[
Wrap(spacing: 12, runSpacing: 12, children: actions!),
],
if (trailing != null) ...[const SizedBox(width: 16), trailing!],
],
),
trailing: trailingSection,
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/widgets.dart';
/// 카드 헤더/푸터 등에서 좌우 영역을 유연하게 배치하는 보조 위젯.
///
/// - 가용 폭이 [breakpoint] 미만이면 세로로 쌓아 overflow를 방지한다.
/// - 넉넉한 폭에서는 기본적으로 좌측은 확장, 우측은 필요한 만큼만 차지한다.
class ResponsiveStackedRow extends StatelessWidget {
const ResponsiveStackedRow({
super.key,
required this.leading,
required this.trailing,
this.breakpoint = 480,
this.gap = 12,
});
/// 좌측에 위치할 위젯.
final Widget leading;
/// 우측(또는 세로 스택 시 아래)에 배치할 위젯.
final Widget trailing;
/// 세로 스택 전환 기준 폭.
final double breakpoint;
/// 스택 전환 시 세로 간격.
final double gap;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxWidth < breakpoint;
if (isCompact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
leading,
SizedBox(height: gap),
trailing,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: leading),
const SizedBox(width: 16),
Flexible(child: trailing),
],
);
},
);
}
}

View File

@@ -264,5 +264,47 @@ void main() {
expect(find.text('C-100'), findsOneWidget);
verify(() => repository.create(any())).called(1);
});
testWidgets('좁은 폭에서도 오버플로 없이 렌더링', (tester) async {
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isPartner: any(named: 'isPartner'),
isGeneral: any(named: 'isGeneral'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Customer>(
items: [
Customer(
id: 1,
customerCode: 'C-SMALL',
customerName: '좁은 화면 고객',
isPartner: false,
isGeneral: true,
),
],
page: 1,
pageSize: 20,
total: 1,
),
);
await tester.pumpWidget(
_buildApp(
Center(
child: SizedBox(
width: 260,
child: CustomerPage(routeUri: Uri(path: '/masters/customers')),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
});
}

View File

@@ -312,5 +312,51 @@ void main() {
expect(find.text('NP-001'), findsOneWidget);
verify(() => productRepository.create(any())).called(1);
});
testWidgets('좁은 폭에서도 오버플로 없이 렌더링', (tester) async {
when(
() => productRepository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
vendorId: any(named: 'vendorId'),
uomId: any(named: 'uomId'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Product>(
items: [
Product(
id: 1,
productCode: 'P-SMALL',
productName: '좁은 화면 제품',
vendor: ProductVendor(
id: 1,
vendorCode: 'V-001',
vendorName: '슈퍼벤더',
),
uom: ProductUom(id: 5, uomName: 'EA'),
),
],
page: 1,
pageSize: 20,
total: 1,
),
);
await tester.pumpWidget(
_buildApp(
Center(
child: SizedBox(
width: 260,
child: ProductPage(routeUri: Uri(path: '/masters/products')),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
});
}

View File

@@ -216,4 +216,40 @@ void main() {
expect(find.text('NV-001'), findsOneWidget);
verify(() => repository.create(any())).called(1);
});
testWidgets('좁은 폭에서도 오버플로 없이 렌더링', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_VENDORS_ENABLED=true\n');
final repository = _MockVendorRepository();
GetIt.I.registerLazySingleton<VendorRepository>(() => repository);
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
),
).thenAnswer(
(_) async => PaginatedResult<Vendor>(
items: [Vendor(id: 1, vendorCode: 'V-SMALL', vendorName: '좁은 화면 벤더')],
page: 1,
pageSize: 20,
total: 1,
),
);
await tester.pumpWidget(
_buildApp(
Center(
child: SizedBox(
width: 260,
child: VendorPage(routeUri: Uri(path: '/masters/vendors')),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}

View File

@@ -247,5 +247,45 @@ void main() {
expect(find.text('WH-100'), findsOneWidget);
verify(() => repository.create(any())).called(1);
});
testWidgets('좁은 폭에서도 오버플로 없이 렌더링', (tester) async {
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
isActive: any(named: 'isActive'),
includeZipcode: any(named: 'includeZipcode'),
),
).thenAnswer(
(_) async => PaginatedResult<Warehouse>(
items: [
Warehouse(
id: 1,
warehouseCode: 'WH-SMALL',
warehouseName: '좁은 화면 창고',
zipcode: WarehouseZipcode(zipcode: '06000'),
),
],
page: 1,
pageSize: 20,
total: 1,
),
);
await tester.pumpWidget(
_buildApp(
Center(
child: SizedBox(
width: 260,
child: WarehousePage(routeUri: Uri(path: '/masters/warehouses')),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
});
}