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

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( return Flex(
direction: showSidePanel ? Axis.horizontal : Axis.vertical, direction: showSidePanel ? Axis.horizontal : Axis.vertical,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: showSidePanel ? MainAxisSize.max : MainAxisSize.min,
children: [ children: [
Expanded( Flexible(
flex: 3, flex: 3,
fit: showSidePanel ? FlexFit.tight : FlexFit.loose,
child: _RecentTransactionsCard( child: _RecentTransactionsCard(
transactions: summary.recentTransactions, transactions: summary.recentTransactions,
), ),
@@ -216,6 +218,7 @@ class _DashboardPageState extends State<DashboardPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
Flexible( Flexible(
flex: 2, flex: 2,
fit: showSidePanel ? FlexFit.tight : FlexFit.loose,
child: _PendingApprovalCard( child: _PendingApprovalCard(
approvals: summary.pendingApprovals, 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/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/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/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/entities/lookup_item.dart';
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
import '../widgets/inbound_detail_view.dart';
const String _inboundTransactionTypeId = '입고'; const String _inboundTransactionTypeId = '입고';
@@ -456,6 +458,7 @@ class _InboundPageState extends State<InboundPage> {
selected: _selectedRecord, selected: _selectedRecord,
onSelect: (record) { onSelect: (record) {
setState(() => _selectedRecord = record); setState(() => _selectedRecord = record);
_showDetailDialog(record);
}, },
dateFormatter: _dateFormatter, dateFormatter: _dateFormatter,
currencyFormatter: _currencyFormatter, 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) => rowSpanExtent: (index) =>
const FixedTableSpanExtent(InboundTableSpec.rowSpanHeight), const FixedTableSpanExtent(InboundTableSpec.rowSpanHeight),
onRowTap: (rowIndex) { onRowTap: (rowIndex) {
final record = records[rowIndex];
setState(() { 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 { Future<void> _handleCreate() async {
final record = await _showInboundFormDialog(); final record = await _showInboundFormDialog();
if (record != null) { 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 { class _SummaryBadge extends StatelessWidget {
const _SummaryBadge({ const _SummaryBadge({
required this.icon, 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/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/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/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/entities/customer.dart';
import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart'; import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart';
import '../../../lookups/domain/entities/lookup_item.dart'; import '../../../lookups/domain/entities/lookup_item.dart';
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
import '../widgets/outbound_detail_view.dart';
const String _outboundTransactionTypeId = '출고'; const String _outboundTransactionTypeId = '출고';
@@ -577,9 +579,11 @@ class _OutboundPageState extends State<OutboundPage> {
OutboundTableSpec.rowSpanHeight, OutboundTableSpec.rowSpanHeight,
), ),
onRowTap: (rowIndex) { onRowTap: (rowIndex) {
final record = visibleRecords[rowIndex];
setState(() { 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 { Future<void> _handleCreate() async {
final record = await _showOutboundFormDialog(); final record = await _showOutboundFormDialog();
if (record != null) { 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 { class _OutboundLineItemRow extends StatelessWidget {
const _OutboundLineItemRow({ const _OutboundLineItemRow({
required this.draft, required this.draft,
@@ -2829,29 +2683,3 @@ enum _OutboundSortField {
writer, writer,
customerCount, 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/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/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/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/entities/lookup_item.dart';
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
import '../widgets/rental_detail_view.dart';
const String _rentalTransactionTypeRent = '대여'; const String _rentalTransactionTypeRent = '대여';
const String _rentalTransactionTypeReturn = '반납'; const String _rentalTransactionTypeReturn = '반납';
@@ -522,9 +524,11 @@ class _RentalPageState extends State<RentalPage> {
RentalTableSpec.rowSpanHeight, RentalTableSpec.rowSpanHeight,
), ),
onRowTap: (rowIndex) { onRowTap: (rowIndex) {
final record = visibleRecords[rowIndex];
setState(() { 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 { Future<void> _handleCreate() async {
final record = await _showRentalFormDialog(); final record = await _showRentalFormDialog();
if (record != null) { if (record != null) {
@@ -2218,208 +2268,6 @@ enum _RentalSortField {
totalQuantity, 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 { class _FormFieldLabel extends StatelessWidget {
const _FormFieldLabel({required this.label, required this.child}); 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/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/models/postal_search_result.dart';
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.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 '../../../../../core/config/environment.dart';
import '../../../../../widgets/spec_page.dart'; import '../../../../../widgets/spec_page.dart';
@@ -103,7 +104,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocus = FocusNode(); final FocusNode _searchFocus = FocusNode();
String? _lastError; String? _lastError;
bool _routeApplied = false; String? _lastAppliedRoute;
@override @override
void initState() { void initState() {
@@ -115,9 +116,14 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
if (!_routeApplied) { _applyRouteIfNeeded();
_routeApplied = true; }
_applyRouteParameters();
@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() { void _applyRouteIfNeeded() {
final params = widget.routeUri.queryParameters; 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 query = params['q'] ?? '';
final type = _typeFromParam(params['type']); final type = _typeFromParam(params['type']);
final status = _statusFromParam(params['status']); final status = _statusFromParam(params['status']);
@@ -231,8 +246,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
), ),
], ],
children: [ children: [
SizedBox( ConstrainedBox(
width: 260, constraints: const BoxConstraints(maxWidth: 260),
child: ShadInput( child: ShadInput(
controller: _searchController, controller: _searchController,
focusNode: _searchFocus, focusNode: _searchFocus,
@@ -241,8 +256,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
onSubmitted: (_) => _applyFilters(), onSubmitted: (_) => _applyFilters(),
), ),
), ),
SizedBox( ConstrainedBox(
width: 200, constraints: const BoxConstraints(maxWidth: 200),
child: ShadSelect<CustomerTypeFilter>( child: ShadSelect<CustomerTypeFilter>(
key: ValueKey(_controller.typeFilter), key: ValueKey(_controller.typeFilter),
initialValue: _controller.typeFilter, initialValue: _controller.typeFilter,
@@ -262,8 +277,8 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
.toList(), .toList(),
), ),
), ),
SizedBox( ConstrainedBox(
width: 200, constraints: const BoxConstraints(maxWidth: 200),
child: ShadSelect<CustomerStatusFilter>( child: ShadSelect<CustomerStatusFilter>(
key: ValueKey(_controller.statusFilter), key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter, initialValue: _controller.statusFilter,
@@ -286,21 +301,27 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
], ],
), ),
child: ShadCard( child: ShadCard(
title: Row( title: ResponsiveStackedRow(
mainAxisAlignment: MainAxisAlignment.spaceBetween, leading: Text('고객사 목록', style: theme.textTheme.h3),
children: [ trailing: Align(
Text('고객사 목록', style: theme.textTheme.h3), alignment: Alignment.centerRight,
Text('$totalCount건', style: theme.textTheme.muted), child: Text('$totalCount건', style: theme.textTheme.muted),
], ),
), ),
footer: Row( footer: ResponsiveStackedRow(
mainAxisAlignment: MainAxisAlignment.spaceBetween, gap: 8,
children: [ breakpoint: 420,
Text( leading: Text(
'페이지 $currentPage / $totalPages', '페이지 $currentPage / $totalPages',
style: theme.textTheme.small, style: theme.textTheme.small,
), ),
Row( trailing: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
runAlignment: WrapAlignment.end,
children: [ children: [
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
@@ -309,7 +330,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
: () => _goToPage(1), : () => _goToPage(1),
child: const Text('처음'), child: const Text('처음'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1 onPressed: _controller.isLoading || currentPage <= 1
@@ -317,7 +337,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
: () => _goToPage(currentPage - 1), : () => _goToPage(currentPage - 1),
child: const Text('이전'), child: const Text('이전'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext onPressed: _controller.isLoading || !hasNext
@@ -325,18 +344,17 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
: () => _goToPage(currentPage + 1), : () => _goToPage(currentPage + 1),
child: const Text('다음'), child: const Text('다음'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: onPressed:
_controller.isLoading || currentPage >= totalPages _controller.isLoading || currentPage >= totalPages
? null ? null
: () => _goToPage(totalPages), : () => _goToPage(totalPages),
child: const Text('마지막'), child: const Text('마지막'),
), ),
], ],
), ),
], ),
), ),
child: _controller.isLoading child: _controller.isLoading
? const Padding( ? const Padding(
@@ -703,8 +721,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> {
return ShadButton.ghost( return ShadButton.ghost(
onPressed: isSaving onPressed: isSaving
? null ? null
: () => : () => Navigator.of(context, rootNavigator: true).pop(false),
Navigator.of(context, rootNavigator: true).pop(false),
child: const Text('취소'), 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/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_dialog.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/superport_table.dart';
import 'package:superport_v2/widgets/components/responsive_section.dart';
import '../../../../../core/config/environment.dart'; import '../../../../../core/config/environment.dart';
import '../../../../../widgets/spec_page.dart'; import '../../../../../widgets/spec_page.dart';
@@ -208,8 +209,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
), ),
], ],
children: [ children: [
SizedBox( ConstrainedBox(
width: 260, constraints: const BoxConstraints(maxWidth: 260),
child: ShadInput( child: ShadInput(
controller: _searchController, controller: _searchController,
focusNode: _searchFocus, focusNode: _searchFocus,
@@ -218,8 +219,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
onSubmitted: (_) => _applyFilters(), onSubmitted: (_) => _applyFilters(),
), ),
), ),
SizedBox( ConstrainedBox(
width: 220, constraints: const BoxConstraints(maxWidth: 220),
child: ShadSelect<int?>( child: ShadSelect<int?>(
key: ValueKey(_controller.vendorFilter), key: ValueKey(_controller.vendorFilter),
initialValue: _controller.vendorFilter, initialValue: _controller.vendorFilter,
@@ -248,8 +249,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
], ],
), ),
), ),
SizedBox( ConstrainedBox(
width: 220, constraints: const BoxConstraints(maxWidth: 220),
child: ShadSelect<int?>( child: ShadSelect<int?>(
key: ValueKey(_controller.uomFilter), key: ValueKey(_controller.uomFilter),
initialValue: _controller.uomFilter, initialValue: _controller.uomFilter,
@@ -277,8 +278,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
], ],
), ),
), ),
SizedBox( ConstrainedBox(
width: 200, constraints: const BoxConstraints(maxWidth: 200),
child: ShadSelect<ProductStatusFilter>( child: ShadSelect<ProductStatusFilter>(
key: ValueKey(_controller.statusFilter), key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter, initialValue: _controller.statusFilter,
@@ -301,21 +302,27 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
], ],
), ),
child: ShadCard( child: ShadCard(
title: Row( title: ResponsiveStackedRow(
mainAxisAlignment: MainAxisAlignment.spaceBetween, leading: Text('제품 목록', style: theme.textTheme.h3),
children: [ trailing: Align(
Text('제품 목록', style: theme.textTheme.h3), alignment: Alignment.centerRight,
Text('$totalCount건', style: theme.textTheme.muted), child: Text('$totalCount건', style: theme.textTheme.muted),
], ),
), ),
footer: Row( footer: ResponsiveStackedRow(
mainAxisAlignment: MainAxisAlignment.spaceBetween, gap: 8,
children: [ breakpoint: 420,
Text( leading: Text(
'페이지 $currentPage / $totalPages', '페이지 $currentPage / $totalPages',
style: theme.textTheme.small, style: theme.textTheme.small,
), ),
Row( trailing: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
runAlignment: WrapAlignment.end,
children: [ children: [
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
@@ -324,7 +331,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
: () => _goToPage(1), : () => _goToPage(1),
child: const Text('처음'), child: const Text('처음'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1 onPressed: _controller.isLoading || currentPage <= 1
@@ -332,7 +338,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
: () => _goToPage(currentPage - 1), : () => _goToPage(currentPage - 1),
child: const Text('이전'), child: const Text('이전'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext onPressed: _controller.isLoading || !hasNext
@@ -340,7 +345,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
: () => _goToPage(currentPage + 1), : () => _goToPage(currentPage + 1),
child: const Text('다음'), child: const Text('다음'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: onPressed:
@@ -351,7 +355,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> {
), ),
], ],
), ),
], ),
), ),
child: _controller.isLoading child: _controller.isLoading
? const Padding( ? 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/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_dialog.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/superport_table.dart';
import 'package:superport_v2/widgets/components/responsive_section.dart';
import '../../../../../core/config/environment.dart'; import '../../../../../core/config/environment.dart';
import '../../../../../widgets/spec_page.dart'; import '../../../../../widgets/spec_page.dart';
@@ -198,8 +199,8 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
), ),
], ],
children: [ children: [
SizedBox( ConstrainedBox(
width: 280, constraints: const BoxConstraints(maxWidth: 280),
child: ShadInput( child: ShadInput(
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode, focusNode: _searchFocusNode,
@@ -208,8 +209,8 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
onSubmitted: (_) => _applyFilters(), onSubmitted: (_) => _applyFilters(),
), ),
), ),
SizedBox( ConstrainedBox(
width: 220, constraints: const BoxConstraints(maxWidth: 220),
child: ShadSelect<VendorStatusFilter>( child: ShadSelect<VendorStatusFilter>(
key: ValueKey(_controller.statusFilter), key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter, initialValue: _controller.statusFilter,
@@ -233,21 +234,27 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
], ],
), ),
child: ShadCard( child: ShadCard(
title: Row( title: ResponsiveStackedRow(
mainAxisAlignment: MainAxisAlignment.spaceBetween, leading: Text('벤더 목록', style: theme.textTheme.h3),
children: [ trailing: Align(
Text('벤더 목록', style: theme.textTheme.h3), alignment: Alignment.centerRight,
Text('$totalCount건', style: theme.textTheme.muted), child: Text('$totalCount건', style: theme.textTheme.muted),
], ),
), ),
footer: Row( footer: ResponsiveStackedRow(
mainAxisAlignment: MainAxisAlignment.spaceBetween, gap: 8,
children: [ breakpoint: 420,
Text( leading: Text(
'페이지 $currentPage / $totalPages', '페이지 $currentPage / $totalPages',
style: theme.textTheme.small, style: theme.textTheme.small,
), ),
Row( trailing: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
runAlignment: WrapAlignment.end,
children: [ children: [
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
@@ -256,7 +263,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
: () => _goToPage(1), : () => _goToPage(1),
child: const Text('처음'), child: const Text('처음'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1 onPressed: _controller.isLoading || currentPage <= 1
@@ -264,7 +270,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
: () => _goToPage(currentPage - 1), : () => _goToPage(currentPage - 1),
child: const Text('이전'), child: const Text('이전'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext onPressed: _controller.isLoading || !hasNext
@@ -272,17 +277,17 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
: () => _goToPage(currentPage + 1), : () => _goToPage(currentPage + 1),
child: const Text('다음'), child: const Text('다음'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage >= totalPages onPressed:
_controller.isLoading || currentPage >= totalPages
? null ? null
: () => _goToPage(totalPages), : () => _goToPage(totalPages),
child: const Text('마지막'), child: const Text('마지막'),
), ),
], ],
), ),
], ),
), ),
child: _controller.isLoading child: _controller.isLoading
? const Padding( ? const Padding(
@@ -510,8 +515,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> {
return ShadButton.ghost( return ShadButton.ghost(
onPressed: isSaving onPressed: isSaving
? null ? null
: () => : () => Navigator.of(context, rootNavigator: true).pop(false),
Navigator.of(context, rootNavigator: true).pop(false),
child: const Text('취소'), 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/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_dialog.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/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/models/postal_search_result.dart';
import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.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 FocusNode _searchFocus = FocusNode();
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm');
String? _lastError; String? _lastError;
bool _routeApplied = false; String? _lastAppliedRoute;
@override @override
void initState() { void initState() {
@@ -112,9 +113,14 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
if (!_routeApplied) { _applyRouteIfNeeded();
_routeApplied = true; }
_applyRouteParameters();
@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: [ children: [
SizedBox( ConstrainedBox(
width: 260, constraints: const BoxConstraints(maxWidth: 260),
child: ShadInput( child: ShadInput(
controller: _searchController, controller: _searchController,
focusNode: _searchFocus, focusNode: _searchFocus,
@@ -215,8 +221,8 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
onSubmitted: (_) => _applyFilters(), onSubmitted: (_) => _applyFilters(),
), ),
), ),
SizedBox( ConstrainedBox(
width: 200, constraints: const BoxConstraints(maxWidth: 200),
child: ShadSelect<WarehouseStatusFilter>( child: ShadSelect<WarehouseStatusFilter>(
key: ValueKey(_controller.statusFilter), key: ValueKey(_controller.statusFilter),
initialValue: _controller.statusFilter, initialValue: _controller.statusFilter,
@@ -240,21 +246,27 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
], ],
), ),
child: ShadCard( child: ShadCard(
title: Row( title: ResponsiveStackedRow(
mainAxisAlignment: MainAxisAlignment.spaceBetween, leading: Text('창고 목록', style: theme.textTheme.h3),
children: [ trailing: Align(
Text('창고 목록', style: theme.textTheme.h3), alignment: Alignment.centerRight,
Text('$totalCount건', style: theme.textTheme.muted), child: Text('$totalCount건', style: theme.textTheme.muted),
], ),
), ),
footer: Row( footer: ResponsiveStackedRow(
mainAxisAlignment: MainAxisAlignment.spaceBetween, gap: 8,
children: [ breakpoint: 420,
Text( leading: Text(
'페이지 $currentPage / $totalPages', '페이지 $currentPage / $totalPages',
style: theme.textTheme.small, style: theme.textTheme.small,
), ),
Row( trailing: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
runAlignment: WrapAlignment.end,
children: [ children: [
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
@@ -263,7 +275,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
: () => _goToPage(1), : () => _goToPage(1),
child: const Text('처음'), child: const Text('처음'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || currentPage <= 1 onPressed: _controller.isLoading || currentPage <= 1
@@ -271,7 +282,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
: () => _goToPage(currentPage - 1), : () => _goToPage(currentPage - 1),
child: const Text('이전'), child: const Text('이전'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: _controller.isLoading || !hasNext onPressed: _controller.isLoading || !hasNext
@@ -279,18 +289,17 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
: () => _goToPage(currentPage + 1), : () => _goToPage(currentPage + 1),
child: const Text('다음'), child: const Text('다음'),
), ),
const SizedBox(width: 8),
ShadButton.outline( ShadButton.outline(
size: ShadButtonSize.sm, size: ShadButtonSize.sm,
onPressed: onPressed:
_controller.isLoading || currentPage >= totalPages _controller.isLoading || currentPage >= totalPages
? null ? null
: () => _goToPage(totalPages), : () => _goToPage(totalPages),
child: const Text('마지막'), child: const Text('마지막'),
), ),
], ],
), ),
], ),
), ),
child: _controller.isLoading child: _controller.isLoading
? const Padding( ? 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() { void _applyFilters() {
final keyword = _searchController.text.trim(); final keyword = _searchController.text.trim();
_controller.updateQuery(keyword); _controller.updateQuery(keyword);
@@ -576,8 +594,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> {
return ShadButton.ghost( return ShadButton.ghost(
onPressed: isSaving onPressed: isSaving
? null ? null
: () => : () => Navigator.of(context, rootNavigator: true).pop(false),
Navigator.of(context, rootNavigator: true).pop(false),
child: const Text('취소'), child: const Text('취소'),
); );
}, },

View File

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

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import 'responsive_section.dart';
/// 페이지 상단 타이틀/설명/액션을 일관되게 출력하는 헤더. /// 페이지 상단 타이틀/설명/액션을 일관되게 출력하는 헤더.
class PageHeader extends StatelessWidget { class PageHeader extends StatelessWidget {
const PageHeader({ const PageHeader({
@@ -22,27 +24,52 @@ class PageHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = ShadTheme.of(context); final theme = ShadTheme.of(context);
return Row( final actionWidgets = actions ?? const <Widget>[];
crossAxisAlignment: CrossAxisAlignment.start,
children: [ final trailingSection = (actionWidgets.isEmpty && trailing == null)
if (leading != null) ...[leading!, const SizedBox(width: 16)], ? const SizedBox.shrink()
Expanded( : Align(
child: Column( alignment: Alignment.centerRight,
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.end,
Text(title, style: theme.textTheme.h2), mainAxisSize: MainAxisSize.min,
if (subtitle != null) ...[ children: [
const SizedBox(height: 6), if (actionWidgets.isNotEmpty)
Text(subtitle!, style: theme.textTheme.muted), 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); expect(find.text('C-100'), findsOneWidget);
verify(() => repository.create(any())).called(1); 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); expect(find.text('NP-001'), findsOneWidget);
verify(() => productRepository.create(any())).called(1); 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); expect(find.text('NV-001'), findsOneWidget);
verify(() => repository.create(any())).called(1); 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); expect(find.text('WH-100'), findsOneWidget);
verify(() => repository.create(any())).called(1); 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);
});
}); });
} }