diff --git a/lib/features/dashboard/presentation/pages/dashboard_page.dart b/lib/features/dashboard/presentation/pages/dashboard_page.dart index 24e435e..b5a188e 100644 --- a/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -203,9 +203,11 @@ class _DashboardPageState extends State { 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 { const SizedBox(height: 16), Flexible( flex: 2, + fit: showSidePanel ? FlexFit.tight : FlexFit.loose, child: _PendingApprovalCard( approvals: summary.pendingApprovals, ), diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index 69038aa..7985fa4 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -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 { selected: _selectedRecord, onSelect: (record) { setState(() => _selectedRecord = record); + _showDetailDialog(record); }, dateFormatter: _dateFormatter, currencyFormatter: _currencyFormatter, @@ -572,51 +575,6 @@ class _InboundPageState extends State { ], ), ), - 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 { 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 { ]; } + Future _showDetailDialog(InboundRecord record) async { + await showInventoryTransactionDetailDialog( + 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 _buildDetailActions(InboundRecord record) { + final isProcessing = _isProcessing(record.id) || _isLoading; + final actions = []; + + 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 _handleCreate() async { final record = await _showInboundFormDialog(); if (record != null) { @@ -1900,208 +1936,6 @@ class _InboundPageState extends State { } } -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 Function()? onSubmit; - final Future Function()? onComplete; - final Future Function()? onApprove; - final Future Function()? onReject; - final Future 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, diff --git a/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart b/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart new file mode 100644 index 0000000..a152662 --- /dev/null +++ b/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index da08419..3ed88ad 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -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 { OutboundTableSpec.rowSpanHeight, ), onRowTap: (rowIndex) { + final record = visibleRecords[rowIndex]; setState(() { - _selectedRecord = visibleRecords[rowIndex]; + _selectedRecord = record; }); + _showDetailDialog(record); }, ), ), @@ -672,36 +676,6 @@ class _OutboundPageState extends State { ], ), ), - 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 { ]; } + Future _showDetailDialog(OutboundRecord record) async { + await showInventoryTransactionDetailDialog( + 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 _buildDetailActions(OutboundRecord record) { + final isProcessing = _isProcessing(record.id) || _isLoading; + final actions = []; + + 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 _handleCreate() async { final record = await _showOutboundFormDialog(); if (record != null) { @@ -2122,202 +2172,6 @@ class _OutboundPageState extends State { } } -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 Function()? onSubmit; - final Future Function()? onComplete; - final Future Function()? onApprove; - final Future Function()? onReject; - final Future 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), - ], - ), - ), - ); - } -} diff --git a/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart b/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart new file mode 100644 index 0000000..f3aace0 --- /dev/null +++ b/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index 0f9a034..da83ecf 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -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 { RentalTableSpec.rowSpanHeight, ), onRowTap: (rowIndex) { + final record = visibleRecords[rowIndex]; setState(() { - _selectedRecord = visibleRecords[rowIndex]; + _selectedRecord = record; }); + _showDetailDialog(record); }, ), ), @@ -619,36 +623,6 @@ class _RentalPageState extends State { ], ), ), - 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 { ]; } + Future _showDetailDialog(RentalRecord record) async { + await showInventoryTransactionDetailDialog( + 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 _buildDetailActions(RentalRecord record) { + final isProcessing = _isProcessing(record.id) || _isLoading; + final actions = []; + + 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 _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 Function()? onSubmit; - final Future Function()? onComplete; - final Future Function()? onApprove; - final Future Function()? onReject; - final Future 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), - ], - ), - ), - ); - } -} diff --git a/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart b/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart new file mode 100644 index 0000000..fe799a8 --- /dev/null +++ b/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart b/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart new file mode 100644 index 0000000..f814d6c --- /dev/null +++ b/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart @@ -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 showInventoryTransactionDetailDialog({ + required BuildContext context, + required String title, + required String transactionNumber, + required Widget body, + List actions = const [], + bool includeDefaultClose = true, + BoxConstraints? constraints, + EdgeInsetsGeometry? contentPadding, + bool scrollable = true, + bool barrierDismissible = true, + FutureOr Function()? onSubmit, +}) { + final resolvedActions = [ + ...actions, + if (includeDefaultClose) + ShadButton.ghost( + onPressed: () => Navigator.of(context).maybePop(), + child: const Text('닫기'), + ), + ]; + + return showSuperportDialog( + context: context, + title: title, + description: '트랜잭션번호 $transactionNumber', + body: body, + actions: resolvedActions, + constraints: constraints, + contentPadding: contentPadding, + scrollable: scrollable, + barrierDismissible: barrierDismissible, + onSubmit: onSubmit, + ); +} diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index a9e59c9..281adb3 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -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( 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( 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('취소'), ); }, diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index 33fac2a..635d7ea 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -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( 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( 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( 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( diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart index 897ddbe..cf262b2 100644 --- a/lib/features/masters/vendor/presentation/pages/vendor_page.dart +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -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( 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('취소'), ); }, diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart index 5bf6b1a..2e0f08b 100644 --- a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -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( 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('취소'), ); }, diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 2a7d30f..bbe7ad8 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -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, + ), ), ), ], diff --git a/lib/widgets/components/filter_bar.dart b/lib/widgets/components/filter_bar.dart index 41c194c..ffe1142 100644 --- a/lib/widgets/components/filter_bar.dart +++ b/lib/widgets/components/filter_bar.dart @@ -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), diff --git a/lib/widgets/components/page_header.dart b/lib/widgets/components/page_header.dart index d2283fd..8f77976 100644 --- a/lib/widgets/components/page_header.dart +++ b/lib/widgets/components/page_header.dart @@ -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 []; + + 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, ); } } diff --git a/lib/widgets/components/responsive_section.dart b/lib/widgets/components/responsive_section.dart new file mode 100644 index 0000000..31fc54a --- /dev/null +++ b/lib/widgets/components/responsive_section.dart @@ -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), + ], + ); + }, + ); + } +} diff --git a/test/features/masters/customer/presentation/pages/customer_page_test.dart b/test/features/masters/customer/presentation/pages/customer_page_test.dart index 7d07225..80cc31d 100644 --- a/test/features/masters/customer/presentation/pages/customer_page_test.dart +++ b/test/features/masters/customer/presentation/pages/customer_page_test.dart @@ -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( + 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); + }); }); } diff --git a/test/features/masters/product/presentation/pages/product_page_test.dart b/test/features/masters/product/presentation/pages/product_page_test.dart index b0893e3..65339fc 100644 --- a/test/features/masters/product/presentation/pages/product_page_test.dart +++ b/test/features/masters/product/presentation/pages/product_page_test.dart @@ -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( + 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); + }); }); } diff --git a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart index c6b1def..bf0908d 100644 --- a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart +++ b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart @@ -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(() => repository); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + 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); + }); } diff --git a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart index 0a6d2f2..f1b81f8 100644 --- a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart +++ b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart @@ -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( + 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); + }); }); }